diff --git a/.changeset/stupid-clocks-fail.md b/.changeset/stupid-clocks-fail.md new file mode 100644 index 000000000..474024ac4 --- /dev/null +++ b/.changeset/stupid-clocks-fail.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/design-system': minor +--- + +useTableState now supports a defaultSortDirection. diff --git a/apps/hostd-e2e/src/fixtures/contracts.ts b/apps/hostd-e2e/src/fixtures/contracts.ts index cbe844d2c..3d8a78ff6 100644 --- a/apps/hostd-e2e/src/fixtures/contracts.ts +++ b/apps/hostd-e2e/src/fixtures/contracts.ts @@ -1,4 +1,4 @@ -import { Page } from '@playwright/test' +import { expect, Page } from '@playwright/test' import { maybeExpectAndReturn, step } from '@siafoundation/e2e' export const getContractRowById = step( @@ -33,6 +33,19 @@ export const getContractRowByIndex = step( } ) +export const expectContractRowByIndex = step( + 'expect contract row by index', + async (page: Page, index: number) => { + return expect( + page + .getByTestId('contractsTable') + .locator('tbody') + .getByRole('row') + .nth(index) + ).toBeVisible() + } +) + export function getContractRows(page: Page) { return page.getByTestId('contractsTable').locator('tbody').getByRole('row') } diff --git a/apps/hostd-e2e/src/fixtures/volumes.ts b/apps/hostd-e2e/src/fixtures/volumes.ts index f958256fc..9a0c04484 100644 --- a/apps/hostd-e2e/src/fixtures/volumes.ts +++ b/apps/hostd-e2e/src/fixtures/volumes.ts @@ -1,6 +1,11 @@ import { Page, expect } from '@playwright/test' import { navigateToVolumes } from './navigate' -import { fillTextInputByName, step } from '@siafoundation/e2e' +import { + clearToasts, + fillTextInputByName, + maybeExpectAndReturn, + step, +} from '@siafoundation/e2e' export const createVolume = step( 'create volume', @@ -20,9 +25,10 @@ export const createVolume = step( await page.locator('input[name=size]').press('Enter') await expect(page.getByRole('dialog')).toBeHidden() const row = page.getByRole('row', { name: fullPath }) - await expect(page.getByText('Volume created')).toBeVisible() + await expect(page.getByText('New volume created')).toBeVisible() await expect(row.getByText('ready')).toBeVisible() await expect(page.getByRole('cell', { name: fullPath })).toBeVisible() + await clearToasts({ page }) } ) @@ -47,7 +53,7 @@ export const deleteVolumeIfExists = step( 'delete volume if exists', async (page: Page, name: string, path: string) => { const doesVolumeExist = await page - .getByRole('table') + .getByTestId('volumesTable') .getByText(path + '/' + name) .isVisible() if (doesVolumeExist) { @@ -67,16 +73,47 @@ export const openVolumeContextMenu = step( } ) +export function getVolumeRows(page: Page) { + return page.getByTestId('volumesTable').locator('tbody').getByRole('row') +} + export const volumeInList = step( 'volume in list', async (page: Page, name: string) => { - await expect(page.getByRole('table').getByText(name)).toBeVisible() + await expect(page.getByTestId('volumesTable').getByText(name)).toBeVisible() } ) export const volumeNotInList = step( 'volume not in list', async (page: Page, name: string) => { - await expect(page.getByRole('table').getByText(name)).toBeHidden() + await expect(page.getByTestId('volumesTable').getByText(name)).toBeHidden() + } +) + +export const getVolumeRowByIndex = step( + 'get volume row by index', + async (page: Page, index: number, shouldExpect?: boolean) => { + return maybeExpectAndReturn( + page + .getByTestId('volumesTable') + .locator('tbody') + .getByRole('row') + .nth(index), + shouldExpect + ) + } +) + +export const expectVolumeRowByIndex = step( + 'expect volume row by index', + async (page: Page, index: number) => { + return expect( + page + .getByTestId('volumesTable') + .locator('tbody') + .getByRole('row') + .nth(index) + ).toBeVisible() } ) diff --git a/apps/hostd-e2e/src/specs/contracts.spec.ts b/apps/hostd-e2e/src/specs/contracts.spec.ts index 3c4b828c1..d5004f1aa 100644 --- a/apps/hostd-e2e/src/specs/contracts.spec.ts +++ b/apps/hostd-e2e/src/specs/contracts.spec.ts @@ -1,15 +1,16 @@ -import { test, expect } from '@playwright/test' +import { test, expect, Page } from '@playwright/test' import { navigateToContracts } from '../fixtures/navigate' import { afterTest, beforeTest } from '../fixtures/beforeTest' import { - getContractRowByIndex, + expectContractRowByIndex, getContractRows, getContractRowsAll, } from '../fixtures/contracts' +import { ContractsResponse, contractsRoute } from '@siafoundation/hostd-types' test.beforeEach(async ({ page }) => { await beforeTest(page, { - renterdCount: 2, + renterdCount: 3, }) }) @@ -20,15 +21,15 @@ test.afterEach(async () => { test('contracts bulk integrity check', async ({ page }) => { await navigateToContracts(page) const rows = await getContractRowsAll(page) - rows.at(0).click() - rows.at(-1).click({ modifiers: ['Shift'] }) + await rows.at(0).click({ position: { x: 5, y: 5 } }) + await rows.at(2).click({ modifiers: ['Shift'] }) const menu = page.getByLabel('contract multi-select menu') // Run check for each contract. await menu.getByLabel('run integrity check for each contract').click() await expect( - page.getByText('Integrity checks started for 2 contracts') + page.getByText('Integrity checks started for 3 contracts') ).toBeVisible() }) @@ -51,5 +52,59 @@ test('viewing a page with no data shows the correct empty state', async ({ await expect(page.getByText('Back to first page')).toBeVisible() await page.getByText('Back to first page').click() // Ensure we are now seeing rows of data. - await getContractRowByIndex(page, 0, true) + await expectContractRowByIndex(page, 0) }) + +test('paginating contracts with known total and client side pagination', async ({ + page, +}) => { + await interceptApiContactsAndEnsure3Results(page) + await navigateToContracts(page) + const url = page.url() + await page.goto(url + '?limit=1') + + const first = page.getByRole('button', { name: 'go to first page' }) + const previous = page.getByRole('button', { name: 'go to previous page' }) + const next = page.getByRole('button', { name: 'go to next page' }) + const last = page.getByRole('button', { name: 'go to last page' }) + const rows = getContractRows(page) + await expect(rows).toHaveCount(1) + await expect(first).toBeDisabled() + await expect(previous).toBeDisabled() + await expect(next).toBeEnabled() + await expect(last).toBeEnabled() + await next.click() + await expect(rows).toHaveCount(1) + await expect(first).toBeEnabled() + await expect(previous).toBeEnabled() + await expect(next).toBeEnabled() + await expect(last).toBeEnabled() + await next.click() + await expect(rows).toHaveCount(1) + await expect(first).toBeEnabled() + await expect(previous).toBeEnabled() + await expect(next).toBeDisabled() + await expect(last).toBeDisabled() +}) + +async function interceptApiContactsAndEnsure3Results(page: Page) { + await page.route(`**/api${contractsRoute}*`, async (route) => { + console.log('Intercepted contracts API request') + // Fetch the original response. + const response = await route.fetch() + + // Parse the response body as JSON. + const originalData: ContractsResponse = await response.json() + + // Slice the contracts down to exactly 3 items. + const modifiedData: ContractsResponse = { + contracts: originalData.contracts.slice(0, 3), + count: Math.min(3, originalData.count), + } + + // Fulfill the route with the modified response. + await route.fulfill({ + json: modifiedData, + }) + }) +} diff --git a/apps/hostd-e2e/src/specs/volumes.spec.ts b/apps/hostd-e2e/src/specs/volumes.spec.ts index 11c3b9251..f99c95335 100644 --- a/apps/hostd-e2e/src/specs/volumes.spec.ts +++ b/apps/hostd-e2e/src/specs/volumes.spec.ts @@ -3,12 +3,16 @@ import { navigateToVolumes } from '../fixtures/navigate' import { createVolume, deleteVolume, + expectVolumeRowByIndex, + getVolumeRows, openVolumeContextMenu, + volumeInList, } from '../fixtures/volumes' import { afterTest, beforeTest } from '../fixtures/beforeTest' import fs from 'fs' import os from 'os' import { fillTextInputByName } from '@siafoundation/e2e' +import path from 'path' let dirPath = '/' @@ -36,7 +40,7 @@ test('can resize volume', async ({ page }) => { await createVolume(page, name, dirPath) await openVolumeContextMenu(page, `${dirPath}/${name}`) await page.getByText('Resize').click() - await fillTextInputByName(page, 'size', '1300') + await fillTextInputByName(page, 'size', '1300000') const dialog = page.getByRole('dialog') await expect(dialog.getByText('Must be between 10.00 GB')).toBeVisible() await fillTextInputByName(page, 'size', '13') @@ -46,3 +50,80 @@ test('can resize volume', async ({ page }) => { await expect(page.getByText('Volume resizing')).toBeVisible() await expect(page.getByText('resizing')).toBeVisible() }) + +test('paginating volumes with known total and client side pagination', async ({ + page, +}) => { + await navigateToVolumes({ page }) + // The cluster creates an initial volume so the following is the second volume. + await createVolume(page, 'v2', dirPath) + await createVolume(page, 'v3', dirPath) + const url = page.url() + await page.goto(url + '?limit=1') + + const first = page.getByRole('button', { name: 'go to first page' }) + const previous = page.getByRole('button', { name: 'go to previous page' }) + const next = page.getByRole('button', { name: 'go to next page' }) + const last = page.getByRole('button', { name: 'go to last page' }) + await expect(getVolumeRows(page).getByText('sia-cluster')).toBeVisible() + await expect(first).toBeDisabled() + await expect(previous).toBeDisabled() + await expect(next).toBeEnabled() + await expect(last).toBeEnabled() + await next.click() + await volumeInList(page, getVolumePath(dirPath, 'v2')) + await expect(first).toBeEnabled() + await expect(previous).toBeEnabled() + await expect(next).toBeEnabled() + await expect(last).toBeEnabled() + await next.click() + await volumeInList(page, getVolumePath(dirPath, 'v3')) + await expect(first).toBeEnabled() + await expect(previous).toBeEnabled() + await expect(next).toBeDisabled() + await expect(last).toBeDisabled() +}) + +test('viewing a page with no data shows the correct empty state', async ({ + page, +}) => { + await navigateToVolumes({ page }) + // The cluster creates an initial volume so the following is the second volume. + await createVolume(page, 'v2', dirPath) + const url = page.url() + await page.goto(url + '?limit=1') + + const first = page.getByRole('button', { name: 'go to first page' }) + const previous = page.getByRole('button', { name: 'go to previous page' }) + const next = page.getByRole('button', { name: 'go to next page' }) + const last = page.getByRole('button', { name: 'go to last page' }) + await expect(getVolumeRows(page).getByText('sia-cluster')).toBeVisible() + await expect(first).toBeDisabled() + await expect(previous).toBeDisabled() + await expect(next).toBeEnabled() + await expect(last).toBeEnabled() + await next.click() + await volumeInList(page, getVolumePath(dirPath, 'v2')) + await expect(first).toBeEnabled() + await expect(previous).toBeEnabled() + await expect(next).toBeDisabled() + await expect(last).toBeDisabled() + + await deleteVolume(page, 'v2', dirPath) + + await expect( + page.getByText('No data on this page, reset pagination to continue.') + ).toBeVisible() + await expect(page.getByText('Back to first page')).toBeVisible() + await page.getByText('Back to first page').click() + // Ensure we are now seeing rows of data. + await expectVolumeRowByIndex(page, 0) + await expect(first).toBeDisabled() + await expect(previous).toBeDisabled() + await expect(next).toBeDisabled() + await expect(last).toBeDisabled() +}) + +function getVolumePath(dirPath: string, name: string) { + return path.join(dirPath, name) +} diff --git a/apps/hostd/components/Volumes/index.tsx b/apps/hostd/components/Volumes/index.tsx index 8368061b8..1d56ca0be 100644 --- a/apps/hostd/components/Volumes/index.tsx +++ b/apps/hostd/components/Volumes/index.tsx @@ -3,14 +3,14 @@ import { useVolumes } from '../../contexts/volumes' import { StateNoneYet } from './StateNoneYet' export function Volumes() { - const { dataset, datasetState, isLoading, visibleColumns } = useVolumes() + const { datasetPage, datasetState, isLoading, visibleColumns } = useVolumes() return (
} /> diff --git a/apps/hostd/contexts/volumes/index.tsx b/apps/hostd/contexts/volumes/index.tsx index b9bfd3f23..736698182 100644 --- a/apps/hostd/contexts/volumes/index.tsx +++ b/apps/hostd/contexts/volumes/index.tsx @@ -36,6 +36,8 @@ function useVolumesMain() { } = useTableState('hostd/v0/volumes', { columns, columnsDefaultVisible, + defaultSortField: 'id', + defaultSortDirection: 'asc', }) const response = useVolumesData({ @@ -68,6 +70,7 @@ function useVolumesMain() { datasetPage, isValidating, error, + offset, }) return { diff --git a/apps/renterd-e2e/src/fixtures/files.ts b/apps/renterd-e2e/src/fixtures/files.ts index 4eddcd847..c949acbfe 100644 --- a/apps/renterd-e2e/src/fixtures/files.ts +++ b/apps/renterd-e2e/src/fixtures/files.ts @@ -1,5 +1,4 @@ import { Page, expect } from '@playwright/test' -import { readFileSync } from 'fs' import { fillTextInputByName, maybeExpectAndReturn, @@ -7,7 +6,6 @@ import { } from '@siafoundation/e2e' import { navigateToBuckets } from './navigate' import { openBucket } from './buckets' -import { join } from 'path' export const deleteFile = step( 'delete file', @@ -185,14 +183,56 @@ export const getFileRowById = step( } ) +export const expectFileRowById = step( + 'expect file row by ID', + async (page: Page, id: string) => { + return expect(page.getByTestId('filesTable').getByTestId(id)).toBeVisible() + } +) + +export const changeExplorerMode = step( + 'change explorer mode', + async (page: Page, mode: 'directory' | 'all files' | 'uploads') => { + const changeMode = page.getByRole('button', { + name: 'change explorer mode', + }) + const modeButton = page + .getByRole('menu') + .getByRole('menuitem', { name: mode }) + await expect(changeMode).toBeVisible() + await changeMode.click() + await expect(modeButton).toBeVisible() + await modeButton.click() + + if (mode === 'uploads') { + await expect(page.getByTestId('uploadsTable')).toBeVisible() + } + if (mode === 'all files') { + await expect(page.getByTestId('filesTable')).toBeVisible() + await expect( + page.getByTestId('navbar').getByText('All files') + ).toBeVisible() + } + if (mode === 'directory') { + await expect(page.getByTestId('filesTable')).toBeVisible() + await expect( + page.getByTestId('navbar').getByText('All files') + ).toBeHidden() + } + } +) + +function generateDummyFile(sizeInBytes: number): Buffer { + return Buffer.alloc(sizeInBytes, 'a') +} + async function simulateDragAndDropFile( page: Page, selector: string, - filePath: string, fileName: string, - fileType = '' + sizeInBytes: number ) { - const buffer = readFileSync(filePath).toString('base64') + const buffer = generateDummyFile(sizeInBytes).toString('base64') const dataTransfer = await page.evaluateHandle( async ({ bufferData, localFileName, localFileType }) => { @@ -209,7 +249,7 @@ async function simulateDragAndDropFile( { bufferData: `data:application/octet-stream;base64,${buffer}`, localFileName: fileName, - localFileType: fileType, + localFileType: '', } ) @@ -218,18 +258,18 @@ async function simulateDragAndDropFile( export const dragAndDropFileFromSystem = step( 'drag and drop file from system', - async (page: Page, localFilePath: string, systemFile?: string) => { + async (page: Page, localFilePath: string, sizeInBytes = 10) => { await simulateDragAndDropFile( page, `[data-testid=filesDropzone]`, - join(__dirname, 'sample-files', systemFile || 'sample.txt'), - '/' + localFilePath + '/' + localFilePath, + sizeInBytes ) } ) -export interface FileMap { - [key: string]: string | FileMap +export type FileMap = { + [key: string]: number | FileMap } // Iterate through the file map and create files/directories. @@ -246,7 +286,8 @@ export const createFilesMap = step( await fileInList(page, path + '/') await create(map[name] as FileMap, stack.concat(name)) } else { - await dragAndDropFileFromSystem(page, name) + const size = (map[name] as number) || 10 + await dragAndDropFileFromSystem(page, name, size) await fileInList(page, path) } } @@ -257,7 +298,7 @@ export const createFilesMap = step( } ) -interface FileExpectMap { +type FileExpectMap = { [key: string]: 'visible' | 'hidden' | FileExpectMap } @@ -265,7 +306,7 @@ interface FileExpectMap { export const expectFilesMap = step( 'expect files map', async (page: Page, bucketName: string, map: FileExpectMap) => { - const check = async (map: FileMap, stack: string[]) => { + const check = async (map: FileExpectMap, stack: string[]) => { for (const name in map) { await openDirectoryFromAnywhere(page, stack.join('/')) const currentDirPath = stack.join('/') @@ -279,7 +320,7 @@ export const expectFilesMap = step( } } else { await fileInList(page, path + '/') - await check(map[name] as FileMap, stack.concat(name)) + await check(map[name], stack.concat(name)) } } } diff --git a/apps/renterd-e2e/src/fixtures/uploads.ts b/apps/renterd-e2e/src/fixtures/uploads.ts new file mode 100644 index 000000000..3e7609116 --- /dev/null +++ b/apps/renterd-e2e/src/fixtures/uploads.ts @@ -0,0 +1,37 @@ +import { Page, expect } from '@playwright/test' +import { maybeExpectAndReturn, step } from '@siafoundation/e2e' + +export const uploadInList = step( + 'expect upload in list', + async (page: Page, id: string, timeout?: number) => { + await expect(page.getByTestId('uploadsTable').getByTestId(id)).toBeVisible({ + timeout, + }) + } +) + +export const uploadNotInList = step( + 'expect upload not in list', + async (page: Page, id: string) => { + await expect(page.getByTestId('uploadsTable').getByTestId(id)).toBeHidden() + } +) + +export const getUploadRowById = step( + 'get upload row by ID', + async (page: Page, id: string, shouldExpect?: boolean) => { + return maybeExpectAndReturn( + page.getByTestId('uploadsTable').getByTestId(id), + shouldExpect + ) + } +) + +export const expectUploadRowById = step( + 'expect upload row by ID', + async (page: Page, id: string) => { + return expect( + page.getByTestId('uploadsTable').getByTestId(id) + ).toBeVisible() + } +) diff --git a/apps/renterd-e2e/src/specs/contracts.spec.ts b/apps/renterd-e2e/src/specs/contracts.spec.ts index 34be50b33..454b75b1f 100644 --- a/apps/renterd-e2e/src/specs/contracts.spec.ts +++ b/apps/renterd-e2e/src/specs/contracts.spec.ts @@ -67,8 +67,8 @@ test('contracts prunable size', async ({ page }) => { test('contracts bulk delete', async ({ page }) => { await navigateToContracts({ page }) const rows = await getContractRowsAll(page) - rows.at(0).click() - rows.at(-1).click({ modifiers: ['Shift'] }) + await rows.at(0).click({ position: { x: 5, y: 5 } }) + await rows.at(-1).click({ modifiers: ['Shift'] }) // Delete selected contracts. const menu = page.getByLabel('contract multi-select menu') @@ -82,8 +82,8 @@ test('contracts bulk delete', async ({ page }) => { test('contracts bulk rescan', async ({ page }) => { await navigateToContracts({ page }) const rows = await getContractRowsAll(page) - rows.at(0).click() - rows.at(-1).click({ modifiers: ['Shift'] }) + await rows.at(0).click({ position: { x: 5, y: 5 } }) + await rows.at(-1).click({ modifiers: ['Shift'] }) // Rescan selected hosts. const menu = page.getByLabel('contract multi-select menu') @@ -94,8 +94,8 @@ test('contracts bulk rescan', async ({ page }) => { test('contracts bulk allowlist', async ({ page }) => { await navigateToContracts({ page }) const rows = await getContractRowsAll(page) - rows.at(0).click() - rows.at(-1).click({ modifiers: ['Shift'] }) + await rows.at(0).click({ position: { x: 5, y: 5 } }) + await rows.at(-1).click({ modifiers: ['Shift'] }) const menu = page.getByLabel('contract multi-select menu') const dialog = page.getByRole('dialog') @@ -112,8 +112,8 @@ test('contracts bulk allowlist', async ({ page }) => { ).toHaveCount(3) await dialog.getByLabel('close').click() - rows.at(0).click() - rows.at(-1).click({ modifiers: ['Shift'] }) + await rows.at(0).click({ position: { x: 5, y: 5 } }) + await rows.at(-1).click({ modifiers: ['Shift'] }) // Remove selected contract hosts from the allowlist. await menu.getByLabel('remove host public keys from allowlist').click() diff --git a/apps/renterd-e2e/src/specs/files.spec.ts b/apps/renterd-e2e/src/specs/files.spec.ts index f72d8c2d2..bdf5c5c62 100644 --- a/apps/renterd-e2e/src/specs/files.spec.ts +++ b/apps/renterd-e2e/src/specs/files.spec.ts @@ -229,10 +229,10 @@ test('shows a new intermediate directory when uploading nested files', async ({ await filterInput.clear() await openDirectory(page, dirPath) const fileRow = await getFileRowById(page, filePath) - await expect(fileRow.getByText('11 B')).toBeVisible() + await expect(fileRow.getByText('10 B')).toBeVisible() await navigateToParentDirectory(page) // The intermediate directory eventually updates to show the correct size. - await expect(dirRow.getByText('11 B')).toBeVisible() + await expect(dirRow.getByText('10 B')).toBeVisible() // Clean up the container directory. await navigateToParentDirectory(page) @@ -251,13 +251,13 @@ test('bulk delete across nested directories', async ({ page }) => { await createBucket(page, bucketName) await createFilesMap(page, bucketName, { dir1: { - 'file1.txt': null, - 'file2.txt': null, + 'file1.txt': 10, + 'file2.txt': 10, }, dir2: { - 'file3.txt': null, - 'file4.txt': null, - 'file5.txt': null, + 'file3.txt': 10, + 'file4.txt': 10, + 'file5.txt': 10, }, }) await navigateToBuckets({ page }) @@ -296,13 +296,13 @@ test('bulk delete using the all files explorer mode', async ({ page }) => { await createBucket(page, bucketName) await createFilesMap(page, bucketName, { dir1: { - 'file1.txt': null, - 'file2.txt': null, + 'file1.txt': 10, + 'file2.txt': 10, }, dir2: { - 'file3.txt': null, - 'file4.txt': null, - 'file5.txt': null, + 'file3.txt': 10, + 'file4.txt': 10, + 'file5.txt': 10, }, }) await navigateToBuckets({ page }) @@ -346,11 +346,11 @@ test('bulk selecting the entire page ignores the .. parent directory nav row', a await navigateToBuckets({ page }) await createBucket(page, bucketName) await createFilesMap(page, bucketName, { - 'file1.txt': null, - 'file2.txt': null, - 'file3.txt': null, - 'file4.txt': null, - 'file5.txt': null, + 'file1.txt': 10, + 'file2.txt': 10, + 'file3.txt': 10, + 'file4.txt': 10, + 'file5.txt': 10, }) await navigateToBuckets({ page }) await openBucket(page, bucketName) diff --git a/apps/renterd-e2e/src/specs/filesMove.spec.ts b/apps/renterd-e2e/src/specs/filesMove.spec.ts index dd51162c5..37a437bf7 100644 --- a/apps/renterd-e2e/src/specs/filesMove.spec.ts +++ b/apps/renterd-e2e/src/specs/filesMove.spec.ts @@ -28,16 +28,16 @@ test('move two files by selecting and dragging from one directory out to another await navigateToBuckets({ page }) await createBucket(page, bucketName) await createFilesMap(page, bucketName, { - 'file1.txt': null, + 'file1.txt': 10, dir1: { - 'file2.txt': null, + 'file2.txt': 10, }, dir2: { - 'file3.txt': null, - 'file4.txt': null, + 'file3.txt': 10, + 'file4.txt': 10, dir3: { - 'file5.txt': null, - 'file6.txt': null, + 'file5.txt': 10, + 'file6.txt': 10, }, }, }) @@ -88,15 +88,15 @@ test('move a file via drag and drop while leaving a separate set of selected fil await navigateToBuckets({ page }) await createBucket(page, bucketName) await createFilesMap(page, bucketName, { - 'file0.txt': null, - 'file1.txt': null, + 'file0.txt': 10, + 'file1.txt': 10, dir1: { - 'file2.txt': null, + 'file2.txt': 10, }, dir2: { - 'file3.txt': null, - 'file4.txt': null, - 'file5.txt': null, + 'file3.txt': 10, + 'file4.txt': 10, + 'file5.txt': 10, }, }) await navigateToBuckets({ page }) @@ -143,16 +143,16 @@ test('move files by selecting and using the docked menu bulk action', async ({ await navigateToBuckets({ page }) await createBucket(page, bucketName) await createFilesMap(page, bucketName, { - 'file1.txt': null, + 'file1.txt': 10, dir1: { - 'file2.txt': null, + 'file2.txt': 10, }, dir2: { - 'file3.txt': null, - 'file4.txt': null, + 'file3.txt': 10, + 'file4.txt': 10, dir3: { - 'file5.txt': null, - 'file6.txt': null, + 'file5.txt': 10, + 'file6.txt': 10, }, }, }) diff --git a/apps/renterd-e2e/src/specs/hosts.spec.ts b/apps/renterd-e2e/src/specs/hosts.spec.ts index 91b7d5ede..2b57d2172 100644 --- a/apps/renterd-e2e/src/specs/hosts.spec.ts +++ b/apps/renterd-e2e/src/specs/hosts.spec.ts @@ -33,8 +33,8 @@ test('hosts bulk allowlist', async ({ page }) => { await navigateToHosts({ page }) const rows = await getHostRowsAll(page) // Range select the rows, add position as default location is a context menu button. - rows.at(0).click() - rows.at(-1).click({ modifiers: ['Shift'], position: { x: 5, y: 5 } }) + await rows.at(0).click({ position: { x: 5, y: 5 } }) + await rows.at(-1).click({ modifiers: ['Shift'], position: { x: 5, y: 5 } }) const menu = page.getByLabel('host multi-select menu') const dialog = page.getByRole('dialog') @@ -57,8 +57,8 @@ test('hosts bulk allowlist', async ({ page }) => { getHostRows(page).getByTestId('allow').getByTestId('allowed') ).toHaveCount(3) - rows.at(0).click() - rows.at(-1).click({ modifiers: ['Shift'], position: { x: 5, y: 5 } }) + await rows.at(0).click({ position: { x: 5, y: 5 } }) + await rows.at(-1).click({ modifiers: ['Shift'], position: { x: 5, y: 5 } }) // Remove selected hosts from the allowlist. await menu.getByLabel('remove host public keys from allowlist').click() @@ -80,8 +80,8 @@ test('hosts bulk allowlist', async ({ page }) => { test('hosts bulk rescan', async ({ page }) => { await navigateToHosts({ page }) const rows = await getHostRowsAll(page) - rows.at(0).click() - rows.at(-1).click({ modifiers: ['Shift'], position: { x: 5, y: 5 } }) + await rows.at(0).click({ position: { x: 5, y: 5 } }) + await rows.at(-1).click({ modifiers: ['Shift'], position: { x: 5, y: 5 } }) // Rescan selected hosts. const menu = page.getByLabel('host multi-select menu') @@ -93,8 +93,8 @@ test('hosts bulk blocklist', async ({ page }) => { await navigateToHosts({ page }) const rows = await getHostRowsAll(page) // Range select the rows, add position as default location is a context menu button. - rows.at(0).click() - rows.at(-1).click({ modifiers: ['Shift'], position: { x: 5, y: 5 } }) + await rows.at(0).click({ position: { x: 5, y: 5 } }) + await rows.at(-1).click({ modifiers: ['Shift'], position: { x: 5, y: 5 } }) const menu = page.getByLabel('host multi-select menu') const dialog = page.getByRole('dialog') @@ -117,8 +117,8 @@ test('hosts bulk blocklist', async ({ page }) => { getHostRows(page).getByTestId('allow').getByTestId('allowed') ).toHaveCount(0) - rows.at(0).click() - rows.at(-1).click({ modifiers: ['Shift'], position: { x: 5, y: 5 } }) + await rows.at(0).click({ position: { x: 5, y: 5 } }) + await rows.at(-1).click({ modifiers: ['Shift'], position: { x: 5, y: 5 } }) // Remove selected hosts from the blocklist. await menu.getByLabel('remove host addresses from blocklist').click() diff --git a/apps/renterd-e2e/src/specs/pagination.spec.ts b/apps/renterd-e2e/src/specs/pagination.spec.ts new file mode 100644 index 000000000..c5d49dece --- /dev/null +++ b/apps/renterd-e2e/src/specs/pagination.spec.ts @@ -0,0 +1,196 @@ +import { test, expect, Page } from '@playwright/test' +import { navigateToBuckets } from '../fixtures/navigate' +import { createBucket, openBucket } from '../fixtures/buckets' +import { + changeExplorerMode, + createFilesMap, + expectFileRowById, +} from '../fixtures/files' +import { afterTest, beforeTest } from '../fixtures/beforeTest' +import { + busMultipartListuploadsRoute, + MultipartUploadListUploadsResponse, +} from '@siafoundation/renterd-types' +import { expectUploadRowById } from '../fixtures/uploads' +import { getContractRowByIndex } from '../fixtures/contracts' + +test.beforeEach(async ({ page }) => { + await beforeTest(page, { + hostdCount: 3, + }) +}) + +test.afterEach(async () => { + await afterTest() +}) + +test('viewing a page with no data shows the correct empty state', async ({ + page, +}) => { + await page.goto('/contracts?offset=100') + // Check that the empty state is correct. + await expect( + page.getByText('No data on this page, reset pagination to continue.') + ).toBeVisible() + await expect(page.getByText('Back to first page')).toBeVisible() + await page.getByText('Back to first page').click() + // Ensure we are now seeing rows of data. + await getContractRowByIndex(page, 0, true) +}) + +test('paginating files works as expected in both directory and all files mode', async ({ + page, +}) => { + const bucketName = 'bucket1' + await navigateToBuckets({ page }) + await createBucket(page, bucketName) + await createFilesMap(page, bucketName, { + 'file1.txt': 10, + 'file2.txt': 10, + 'file3.txt': 10, + 'file4.txt': 10, + 'file5.txt': 10, + 'file6.txt': 10, + }) + await navigateToBuckets({ page }) + await openBucket(page, bucketName) + let url = page.url() + await page.goto(url + '?limit=2') + + const first = page.getByRole('button', { name: 'go to first page' }) + const next = page.getByRole('button', { name: 'go to next page' }) + await expectFileRowById(page, 'bucket1/file5.txt') + await expect(first).toBeDisabled() + await expect(next).toBeEnabled() + await next.click() + await expectFileRowById(page, 'bucket1/file3.txt') + await expect(first).toBeEnabled() + await expect(next).toBeEnabled() + await next.click() + await expectFileRowById(page, 'bucket1/file1.txt') + await expect(first).toBeEnabled() + await expect(next).toBeDisabled() + + await changeExplorerMode(page, 'all files') + url = page.url() + await page.goto(url + '?limit=2') + + await expectFileRowById(page, 'bucket1/file5.txt') + await expect(first).toBeDisabled() + await expect(next).toBeEnabled() + await next.click() + await expectFileRowById(page, 'bucket1/file3.txt') + await expect(first).toBeEnabled() + await expect(next).toBeEnabled() + await next.click() + await expectFileRowById(page, 'bucket1/file1.txt') + await expect(first).toBeEnabled() + await expect(next).toBeDisabled() +}) + +test('paginating uploads works as expected', async ({ page }) => { + const bucketName = 'bucket1' + // We use a mock for the multipart uploads API because it is otherwise hard to + // catch a stable list of uploads before they complete and the list clears out. + await mockApiMultipartUploads(page) + await navigateToBuckets({ page }) + await createBucket(page, bucketName) + await openBucket(page, bucketName) + const navToUploads = page.getByRole('button', { name: 'Active uploads' }) + await expect(navToUploads).toBeVisible() + await navToUploads.click() + await expect(page.getByTestId('uploadsTable')).toBeVisible() + const first = page.getByRole('button', { name: 'go to first page' }) + const next = page.getByRole('button', { name: 'go to next page' }) + await expectUploadRowById(page, 'upload1') + await expect(first).toBeDisabled() + await expect(next).toBeEnabled() + await next.click() + await expectUploadRowById(page, 'upload3') + await expect(first).toBeEnabled() + await expect(next).toBeEnabled() + await next.click() + await expectUploadRowById(page, 'upload5') + await expect(first).toBeEnabled() + await expect(next).toBeDisabled() +}) + +async function mockApiMultipartUploads(page: Page) { + // Define responses keyed by "uploadIDMarker|keyMarker" + const responseMap: Record = { + // Initial page. + default: { + hasMore: true, + nextUploadIDMarker: 'upload1', + nextMarker: 'key1', + uploads: [ + { + bucket: 'bucket1', + key: 'file1', + encryptionKey: 'encKey1', + uploadID: 'upload1', + createdAt: new Date().toISOString(), + }, + { + bucket: 'bucket1', + key: 'file2', + encryptionKey: 'encKey2', + uploadID: 'upload2', + createdAt: new Date().toISOString(), + }, + ], + }, + // Second page. + 'upload1|key1': { + hasMore: true, + nextUploadIDMarker: 'upload2', + nextMarker: 'key2', + uploads: [ + { + bucket: 'bucket1', + key: 'file3', + encryptionKey: 'encKey3', + uploadID: 'upload3', + createdAt: new Date().toISOString(), + }, + { + bucket: 'bucket1', + key: 'file4', + encryptionKey: 'encKey4', + uploadID: 'upload4', + createdAt: new Date().toISOString(), + }, + ], + }, + // Final page. + 'upload2|key2': { + hasMore: false, + nextMarker: null, + nextUploadIDMarker: null, + uploads: [ + { + bucket: 'bucket1', + key: 'file5', + encryptionKey: 'encKey5', + uploadID: 'upload5', + createdAt: new Date().toISOString(), + }, + ], + }, + } + + await page.route(`**/api${busMultipartListuploadsRoute}*`, async (route) => { + const postData = route.request().postData() + const data = JSON.parse(postData) + const uploadIDMarker = data?.uploadIDMarker + const keyMarker = data?.keyMarker + const key = + uploadIDMarker && keyMarker ? `${uploadIDMarker}|${keyMarker}` : 'default' + const response = responseMap[key] + await route.fulfill({ + json: response, + }) + }) + + return responseMap +} diff --git a/apps/renterd/components/Uploads/UploadsTable.tsx b/apps/renterd/components/Uploads/UploadsTable.tsx index e84d8b888..f1b9aff3a 100644 --- a/apps/renterd/components/Uploads/UploadsTable.tsx +++ b/apps/renterd/components/Uploads/UploadsTable.tsx @@ -15,6 +15,7 @@ export function UploadsTable() { return (
} pageSize={10} diff --git a/apps/renterd/contexts/uploads/index.tsx b/apps/renterd/contexts/uploads/index.tsx index d12fa9cab..2b4c9c05b 100644 --- a/apps/renterd/contexts/uploads/index.tsx +++ b/apps/renterd/contexts/uploads/index.tsx @@ -2,6 +2,7 @@ import { useTableState, useDatasetState, useServerFilters, + usePaginationMarker, } from '@siafoundation/design-system' import { useMultipartUploadAbort, @@ -16,15 +17,12 @@ import { ObjectUploadData } from '../filesManager/types' import { MultipartUploadListUploadsPayload } from '@siafoundation/renterd-types' import { maybeFromNullishArrayResponse } from '@siafoundation/react-core' import { Maybe, Nullable } from '@siafoundation/types' -import { useSearchParams } from '@siafoundation/next' const defaultLimit = 50 function useUploadsMain() { const { uploadsMap, activeBucket } = useFilesManager() - const searchParams = useSearchParams() - const limit = Number(searchParams.get('limit') || defaultLimit) - const marker = searchParams.get('marker') || null + const { limit, marker } = usePaginationMarker(defaultLimit) const markers = useMarkersFromParam(marker) const { filters, setFilter, removeFilter, removeLastFilter, resetFilters } = diff --git a/libs/design-system/src/components/PaginatorKnownTotal.tsx b/libs/design-system/src/components/PaginatorKnownTotal.tsx index f20b63825..bc767b79d 100644 --- a/libs/design-system/src/components/PaginatorKnownTotal.tsx +++ b/libs/design-system/src/components/PaginatorKnownTotal.tsx @@ -28,6 +28,7 @@ export function PaginatorKnownTotal({ return ( ) : pageTotal ? ( - ) : ( )} = { columns: Col[] columnsDefaultVisible: Col['id'][] defaultSortField?: SortField + defaultSortDirection?: 'desc' | 'asc' sortOptions?: { id: SortField }[] } @@ -24,7 +25,13 @@ export function useTableState< Column extends TableColumn, SortField extends string >(scope: string, params: Params) { - const { columns, columnsDefaultVisible, defaultSortField, sortOptions } = { + const { + columns, + columnsDefaultVisible, + defaultSortField, + defaultSortDirection, + sortOptions, + } = { ...params, } @@ -97,6 +104,7 @@ export function useTableState< toggleSort, } = useSorting(scope, { defaultSortField, + defaultSortDirection, sortOptions, visibleColumnIds, })