diff --git a/web/packages/design/src/DataTable/useTable.ts b/web/packages/design/src/DataTable/useTable.ts index e8c70f3bd2dd0..0a44dd038cce8 100644 --- a/web/packages/design/src/DataTable/useTable.ts +++ b/web/packages/design/src/DataTable/useTable.ts @@ -28,76 +28,31 @@ import { PagedTableProps, } from './types'; -export default function useTable({ - data, - columns, - pagination, - showFirst, - clientSearch, - searchableProps, - customSearchMatchers = [], - serversideProps, - fetching, - customSort, - disableFilter = false, - ...props -}: TableProps) { - const [state, setState] = useState<{ - data: T[]; - searchValue: string; - sort?: Sort; - pagination?: Pagination; - }>(() => { - // Finds the first sortable column to use for the initial sorting - let col: TableColumn | undefined; - if (!customSort) { - const { initialSort } = props; - if (initialSort) { - col = initialSort.altSortKey - ? columns.find(col => col.altSortKey === initialSort.altSortKey) - : columns.find(col => col.key === initialSort.key); - } else { - col = columns.find(column => column.isSortable); - } - } - - return { - data: [], - searchValue: clientSearch?.initialSearchValue || '', - sort: col - ? { - key: (col.altSortKey || col.key) as keyof T, - onSort: col.onSort, - dir: props.initialSort?.dir || 'ASC', - } - : undefined, - pagination: pagination - ? { - paginatedData: paginateData([], pagination.pageSize), - currentPage: 0, - pagerPosition: pagination.pagerPosition, - pageSize: pagination.pageSize || 15, - CustomTable: pagination.CustomTable, - } - : undefined, - }; - }); +type TableState = { + data: T[]; + searchValue: string; + sort?: Sort; + pagination?: Pagination; +}; - function searchAndFilterCb( - targetValue: any, - searchValue: string, - propName: keyof T & string - ) { - for (const matcher of customSearchMatchers) { - const isMatched = matcher(targetValue, searchValue, propName); - if (isMatched) { - return true; - } - } +export default function useTable(props: TableProps) { + const { + data, + columns, + pagination, + showFirst, + clientSearch, + searchableProps, + customSearchMatchers = [], + serversideProps, + fetching, + customSort, + disableFilter = false, + } = props; - // No match found. - return false; - } + const [state, setState] = useState>(() => + getInitialState(props) + ); const updateData = ( sort: Sort | undefined, @@ -115,7 +70,7 @@ export default function useTable({ (columns .filter(column => column.key) .map(column => column.key) as (keyof T & string)[]), - searchAndFilterCb, + searchAndFilterCb(customSearchMatchers), showFirst ); if (pagination && !serversideProps) { @@ -224,7 +179,6 @@ export default function useTable({ return { state, - columns, setState, setSearchValue, onSort, @@ -238,6 +192,85 @@ export default function useTable({ }; } +const getInitialState = (props: TableProps): TableState => { + // Determine the initial sort + let initialSort: Sort | undefined; + if (!props.customSort) { + // Finds the first sortable column to use for the initial sorting + let col: TableColumn | undefined; + if (props.initialSort) { + col = props.initialSort.altSortKey + ? props.columns.find( + col => col.altSortKey === props.initialSort?.altSortKey + ) + : props.columns.find(col => col.key === props.initialSort?.key); + } + col ||= props.columns.find(column => column.isSortable); + if (col) { + initialSort = { + key: (col.altSortKey || col.key) as keyof T, + onSort: col.onSort, + dir: props.initialSort?.dir || 'ASC', + }; + } + } + + // Compute the initial data + const initialSearchValue = props.clientSearch?.initialSearchValue || ''; + let initialData: T[]; + if (props.serversideProps || props.disableFilter || !props.data?.length) { + initialData = props.data || []; + } else { + initialData = sortAndFilter( + props.data, + initialSearchValue, + initialSort, + props.searchableProps || + (props.columns + .filter(column => column.key) + .map(column => column.key) as (keyof T & string)[]), + searchAndFilterCb(props.customSearchMatchers), + props.showFirst + ); + } + + // Compute initial pagination if applicable + let initialPagination: Pagination | undefined; + if (props.pagination) { + const pages = paginateData(initialData, props.pagination.pageSize); + initialPagination = { + paginatedData: pages, + currentPage: 0, + pagerPosition: props.pagination.pagerPosition, + pageSize: props.pagination.pageSize || 15, + CustomTable: props.pagination.CustomTable, + }; + } + + return { + data: initialData, + searchValue: initialSearchValue, + sort: initialSort, + pagination: initialPagination, + }; +}; + +const searchAndFilterCb = + (matchers: TableProps['customSearchMatchers']) => + (targetValue: any, searchValue: string, propName: keyof T & string) => { + if (!matchers?.length) { + return false; + } + for (const matcher of matchers) { + const isMatched = matcher(targetValue, searchValue, propName); + if (isMatched) { + return true; + } + } + // No match found. + return false; + }; + function sortAndFilter( data: T[] = [], searchValue = '', diff --git a/web/packages/design/src/utils/testing.tsx b/web/packages/design/src/utils/testing.tsx index 2158692911414..6a133a97b605a 100644 --- a/web/packages/design/src/utils/testing.tsx +++ b/web/packages/design/src/utils/testing.tsx @@ -50,6 +50,16 @@ function render( return testingRender(ui, { wrapper: Providers, ...options }); } +/* + Returns a Promise resolving on the next macrotask, allowing any pending state + updates / timeouts to finish. + */ +function tick() { + return new Promise(res => + jest.requireActual('timers').setImmediate(res) + ); +} + screen.debug = () => { window.console.log(prettyDOM()); }; @@ -64,6 +74,7 @@ export { screen, fireEvent, darkTheme as theme, + tick, render, prettyDOM, waitFor, diff --git a/web/packages/teleport/src/JoinTokens/JoinTokens.test.tsx b/web/packages/teleport/src/JoinTokens/JoinTokens.test.tsx index 8b0962faabd7d..e4e1aacbb9391 100644 --- a/web/packages/teleport/src/JoinTokens/JoinTokens.test.tsx +++ b/web/packages/teleport/src/JoinTokens/JoinTokens.test.tsx @@ -15,7 +15,7 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ -import { render, screen, fireEvent } from 'design/utils/testing'; +import { render, screen, fireEvent, act, tick } from 'design/utils/testing'; import userEvent from '@testing-library/user-event'; import selectEvent from 'react-select-event'; @@ -39,6 +39,13 @@ describe('JoinTokens', () => { test('edit dialog opens with values', async () => { const token = tokens[0]; render(); + + // DataTable re-renders before `userEvent.click` is fired, so `act(tick)` + // is used to wait for re-renders to complete. + // This wasn't an issue prior, as DataTable used to always mount with empty data, + // so `findAllByText` would wait a few ms before finding the text on commit 1. + await act(tick); + const optionButtons = await screen.findAllByText(/options/i); await userEvent.click(optionButtons[0]); const editButtons = await screen.findAllByText(/view\/edit/i);