feat(editor): Add an All option to pagination and remove the custom datatable page-size controls (#39850)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sebastien Powell 2026-10-21 09:15:29 +00:00 committed by GitHub
parent e167fd5e09
commit d7fa8ada34
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 434 additions and 219 deletions

View File

@ -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<TableHeader<Person>> = [
{ 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<TableOptions['sortBy']>([]);
const latest = ref<TableOptions>({
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: `
<div style="display: flex; flex-direction: column; gap: var(--spacing--sm); padding: var(--spacing--md);">
<N8nDataTableServer
v-model:page="page"
v-model:items-per-page="itemsPerPage"
v-model:sort-by="sortBy"
:headers="headers"
:items="pageItems"
:items-length="total"
show-all
@update:options="onOptions"
/>
<p style="margin: 0; font-size: var(--font-size--2xs); color: var(--color--text--tint-1);">
update:options — page {{ latest.page }} (0-indexed), {{ pageSizeLabel }}, sort {{ sortLabel }}
</p>
</div>
`,
});

View File

@ -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 }),
]);
});
});

View File

@ -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<string, boolean> | undefined;
@ -69,12 +70,25 @@ const props = withDefaults(
itemSelectable?: boolean | DeepKeys<T> | ((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<number>('page', { default: 0 });
watch(page, () => table.setPageIndex(page.value));
const itemsPerPage = defineModel<number>('items-per-page', { default: 10 });
watch(itemsPerPage, () => table.setPageSize(itemsPerPage.value));
const pagination = computed<PaginationState>({
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<PaginationState>(() => ({
pageIndex: page.value,
pageSize: itemsPerPage.value,
}));
const showPagination = computed(
() => props.pagination && props.itemsLength > Math.min(...props.pageSizes),
);
const sortBy = defineModel<SortingState>('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<T>();
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({
</tr>
</template>
<template v-if="loading && !table.getRowModel().rows.length">
<tr v-for="item in itemsPerPage" :key="item">
<tr v-for="item in skeletonRowCount" :key="item">
<td
v-for="coll in table.getVisibleFlatColumns()"
:key="coll.id"
@ -502,22 +521,12 @@ const table = useVueTable({
:page="page + 1"
:items-per-page="itemsPerPage"
:total="itemsLength"
:page-sizes="pageSizes"
:show-all="showAll"
:show-total="false"
:show-sizes="false"
@update:page="page = $event - 1"
@update:items-per-page="itemsPerPage = $event"
/>
<div class="table-pagination__sizes">
<div class="table-pagination__sizes__label">Page size</div>
<ElSelect
v-model.number="itemsPerPage"
class="table-pagination__sizes__select"
size="small"
:teleported="false"
@update:model-value="handlePageSizeChange"
>
<ElOption v-for="item in pageSizes" :key="item" :label="item" :value="item" />
</ElSelect>
</div>
</div>
</div>
</template>
@ -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 {

View File

@ -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: '<n8n-datatable v-bind="args"></n8n-datatable>',
template: `
<n8n-datatable
v-bind="args"
:current-page="currentPage"
:rows-per-page="rowsPerPage"
@update:current-page="currentPage = $event"
@update:rows-per-page="rowsPerPage = $event"
/>
`,
});
Default.args = {

View File

@ -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);
});
});
});

View File

@ -1,16 +1,13 @@
<script lang="ts" setup generic="Item extends DatatableRow">
import { computed, ref } from 'vue';
import { computed } from 'vue';
import type { DatatableProps } from './Datatable.types';
import { useI18n } from '../../composables/useI18n';
import type { DatatableColumn, DatatableRow, DatatableRowDataType } from '../../types';
import { getValueByPath } from '../../utils';
import N8nOption from '../N8nOption';
import N8nPagination from '../N8nPagination';
import N8nSelect from '../N8nSelect';
import { N8nPagination, PAGINATION_ALL_ITEMS_PER_PAGE } from '../N8nPagination';
import N8nTableBase from '../TableBase';
const ALL_ROWS = -1;
const rowsPerPageOptions = [1, 10, 25, 50, 100];
defineOptions({ name: 'N8nDatatable' });
const props = withDefaults(defineProps<DatatableProps<Item>>(), {
@ -24,19 +21,12 @@ const emit = defineEmits<{
'update:rowsPerPage': [value: number];
}>();
const { t } = useI18n();
const rowsPerPageOptions = ref([1, 10, 25, 50, 100]);
const totalPages = computed(() => {
return Math.ceil(props.rows.length / props.rowsPerPage);
});
const totalRows = computed(() => {
return props.rows.length;
});
const visibleRows = computed(() => {
if (props.rowsPerPage === ALL_ROWS) return props.rows;
if (!props.pagination || props.rowsPerPage === PAGINATION_ALL_ITEMS_PER_PAGE) return props.rows;
const start = (props.currentPage - 1) * props.rowsPerPage;
const end = start + props.rowsPerPage;
@ -50,16 +40,6 @@ function onUpdateCurrentPage(value: number) {
function onRowsPerPageChange(value: number) {
emit('update:rowsPerPage', value);
if (value === ALL_ROWS) {
onUpdateCurrentPage(1);
return;
}
const maxPage = Math.ceil(totalRows.value / value);
if (maxPage < props.currentPage) {
onUpdateCurrentPage(maxPage);
}
}
function getTdValue(row: Item, column: DatatableColumn) {
@ -104,35 +84,17 @@ function getThStyle(column: DatatableColumn) {
<slot name="postdata" />
<div class="pagination">
<N8nPagination
v-if="totalPages > 1"
:page="currentPage"
:items-per-page="rowsPerPage"
:total="totalRows"
:show-total="false"
:show-sizes="false"
@update:page="onUpdateCurrentPage"
/>
<div class="pageSizeSelector">
<N8nSelect
size="mini"
:model-value="rowsPerPage"
teleported
@update:model-value="onRowsPerPageChange"
>
<template #prepend>{{ t('datatable.pageSize') }}</template>
<N8nOption
v-for="size in rowsPerPageOptions"
:key="size"
:label="`${size}`"
:value="size"
/>
<N8nOption :label="`All`" :value="ALL_ROWS"> </N8nOption>
</N8nSelect>
</div>
</div>
<N8nPagination
v-if="pagination"
class="pagination"
:page="currentPage"
:items-per-page="rowsPerPage"
:total="totalRows"
:page-sizes="rowsPerPageOptions"
show-all
@update:page="onUpdateCurrentPage"
@update:items-per-page="onRowsPerPageChange"
/>
</div>
</template>
@ -143,18 +105,8 @@ function getThStyle(column: DatatableColumn) {
}
.pagination {
width: 100%;
display: flex;
justify-content: flex-end;
align-items: center;
bottom: 0;
overflow: visible;
margin-top: var(--spacing--sm);
}
.pageSizeSelector {
text-transform: capitalize;
max-width: 150px;
flex: 0 1 auto;
}
</style>

View File

@ -98,41 +98,22 @@ exports[`components > N8nDatatable > should render correctly 1`] = `
</table>
</div>
</div>
<div data-v-c73ff18f="" class="pagination">
<div data-v-c73ff18f="" class="n8n-pagination paginationContainer medium" data-test-id="pagination">
<!--v-if-->
<nav>
<div class="paginationList" data-test-id="pagination-list"><span class="disabledTrigger" data-state="closed" data-grace-area-trigger=""><n8n-button-stub icon="chevron-left" variant="ghost" size="medium" loading="false" icononly="true" disabled="true" class="" aria-label="Previous page" type="button" data-test-id="pagination-prev"></n8n-button-stub></span>
<!--v-if--><button data-type="page" aria-label="Page 1" aria-current="page" data-selected="true" type="button" class="paginationItem" data-test-id="pagination-item" value="1">1</button><button data-type="page" aria-label="Page 2" type="button" class="paginationItem" data-test-id="pagination-item" value="2">2</button><span class="" data-state="closed" data-grace-area-trigger=""><n8n-button-stub icon="chevron-right" variant="ghost" size="medium" loading="false" icononly="true" disabled="false" class="" aria-label="Next page" type="button" data-test-id="pagination-next"></n8n-button-stub></span>
<!--v-if-->
</div>
</nav>
<!--v-if-->
<!--v-if-->
</div>
<div data-v-c73ff18f="" class="pageSizeSelector">
<div data-v-c73ff18f="" class="n8n-select container withPrepend">
<div class="prepend">Page size</div>
<div class="el-select el-select--small" popperappendtobody="false" limitpopperwidth="false">
<div class="select-trigger el-tooltip__trigger el-tooltip__trigger">
<!--v-if-->
<!--v-if-->
<div class="el-input el-input--small el-input--suffix">
<!-- input -->
<!-- prepend slot -->
<!--v-if-->
<div class="el-input__wrapper">
<!-- prefix slot -->
<!--v-if--><input class="el-input__inner" role="combobox" aria-activedescendant="" aria-expanded="false" aria-autocomplete="none" aria-haspopup="listbox" type="text" readonly="" autocomplete="off" tabindex="0" placeholder="Select"><!-- suffix slot --><span class="el-input__suffix"><span class="el-input__suffix-inner"><i class="el-icon el-select__caret el-select__icon"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path fill="currentColor" d="M831.872 340.864 512 652.672 192.128 340.864a30.592 30.592 0 0 0-42.752 0 29.12 29.12 0 0 0 0 41.6L489.664 714.24a32 32 0 0 0 44.672 0l340.288-331.712a29.12 29.12 0 0 0 0-41.728 30.592 30.592 0 0 0-42.752 0z"></path></svg></i><!--v-if--><!--v-if--><!--v-if--><!--v-if--><!--v-if--><!--v-if--></span></span>
</div><!-- append slot -->
<!--v-if-->
</div>
</div>
<!--teleport start-->
<!--teleport end-->
</div>
<div data-v-c73ff18f="" class="n8n-pagination paginationContainer medium pagination" data-test-id="pagination">
<div class="total" data-test-id="pagination-total">Total 15</div>
<nav>
<div class="paginationList" data-test-id="pagination-list"><span class="disabledTrigger" data-state="closed" data-grace-area-trigger=""><n8n-button-stub icon="chevron-left" variant="ghost" size="medium" loading="false" icononly="true" disabled="true" class="" aria-label="Previous page" type="button" data-test-id="pagination-prev"></n8n-button-stub></span>
<!--v-if--><button data-type="page" aria-label="Page 1" aria-current="page" data-selected="true" type="button" class="paginationItem" data-test-id="pagination-item" value="1">1</button><button data-type="page" aria-label="Page 2" type="button" class="paginationItem" data-test-id="pagination-item" value="2">2</button><span class="" data-state="closed" data-grace-area-trigger=""><n8n-button-stub icon="chevron-right" variant="ghost" size="medium" loading="false" icononly="true" disabled="false" class="" aria-label="Next page" type="button" data-test-id="pagination-next"></n8n-button-stub></span>
<!--v-if-->
</div>
</nav>
<div data-test-id="pagination-sizes" aria-label="Page size" tabindex="0" class="selectTrigger medium pageSizes" role="combobox" aria-expanded="false" aria-required="false" aria-autocomplete="none" disabled="false" dir="ltr" data-state="closed">
<!--v-if--><span style="pointer-events: none;" data-placeholder="Select an option" class="selectValue">10/page</span>
<!--v-if--><svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 16 16" class="n8n-icon trailingIcon" aria-hidden="true" focusable="false" role="img" height="1em" width="1em" data-icon="chevron-down">
<path d="M15.56 5.25 8 12.81.44 5.25 1.5 4.19l6.5 6.5 6.5-6.5z"></path>
</svg>
</div>
<!--v-if--><select aria-hidden="true" tabindex="-1" style="position: absolute; border: 0px; width: 1px; height: 1px; padding: 0px; margin: -1px; overflow: hidden; clip: rect(0px, 0px, 0px, 0px); clip-path: inset(50%); white-space: nowrap; word-wrap: normal;" value="10"></select>
<!--v-if-->
</div>
</div>"
`;

View File

@ -38,6 +38,11 @@ export default {
control: 'object',
description: 'Options for the page size selector',
},
showAll: {
control: 'boolean',
description:
'Show an All option in the page size selector. Selecting it emits PAGINATION_ALL_ITEMS_PER_PAGE (-1) and shows every item on one page.',
},
showTotal: {
control: 'boolean',
description: 'Show the total item count',
@ -115,6 +120,28 @@ export const WithPageSizes: Story = {
total: 500,
itemsPerPage: 20,
pageSizes: [10, 20, 50, 100],
showAll: false,
showTotal: false,
showSizes: true,
},
};
export const WithAll: Story = {
name: 'With Page Sizes (All)',
render: Template,
parameters: {
docs: {
description: {
story:
'Set showAll to add an All option. Selecting it emits PAGINATION_ALL_ITEMS_PER_PAGE (-1) and shows every item on one page.',
},
},
},
args: {
total: 100,
itemsPerPage: 10,
pageSizes: [10, 25, 50, 100],
showAll: true,
showTotal: false,
showSizes: true,
},

View File

@ -2,6 +2,7 @@ import userEvent from '@testing-library/user-event';
import { render, waitFor } from '@testing-library/vue';
import Pagination from './Pagination.vue';
import { PAGINATION_ALL_ITEMS_PER_PAGE } from './Pagination.types';
describe('components/N8nPagination', () => {
describe('rendering', () => {
@ -276,6 +277,29 @@ describe('components/N8nPagination', () => {
});
});
it('should offer All when showAll is set', async () => {
const wrapper = render(Pagination, {
props: {
page: 1,
itemsPerPage: 10,
total: 100,
showAll: true,
},
});
await userEvent.click(wrapper.getByRole('combobox'));
await waitFor(async () => {
await userEvent.click(wrapper.getByRole('option', { name: 'All' }));
});
await waitFor(() => {
expect(wrapper.emitted('update:itemsPerPage')?.[0]).toEqual([
PAGINATION_ALL_ITEMS_PER_PAGE,
]);
});
});
it('should emit update:itemsPerPage when page size changes', async () => {
const wrapper = render(Pagination, {
props: {

View File

@ -2,6 +2,12 @@ import type { PaginationRootEmits, PaginationRootProps } from './reka-ui';
export type PaginationSizes = 'small' | 'medium';
/**
* Items-per-page value for the All option.
* Selecting All emits this value and shows every item on one page.
*/
export const PAGINATION_ALL_ITEMS_PER_PAGE = -1;
export type PaginationProps = Omit<
PaginationRootProps,
'dir' | 'asChild' | 'as' | 'itemsPerPage' | 'total'
@ -20,6 +26,12 @@ export type PaginationProps = Omit<
* @defaultValue [10, 20, 30, 40, 50, 100]
*/
pageSizes?: number[];
/**
* Show an All option in the page size selector.
* Selecting it emits `PAGINATION_ALL_ITEMS_PER_PAGE`.
* @defaultValue false
*/
showAll?: boolean;
/**
* Show the total item count.
* @defaultValue true

View File

@ -8,7 +8,13 @@ import { useI18n } from '@n8n/design-system/composables/useI18n';
import type { SelectValue } from '@n8n/design-system/v2/components/Select/Select.types';
import N8nSelect from '@n8n/design-system/v2/components/Select/Select.vue';
import type { PaginationEmits, PaginationProps, PaginationSlots } from './Pagination.types';
import {
PAGINATION_ALL_ITEMS_PER_PAGE,
type PaginationEmits,
type PaginationProps,
type PaginationSlots,
} from './Pagination.types';
import {
PaginationRoot,
PaginationList,
@ -30,6 +36,7 @@ const $style = useCssModule();
const props = withDefaults(defineProps<PaginationProps>(), {
size: 'medium',
pageSizes: () => [10, 20, 30, 40, 50, 100],
showAll: false,
showTotal: true,
showSizes: true,
showJumper: false,
@ -103,11 +110,20 @@ watch(
{ immediate: true },
);
function isAllPageSize() {
return currentItemsPerPage.value === PAGINATION_ALL_ITEMS_PER_PAGE;
}
function resolvedPageCount() {
if (!props.total || !currentItemsPerPage.value) return 1;
if (isAllPageSize() || !props.total || !currentItemsPerPage.value) return 1;
return Math.ceil(props.total / currentItemsPerPage.value);
}
function rootItemsPerPage() {
if (isAllPageSize()) return Math.max(props.total, 1);
return currentItemsPerPage.value;
}
function shouldHide() {
return props.hideOnSinglePage && resolvedPageCount() <= 1;
}
@ -121,10 +137,16 @@ function isNextDisabled(page: number, pageCount: number) {
}
function pageSizeItems() {
return props.pageSizes.map((s) => ({
const items = props.pageSizes.map((s) => ({
value: String(s),
label: t('pagination.pageSizeOption', { size: s }),
}));
if (props.showAll) {
items.push({ value: String(PAGINATION_ALL_ITEMS_PER_PAGE), label: t('pagination.all') });
}
return items;
}
function handlePageUpdate(newPage: number) {
@ -245,7 +267,7 @@ function handlePagerKeydown(event: KeyboardEvent) {
v-slot="{ page: rootPage, pageCount: rootPageCount }"
v-bind="rootProps"
:page="currentPage"
:items-per-page="currentItemsPerPage"
:items-per-page="rootItemsPerPage()"
:total="total"
@update:page="handlePageUpdate"
>

View File

@ -27,6 +27,7 @@ Extends Reka UI [`PaginationRootProps`](https://reka-ui.com/docs/components/pagi
- `defaultItemsPerPage?: number` - Initial items-per-page in uncontrolled mode. Default: `10`
- `pageSizes?: number[]` - Options for the page size selector. Default: `[10, 20, 30, 40, 50, 100]`
- `showAll?: boolean` - Show an All option in the page size selector. Selecting it emits `PAGINATION_ALL_ITEMS_PER_PAGE` (`-1`) and shows every item on one page. Default: `false`
- `showTotal?: boolean` - Show the total item count. Default: `true`
- `showSizes?: boolean` - Show the page size selector. Default: `true`
- `showJumper?: boolean` - Show the go-to-page jumper. Default: `false`
@ -36,7 +37,7 @@ Extends Reka UI [`PaginationRootProps`](https://reka-ui.com/docs/components/pagi
**Events**
- `@update:page` - Emitted when the current page changes. Payload: `(value: number) => void`
- `@update:items-per-page` - Emitted when the page size changes. Payload: `(value: number) => void`
- `@update:items-per-page` - Emitted when the page size changes. Payload: `(value: number) => void`. All emits `PAGINATION_ALL_ITEMS_PER_PAGE` (`-1`).
**Slots**

View File

@ -2,4 +2,10 @@ import N8nPagination from './Pagination.vue';
export default N8nPagination;
export { N8nPagination };
export type * from './Pagination.types';
export type {
PaginationEmits,
PaginationProps,
PaginationSizes,
PaginationSlots,
} from './Pagination.types';
export { PAGINATION_ALL_ITEMS_PER_PAGE } from './Pagination.types';

View File

@ -114,7 +114,7 @@ export { default as N8nNodeCreatorNode } from './N8nNodeCreatorNode';
export { default as N8nNodeIcon } from './N8nNodeIcon';
export { default as N8nNotice } from './N8nNotice';
export { default as N8nOption } from './N8nOption';
export { default as N8nPagination } from './N8nPagination';
export { default as N8nPagination, PAGINATION_ALL_ITEMS_PER_PAGE } from './N8nPagination';
export { default as N8nSectionHeader } from './N8nSectionHeader';
export { default as N8nSelectableList } from './N8nSelectableList';
export { default as N8nSelectedItemsInfo } from './N8nSelectedItemsInfo';

View File

@ -24,7 +24,12 @@ export { default as N8nSelect2Item } from './v2/components/Select/SelectItem.vue
export type * from './v2/components/Select/Select.types';
export { default as N8nCheckbox } from './v2/components/Checkbox/Checkbox.vue';
export type * from './v2/components/Checkbox/Checkbox.types';
export type * from './components/N8nPagination/Pagination.types';
export type {
PaginationEmits,
PaginationProps,
PaginationSizes,
PaginationSlots,
} from './components/N8nPagination/Pagination.types';
export { default as N8nLoading2 } from './v2/components/Loading/Loading.vue';
export type * from './v2/components/Loading/Loading.types';
export { default as N8nRadioGroupItem } from './components/N8nRadioGroup/RadioGroupItem.vue';

View File

@ -55,12 +55,12 @@ export default {
'tags.showMore': (count: number) => `+${count} more`,
'tabs.scrollLeft': 'Show previous tabs',
'tabs.scrollRight': 'Show next tabs',
'datatable.pageSize': 'Page size',
'pagination.previousPage': 'Previous page',
'pagination.nextPage': 'Next page',
'pagination.total': 'Total {total}',
'pagination.pageSize': 'Page size',
'pagination.pageSizeOption': '{size}/page',
'pagination.all': 'All',
'pagination.goTo': 'Go to',
'pagination.goToPage': 'Go to page',
'selectedItemsInfo.selectedOne': '{count} row selected',

View File

@ -125,7 +125,7 @@ const onTableAction = (action: string, settings: ChatProviderSettingsDto) => {
</script>
<template>
<div :class="$style.tableContainer">
<div>
<div v-if="props.loading">
<N8nLoading :loading="props.loading" variant="h1" :class="$style.header" />
<N8nLoading :loading="props.loading" variant="p" :rows="5" :shrink-last="false" />
@ -159,6 +159,7 @@ const onTableAction = (action: string, settings: ChatProviderSettingsDto) => {
:headers="tableHeaders"
:items="settingItems"
:items-length="settingItems.length"
:pagination="false"
>
<template #[`item.provider`]="{ item }">
<div :class="$style.providerCell">
@ -210,12 +211,6 @@ const onTableAction = (action: string, settings: ChatProviderSettingsDto) => {
</template>
<style lang="scss" module>
.tableContainer {
:global(.table-pagination) {
display: none;
}
}
.actions {
display: flex;
align-items: center;

View File

@ -192,7 +192,7 @@ onMounted(async () => {
/>
</header>
<div :class="$style.usageTableContainer">
<div>
<div v-if="showUsageSectionSkeleton">
<N8nLoading :loading="true" variant="h1" :class="$style.usageTableHeader" />
<N8nLoading :loading="true" variant="p" :rows="5" :shrink-last="false" />
@ -226,6 +226,7 @@ onMounted(async () => {
:headers="tableHeaders"
:items="entries"
:items-length="entries.length"
:pagination="false"
:loading="isLoading && isAppending"
:item-value="rowId"
:row-props="(row) => (isRowClickable(row) ? { class: $style.clickableRow } : {})"
@ -306,12 +307,6 @@ onMounted(async () => {
margin-bottom: var(--spacing--5xs);
}
.usageTableContainer {
:global(.table-pagination) {
display: none;
}
}
.usageTableHeader {
display: flex;
justify-content: space-between;

View File

@ -254,9 +254,3 @@ function resolveCountRoute(row: RoleRow): RouteLocationRaw {
color: var(--color--primary);
}
</style>
<style scoped>
:deep(.pageSizeSelector) {
display: none;
}
</style>