From 634cda78a7977e708249ba3f0b8ec3bdaaabb14b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Fri, 23 Oct 2026 12:02:10 -0400 Subject: [PATCH] fix(mosaic): disable table pagination while a page loads --- .changeset/mosaic-lock-pagination-while-loading.md | 2 ++ .../api-keys/__tests__/api-keys-table.feature.test.tsx | 2 ++ .../mosaic/src/features/api-keys/api-keys-table.view.tsx | 1 + .../__tests__/invitations-table-tab.view.test.tsx | 6 ++++++ .../__tests__/members-table-tab.view.test.tsx | 6 ++++++ .../__tests__/requests-table-tab.view.test.tsx | 6 ++++++ .../invitations-table-tab.view.tsx | 1 + .../members-table-tab.view.tsx | 1 + .../requests-table-tab.view.tsx | 1 + packages/mosaic/src/hooks/use-server-data-table.ts | 5 ++++- packages/swingset/src/stories/use-server-data-table.mdx | 2 ++ 11 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 .changeset/mosaic-lock-pagination-while-loading.md diff --git a/.changeset/mosaic-lock-pagination-while-loading.md b/.changeset/mosaic-lock-pagination-while-loading.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-lock-pagination-while-loading.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.feature.test.tsx b/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.feature.test.tsx index 8ab2785e764..ba9b7a8c73f 100644 --- a/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.feature.test.tsx +++ b/packages/mosaic/src/features/api-keys/__tests__/api-keys-table.feature.test.tsx @@ -359,6 +359,8 @@ describe('APIKeysTable', () => { await user.click(screen.getByRole('button', { name: 'Next API keys page' })); await waitFor(() => expect(table()).toHaveAttribute('aria-busy', 'true')); expect(within(table()).getByText('Key 1')).toBeVisible(); + expect(screen.getByRole('button', { name: 'Previous API keys page' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Next API keys page' })).toBeDisabled(); next.release(); expect(await within(await loadedTable()).findByText('Key 11')).toBeVisible(); expect(within(table()).queryByText('Key 1')).toBeNull(); diff --git a/packages/mosaic/src/features/api-keys/api-keys-table.view.tsx b/packages/mosaic/src/features/api-keys/api-keys-table.view.tsx index 50b680bd28f..a6c1fee8b94 100644 --- a/packages/mosaic/src/features/api-keys/api-keys-table.view.tsx +++ b/packages/mosaic/src/features/api-keys/api-keys-table.view.tsx @@ -59,6 +59,7 @@ export function APIKeysTableView({ onPageSizeChange, searchValue, onSearchChange, + isFetching, }); const columnCount = 3 + Number(Boolean(revoke)) + Number(Boolean(onBulkAction)); const query = searchValue.trim(); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/invitations-table-tab.view.test.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/invitations-table-tab.view.test.tsx index db1f0a4e079..a0fc791a8de 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/invitations-table-tab.view.test.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/invitations-table-tab.view.test.tsx @@ -101,6 +101,12 @@ describe('InvitationsTableTabView', () => { expect(screen.getAllByRole('button', { name: 'Try again' })).toHaveLength(1); }); + it('locks paging while a page loads', () => { + renderView({ totalCount: 25, page: 2, isFetching: true }); + expect(screen.getByRole('button', { name: 'Previous invitations page' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Next invitations page' })).toBeDisabled(); + }); + it('forwards paging without search, sorting, or page-size controls', async () => { const user = userEvent.setup(); const { props } = renderView({ totalCount: 21, onBulkAction: vi.fn() }); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/members-table-tab.view.test.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/members-table-tab.view.test.tsx index b8556b3e2a3..e91856a512e 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/members-table-tab.view.test.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/members-table-tab.view.test.tsx @@ -154,6 +154,12 @@ describe('MembersTableTabView', () => { expect(screen.queryByRole('combobox', { name: /^Results per page/ })).toBeNull(); expect(screen.queryByRole('button', { name: 'Joined' })).toBeNull(); }); + it('locks paging while a page loads', () => { + renderView({ totalCount: 25, page: 2, isFetching: true }); + expect(screen.getByRole('button', { name: 'Previous members page' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Next members page' })).toBeDisabled(); + }); + it('does not retain hidden selection for protected members within a Shift-click range', async () => { const user = userEvent.setup(); const member = propsFor().members[1]; diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/requests-table-tab.view.test.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/requests-table-tab.view.test.tsx index 85e2507908a..a980799e75a 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/requests-table-tab.view.test.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/__tests__/requests-table-tab.view.test.tsx @@ -84,6 +84,12 @@ describe('RequestsTableTabView', () => { }, ); + it('locks paging while a page loads', () => { + renderView({ totalCount: 25, page: 2, isFetching: true }); + expect(screen.getByRole('button', { name: 'Previous requests page' })).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Next requests page' })).toBeDisabled(); + }); + it('does not report requests as loaded when they fail to load', () => { renderView({ isError: true, onRetry: vi.fn() }); expect(screen.getByRole('alert')).toHaveTextContent('Unable to load requests'); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.view.tsx index 0e4ce253882..dbd77035c2d 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/invitations-table-tab.view.tsx @@ -50,6 +50,7 @@ export function InvitationsTableTabView({ onPageChange, searchValue: '', onSearchChange: () => {}, + isFetching, }); const columnCount = 3 + Number(Boolean(revoke)) + Number(Boolean(onBulkAction)); return ( diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.view.tsx index b2990eda10d..b8228e4f24b 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/members-table-tab.view.tsx @@ -64,6 +64,7 @@ export function MembersTableTabView({ onPageChange, searchValue, onSearchChange, + isFetching, }); const columnCount = 3 + Number(Boolean(remove)) + Number(Boolean(onBulkAction)); const query = searchValue.trim(); diff --git a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/requests-table-tab.view.tsx b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/requests-table-tab.view.tsx index 0b45bc9b6ef..49068db0ee6 100644 --- a/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/requests-table-tab.view.tsx +++ b/packages/mosaic/src/features/organization-profile/organization-profile-members-panel/requests-table-tab.view.tsx @@ -54,6 +54,7 @@ export function RequestsTableTabView({ onPageChange, searchValue: '', onSearchChange: () => {}, + isFetching, }); const hasActions = Boolean(onAccept || onDecline); const columnCount = 2 + Number(hasActions) + Number(Boolean(onBulkAction)); diff --git a/packages/mosaic/src/hooks/use-server-data-table.ts b/packages/mosaic/src/hooks/use-server-data-table.ts index 6c160c5b24f..617df677cd3 100644 --- a/packages/mosaic/src/hooks/use-server-data-table.ts +++ b/packages/mosaic/src/hooks/use-server-data-table.ts @@ -8,7 +8,7 @@ export interface ServerDataTableSort { } export type ServerDataTablePagination = Required< - Pick + Pick > & Pick; @@ -26,6 +26,7 @@ export function useServerDataTable({ onPageSizeChange, searchValue, onSearchChange, + isFetching = false, }: { data: TData[]; totalCount: number; @@ -40,6 +41,7 @@ export function useServerDataTable({ onPageSizeChange?: (pageSize: number) => void; searchValue: string; onSearchChange: (value: string) => void; + isFetching?: boolean; }) { const sorting: SortingState = sort ? [{ id: sort.column, desc: sort.direction === 'descending' }] : []; const pagination: PaginationState = { pageIndex: page - 1, pageSize }; @@ -97,6 +99,7 @@ export function useServerDataTable({ page: table.pagination.pageIndex + 1, pageSize: table.pagination.pageSize, totalItems: totalCount, + disabled: isFetching, onChange: next => table.setPagination(current => ({ ...current, pageIndex: next - 1 })), onPageSizeChange: onPageSizeChange ? next => table.setPagination({ pageIndex: 0, pageSize: next }) : undefined, } diff --git a/packages/swingset/src/stories/use-server-data-table.mdx b/packages/swingset/src/stories/use-server-data-table.mdx index 28778e64729..ec103f83815 100644 --- a/packages/swingset/src/stories/use-server-data-table.mdx +++ b/packages/swingset/src/stories/use-server-data-table.mdx @@ -74,6 +74,7 @@ const { table, sortHeader, pagination } = useServerDataTable({ | `onPageSizeChange` | `(pageSize: number) => void` | — | Called with the next page size, followed by `onPageChange(1)`. Without it, `Pagination` has no page size control | | `searchValue` | `string` | — (required) | Current search query | | `onSearchChange` | `(value: string) => void` | — (required) | Called with the next search query | +| `isFetching` | `boolean` | `false` | Whether a page is loading. While `true`, `Pagination` is disabled | ## Return @@ -97,6 +98,7 @@ type ServerDataTablePagination = { page: number; pageSize: number; totalItems: number; + disabled: boolean; onChange: (page: number) => void; onPageSizeChange?: (pageSize: number) => void; };