Skip to content

Commit

Permalink
fix: Set initial data in useTable state
Browse files Browse the repository at this point in the history
* Fix flickering between 'No data' state on first render, before
  `updateData` useEffect fires.
  • Loading branch information
kiosion committed Nov 15, 2024
1 parent bb8c2f3 commit 9a0951d
Show file tree
Hide file tree
Showing 3 changed files with 122 additions and 71 deletions.
173 changes: 103 additions & 70 deletions web/packages/design/src/DataTable/useTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,76 +28,31 @@ import {
PagedTableProps,
} from './types';

export default function useTable<T>({
data,
columns,
pagination,
showFirst,
clientSearch,
searchableProps,
customSearchMatchers = [],
serversideProps,
fetching,
customSort,
disableFilter = false,
...props
}: TableProps<T>) {
const [state, setState] = useState<{
data: T[];
searchValue: string;
sort?: Sort<T>;
pagination?: Pagination<T>;
}>(() => {
// Finds the first sortable column to use for the initial sorting
let col: TableColumn<T> | 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<T> = {
data: T[];
searchValue: string;
sort?: Sort<T>;
pagination?: Pagination<T>;
};

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<T>(props: TableProps<T>) {
const {
data,
columns,
pagination,
showFirst,
clientSearch,
searchableProps,
customSearchMatchers = [],
serversideProps,
fetching,
customSort,
disableFilter = false,
} = props;

// No match found.
return false;
}
const [state, setState] = useState<TableState<T>>(() =>
getInitialState(props)
);

const updateData = (
sort: Sort<T> | undefined,
Expand All @@ -115,7 +70,7 @@ export default function useTable<T>({
(columns
.filter(column => column.key)
.map(column => column.key) as (keyof T & string)[]),
searchAndFilterCb,
searchAndFilterCb(customSearchMatchers),
showFirst
);
if (pagination && !serversideProps) {
Expand Down Expand Up @@ -224,7 +179,6 @@ export default function useTable<T>({

return {
state,
columns,
setState,
setSearchValue,
onSort,
Expand All @@ -238,6 +192,85 @@ export default function useTable<T>({
};
}

const getInitialState = <T>(props: TableProps<T>): TableState<T> => {
// Determine the initial sort
let initialSort: Sort<T> | undefined;
if (!props.customSort) {
// Finds the first sortable column to use for the initial sorting
let col: TableColumn<T> | 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<T> | 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 =
<T>(matchers: TableProps<T>['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<T>(
data: T[] = [],
searchValue = '',
Expand Down
11 changes: 11 additions & 0 deletions web/packages/design/src/utils/testing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>(res =>
jest.requireActual('timers').setImmediate(res)
);
}

screen.debug = () => {
window.console.log(prettyDOM());
};
Expand All @@ -64,6 +74,7 @@ export {
screen,
fireEvent,
darkTheme as theme,
tick,
render,
prettyDOM,
waitFor,
Expand Down
9 changes: 8 additions & 1 deletion web/packages/teleport/src/JoinTokens/JoinTokens.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
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';
Expand All @@ -39,6 +39,13 @@ describe('JoinTokens', () => {
test('edit dialog opens with values', async () => {
const token = tokens[0];
render(<Component />);

// 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);
Expand Down

0 comments on commit 9a0951d

Please sign in to comment.