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 (