diff --git a/.changeset/light-pens-turn.md b/.changeset/light-pens-turn.md new file mode 100644 index 000000000..6a63ede97 --- /dev/null +++ b/.changeset/light-pens-turn.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The contracts table now supports multi-select. diff --git a/.changeset/shiny-gifts-hope.md b/.changeset/shiny-gifts-hope.md new file mode 100644 index 000000000..71a8a5523 --- /dev/null +++ b/.changeset/shiny-gifts-hope.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The contracts multi-select menu now supports batch deletion. diff --git a/apps/renterd-e2e/src/specs/contracts.spec.ts b/apps/renterd-e2e/src/specs/contracts.spec.ts index 4c7a93f7f..676b674d0 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('batch 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/components/Contracts/ContractContextMenu.tsx b/apps/renterd/components/Contracts/ContractContextMenu.tsx index 74a175385..5c6cb5bec 100644 --- a/apps/renterd/components/Contracts/ContractContextMenu.tsx +++ b/apps/renterd/components/Contracts/ContractContextMenu.tsx @@ -49,7 +49,12 @@ export function ContractContextMenu({ + ) diff --git a/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsBatchDelete.tsx b/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsBatchDelete.tsx new file mode 100644 index 000000000..cf12076c2 --- /dev/null +++ b/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsBatchDelete.tsx @@ -0,0 +1,63 @@ +import { Button, 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 { handleBatchOperation } from '../../../lib/handleBatchOperation' +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..2224b51b1 100644 --- a/apps/renterd/components/Files/batchActions/FilesBatchDelete.tsx +++ b/apps/renterd/components/Files/batchActions/FilesBatchDelete.tsx @@ -1,15 +1,11 @@ -import { - Button, - Paragraph, - triggerSuccessToast, - triggerErrorToast, - MultiSelect, -} from '@siafoundation/design-system' +import { Button, Paragraph, MultiSelect } 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 { handleBatchOperation } from '../../../lib/handleBatchOperation' +import { pluralize } from '@siafoundation/units' export function FilesBatchDelete({ multiSelect, @@ -26,29 +22,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 +60,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/apps/renterd/contexts/contracts/columns.tsx b/apps/renterd/contexts/contracts/columns.tsx index a13ff4c98..249184c72 100644 --- a/apps/renterd/contexts/contracts/columns.tsx +++ b/apps/renterd/contexts/contracts/columns.tsx @@ -11,6 +11,7 @@ import { Separator, Button, LoadingDots, + Checkbox, } from '@siafoundation/design-system' import { ArrowUpLeft16, @@ -40,7 +41,14 @@ export const columns: ContractsTableColumn[] = [ id: 'actions', label: '', fixed: true, - cellClassName: 'w-[50px] !pl-2 !pr-4 [&+*]:!pl-0', + contentClassName: '!pl-3 !pr-4', + cellClassName: 'w-[20px] !pl-0 !pr-0', + heading: ({ context: { multiSelect } }) => ( + + ), render: ({ data: { id, hostIp, hostKey } }) => ( ), diff --git a/apps/renterd/contexts/contracts/index.tsx b/apps/renterd/contexts/contracts/index.tsx index 5eda4256b..94d248a47 100644 --- a/apps/renterd/contexts/contracts/index.tsx +++ b/apps/renterd/contexts/contracts/index.tsx @@ -4,6 +4,7 @@ import { useDatasetEmptyState, useClientFilters, useClientFilteredDataset, + useMultiSelect, } from '@siafoundation/design-system' import { useRouter } from 'next/router' import { useContracts as useContractsData } from '@siafoundation/renterd-react' @@ -112,7 +113,7 @@ function useContractsMain() { sortDirection, }) - const datasetPage = useMemo(() => { + const _datasetPage = useMemo(() => { if (!datasetFiltered) { return undefined } @@ -120,8 +121,8 @@ function useContractsMain() { }, [datasetFiltered, offset, limit]) const { range: contractsTimeRange } = useMemo( - () => getContractsTimeRangeBlockHeight(currentHeight, datasetPage || []), - [currentHeight, datasetPage] + () => getContractsTimeRangeBlockHeight(currentHeight, _datasetPage || []), + [currentHeight, _datasetPage] ) const filteredTableColumns = useMemo( @@ -143,6 +144,22 @@ function useContractsMain() { const filteredStats = useFilteredStats({ datasetFiltered }) + const multiSelect = useMultiSelect(_datasetPage) + + const datasetPage = useMemo(() => { + if (!_datasetPage) { + return undefined + } + return _datasetPage.map((datum) => { + return { + ...datum, + onClick: (e: React.MouseEvent) => + multiSelect.onSelect(datum.id, e), + isSelected: !!multiSelect.selectionMap[datum.id], + } + }) + }, [_datasetPage, multiSelect]) + const cellContext = useMemo(() => { const context: ContractTableContext = { currentHeight: syncStatus.estimatedBlockHeight, @@ -152,6 +169,7 @@ function useContractsMain() { isFetchingPrunableSizeAll, fetchPrunableSizeAll, filteredStats, + multiSelect, } return context }, [ @@ -162,6 +180,7 @@ function useContractsMain() { isFetchingPrunableSizeAll, fetchPrunableSizeAll, filteredStats, + multiSelect, ]) const thirtyDaysAgo = new Date().getTime() - daysInMilliseconds(30) @@ -220,6 +239,7 @@ function useContractsMain() { isFetchingPrunableSizeById, fetchPrunableSize, fetchPrunableSizeAll, + multiSelect, } } diff --git a/apps/renterd/contexts/contracts/types.ts b/apps/renterd/contexts/contracts/types.ts index 9b1d0ee6f..a030e6d9f 100644 --- a/apps/renterd/contexts/contracts/types.ts +++ b/apps/renterd/contexts/contracts/types.ts @@ -1,6 +1,7 @@ import { ContractState, ContractUsability } from '@siafoundation/renterd-types' import BigNumber from 'bignumber.js' import { useFilteredStats } from './useFilteredStats' +import { MultiSelect } from '@siafoundation/design-system' export type ContractTableContext = { currentHeight: number @@ -15,11 +16,13 @@ export type ContractTableContext = { isFetchingPrunableSizeAll: boolean // totals filteredStats: ReturnType + multiSelect: MultiSelect } export type ContractDataWithoutPrunable = { id: string onClick: () => void + isSelected: boolean hostIp: string hostKey: string state: ContractState diff --git a/apps/renterd/lib/handleBatchOperation.ts b/apps/renterd/lib/handleBatchOperation.ts new file mode 100644 index 000000000..9930dc5a5 --- /dev/null +++ b/apps/renterd/lib/handleBatchOperation.ts @@ -0,0 +1,42 @@ +import { + ToastParams, + triggerErrorToast, + triggerSuccessToast, +} from '@siafoundation/design-system' + +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