Skip to content

Commit

Permalink
feat(renterd): contract multiselect and batch delete
Browse files Browse the repository at this point in the history
  • Loading branch information
alexfreska committed Nov 22, 2024
1 parent afc1830 commit 112cc4f
Show file tree
Hide file tree
Showing 13 changed files with 216 additions and 36 deletions.
5 changes: 5 additions & 0 deletions .changeset/light-pens-turn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'renterd': minor
---

The contracts table now supports multi-select.
5 changes: 5 additions & 0 deletions .changeset/shiny-gifts-hope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'renterd': minor
---

The contracts multi-select menu now supports batch deletion.
18 changes: 18 additions & 0 deletions apps/renterd-e2e/src/specs/contracts.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
7 changes: 6 additions & 1 deletion apps/renterd/components/Contracts/ContractContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,12 @@ export function ContractContextMenu({
<DropdownMenu
trigger={
trigger || (
<Button variant="ghost" icon="hover" {...buttonProps}>
<Button
aria-label="contract context menu"
icon="hover"
size="none"
{...buttonProps}
>
<CaretDown16 />
</Button>
)
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<Button
aria-label="delete selected contracts"
tip="Delete selected contracts"
onClick={() => {
openConfirmDialog({
title: `Delete contracts`,
action: 'Delete',
variant: 'red',
body: (
<div className="flex flex-col gap-1">
<Paragraph size="14">
Are you sure you would like to delete the{' '}
{pluralize(multiSelect.selectionCount, 'selected contract')}?
</Paragraph>
</div>
),
onConfirm: async () => {
deleteAll()
},
})
}}
>
<Delete16 />
</Button>
)
}
13 changes: 13 additions & 0 deletions apps/renterd/components/Contracts/ContractsBatchMenu/index.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<MultiSelectionMenu multiSelect={multiSelect} entityWord="contract">
<ContractsBatchDelete />
</MultiSelectionMenu>
)
}
2 changes: 2 additions & 0 deletions apps/renterd/components/Contracts/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -20,5 +21,6 @@ export function useLayoutProps(): RenterdAuthedPageLayoutProps {
stats: <ContractsFilterBar />,
size: 'full',
scroll: false,
dockedControls: <ContractsBatchMenu />,
}
}
56 changes: 26 additions & 30 deletions apps/renterd/components/Files/batchActions/FilesBatchDelete.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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 (
Expand All @@ -64,12 +60,12 @@ export function FilesBatchDelete({
<div className="flex flex-col gap-1">
<Paragraph size="14">
Are you sure you would like to delete the{' '}
{multiSelect.selectionCount.toLocaleString()} selected files?
{pluralize(multiSelect.selectionCount, 'selected file')}?
</Paragraph>
</div>
),
onConfirm: async () => {
deleteFiles()
deleteAll()
},
})
}}
Expand Down
10 changes: 9 additions & 1 deletion apps/renterd/contexts/contracts/columns.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
Separator,
Button,
LoadingDots,
Checkbox,
} from '@siafoundation/design-system'
import {
ArrowUpLeft16,
Expand Down Expand Up @@ -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 } }) => (
<Checkbox
onClick={multiSelect.onSelectPage}
checked={multiSelect.isPageAllSelected}
/>
),
render: ({ data: { id, hostIp, hostKey } }) => (
<ContractContextMenu id={id} hostAddress={hostIp} hostKey={hostKey} />
),
Expand Down
26 changes: 23 additions & 3 deletions apps/renterd/contexts/contracts/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -112,16 +113,16 @@ function useContractsMain() {
sortDirection,
})

const datasetPage = useMemo<ContractData[] | undefined>(() => {
const _datasetPage = useMemo<ContractData[] | undefined>(() => {
if (!datasetFiltered) {
return undefined
}
return datasetFiltered.slice(offset, offset + limit)
}, [datasetFiltered, offset, limit])

const { range: contractsTimeRange } = useMemo(
() => getContractsTimeRangeBlockHeight(currentHeight, datasetPage || []),
[currentHeight, datasetPage]
() => getContractsTimeRangeBlockHeight(currentHeight, _datasetPage || []),
[currentHeight, _datasetPage]
)

const filteredTableColumns = useMemo(
Expand All @@ -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<HTMLTableRowElement>) =>
multiSelect.onSelect(datum.id, e),
isSelected: !!multiSelect.selectionMap[datum.id],
}
})
}, [_datasetPage, multiSelect])

const cellContext = useMemo(() => {
const context: ContractTableContext = {
currentHeight: syncStatus.estimatedBlockHeight,
Expand All @@ -152,6 +169,7 @@ function useContractsMain() {
isFetchingPrunableSizeAll,
fetchPrunableSizeAll,
filteredStats,
multiSelect,
}
return context
}, [
Expand All @@ -162,6 +180,7 @@ function useContractsMain() {
isFetchingPrunableSizeAll,
fetchPrunableSizeAll,
filteredStats,
multiSelect,
])

const thirtyDaysAgo = new Date().getTime() - daysInMilliseconds(30)
Expand Down Expand Up @@ -220,6 +239,7 @@ function useContractsMain() {
isFetchingPrunableSizeById,
fetchPrunableSize,
fetchPrunableSizeAll,
multiSelect,
}
}

Expand Down
3 changes: 3 additions & 0 deletions apps/renterd/contexts/contracts/types.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -15,11 +16,13 @@ export type ContractTableContext = {
isFetchingPrunableSizeAll: boolean
// totals
filteredStats: ReturnType<typeof useFilteredStats>
multiSelect: MultiSelect<ContractData>
}

export type ContractDataWithoutPrunable = {
id: string
onClick: () => void
isSelected: boolean
hostIp: string
hostKey: string
state: ContractState
Expand Down
42 changes: 42 additions & 0 deletions apps/renterd/lib/handleBatchOperation.ts
Original file line number Diff line number Diff line change
@@ -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<void>
}

export async function handleBatchOperation<T>(
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?.()
}
2 changes: 1 addition & 1 deletion libs/design-system/src/lib/toast.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ function ToastLayout({
)
}

type ToastParams = {
export type ToastParams = {
title: React.ReactNode
body?: React.ReactNode
icon?: React.ReactNode
Expand Down

0 comments on commit 112cc4f

Please sign in to comment.