import { CollectionTableUniDriver } from '../CollectionTable/CollectionTable.uni.driver'; import { UniDriver, waitFor } from '@wix/wix-ui-test-utils/unidriver'; import { LoaderUniDriver, MessageModalLayoutUniDriver, TextButtonUniDriver, } from '@wix/design-system/dist/testkit/unidriver'; import { DragAndDropUniDriver } from '../DragAndDrop/DragAndDrop.uni.driver'; import { ToolbarCollectionErrorStateUniDriver } from '../ErrorState/ToolbarCollectionErrorState.uni.driver'; import type { Page } from 'puppeteer'; import { CustomFieldModalUniDriver } from '../CustomFieldModal/CustomFieldModal.uni.driver'; import { ArchiveFieldModalUniDriver } from '../CustomFieldsPanel/ArchiveFieldModal.uni.driver'; import { baseUniDriverFactory, isSled2, isSled3 } from '../../unidriver'; import { ManageTagsModalUniDriver } from '../Tags/ManageTags/ManageTagsModal/ManageTagsModal.uni.driver'; import TagsAssignButtonUniDriver from '../TagsAssignButton/TagsAssignButton.uni.driver'; import { CollectionEmptyStateUniDriver } from '../EmptyState/CollectionEmptyState.uni.driver'; import { CollectionPremiumEmptyStateUniDriver } from '../EmptyState/CollectionPremiumEmptyState.uni.driver'; import { CollectionNoResultsStateUniDriver } from '../EmptyState/CollectionNoResultsState.uni.driver'; import { CollectionSectionHeaderUniDriver } from '../CollectionSectionHeader/CollectionSectionHeader.uni.driver'; export function TableUniDriver(base: UniDriver, body: UniDriver) { const collectionTable = CollectionTableUniDriver( base.$('[data-hook="collection-table"]'), body, ); const initialLoaderBase = () => base.$(`[data-hook="placeholder-states-loading-state"]`); const initialLoader = () => initialLoaderBase(); const infiniteScrollLoaderBase = () => base.$(`[data-hook="infinite-scroll-loader"]`); const infiniteScrollLoader = () => LoaderUniDriver(infiniteScrollLoaderBase(), body); const getEmptyStateByDataHook = (dataHook = 'collection-empty-state') => CollectionEmptyStateUniDriver(base.$(`[data-hook="${dataHook}"]`), body); const getPremiumEmptyStateByDataHook = (dataHook: string) => CollectionPremiumEmptyStateUniDriver( base.$(`[data-hook="${dataHook}"]`), body, ); const getNoResultsStateByDataHook = (dataHook = 'collection-empty-state') => CollectionNoResultsStateUniDriver( base.$(`[data-hook="${dataHook}"]`), body, ); const getErrorStateByDataHook = ( dataHook = 'placeholder-states-error-state', ) => ToolbarCollectionErrorStateUniDriver( base.$(`[data-hook="${dataHook}"]`), body, ); const viewsToolbarItem = () => base.$('[data-hook="collection-table-toolbar-views-item"]'); const scrollUp = async ( scrollableElementDataHook = 'page-scrollable-content', options: { top?: number } = {}, ) => { const tableContent = base.$('[data-hook="table-content"]'); const tableContentElement = await tableContent.getNative(); // puppeteer if (isSled2(tableContentElement)) { const { page } = tableContentElement; let up; // if table is virtual by default we scroll down to the not rendered items, if `options.top` not passed if (await isTableVirtualized()) { const rowsCount = await getRenderedRowsCount(); const avgRowHeight = +((await getAverageRowHeight()) ?? 0); up = rowsCount * avgRowHeight; } await page.evaluate( (_dataHook: string, _options: { top?: number; up: number }) => { const scrollableElement = document.querySelector( `[data-hook="${_dataHook}"]`, )!; const optionsTopVal = _options.top === 0 ? -scrollableElement.scrollTop : _options.top; scrollableElement.scrollBy({ behavior: 'smooth', top: optionsTopVal ?? _options.up, }); }, scrollableElementDataHook, { ...options, up: -(up ?? 0) }, ); await page.waitForTimeout(1000); // delay scroll resolve by 1 sec } // playwright else if (isSled3(tableContentElement)) { let up; if (await isTableVirtualized()) { const rowsCount = await getRenderedRowsCount(); const avgRowHeight = +((await getAverageRowHeight()) ?? 0); up = rowsCount * avgRowHeight; } const locator = tableContentElement as { page: () => { evaluate: ( fn: (args: { dataHook: string; top?: number; up: number }) => void, args: { dataHook: string; top?: number; up: number }, ) => Promise; waitForTimeout: (ms: number) => Promise; }; }; const pwPage = locator.page(); await pwPage.evaluate( ({ dataHook, top: targetTop, up: upDist }) => { const scrollableElement = document.querySelector( `[data-hook="${dataHook}"]`, )!; const optionsTopVal = targetTop === 0 ? -scrollableElement.scrollTop : targetTop; scrollableElement.scrollBy({ behavior: 'smooth', top: optionsTopVal ?? upDist, }); }, { dataHook: scrollableElementDataHook, top: options.top, up: -(up ?? 0), }, ); await pwPage.waitForTimeout(1000); } // jsdom else { const pageScrollContent = body.$( `[data-hook="${scrollableElementDataHook}"]`, ); if (await pageScrollContent.exists()) { await ( await pageScrollContent.getNative() ).dispatchEvent( Object.assign(new Event('scroll'), { _testScroll: options.top === 0 ? 'top' : 'up', }), ); } } }; const scrollDown = async ( scrollableElementDataHook = 'page-scrollable-content', options: { top?: number } = {}, ) => { const tableContent = base.$('[data-hook="table-content"]'); const tableContentElement = await tableContent.getNative(); // puppeteer if (isSled2(tableContentElement)) { const { page } = tableContentElement; let top; // if table is virtual by default we scroll down to the not rendered items, if `options.top` not passed if (await isTableVirtualized()) { const rowsCount = await getRenderedRowsCount(); const avgRowHeight = +((await getAverageRowHeight()) ?? 0); top = rowsCount * avgRowHeight; } await page.evaluate( (_dataHook: string, _options: { top?: number }) => { const scrollableElement = document.querySelector( `[data-hook="${_dataHook}"]`, )!; scrollableElement.scrollBy({ behavior: 'smooth', top: _options.top ?? scrollableElement.scrollHeight, }); }, scrollableElementDataHook, { ...options, top: options.top ?? top ?? undefined }, ); await page.waitForTimeout(1_000); // delay scroll resolve by 1 sec } // playwright else if (isSled3(tableContentElement)) { let top; if (await isTableVirtualized()) { const rowsCount = await getRenderedRowsCount(); const avgRowHeight = +((await getAverageRowHeight()) ?? 0); top = rowsCount * avgRowHeight; } const locator = tableContentElement as { page: () => { evaluate: ( fn: (args: { dataHook: string; top?: number }) => void, args: { dataHook: string; top?: number }, ) => Promise; waitForTimeout: (ms: number) => Promise; }; }; const pwPage = locator.page(); await pwPage.evaluate( ({ dataHook, top: targetTop }) => { const scrollableElement = document.querySelector( `[data-hook="${dataHook}"]`, )!; scrollableElement.scrollBy({ behavior: 'smooth', top: targetTop ?? scrollableElement.scrollHeight, }); }, { dataHook: scrollableElementDataHook, top: options.top ?? top ?? undefined, }, ); await pwPage.waitForTimeout(1_000); } // jsdom else { const pageScrollContent = body.$( `[data-hook="${scrollableElementDataHook}"]`, ); if (await pageScrollContent.exists()) { const native: HTMLElement = await pageScrollContent.getNative(); native.dispatchEvent(new Event('scroll')); } } }; const getVirtualizedBody = () => base.$('[data-hook="virtual-inner-element"]'); const isTableVirtualized = () => getVirtualizedBody().exists(); const getRows = () => base.$$('tbody tr[data-table-row="dataTableRow"]'); const getAverageRowHeight = async (): Promise => { const visibleRow = await (await getRows()).get(0); return (await visibleRow.attr('style')) ?.split('height: ')[1] .split('px')[0]; }; const _getRowByKey = (rowKey: string) => base.$(`tr[data-hook="table-row-${rowKey}"]`); const getRenderedItemIndexAt = async (i: number) => { const row = getRows().get(i); const dataIndexAttr = await row.attr('data-index'); const index = Number(dataIndexAttr?.replace(/^index-/, '')); return Number.isInteger(index) ? index : -1; }; const getRenderedItemKeyAt = async (i: number) => { const row = getRows().get(i); const dataHookAttr = await row.attr('data-hook'); return dataHookAttr?.split('table-row-')[1]; }; const getRenderedItemIndexByKey = async (rowKey: string) => { const row = _getRowByKey(rowKey); const dataIndexAttr = await row.attr('data-index'); const index = Number(dataIndexAttr?.replace(/^index-/, '')); return Number.isInteger(index) ? index : -1; }; const getLastRenderedItemIndex = async () => getRenderedItemIndexAt((await getRows().count()) - 1); const getLastRenderedItemKey = async () => getRenderedItemKeyAt((await getRows().count()) - 1); const getFirstRenderedItemKey = async () => getRenderedItemKeyAt(0); const getFirstRenderedItemIndex = async () => getRenderedItemIndexAt(0); // works for both virtual & non-virtual tables with WDS >= 1.81.0 const getRowByIndex = (index: number) => base.$(`tr[data-index="index-${index}"]`); const getVirtualizedRowsCount = async () => { if (await isTableVirtualized()) { const str = await getVirtualizedBody().attr('data-count'); return !isNaN(Number(str)) ? Number(str) : 0; } return 0; }; const getRenderedRowsCount = async () => (await isTableVirtualized()) ? getVirtualizedBody().$$(`tr[data-table-row="dataTableRow"]`).count() : collectionTable.getRowsCount(); const getRowCheckbox = (index: number) => { return getRowByIndex(index) ?.$('[data-hook="bulk-selection-cell"]') ?.$('[data-hook="row-select"]'); }; const getRowKey = async (rowIndex: number, dataHook = 'table-row') => { const [attr] = await getRowsKeys([rowIndex], dataHook); return attr; }; const getRowsKeys = async (rowsIndexes: number[], dataHook = 'table-row') => { return Promise.all( rowsIndexes.map(async (rowIndex) => { const dataHookAttrValue = await ( await getRowByIndex(rowIndex) ).attr('data-hook'); return dataHookAttrValue?.split(`${dataHook}-`)[1]; }), ); }; const getDragAndDrop = () => DragAndDropUniDriver(base); const getNavigationModalBase = () => body.$('[data-hook="navigation-modal"]'); const getNavigationModal = () => ({ ...MessageModalLayoutUniDriver(getNavigationModalBase(), body), getContent: () => getNavigationModalBase().$('[data-hook="action-modal-content"]'), }); const getCustomFieldModal = () => CustomFieldModalUniDriver(base, body); const getArchiveFieldModal = () => ArchiveFieldModalUniDriver(base, body); const getManageTagsModal = () => ManageTagsModalUniDriver(base, body); const getTagsAssignButton = () => TagsAssignButtonUniDriver(base, body); const scrollDownUntil = ( fn: () => Promise, { timeout = 7_000, delay = 1_000, scrollableElementDataHook = 'page-scrollable-content', top, }: { timeout?: number; delay?: number; scrollableElementDataHook?: string; top?: number; } = {}, ) => { return waitFor( async () => { if (await fn()) { return true; } await scrollDown(scrollableElementDataHook, { top }); return fn(); }, timeout, delay, ); }; const scrollUpUntil = async ( fn: () => Promise, { timeout = 5_000, delay = 1_000, scrollableElementDataHook = 'page-scrollable-content', }: { timeout?: number; delay?: number; scrollableElementDataHook?: string; } = {}, ) => { return waitFor( async () => { if (await fn()) { return true; } await scrollUp(scrollableElementDataHook); return fn(); }, timeout, delay, ); }; const scrollUpToItem = ( index: number, options: { timeout?: number; delay?: number; scrollableElementDataHook?: string; } = {}, ) => scrollUpUntil(() => getRowByIndex(index).exists(), options); const scrollUpToItemByKey = ( rowKey: string, options: { timeout?: number; delay?: number; scrollableElementDataHook?: string; } = {}, ) => scrollUpUntil(async () => _getRowByKey(rowKey).exists(), options); const scrollDownToItem = ( index: number, options: { timeout?: number; delay?: number; scrollableElementDataHook?: string; top?: number; } = {}, ) => scrollDownUntil(async () => { await isTableVirtualized(); return (await getRowByIndex(index)).exists(); }, options); const scrollDownToItemByKey = ( rowKey: string, options: { timeout?: number; delay?: number; scrollableElementDataHook?: string; top?: number; } = {}, ) => scrollDownUntil(async () => _getRowByKey(rowKey).exists(), options); return { ...collectionTable, /** * Wait until root element to exist * @param timeout - time to wait until bailing */ wait: (timeout?: number) => base.wait(timeout), /** Wait for the initial fetch loader to be shown */ waitInitialLoader: (timeout?: number) => initialLoaderBase().wait(timeout), /** Wait for the initial fetch loader to be removed */ waitInitialLoaderRemoved: (timeout?: number, delay?: number) => waitFor( async () => !(await initialLoaderBase().exists()), timeout, delay, ), /** Indicates whether initial loader is in loading status */ isInitialLoaderLoading: () => initialLoader().exists(), /** Indicates whether initial loader exists */ initialLoaderExists: () => initialLoader().exists(), /** Wait for the infinite scroll loader to be shown */ waitInfiniteScrollLoader: (timeout?: number) => infiniteScrollLoaderBase().wait(timeout), /** Indicates whether infinite scroll loader is in loading status */ isInfiniteScrollLoaderLoading: () => infiniteScrollLoader().isLoading(), /** Indicates whether infinite scroll loader exists */ infiniteScrollLoaderExists: () => infiniteScrollLoader().exists(), getInfiniteScrollLoaderStatusMessage: () => infiniteScrollLoader().getStatusMessage(), getErrorStateByDataHook, /** Get initial error state before table renders */ getCardErrorStateByDataHook: (dataHook = 'collection-card-error-state') => getErrorStateByDataHook(dataHook), getTableDefaultNoResultsState: () => getNoResultsStateByDataHook(), getTableDefaultEmptyState: () => getEmptyStateByDataHook(), /** Get empty state within the table that by a specific data hook */ getEmptyStateByDataHook, /** Get premium empty state within the table that by a specific data hook */ getPremiumEmptyStateByDataHook, /** Get no results state within the table that by a specific data hook */ getNoResultsStateByDataHook, viewsToolbarItemLabelExists: () => viewsToolbarItem().$('span').exists(), getEmptyStateClearFiltersButton: (emptyStateDataHook: string) => TextButtonUniDriver( base .$(`[data-hook="${emptyStateDataHook}"]`) .$('[data-hook="empty-state-clear-filters-button"]'), body, ), /** * Triggers "scroll" event on the scroll element. For the virtual table by default scrolls down to the not rendered items. * @param scrollableElementDataHook - The data hook of the scrollable element (Optional, defaults to "page-scrollable-content"). */ scrollDown, scrollHorizontally: async (options: { page?: Page } = {}) => { const tableContent = base.$('[data-hook="table-content"]'); const tableContentElement = await tableContent.getNative(); // puppeteer if (isSled2(tableContentElement)) { const page = options.page ?? tableContentElement.page; page.evaluate((_dataHook: string) => { const scrollableElement = document.querySelector( `[data-hook="${_dataHook}"]`, )!; scrollableElement.scrollBy({ left: scrollableElement.scrollWidth, }); }, 'table-content'); } // playwright else if (isSled3(tableContentElement)) { const locator = tableContentElement as { page: () => { evaluate: ( fn: (dataHook: string) => void, dataHook: string, ) => Promise; }; }; await locator.page().evaluate((dataHook) => { const scrollableElement = document.querySelector( `[data-hook="${dataHook}"]`, )!; scrollableElement.scrollBy({ left: scrollableElement.scrollWidth, }); }, 'table-content'); } }, /** * Triggers "scroll" event on the scroll element, until item exists * @param index * @param timeout - how much to wait for item exists * @param delay - how much to wait between each check * @param scrollableElementDataHook */ scrollDownToItem, scrollDownToItemByKey, scrollDownTo: (row: UniDriver) => scrollDownUntil(async () => row.exists()), scrollUpTo: (row: UniDriver) => scrollUpUntil(async () => row.exists()), /** * Triggers "scroll" up event on the scroll element. * @param scrollableElementDataHook - The data hook of the scrollable element (Optional, defaults to "page-scrollable-content"). */ scrollUp, /** * Triggers "scroll" up event on the scroll element, until item exists * @param index * @param timeout - how much to wait for item exists * @param delay - how much to wait between each check * @param scrollableElementDataHook */ scrollUpToItem, scrollUpToItemByKey, /** * Triggers "scroll" event on the scroll element to the top. * @param scrollableElementDataHook - The data hook of the scrollable element (Optional, defaults to "page-scrollable-content"). */ scrollToTop: async ({ scrollableElementDataHook = 'page-scrollable-content', }: { scrollableElementDataHook?: string; } = {}) => { await scrollUp(scrollableElementDataHook, { top: 0 }); }, hoverRowByIndex: (index: number) => { return getRowByIndex(index).hover(); }, getRowByIndex, getCellTextValue: (index: number, col: number) => getRowByIndex(index).$$('td').get(col).text(), _getRowByKey, _getCellByKey: (rowKey: string, cellDataHook: string) => { return _getRowByKey(rowKey).$(`td[data-hook="${cellDataHook}"]`); }, _getCellByRowIndexAndCellKey: (rowIndex: number, cellDataHook: string) => { return getRowByIndex(rowIndex).$(`td[data-hook="${cellDataHook}"]`); }, /** Gets a driver by row number and column index */ getCellDriverByIndex: (rowNum: number, col: number) => { return baseUniDriverFactory( collectionTable.getRow(rowNum).$$('td').get(col), ); }, /** Gets a driver by row number and column data-hook */ getCellDriverByDataHook: (rowNum: number, dataHook: string) => { return baseUniDriverFactory( collectionTable.getRow(rowNum).$(`td[data-hook="${dataHook}"]`), ); }, clickRowCheckbox: async (index: number) => { const checkbox = await getRowCheckbox(index); await checkbox.click(); }, /** * Gets number of rendered rows (not all items are rendered for virtualized table) */ getRenderedRowsCount, /** * Gets number of loaded items for virtualized table (not all items are rendered) */ getVirtualizedRowsCount, /** Gets number of loaded items for both virtual and non-virtual table */ getRowsCount: async (): Promise => (await isTableVirtualized()) ? getVirtualizedRowsCount() : collectionTable.getRowsCount(), getVirtualizedBody, isTableVirtualized, getLastRenderedItemIndex, getLastRenderedItemKey, getFirstRenderedItemKey, getFirstRenderedItemIndex, getRenderedItemIndexAt, getRenderedItemIndexByKey, /** Get the row key according to the `itemKey` function */ getRowKey, /** Get multiple rows keys according to the `itemKey` function */ getRowsKeys, /** * Drags an element over another element. * @param {number} sourceIndex * @param {number} targetIndex * @param [options] * @param {number} [options.steps] number of movement steps (only for puppeteer) */ dragOver: async ( sourceIndex: number, targetIndex: number, options: { steps?: number } = {}, ) => getDragAndDrop().dragOver( getRowByIndex(sourceIndex), getRowByIndex(targetIndex), options, ), dragStart: async (index: number) => getDragAndDrop().dragStart(getRowByIndex(index)), dragMove: () => getDragAndDrop().dragMove(), dragEnd: async (index: number) => getDragAndDrop().dragEnd(getRowByIndex(index)), _getDragAndDropDriver: getDragAndDrop, getNavigationModal, getCustomFieldModal, getArchiveFieldModal, getManageTagsModal, getTagsAssignButton, getInternalScrollElement: () => base.$('[data-hook="toolbar-collection-content-auto-sizer"]'), internalScrollDownToItem: ( index: number, options: { timeout?: number; delay?: number; } = {}, ) => scrollDownToItem(index, { ...options, scrollableElementDataHook: 'toolbar-collection-content-auto-sizer', }), getSectionHeadersCount: () => base.$$('[data-hook="table-section-header"]').count(), getSectionHeaderAt: (index: number) => CollectionSectionHeaderUniDriver( base.$$('[data-hook="table-section-header"]').get(index), body, ), /** Get column indices that are currently searchable */ getSearchableColumns: async () => { const searchableColumnIndices: number[] = []; // Get all data rows and check the first one const rows = base.$$('tbody tr'); const rowCount = await rows.count(); if (rowCount === 0) { return searchableColumnIndices; } const firstRow = rows.get(0); const cells = firstRow.$$('td'); const cellCount = await cells.count(); // Check each cell to see if it contains SearchableCollectionContent for (let columnIndex = 0; columnIndex < cellCount; columnIndex++) { const cell = cells.get(columnIndex); const searchableContent = cell.$( '[data-hook="searchable-collection-content"]', ); if (await searchableContent.exists()) { searchableColumnIndices.push(columnIndex); } } return searchableColumnIndices; }, }; } export default TableUniDriver;