diff --git a/docs/contracts/storage-pagination.md b/docs/contracts/storage-pagination.md new file mode 100644 index 00000000..371e8c25 --- /dev/null +++ b/docs/contracts/storage-pagination.md @@ -0,0 +1,39 @@ +# Dataset and snapshot pagination + +This change requires the ordered cursor contract from +[vpsfreecz/vpsadmin#44](https://github.com/vpsfreecz/vpsadmin/pull/44), currently +`320af0e152ed223bf0365e0f1cf4b38cf00d7b1d`. Do not release the UI fix as a +standalone guarantee against an older API using numeric ID comparisons. +The branch is based on UI PR496 for browser-history cursor restoration. + +- Dataset index (including NAS): ascending `(full_name, id)`. +- Dataset snapshots: ascending `(created_at, id)`. +- `from_id` identifies the last displayed row in the scoped ordered list. It + is not the minimum or maximum ID and does not include the lookahead record. +- Fetch `limit + 1`, display `limit`, and enable Next only with a fresh extra row. + A historical forward cursor alone cannot prove that another page exists. +- Next replaces its visited forward edge with the current row boundary. +- Requests carry cancellation signals. URL filters changed by the UI clear the + cursor; browser navigation and reload restore the URL cursor. +- Invalid/deleted/out-of-scope anchors return an API error. Retry preserves the + cursor; the explicit first-page action preserves filters. Empty cursor results + retain a return path. Text search remains limited to the displayed API page. +- Member NAS does not submit an arbitrary owner filter; API authorization scopes + the records. These frontend checks do not replace backend authorization. + +Concurrent renames, inserts, deletes or timestamp changes are not a consistent +snapshot. Restart traversal after such changes; no exactly-once promise is made. +Property history, downloads and expansion history are separate endpoints and +are not certified by this change. + +## Evidence + +Fixture Playwright covers three pages with non-monotonic IDs, tied snapshot +creation times, exact end, desktop/mobile, cs/en, admin owner filter/member NAS, +reload, Back, invalid cursors, empty pages, and a changed visited boundary. +Existing local text-filter and snapshot pagination tests remain covered. +The dedicated filter regression also checks reset and browser-history recovery. + +API specs for PR44 and UI fixtures are separate evidence. Actual combined +API44/UI496/storage execution in the existing isolated VM cluster is still a +release gate; these tests must not be reported as live VM certification. diff --git a/e2e/specs/app/datasets_list_keyset_pagination.spec.ts b/e2e/specs/app/datasets_list_keyset_pagination.spec.ts index 438f2b4d..97a29778 100644 --- a/e2e/specs/app/datasets_list_keyset_pagination.spec.ts +++ b/e2e/specs/app/datasets_list_keyset_pagination.spec.ts @@ -29,7 +29,7 @@ test.describe('Datasets list keyset pagination', () => { handlers: { 'GET datasets': ({ searchParams }) => { const fromId = searchParams.get('dataset[from_id]'); - return { datasets: fromId ? page2 : page1, _meta: { total_count: 100 } }; + return { datasets: fromId ? page2 : [...page1, page2[0]], _meta: { total_count: 100 } }; }, }, }); @@ -99,7 +99,7 @@ test.describe('Datasets list text filter contract', () => { expect(searchParams.get('dataset[q]')).toBeNull(); const fromId = searchParams.get('dataset[from_id]'); return { - datasets: fromId ? secondPage : firstPage, + datasets: fromId ? secondPage : [...firstPage, secondPage[0]], _meta: { total_count: 51 }, }; }, diff --git a/e2e/specs/app/storage_ordered_cursors.spec.ts b/e2e/specs/app/storage_ordered_cursors.spec.ts new file mode 100644 index 00000000..369ceebe --- /dev/null +++ b/e2e/specs/app/storage_ordered_cursors.spec.ts @@ -0,0 +1,159 @@ +import { expect, test } from '../../fixtures/vpsadmin-window'; +import { bootstrapVpsAdminWindow } from '../../fixtures/bootstrap'; +import { failEnvelope, installHaveApiMock, jsonFulfill } from '../../fixtures/haveapi'; +import { setUiSettingsLocalStorage } from '../../fixtures/uiSettings'; + +const dataset = { id: 10, name: 'fixture', full_name: 'tank/fixture', user: { id: 7, login: 'member' }, object_state: 'active' }; +const records = Array.from({ length: 75 }, (_, i) => ({ + id: ((i * 29) % 75) + 100, + full_name: `tank/${String(i).padStart(3, '0')}`, + name: `snapshot-${i}`, label: `Snapshot ${i}`, + created_at: new Date(Date.UTC(2026, 0, 1, 0, Math.floor(i / 3))).toISOString(), + user: { id: 7, login: 'member' }, object_state: 'active', +})); + +for (const kind of ['datasets', 'snapshots'] as const) { + for (const role of ['admin', 'member'] as const) { + const language = role === 'admin' ? 'cs' : 'en'; + test(`@pr-smoke @pr-smoke-mobile ${kind} follows ordered anchors with ${role} scope (${language})`, async ({ page }, info) => { + await setUiSettingsLocalStorage(page, { language }); + await bootstrapVpsAdminWindow(page); + const mobile = info.project.name === 'mobile-chrome'; + const prefix = kind === 'datasets' ? 'datasets' : 'dataset.snapshots'; + const pager = `${prefix}.pagination.${mobile ? 'mobile' : 'desktop'}`; + const item = (id: number) => page.getByTestId(`${prefix}.${mobile ? 'card' : 'row'}.${id}`); + const rows = [...records].sort((a, b) => kind === 'datasets' + ? a.full_name.localeCompare(b.full_name) || a.id - b.id + : a.created_at.localeCompare(b.created_at) || a.id - b.id); + const ns = kind === 'datasets' ? 'dataset' : 'snapshot'; + const cursors: number[] = []; + let ownerFilterCleared = false; + await installHaveApiMock(page, { + user: { id: 7, login: role, level: role === 'admin' ? 99 : 1 }, + handlers: { + 'GET datasets/10': () => dataset, + [kind === 'datasets' ? 'GET datasets' : 'GET datasets/10/snapshots']: ({ searchParams }) => { + const cursor = Number(searchParams.get(`${ns}[from_id]`) ?? 0); + expect(searchParams.get(`${ns}[limit]`)).toBe('26'); + if (kind === 'datasets') { + expect(searchParams.get('dataset[user]')).toBe(role === 'admin' && !ownerFilterCleared ? '7' : null); + if (role === 'member') expect(searchParams.get('dataset[role]')).toBe('primary'); + } + cursors.push(cursor); + const start = cursor ? rows.findIndex(row => row.id === cursor) + 1 : 0; + return { [kind]: rows.slice(start, start + 26) }; + }, + }, + }); + const base = kind === 'datasets' && role === 'member' ? '/app/nas' + : `/${role === 'admin' ? 'admin' : 'app'}/datasets${kind === 'snapshots' ? '/10/snapshots' : ''}`; + await page.goto(`${base}?limit=25${kind === 'datasets' ? (role === 'admin' ? '&user=7' : '&user=999') : ''}`); + for (let p = 0; p < 3; p++) { + await expect(item(rows[p * 25].id)).toBeVisible(); + const visible = page.locator(`[data-testid^="${prefix}.${mobile ? 'card' : 'row'}."]:visible`); + // Ignore nested status-dot test IDs; compare every actual row in API order. + const ids = () => visible.evaluateAll(elements => elements.map(el => el.getAttribute('data-testid')).filter(id => /^.*\.(row|card)\.\d+$/.test(id ?? ''))); + await expect.poll(ids).toEqual(rows.slice(p * 25, (p + 1) * 25).map(row => `${prefix}.${mobile ? 'card' : 'row'}.${row.id}`)); + if (p < 2) await page.getByTestId(`${pager}.next`).click(); + } + await expect(page.getByTestId(`${pager}.next`)).toBeDisabled(); + expect(cursors).toEqual([0, rows[24].id, rows[49].id]); + await page.reload(); + await expect(item(rows[50].id)).toBeVisible(); + await expect(page.getByTestId(`${pager}.next`)).toBeDisabled(); + await page.getByTestId(`${pager}.prev`).click(); + await expect(item(rows[25].id)).toBeVisible(); + await page.goBack(); + await expect(item(rows[50].id)).toBeVisible(); + if (kind === 'datasets' && role === 'admin') { + ownerFilterCleared = true; + await page.getByTestId('datasets.filter.clear').click(); + await expect(page).not.toHaveURL(/from_id=|user=/); + await expect(item(rows[0].id)).toBeVisible(); + ownerFilterCleared = false; + await page.goBack(); + await expect(page).toHaveURL(/user=7/); + await expect(item(rows[50].id)).toBeVisible(); + } + }); + } + + test(`@pr-smoke @pr-smoke-mobile ${kind} retries invalid anchors and recovers empty pages`, async ({ page }, info) => { + await setUiSettingsLocalStorage(page, { language: 'en' }); + await bootstrapVpsAdminWindow(page); + let fail = true; + const mobile = info.project.name === 'mobile-chrome'; + const prefix = kind === 'datasets' ? 'datasets' : 'dataset.snapshots'; + const error = kind === 'datasets' ? 'datasets.list.error' : 'dataset.snapshots.error'; + const base = `/admin/datasets${kind === 'snapshots' ? '/10/snapshots' : ''}`; + const url = `${base}?limit=25&from_id=999&page=2${kind === 'datasets' ? '&user=7' : ''}`; + await installHaveApiMock(page, { + user: { id: 1, login: 'admin', level: 99 }, + handlers: { + 'GET datasets/10': () => dataset, + [kind === 'datasets' ? 'GET datasets' : 'GET datasets/10/snapshots']: ({ searchParams }) => { + const cursor = searchParams.get(`${kind === 'datasets' ? 'dataset' : 'snapshot'}[from_id]`); + if (kind === 'datasets') expect(searchParams.get('dataset[user]')).toBe('7'); + if (cursor && fail) return jsonFulfill(failEnvelope('Invalid pagination cursor'), 400); + return { [kind]: cursor ? [] : [records[0]] }; + }, + }, + }); + await page.goto(url); + await expect(page.getByTestId(error)).toBeVisible(); + fail = false; + await page.getByTestId(`${error}.primary`).click(); + await expect(page.getByTestId(error)).toHaveCount(0); + if (kind === 'datasets') await page.getByTestId('datasets.pagination.empty.restart').click(); + else await page.getByTestId(`${prefix}.pagination.${mobile ? 'mobile' : 'desktop'}.prev`).click(); + await expect(page.getByTestId(`${prefix}.${mobile ? 'card' : 'row'}.${records[0].id}`)).toBeVisible(); + fail = true; + await page.goto(url); + await page.getByTestId(`${error}.secondary`).click(); + await expect(page).not.toHaveURL(/from_id=/); + await expect(page.getByTestId(`${prefix}.${mobile ? 'card' : 'row'}.${records[0].id}`)).toBeVisible(); + if (kind === 'datasets') await expect(page).toHaveURL(/user=7/); + }); +} + +for (const kind of ['datasets', 'snapshots'] as const) { + test(`@pr-smoke @pr-smoke-mobile ${kind} replaces a visited forward edge after refresh`, async ({ page }, info) => { + await bootstrapVpsAdminWindow(page); + let rows = [...records].sort((a, b) => kind === 'datasets' + ? a.full_name.localeCompare(b.full_name) || a.id - b.id + : a.created_at.localeCompare(b.created_at) || a.id - b.id); + const mobile = info.project.name === 'mobile-chrome'; + const prefix = kind === 'datasets' ? 'datasets' : 'dataset.snapshots'; + const pager = `${prefix}.pagination.${mobile ? 'mobile' : 'desktop'}`; + const item = (id: number) => page.getByTestId(`${prefix}.${mobile ? 'card' : 'row'}.${id}`); + const cursors: number[] = []; + await installHaveApiMock(page, { + user: { id: 1, login: 'admin', level: 99 }, + handlers: { + 'GET datasets/10': () => dataset, + [kind === 'datasets' ? 'GET datasets' : 'GET datasets/10/snapshots']: ({ searchParams }) => { + const cursor = Number(searchParams.get(`${kind === 'datasets' ? 'dataset' : 'snapshot'}[from_id]`) ?? 0); + cursors.push(cursor); + const start = cursor ? rows.findIndex(row => row.id === cursor) + 1 : 0; + return { [kind]: rows.slice(start, start + 26) }; + }, + }, + }); + await page.goto(`/admin/datasets${kind === 'snapshots' ? '/10/snapshots' : ''}?limit=25`); + await page.getByTestId(`${pager}.next`).click(); + await expect(item(rows[25].id)).toBeVisible(); + await page.getByTestId(`${pager}.prev`).click(); + await expect(item(rows[0].id)).toBeVisible(); + rows = rows.slice(1); + await page.reload(); + await expect(item(rows[24].id)).toBeVisible(); + await page.getByTestId(`${pager}.next`).click(); + await expect(item(rows[25].id)).toBeVisible(); + expect(cursors.at(-1)).toBe(rows[24].id); + await page.getByTestId(`${pager}.prev`).click(); + rows = rows.slice(0, 25); + await page.reload(); + await expect(item(rows[24].id)).toBeVisible(); + await expect(page.getByTestId(`${pager}.next`)).toBeDisabled(); + }); +} diff --git a/src/i18n/locales/cs/storage.ts b/src/i18n/locales/cs/storage.ts index bc3be733..39200660 100644 --- a/src/i18n/locales/cs/storage.ts +++ b/src/i18n/locales/cs/storage.ts @@ -3,6 +3,7 @@ import { csStorageExports } from "./storage/exports"; import { csDatasetExpansion } from './storage/dataset_expansion'; import { csBackups } from './storage/backups'; export const csStorage = { + "datasets.pagination.restart": "Zpět na první stránku", ...csBackups, "datasets.list.title": "Datasety", "datasets.list.description": diff --git a/src/i18n/locales/en/storage.ts b/src/i18n/locales/en/storage.ts index 9f91340a..72bd1330 100644 --- a/src/i18n/locales/en/storage.ts +++ b/src/i18n/locales/en/storage.ts @@ -3,6 +3,7 @@ import { enStorageExports } from "./storage/exports"; import { enDatasetExpansion } from './storage/dataset_expansion'; import { enBackups } from './storage/backups'; export const enStorage = { + "datasets.pagination.restart": "Back to first page", ...enBackups, "datasets.list.title": "Datasets", "datasets.list.description": diff --git a/src/lib/api/datasets.ts b/src/lib/api/datasets.ts index 549a0725..3f7f6764 100644 --- a/src/lib/api/datasets.ts +++ b/src/lib/api/datasets.ts @@ -140,6 +140,7 @@ export async function fetchDatasets(opts?: { count?: boolean; reversed?: boolean; role?: 'primary' | 'hypervisor'; + signal?: AbortSignal; }) { const params: Record = {}; if (opts?.fromId !== undefined) params['from_id'] = opts.fromId; @@ -154,6 +155,7 @@ export async function fetchDatasets(opts?: { const res = await haveApiCall({ method: 'GET', path: '/datasets', + signal: opts?.signal, namespace: 'dataset', params, meta: @@ -233,6 +235,7 @@ export async function fetchDatasetSnapshots(datasetId: number, opts?: { fromId?: number; limit?: number; count?: boolean; + signal?: AbortSignal; }) { const params: Record = {}; if (opts?.fromId !== undefined) params['from_id'] = opts.fromId; @@ -241,6 +244,7 @@ export async function fetchDatasetSnapshots(datasetId: number, opts?: { const res = await haveApiCall({ method: 'GET', path: `/datasets/${datasetId}/snapshots`, + signal: opts?.signal, namespace: 'snapshot', params, meta: opts?.count ? { count: true } : undefined, diff --git a/src/pages/app/datasets/DatasetPageSearchState.tsx b/src/pages/app/datasets/DatasetPageSearchState.tsx index 829344ec..d755a9ad 100644 --- a/src/pages/app/datasets/DatasetPageSearchState.tsx +++ b/src/pages/app/datasets/DatasetPageSearchState.tsx @@ -24,6 +24,7 @@ export function DatasetPageSearchEmpty(props: { hasMore: boolean; hasSourceRows: boolean; onClear: () => void; + onNext: () => void; }) { const { t } = useI18n(); const { pagination } = props; @@ -37,15 +38,15 @@ export function DatasetPageSearchEmpty(props: { actionLabel={t('common.clear_filters')} onAction={props.onClear} /> - {props.hasSourceRows ? ( + {props.hasSourceRows || pagination.canPrev ? ( pagination.goNext(props.pageCursor)} + onNext={props.onNext} onGoToPage={pagination.goToPage} limit={pagination.limit} allowedLimits={pagination.allowedLimits} diff --git a/src/pages/app/datasets/DatasetSnapshotsPage.test.tsx b/src/pages/app/datasets/DatasetSnapshotsPage.test.tsx index eccacb6e..8b58cf07 100644 --- a/src/pages/app/datasets/DatasetSnapshotsPage.test.tsx +++ b/src/pages/app/datasets/DatasetSnapshotsPage.test.tsx @@ -146,7 +146,7 @@ describe('DatasetSnapshotsPage', () => { await waitFor(() => expect(api.fetchDatasetSnapshots).toHaveBeenCalledWith( 10402, - { limit: 51, fromId: undefined, count: true } + { limit: 51, fromId: undefined, count: true, signal: expect.any(AbortSignal) } ) ); expect(screen.queryByTestId('dataset.snapshots.search.input')).not.toBeInTheDocument(); diff --git a/src/pages/app/datasets/DatasetSnapshotsPage.tsx b/src/pages/app/datasets/DatasetSnapshotsPage.tsx index 62ecf42c..3599cea5 100644 --- a/src/pages/app/datasets/DatasetSnapshotsPage.tsx +++ b/src/pages/app/datasets/DatasetSnapshotsPage.tsx @@ -32,7 +32,6 @@ import { import { formatErrorMessage } from '../../../lib/errors'; import { formatDateTime } from '../../../lib/format'; import { useKeysetPagination } from '../../../lib/hooks/useKeysetPagination'; -import { cursorFromAscendingPage } from '../../../lib/lockIndex'; import { hasActiveChains } from '../../../lib/taskStatus'; import { useDatasetContext } from './DatasetContext'; @@ -95,6 +94,7 @@ export function DatasetSnapshotsPage({ queryParamPrefix = '' }: DatasetSnapshots paramPrefix: queryParamPrefix, defaultLimit: 50, allowedLimits: [25, 50, 100], + restoreUrlCursorOnSignatureChange: true, }); const [createOpen, setCreateOpen] = useState(false); const [createLabel, setCreateLabel] = useState(''); @@ -116,13 +116,14 @@ export function DatasetSnapshotsPage({ queryParamPrefix = '' }: DatasetSnapshots const snapsQ = useQuery({ queryKey: ['datasets', dataset.id, 'snapshots', { limit: pagination.limit, fromId: pagination.fromId }], - queryFn: async () => + queryFn: async ({ signal }) => fetchDatasetSnapshots(dataset.id, { // HaveAPI's cursor does not expose an end marker. Fetch one extra row so // Next remains correct for exact-size pages, deep links and count churn. limit: pagination.limit + 1, fromId: pagination.fromId, count: true, + signal, }), }); @@ -295,8 +296,17 @@ export function DatasetSnapshotsPage({ queryParamPrefix = '' }: DatasetSnapshots const rows = pageData.slice(0, pagination.limit); const totalCount = reportedTotalCount ?? rows.length; - const pageCursor = useMemo(() => cursorFromAscendingPage(rows as any), [rows]); - const hasMore = pagination.hasForward || pageData.length > pagination.limit; + // The API anchors in (created_at, id) order, not numeric ID order. + const pageCursor = rows.at(-1)?.id ?? null; + const hasMore = !snapsQ.isFetching && !snapsQ.isError + && pageData.length > pagination.limit && Number.isSafeInteger(pageCursor) && Number(pageCursor) > 0; + const restart = () => pagination.goToPageWithStack(1, [null]); + const goNext = () => { + if (!hasMore || pageCursor == null) return; + pagination.goToPageWithStack(pagination.page + 1, [ + ...pagination.stack.slice(0, pagination.index + 1), pageCursor, + ]); + }; function requestSnapshotDownload(s: Snapshot) { createDl.mutate(s); @@ -397,7 +407,11 @@ export function DatasetSnapshotsPage({ queryParamPrefix = '' }: DatasetSnapshots testId="dataset.snapshots.error" title={t('dataset.snapshots.load_error.title')} error={snapsQ.error} - onRetry={() => void snapsQ.refetch()} + actions={{ + primary: { label: t('common.retry'), onClick: () => snapsQ.refetch() }, + secondary: pagination.cursor != null + ? { label: t('datasets.pagination.restart'), onClick: restart } : undefined, + }} showBack={false} detailsExtra={{ page: 'dataset.snapshots', datasetId: dataset.id }} /> @@ -543,7 +557,7 @@ export function DatasetSnapshotsPage({ queryParamPrefix = '' }: DatasetSnapshots canPrev={pagination.canPrev} canNext={hasMore} onPrev={pagination.goPrev} - onNext={() => pagination.goNext(pageCursor)} + onNext={goNext} onGoToPage={pagination.goToPage} limit={pagination.limit} allowedLimits={pagination.allowedLimits} @@ -561,7 +575,7 @@ export function DatasetSnapshotsPage({ queryParamPrefix = '' }: DatasetSnapshots canPrev={pagination.canPrev} canNext={hasMore} onPrev={pagination.goPrev} - onNext={() => pagination.goNext(pageCursor)} + onNext={goNext} onGoToPage={pagination.goToPage} limit={pagination.limit} allowedLimits={pagination.allowedLimits} diff --git a/src/pages/app/datasets/DatasetsListPage.tsx b/src/pages/app/datasets/DatasetsListPage.tsx index 10ab1c1d..603ffbd7 100644 --- a/src/pages/app/datasets/DatasetsListPage.tsx +++ b/src/pages/app/datasets/DatasetsListPage.tsx @@ -13,7 +13,6 @@ import { PageHeader } from '../../../components/layout/PageHeader'; import { fetchDatasets, type Dataset } from '../../../lib/api/datasets'; import { searchUsers } from '../../../lib/api/users'; import { useKeysetPagination } from '../../../lib/hooks/useKeysetPagination'; -import { cursorFromDescendingPage } from '../../../lib/lockIndex'; import { objectStateBadge } from '../../../lib/taskStatus'; import { dotVariantFromBadgeVariant, dotVariantFromRowVariant } from '../../../lib/variantMap'; import { parsePositiveInt } from '../../../lib/parse'; @@ -158,6 +157,7 @@ export function DatasetsListPage(props: DatasetsListPageProps = {}) { setSearchParams, defaultLimit: 50, allowedLimits: [25, 50, 100], + restoreUrlCursorOnSignatureChange: true, }); const datasetsQ = useQuery({ @@ -173,10 +173,11 @@ export function DatasetsListPage(props: DatasetsListPageProps = {}) { includes, }, ], - queryFn: async () => ( + queryFn: async ({ signal }) => ( await fetchDatasets({ - limit: pagination.limit, + limit: pagination.limit + 1, fromId: pagination.fromId, + signal, includes, user: mode === 'admin' ? userIdNum : scope.mineUserId, vps: showVpsFilter ? vpsIdNum || undefined : undefined, @@ -185,7 +186,7 @@ export function DatasetsListPage(props: DatasetsListPageProps = {}) { ).data, }); - const pageRows = datasetsQ.data ?? []; + const pageRows = useMemo(() => (datasetsQ.data ?? []).slice(0, pagination.limit), [datasetsQ.data, pagination.limit]); const rows = useMemo(() => filterDatasetPage(pageRows, qText), [pageRows, qText]); const showSnapshotColumn = rows.some((ds) => hasValue(ds.snapshots_count)); const showMountColumn = rows.some((ds) => hasValue(ds.mount_count)); @@ -193,8 +194,17 @@ export function DatasetsListPage(props: DatasetsListPageProps = {}) { const showStateColumn = rows.some((ds) => hasValue((ds as any).object_state)); const showRelatedMeta = showSnapshotColumn || showMountColumn || showExportColumn; - const pageCursor = useMemo(() => cursorFromDescendingPage(pageRows as any), [pageRows]); - const hasMore = pageRows.length >= pagination.limit; + // The API anchors in (full_name, id) order, not numeric ID order. + const pageCursor = pageRows.at(-1)?.id ?? null; + const hasMore = !datasetsQ.isFetching && !datasetsQ.isError + && (datasetsQ.data?.length ?? 0) > pagination.limit && Number.isSafeInteger(pageCursor) && Number(pageCursor) > 0; + const restart = () => pagination.goToPageWithStack(1, [null]); + const goNext = () => { + if (!hasMore || pageCursor == null) return; + pagination.goToPageWithStack(pagination.page + 1, [ + ...pagination.stack.slice(0, pagination.index + 1), pageCursor, + ]); + }; const filtersActive = Boolean(qText) || Boolean(userIdNum !== undefined) || Boolean(showVpsFilter && vpsIdNum !== undefined); @@ -210,6 +220,8 @@ export function DatasetsListPage(props: DatasetsListPageProps = {}) { const v = String(value ?? '').trim(); if (v) next.set(key, v); else next.delete(key); + next.delete('from_id'); + next.set('page', '1'); return next; }); } @@ -236,6 +248,8 @@ export function DatasetsListPage(props: DatasetsListPageProps = {}) { next.delete('q'); next.delete('user'); next.delete('vps'); + next.delete('from_id'); + next.set('page', '1'); return next; }); setSmart(''); @@ -626,7 +640,11 @@ export function DatasetsListPage(props: DatasetsListPageProps = {}) { testId="datasets.list.error" title={t(loadErrorTitleKey)} error={datasetsQ.error} - onRetry={() => void datasetsQ.refetch()} + actions={{ + primary: { label: t('common.retry'), onClick: () => datasetsQ.refetch() }, + secondary: pagination.cursor != null + ? { label: t('datasets.pagination.restart'), onClick: restart } : undefined, + }} showBack={false} detailsExtra={{ page: 'datasets.list', scope: scope.scope }} /> @@ -636,16 +654,22 @@ export function DatasetsListPage(props: DatasetsListPageProps = {}) { pageCursor={pageCursor} hasMore={hasMore} hasSourceRows={pageRows.length > 0} + onNext={goNext} onClear={clearFilters} /> ) : rows.length === 0 ? ( - + <> + + {pagination.canPrev ? : null} + ) : ( <> {/* Mobile: cards */} @@ -739,9 +763,9 @@ export function DatasetsListPage(props: DatasetsListPageProps = {}) { page={pagination.page} pageCount={pagination.stack.length} canPrev={pagination.canPrev} - canNext={pagination.hasForward || (hasMore && pageCursor !== null)} + canNext={hasMore} onPrev={pagination.goPrev} - onNext={() => pagination.goNext(pageCursor)} + onNext={goNext} onGoToPage={pagination.goToPage} limit={pagination.limit} allowedLimits={pagination.allowedLimits} @@ -759,9 +783,9 @@ export function DatasetsListPage(props: DatasetsListPageProps = {}) { page={pagination.page} pageCount={pagination.stack.length} canPrev={pagination.canPrev} - canNext={pagination.hasForward || (hasMore && pageCursor !== null)} + canNext={hasMore} onPrev={pagination.goPrev} - onNext={() => pagination.goNext(pageCursor)} + onNext={goNext} onGoToPage={pagination.goToPage} limit={pagination.limit} allowedLimits={pagination.allowedLimits}