From d7fa8ada3407ea00a2f9140af96d6c12cc91d34c Mon Sep 17 00:00:00 2001 From: Sebastien Powell Date: Wed, 21 Oct 2026 09:15:29 +0000 Subject: [PATCH] feat(editor): Add an All option to pagination and remove the custom datatable page-size controls (#39850) Co-authored-by: Cursor --- .../N8nDataTableServer.stories.ts | 120 ++++++++++++++++++ .../N8nDataTableServer.test.ts | 60 +++++++-- .../N8nDataTableServer/N8nDataTableServer.vue | 114 ++++++++--------- .../N8nDatatable/Datatable.stories.ts | 23 +++- .../components/N8nDatatable/Datatable.test.ts | 72 ++++++++--- .../src/components/N8nDatatable/Datatable.vue | 78 +++--------- .../__snapshots__/Datatable.test.ts.snap | 47 ++----- .../N8nPagination/Pagination.stories.ts | 27 ++++ .../N8nPagination/Pagination.test.ts | 24 ++++ .../N8nPagination/Pagination.types.ts | 12 ++ .../components/N8nPagination/Pagination.vue | 30 ++++- .../N8nPagination/component-pagination.md | 3 +- .../src/components/N8nPagination/index.ts | 8 +- .../design-system/src/components/index.ts | 2 +- .../frontend/@n8n/design-system/src/index.ts | 7 +- .../@n8n/design-system/src/locale/lang/en.ts | 2 +- .../chatHub/components/ChatProvidersTable.vue | 9 +- .../gateway/views/SettingsAiGatewayView.vue | 9 +- .../features/roles/components/RolesTable.vue | 6 - 19 files changed, 434 insertions(+), 219 deletions(-) create mode 100644 packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.stories.ts diff --git a/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.stories.ts new file mode 100644 index 00000000000..269e208d215 --- /dev/null +++ b/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.stories.ts @@ -0,0 +1,120 @@ +import type { StoryFn } from '@storybook/vue3-vite'; +import { computed, ref, type Component } from 'vue'; + +import { PAGINATION_ALL_ITEMS_PER_PAGE } from '../N8nPagination'; +import N8nDataTableServer, { type TableHeader, type TableOptions } from './N8nDataTableServer.vue'; + +type Person = { + id: string; + name: string; + role: string; +}; + +const roles = ['Admin', 'Member', 'Viewer']; + +const people: Person[] = Array.from({ length: 48 }, (_, index) => { + const label = String(index + 1).padStart(2, '0'); + return { + id: label, + name: `Person ${label}`, + role: roles[index % roles.length], + }; +}); + +const headers: Array> = [ + { title: 'Name', key: 'name' }, + { title: 'Role', key: 'role' }, +]; + +function sortedPeople(rows: Person[], sortBy: TableOptions['sortBy']) { + if (sortBy.length === 0) return rows; + + return rows.slice().sort((a, b) => { + for (const sort of sortBy) { + const key = sort.id as keyof Person; + const diff = String(a[key]).localeCompare(String(b[key])); + if (diff !== 0) return sort.desc ? -diff : diff; + } + return 0; + }); +} + +function formatSort(sortBy: TableOptions['sortBy']) { + if (sortBy.length === 0) return 'none'; + return sortBy.map((sort) => `${sort.id} ${sort.desc ? 'descending' : 'ascending'}`).join(', '); +} + +export default { + title: 'Core/DataTableServer', + component: N8nDataTableServer, + parameters: { + docs: { + description: { + component: + 'Renders one page of rows. The parent supplies that page and the total count. Page, page size, and sort changes emit update:options. Page is 0-indexed.', + }, + }, + }, +}; + +export const Default: StoryFn = () => ({ + // The generic table is not assignable to Storybook's component type. + components: { N8nDataTableServer: N8nDataTableServer as unknown as Component }, + setup() { + const page = ref(0); + const itemsPerPage = ref(10); + const sortBy = ref([]); + const latest = ref({ + page: page.value, + itemsPerPage: itemsPerPage.value, + sortBy: [], + }); + const pageItems = computed(() => { + const sorted = sortedPeople(people, sortBy.value); + if (itemsPerPage.value === PAGINATION_ALL_ITEMS_PER_PAGE) return sorted; + + const start = page.value * itemsPerPage.value; + return sorted.slice(start, start + itemsPerPage.value); + }); + const pageSizeLabel = computed(() => + latest.value.itemsPerPage === PAGINATION_ALL_ITEMS_PER_PAGE + ? 'all' + : `${latest.value.itemsPerPage} per page`, + ); + const sortLabel = computed(() => formatSort(latest.value.sortBy)); + + function onOptions(payload: TableOptions) { + latest.value = payload; + } + + return { + headers, + page, + itemsPerPage, + sortBy, + pageItems, + total: people.length, + latest, + pageSizeLabel, + sortLabel, + onOptions, + }; + }, + template: ` +
+ +

+ update:options — page {{ latest.page }} (0-indexed), {{ pageSizeLabel }}, sort {{ sortLabel }} +

+
+ `, +}); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.test.ts index fca798e4bd6..3d242bbafdf 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.test.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.test.ts @@ -1,6 +1,7 @@ import userEvent from '@testing-library/user-event'; import { render, screen, waitFor, within } from '@testing-library/vue'; +import { PAGINATION_ALL_ITEMS_PER_PAGE } from '../N8nPagination'; import N8nDataTableServer, { type TableHeader } from './N8nDataTableServer.vue'; import { createComponentRenderer } from '../../__tests__/render'; @@ -9,7 +10,7 @@ const renderComponent = createComponentRenderer(N8nDataTableServer); const getRenderedOptions = async () => { const dropdown = await waitFor(() => screen.getByRole('listbox')); expect(dropdown).toBeInTheDocument(); - return dropdown.querySelectorAll('.el-select-dropdown__item'); + return dropdown.querySelectorAll('[data-test-id="select-item"]'); }; const itemFactory = () => ({ @@ -119,12 +120,12 @@ describe('N8nDataTableServer', () => { // account for the debounce await new Promise((r) => setTimeout(r, 100)); - const option50 = Array.from(options).find((option) => option.textContent === '50'); + const option50 = Array.from(options).find((option) => option.textContent === '50/page'); await userEvent.click(option50!); expect(emitted('update:options').length).toBe(4); expect(emitted('update:options').at(-1)).toStrictEqual([ - expect.objectContaining({ page: 1, itemsPerPage: 50 }), + expect.objectContaining({ page: 0, itemsPerPage: 50 }), ]); }); @@ -184,27 +185,66 @@ describe('N8nDataTableServer', () => { }); }); - it('should adjust page to highest available when page size changes and current page exceeds maximum', async () => { + it('should render one skeleton row per page size while the first page loads', () => { + const { container } = render(N8nDataTableServer, { + //@ts-expect-error testing-library errors due to header generics + props: { items: [], headers, itemsLength: 100, loading: true, itemsPerPage: 25 }, + }); + + expect(container.querySelectorAll('tbody tr').length).toBe(25); + }); + + it('should render a bounded skeleton while All is loading', () => { + const { container } = render(N8nDataTableServer, { + props: { + items: [], + //@ts-expect-error testing-library errors due to header generics + headers, + itemsLength: 100, + loading: true, + itemsPerPage: PAGINATION_ALL_ITEMS_PER_PAGE, + showAll: true, + }, + }); + + expect(container.querySelectorAll('tbody tr').length).toBe(10); + }); + + it('should return to the first page when the page size changes', async () => { const { emitted, findAllByRole } = renderComponent({ props: { items, headers, itemsLength: 106, itemsPerPage: 50, page: 2 }, }); - // change the page size select option - const selectInput = await findAllByRole('combobox'); // Find the select input + const selectInput = await findAllByRole('combobox'); await userEvent.click(selectInput[0]); const options = await getRenderedOptions(); expect(options.length).toBe(4); - const option100 = Array.from(options).find((option) => option.textContent === '100'); + const option100 = Array.from(options).find((option) => option.textContent === '100/page'); await userEvent.click(option100!); - // With 106 items and 50 per page, max page should be 2 (0-based index 1) - // Since we were on page 2, we should be adjusted to page 1 expect(emitted('update:options')).toEqual( expect.arrayContaining([ - expect.arrayContaining([expect.objectContaining({ page: 1, itemsPerPage: 100 })]), + expect.arrayContaining([expect.objectContaining({ page: 0, itemsPerPage: 100 })]), ]), ); }); + + it('should emit all items per page when All is selected', async () => { + const { emitted, findAllByRole } = renderComponent({ + props: { items, headers, itemsLength: 106, showAll: true }, + }); + + const selectInput = await findAllByRole('combobox'); + await userEvent.click(selectInput[0]); + + const options = await getRenderedOptions(); + const allOption = Array.from(options).find((option) => option.textContent === 'All'); + await userEvent.click(allOption!); + + expect(emitted('update:options').at(-1)).toStrictEqual([ + expect.objectContaining({ itemsPerPage: PAGINATION_ALL_ITEMS_PER_PAGE }), + ]); + }); }); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.vue b/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.vue index a15d6d46175..eba73946b2e 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nDataTableServer/N8nDataTableServer.vue @@ -39,7 +39,8 @@ import type { } from '@tanstack/vue-table'; import { createColumnHelper, FlexRender, getCoreRowModel, useVueTable } from '@tanstack/vue-table'; import { useThrottleFn } from '@vueuse/core'; -import { ElOption, ElSelect, ElSkeletonItem } from 'element-plus'; +import { ElSkeletonItem } from 'element-plus'; + // `.js` on purpose, unlike the extensionless form used elsewhere in the repo: // `lodash` is CJS with no `exports` map, so Node cannot resolve the extensionless // subpath once a consumer loads our `dist` as native ESM. Bundlers accept both. @@ -47,7 +48,7 @@ import get from 'lodash/get.js'; import { computed, h, shallowRef, useSlots, watch } from 'vue'; import N8nCheckbox from '../../v2/components/Checkbox/Checkbox.vue'; -import N8nPagination from '../N8nPagination'; +import N8nPagination, { PAGINATION_ALL_ITEMS_PER_PAGE } from '../N8nPagination'; type VueClass = string | string[] | Record | undefined; @@ -69,12 +70,25 @@ const props = withDefaults( itemSelectable?: boolean | DeepKeys | ((row: T) => boolean); pageSizes?: number[]; + /** + * Render the pager. Set this to false when the screen loads more rows itself. + * @default true + */ + pagination?: boolean; + /** + * Show an All option in the page size selector. + * Selecting it sets items per page to `PAGINATION_ALL_ITEMS_PER_PAGE`. + * @default false + */ + showAll?: boolean; rowProps?: { class?: VueClass } | ((row: T, index: number) => { class?: VueClass }); }>(), { itemSelectable: undefined, itemValue: 'id', pageSizes: () => [10, 25, 50, 100], + pagination: true, + showAll: false, rowProps: undefined, }, ); @@ -225,22 +239,36 @@ const page = defineModel('page', { default: 0 }); watch(page, () => table.setPageIndex(page.value)); const itemsPerPage = defineModel('items-per-page', { default: 10 }); -watch(itemsPerPage, () => table.setPageSize(itemsPerPage.value)); -const pagination = computed({ - get() { - return { - pageIndex: page.value, - pageSize: itemsPerPage.value, - }; - }, - set(newValue) { - page.value = newValue.pageIndex; - itemsPerPage.value = newValue.pageSize; - }, +const ALL_PAGE_SKELETON_ROWS = 10; + +const skeletonRowCount = computed(() => + itemsPerPage.value === PAGINATION_ALL_ITEMS_PER_PAGE + ? ALL_PAGE_SKELETON_ROWS + : itemsPerPage.value, +); + +watch(itemsPerPage, (value) => { + if (value === PAGINATION_ALL_ITEMS_PER_PAGE) { + void emitUpdateOptions({ + page: page.value, + itemsPerPage: value, + sortBy: sortBy.value, + }); + return; + } + + table.setPageSize(value); }); -const showPagination = computed(() => props.itemsLength > Math.min(...props.pageSizes)); +const paginationState = computed(() => ({ + pageIndex: page.value, + pageSize: itemsPerPage.value, +})); + +const showPagination = computed( + () => props.pagination && props.itemsLength > Math.min(...props.pageSizes), +); const sortBy = defineModel('sort-by', { default: [], required: false }); @@ -326,15 +354,6 @@ const emitUpdateOptions = useThrottleFn( 100, ); -function handlePageSizeChange(newPageSize: number) { - // Calculate the maximum available page (0-indexed) - const maxPage = Math.max(0, Math.ceil(props.itemsLength / newPageSize) - 1); - const newPage = Math.min(page.value, maxPage); - - page.value = newPage; - itemsPerPage.value = newPageSize; -} - const columnHelper = createColumnHelper(); const table = useVueTable({ data, @@ -351,7 +370,7 @@ const table = useVueTable({ return sortBy.value; }, get pagination() { - return pagination.value; + return paginationState.value; }, get rowSelection() { return rowSelection.value; @@ -361,7 +380,7 @@ const table = useVueTable({ onSortingChange: handleSortingChange, onPaginationChange(updaterOrValue) { const newValue = - typeof updaterOrValue === 'function' ? updaterOrValue(pagination.value) : updaterOrValue; + typeof updaterOrValue === 'function' ? updaterOrValue(paginationState.value) : updaterOrValue; // prevent duplicate events from being fired void emitUpdateOptions({ @@ -458,7 +477,7 @@ const table = useVueTable({ @@ -659,33 +668,10 @@ th.loading-row { } .table-pagination { - margin-top: 10px; + margin-top: var(--spacing--xs); display: flex; justify-content: flex-end; align-items: center; - - &__sizes { - display: flex; - - &__label { - color: var(--color--text); - background-color: var(--color--background--light-2); - border: 1px solid var(--color--foreground); - border-right: 0; - font-size: 12px; - display: flex; - align-items: center; - padding: 0 8px; - border-top-left-radius: var(--radius); - border-bottom-left-radius: var(--radius); - } - - &__select { - --input--radius--top-left: 0; - --input--radius--bottom-left: 0; - width: 70px; - } - } } .resizer { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nDatatable/Datatable.stories.ts b/packages/frontend/@n8n/design-system/src/components/N8nDatatable/Datatable.stories.ts index 9c22c643740..a084bba5d0e 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nDatatable/Datatable.stories.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nDatatable/Datatable.stories.ts @@ -1,4 +1,5 @@ import type { StoryFn } from '@storybook/vue3-vite'; +import { ref } from 'vue'; import { rows, columns } from './__tests__/data'; import N8nDatatable from './Datatable.vue'; @@ -10,19 +11,31 @@ export default { parameters: { docs: { description: { - component: 'A tabular data component for displaying rows, columns, and table interactions.', + component: + 'A tabular data component for displaying rows, columns, and table interactions. The footer is the shared pagination control. It sits on the right, with a top margin. Page sizes include All.', }, }, }, }; -export const Default: StoryFn = (args, { argTypes }) => ({ - setup: () => ({ args }), - props: Object.keys(argTypes), +export const Default: StoryFn = (args) => ({ + setup() { + const currentPage = ref(typeof args.currentPage === 'number' ? args.currentPage : 1); + const rowsPerPage = ref(typeof args.rowsPerPage === 'number' ? args.rowsPerPage : 10); + return { args, currentPage, rowsPerPage }; + }, components: { N8nDatatable, }, - template: '', + template: ` + + `, }); Default.args = { diff --git a/packages/frontend/@n8n/design-system/src/components/N8nDatatable/Datatable.test.ts b/packages/frontend/@n8n/design-system/src/components/N8nDatatable/Datatable.test.ts index fcf84d2e399..c58a1e3656a 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nDatatable/Datatable.test.ts +++ b/packages/frontend/@n8n/design-system/src/components/N8nDatatable/Datatable.test.ts @@ -1,16 +1,16 @@ -import { render } from '@testing-library/vue'; +import userEvent from '@testing-library/user-event'; +import { render, waitFor } from '@testing-library/vue'; +import { PAGINATION_ALL_ITEMS_PER_PAGE } from '../N8nPagination'; import { removeDynamicAttributes } from '../../utils'; import { rows, columns } from './__tests__/data'; import N8nDatatable from './Datatable.vue'; const stubs = [ - 'N8nOption', 'N8nButton', - // Ideally we'd like to stub N8nSelect & N8nPagination, but it doesn't work + // Ideally we'd like to stub N8nPagination, but it doesn't work // after migrating to setup script: // https://github.com/vuejs/vue-test-utils/issues/2048 - // 'n8n-select', // 'n8n-pagination', ]; @@ -77,25 +77,67 @@ describe('components', () => { expect(wrapper.container.querySelector('tbody td')?.textContent).toEqual('Row slot'); }); - it('should render all rows when rowsPerPage is set to -1', () => { + it('should use the default pagination control', () => { const wrapper = render(N8nDatatable, { - props: { columns, rows, rowsPerPage: -1 }, + props: { columns, rows, rowsPerPage }, global: { stubs }, }); - const pagination = wrapper.container.querySelector('.pagination'); - expect(pagination?.querySelector('[data-test-id="pagination-list"]')).toBeNull(); + expect(wrapper.getByTestId('pagination')).toBeInTheDocument(); + expect(wrapper.getByTestId('pagination-sizes')).toBeInTheDocument(); + expect(wrapper.container.querySelector('.pageSizeSelector')).toBeNull(); + }); - const pageSizeSelector = wrapper.container.querySelector('.pageSizeSelector'); - expect(pageSizeSelector?.textContent).toContain('Page size'); + it('should render every row when rowsPerPage is All', () => { + const wrapper = render(N8nDatatable, { + props: { columns, rows, rowsPerPage: PAGINATION_ALL_ITEMS_PER_PAGE }, + global: { stubs }, + }); - const allOption = wrapper.getByText('All'); - expect(allOption).not.toBeNull(); + expect(wrapper.getByRole('combobox')).toHaveTextContent('All'); + expect(wrapper.container.querySelectorAll('tbody tr').length).toEqual(rows.length); + }); + + it('should select All from a later page and show every row', async () => { + const currentPage = 2; + const wrapper = render(N8nDatatable, { + props: { columns, rows, rowsPerPage, currentPage }, + global: { stubs }, + }); + + expect(wrapper.container.querySelectorAll('tbody tr').length).toEqual( + rows.length - rowsPerPage, + ); + + await userEvent.click(wrapper.getByRole('combobox')); + + await waitFor(async () => { + await userEvent.click(wrapper.getByRole('option', { name: 'All' })); + }); + + await waitFor(() => { + expect(wrapper.emitted('update:rowsPerPage')?.[0]).toEqual([PAGINATION_ALL_ITEMS_PER_PAGE]); + expect(wrapper.emitted('update:currentPage')?.[0]).toEqual([1]); + }); + + await wrapper.rerender({ + columns, + rows, + rowsPerPage: PAGINATION_ALL_ITEMS_PER_PAGE, + currentPage: 1, + }); expect(wrapper.container.querySelectorAll('tbody tr').length).toEqual(rows.length); - expect(wrapper.container.querySelectorAll('tbody tr td').length).toEqual( - columns.length * rows.length, - ); + }); + + it('should render every row and hide pagination when pagination is disabled', () => { + const wrapper = render(N8nDatatable, { + props: { columns, rows, pagination: false }, + global: { stubs }, + }); + + expect(wrapper.queryByTestId('pagination')).not.toBeInTheDocument(); + expect(wrapper.container.querySelectorAll('tbody tr').length).toEqual(rows.length); }); }); }); diff --git a/packages/frontend/@n8n/design-system/src/components/N8nDatatable/Datatable.vue b/packages/frontend/@n8n/design-system/src/components/N8nDatatable/Datatable.vue index 56fc5c2ec8b..b92d435de46 100644 --- a/packages/frontend/@n8n/design-system/src/components/N8nDatatable/Datatable.vue +++ b/packages/frontend/@n8n/design-system/src/components/N8nDatatable/Datatable.vue @@ -1,16 +1,13 @@