diff --git a/.changeset/perfect-houses-approve.md b/.changeset/perfect-houses-approve.md new file mode 100644 index 000000000..16b519d28 --- /dev/null +++ b/.changeset/perfect-houses-approve.md @@ -0,0 +1,5 @@ +--- +'@siafoundation/design-system': minor +--- + +Add handleBatchOperation method. diff --git a/.changeset/shiny-gifts-hope.md b/.changeset/shiny-gifts-hope.md new file mode 100644 index 000000000..e1a61e25c --- /dev/null +++ b/.changeset/shiny-gifts-hope.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The contracts multi-select menu now supports bulk deletion. diff --git a/apps/renterd-e2e/src/specs/contracts.spec.ts b/apps/renterd-e2e/src/specs/contracts.spec.ts index 4c7a93f7f..984c7f44d 100644 --- a/apps/renterd-e2e/src/specs/contracts.spec.ts +++ b/apps/renterd-e2e/src/specs/contracts.spec.ts @@ -61,3 +61,21 @@ test('contracts prunable size', async ({ page }) => { await expect(prunableSize).toBeVisible() } }) + +test('bulk delete contracts', async ({ page }) => { + await navigateToContracts({ page }) + const rows = await getContractRows(page) + for (const row of rows) { + await row.click() + } + + // Delete selected contracts. + const menu = page.getByLabel('contract multi-select menu') + await menu.getByLabel('delete selected contracts').click() + const dialog = page.getByRole('dialog') + await dialog.getByRole('button', { name: 'Delete' }).click() + + await expect( + page.getByText('There are currently no active contracts') + ).toBeVisible() +}) diff --git a/apps/renterd-e2e/src/specs/files.spec.ts b/apps/renterd-e2e/src/specs/files.spec.ts index 0954dcf0c..d61e53491 100644 --- a/apps/renterd-e2e/src/specs/files.spec.ts +++ b/apps/renterd-e2e/src/specs/files.spec.ts @@ -222,7 +222,7 @@ test('shows a new intermediate directory when uploading nested files', async ({ await deleteBucket(page, bucketName) }) -test('batch delete across nested directories', async ({ page }) => { +test('bulk delete across nested directories', async ({ page }) => { const bucketName = 'bucket1' await navigateToBuckets({ page }) await createBucket(page, bucketName) @@ -267,7 +267,7 @@ test('batch delete across nested directories', async ({ page }) => { }) }) -test('batch delete using the all files explorer mode', async ({ page }) => { +test('bulk delete using the all files explorer mode', async ({ page }) => { const bucketName = 'bucket1' await navigateToBuckets({ page }) await createBucket(page, bucketName) diff --git a/apps/renterd-e2e/src/specs/filesMove.spec.ts b/apps/renterd-e2e/src/specs/filesMove.spec.ts index 1582e2305..b0d5eb0b0 100644 --- a/apps/renterd-e2e/src/specs/filesMove.spec.ts +++ b/apps/renterd-e2e/src/specs/filesMove.spec.ts @@ -136,7 +136,7 @@ test('move a file via drag and drop while leaving a separate set of selected fil }) }) -test('move files by selecting and using the docked menu batch action', async ({ +test('move files by selecting and using the docked menu bulk action', async ({ page, }) => { const bucketName = 'bucket1' diff --git a/apps/renterd-e2e/src/specs/keys.spec.ts b/apps/renterd-e2e/src/specs/keys.spec.ts index cc73c804a..22167d6ff 100644 --- a/apps/renterd-e2e/src/specs/keys.spec.ts +++ b/apps/renterd-e2e/src/specs/keys.spec.ts @@ -25,7 +25,7 @@ test('create and delete a key', async ({ page }) => { await expect(row).toBeHidden() }) -test('batch delete multiple keys', async ({ page }) => { +test('bulk delete multiple keys', async ({ page }) => { // Create 3 keys. Note: 1 already exists. const key1 = await createKey(page) const key2 = await createKey(page) diff --git a/apps/renterd/CHANGELOG.md b/apps/renterd/CHANGELOG.md index 1564dfde5..101cfe6a8 100644 --- a/apps/renterd/CHANGELOG.md +++ b/apps/renterd/CHANGELOG.md @@ -12,13 +12,13 @@ - 17b29cf3: Navigating into a directory in the file explorer is now by clicking on the directory name rather than anywhere on the row. - 17b29cf3: The directory-based file explorer now supports multiselect across any files and directories. -- 6c7e3681: The key management table now supports multiselect and batch deletion. +- 6c7e3681: The key management table now supports multiselect and bulk deletion. - 17b29cf3: The "all files" file explorer now supports multiselect across any files. -- 17b29cf3: The "all files" file explorer multiselect menu now supports batch deletion of selected files. +- 17b29cf3: The "all files" file explorer multiselect menu now supports bulk deletion of selected files. - 6c7e3681: The onboarding wizard now animates in and out. - ed264a0d: The transfers bar now animates in and out. - 09142864: The keys table now has pagination controls. -- 17b29cf3: The directory-based file explorer multiselect menu now supports batch deletion of selected files and directories. +- 17b29cf3: The directory-based file explorer multiselect menu now supports bulk deletion of selected files and directories. ### Patch Changes diff --git a/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsBatchDelete.tsx b/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsBatchDelete.tsx new file mode 100644 index 000000000..66472c297 --- /dev/null +++ b/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsBatchDelete.tsx @@ -0,0 +1,66 @@ +import { + Button, + handleBatchOperation, + Paragraph, +} from '@siafoundation/design-system' +import { Delete16 } from '@siafoundation/react-icons' +import { useContractDelete } from '@siafoundation/renterd-react' +import { useCallback, useMemo } from 'react' +import { useDialog } from '../../../contexts/dialog' +import { useContracts } from '../../../contexts/contracts' +import { pluralize } from '@siafoundation/units' + +export function ContractsBatchDelete() { + const { multiSelect } = useContracts() + + const ids = useMemo( + () => Object.entries(multiSelect.selectionMap).map(([_, item]) => item.id), + [multiSelect.selectionMap] + ) + const { openConfirmDialog } = useDialog() + const deleteContract = useContractDelete() + const deleteAll = useCallback(async () => { + await handleBatchOperation( + ids.map((id) => deleteContract.delete({ params: { id } })), + { + toastError: ({ successCount, errorCount, totalCount }) => ({ + title: `${pluralize(successCount, 'contract')} deleted`, + body: `Error deleting ${errorCount}/${totalCount} total contracts.`, + }), + toastSuccess: ({ totalCount }) => ({ + title: `${pluralize(totalCount, 'contract')} deleted`, + }), + after: () => { + multiSelect.deselectAll() + }, + } + ) + }, [multiSelect, ids, deleteContract]) + + return ( + + ) +} diff --git a/apps/renterd/components/Contracts/ContractsBatchMenu/index.tsx b/apps/renterd/components/Contracts/ContractsBatchMenu/index.tsx new file mode 100644 index 000000000..e45343bf8 --- /dev/null +++ b/apps/renterd/components/Contracts/ContractsBatchMenu/index.tsx @@ -0,0 +1,13 @@ +import { MultiSelectionMenu } from '@siafoundation/design-system' +import { useContracts } from '../../../contexts/contracts' +import { ContractsBatchDelete } from './ContractsBatchDelete' + +export function ContractsBatchMenu() { + const { multiSelect } = useContracts() + + return ( + + + + ) +} diff --git a/apps/renterd/components/Contracts/Layout.tsx b/apps/renterd/components/Contracts/Layout.tsx index 381364aa2..5a86058f2 100644 --- a/apps/renterd/components/Contracts/Layout.tsx +++ b/apps/renterd/components/Contracts/Layout.tsx @@ -7,6 +7,7 @@ import { } from '../RenterdAuthedLayout' import { ContractsActionsMenu } from './ContractsActionsMenu' import { ContractsFilterBar } from './ContractsFilterBar' +import { ContractsBatchMenu } from './ContractsBatchMenu' export const Layout = RenterdAuthedLayout export function useLayoutProps(): RenterdAuthedPageLayoutProps { @@ -20,5 +21,6 @@ export function useLayoutProps(): RenterdAuthedPageLayoutProps { stats: , size: 'full', scroll: false, + dockedControls: , } } diff --git a/apps/renterd/components/Files/batchActions/FilesBatchDelete.tsx b/apps/renterd/components/Files/batchActions/FilesBatchDelete.tsx index a0a8def17..ec3d911b7 100644 --- a/apps/renterd/components/Files/batchActions/FilesBatchDelete.tsx +++ b/apps/renterd/components/Files/batchActions/FilesBatchDelete.tsx @@ -1,15 +1,15 @@ import { Button, Paragraph, - triggerSuccessToast, - triggerErrorToast, MultiSelect, + handleBatchOperation, } from '@siafoundation/design-system' import { Delete16 } from '@siafoundation/react-icons' import { useCallback, useMemo } from 'react' import { useDialog } from '../../../contexts/dialog' import { useObjectsRemove } from '@siafoundation/renterd-react' import { ObjectData } from '../../../contexts/filesManager/types' +import { pluralize } from '@siafoundation/units' export function FilesBatchDelete({ multiSelect, @@ -26,29 +26,29 @@ export function FilesBatchDelete({ ) const { openConfirmDialog } = useDialog() const objectsRemove = useObjectsRemove() - const deleteFiles = useCallback(async () => { - const totalCount = filesToDelete.length - let errorCount = 0 - for (const { bucket, prefix } of filesToDelete) { - const response = await objectsRemove.post({ - payload: { - bucket, - prefix, + const deleteAll = useCallback(async () => { + await handleBatchOperation( + filesToDelete.map(({ bucket, prefix }) => + objectsRemove.post({ + payload: { + bucket, + prefix, + }, + }) + ), + { + toastError: ({ totalCount, errorCount, successCount }) => ({ + title: `${pluralize(successCount, 'file')} deleted`, + body: `Error deleting ${errorCount}/${totalCount} total files.`, + }), + toastSuccess: ({ totalCount }) => ({ + title: `${pluralize(totalCount, 'file')} deleted`, + }), + after: () => { + multiSelect.deselectAll() }, - }) - if (response.error) { - errorCount++ } - } - if (errorCount > 0) { - triggerErrorToast({ - title: `${totalCount - errorCount} files deleted`, - body: `Error deleting ${errorCount}/${totalCount} total files.`, - }) - } else { - triggerSuccessToast({ title: `${totalCount} files deleted` }) - } - multiSelect.deselectAll() + ) }, [multiSelect, filesToDelete, objectsRemove]) return ( @@ -64,12 +64,12 @@ export function FilesBatchDelete({
Are you sure you would like to delete the{' '} - {multiSelect.selectionCount.toLocaleString()} selected files? + {pluralize(multiSelect.selectionCount, 'selected file')}?
), onConfirm: async () => { - deleteFiles() + deleteAll() }, }) }} diff --git a/libs/design-system/CHANGELOG.md b/libs/design-system/CHANGELOG.md index be54e736f..bbd3a385f 100644 --- a/libs/design-system/CHANGELOG.md +++ b/libs/design-system/CHANGELOG.md @@ -7,7 +7,7 @@ - d891861b: Checkbox now supports an indeterminate state. - d891861b: Added useMultiSelect hook that tracks multiselect state. It supports selection, shift-selecting for ranges, deselection, and works across pagination. - ed264a0d: Added dockedControls to AppAuthedLayout. -- d891861b: Added MultiSelectMenu. The component can be used along with useMultiSelect for batch menus. +- d891861b: Added MultiSelectMenu. The component can be used along with useMultiSelect for bulk menus. - 09142864: Table row data now supports an isSelected prop. - d891861b: Checkbox light mode background color is now white. - d891861b: Table column sort icons are now chevrons to differentiate from context menus which often use carets. diff --git a/libs/design-system/src/index.ts b/libs/design-system/src/index.ts index 59d276395..57b2fe863 100644 --- a/libs/design-system/src/index.ts +++ b/libs/design-system/src/index.ts @@ -191,4 +191,5 @@ export * from './lib/countryEmoji' export * from './lib/nodeToImage' export * from './lib/colors' export * from './lib/object' +export * from './lib/handleBatchOperation' export type * from './lib/types' diff --git a/libs/design-system/src/lib/handleBatchOperation.ts b/libs/design-system/src/lib/handleBatchOperation.ts new file mode 100644 index 000000000..5bc605a7e --- /dev/null +++ b/libs/design-system/src/lib/handleBatchOperation.ts @@ -0,0 +1,38 @@ +import { ToastParams, triggerErrorToast, triggerSuccessToast } from './toast' + +type Results = { + totalCount: number + errorCount: number + successCount: number +} + +type Params = { + toastSuccess: (results: Results) => ToastParams + toastError: (results: Results) => ToastParams + after?: () => void | Promise +} + +export async function handleBatchOperation( + operations: Promise<{ data?: T; error?: string }>[], + params: Params +) { + const totalCount = operations.length + let errorCount = 0 + const results = await Promise.all(operations) + for (const r of results) { + if (r.error) { + errorCount++ + } + } + const successCount = totalCount - errorCount + if (errorCount > 0) { + triggerErrorToast( + params.toastError({ totalCount, errorCount, successCount }) + ) + } else { + triggerSuccessToast( + params.toastSuccess({ totalCount, errorCount, successCount }) + ) + } + await params.after?.() +} diff --git a/libs/design-system/src/lib/toast.tsx b/libs/design-system/src/lib/toast.tsx index 63de5f85f..d5ae57762 100644 --- a/libs/design-system/src/lib/toast.tsx +++ b/libs/design-system/src/lib/toast.tsx @@ -69,7 +69,7 @@ function ToastLayout({ ) } -type ToastParams = { +export type ToastParams = { title: React.ReactNode body?: React.ReactNode icon?: React.ReactNode