diff --git a/.changeset/shiny-gifts-hope.md b/.changeset/shiny-gifts-hope.md index 71a8a5523..e1a61e25c 100644 --- a/.changeset/shiny-gifts-hope.md +++ b/.changeset/shiny-gifts-hope.md @@ -2,4 +2,4 @@ 'renterd': minor --- -The contracts multi-select menu now supports batch deletion. +The contracts multi-select menu now supports bulk deletion. diff --git a/.changeset/shy-dingos-roll.md b/.changeset/shy-dingos-roll.md new file mode 100644 index 000000000..2e5120e60 --- /dev/null +++ b/.changeset/shy-dingos-roll.md @@ -0,0 +1,5 @@ +--- +'renterd': minor +--- + +The contracts multi-select menu now supports bulk adding and removing to both the allowlist and blocklists. diff --git a/apps/renterd-e2e/src/fixtures/hosts.ts b/apps/renterd-e2e/src/fixtures/hosts.ts index 9dc8edad4..03c52f9cb 100644 --- a/apps/renterd-e2e/src/fixtures/hosts.ts +++ b/apps/renterd-e2e/src/fixtures/hosts.ts @@ -1,5 +1,10 @@ import { Locator, Page, expect } from '@playwright/test' -import { maybeExpectAndReturn, step } from '@siafoundation/e2e' +import { + fillTextInputByName, + maybeExpectAndReturn, + openCmdkMenu, + step, +} from '@siafoundation/e2e' export const getHostRowById = step( 'get host row by ID', @@ -57,3 +62,16 @@ export const openRowHostContextMenu = step( return menu.click() } ) + +export const openManageListsDialog = step( + 'open manage lists dialog', + async (page: Page) => { + const dialog = await openCmdkMenu(page) + await fillTextInputByName(page, 'cmdk-input', 'manage filter lists') + await expect(dialog.locator('div[cmdk-item]')).toHaveCount(1) + await dialog + .locator('div[cmdk-item]') + .getByText('manage filter lists') + .click() + } +) diff --git a/apps/renterd-e2e/src/specs/contracts.spec.ts b/apps/renterd-e2e/src/specs/contracts.spec.ts index 676b674d0..407f8ec70 100644 --- a/apps/renterd-e2e/src/specs/contracts.spec.ts +++ b/apps/renterd-e2e/src/specs/contracts.spec.ts @@ -7,6 +7,7 @@ import { getContractRows, getContractsSummaryRow, } from '../fixtures/contracts' +import { openManageListsDialog } from '../fixtures/hosts' test.beforeEach(async ({ page }) => { await beforeTest(page, { @@ -62,7 +63,7 @@ test('contracts prunable size', async ({ page }) => { } }) -test('batch delete contracts', async ({ page }) => { +test('contracts bulk delete', async ({ page }) => { await navigateToContracts({ page }) const rows = await getContractRows(page) for (const row of rows) { @@ -79,3 +80,75 @@ test('batch delete contracts', async ({ page }) => { page.getByText('There are currently no active contracts') ).toBeVisible() }) + +test('contracts bulk allowlist', async ({ page }) => { + await navigateToContracts({ page }) + const rows = await getContractRows(page) + for (const row of rows) { + await row.click() + } + + const menu = page.getByLabel('contract multi-select menu') + const dialog = page.getByRole('dialog') + + // Add selected contract hosts to the allowlist. + await menu.getByLabel('add host public keys to allowlist').click() + await dialog.getByRole('button', { name: 'Add to allowlist' }).click() + + await openManageListsDialog(page) + await expect(dialog.getByText('The blocklist is empty')).toBeVisible() + await dialog.getByLabel('view allowlist').click() + await expect( + dialog.getByTestId('allowlistPublicKeys').getByTestId('item') + ).toHaveCount(3) + await dialog.getByLabel('close').click() + + for (const row of rows) { + await row.click() + } + + // Remove selected contract hosts from the allowlist. + await menu.getByLabel('remove host public keys from allowlist').click() + await dialog.getByRole('button', { name: 'Remove from allowlist' }).click() + + await openManageListsDialog(page) + await expect(dialog.getByText('The blocklist is empty')).toBeVisible() + await dialog.getByLabel('view allowlist').click() + await expect(dialog.getByText('The allowlist is empty')).toBeVisible() +}) + +test('contracts bulk blocklist', async ({ page }) => { + await navigateToContracts({ page }) + const rows = await getContractRows(page) + for (const row of rows) { + await row.click() + } + + const menu = page.getByLabel('contract multi-select menu') + const dialog = page.getByRole('dialog') + + // Add selected contract hosts to the allowlist. + await menu.getByLabel('add host addresses to blocklist').click() + await dialog.getByRole('button', { name: 'Add to blocklist' }).click() + + await openManageListsDialog(page) + await expect( + dialog.getByTestId('blocklistAddresses').getByTestId('item') + ).toHaveCount(3) + await dialog.getByLabel('view allowlist').click() + await expect(dialog.getByText('The allowlist is empty')).toBeVisible() + await dialog.getByLabel('close').click() + + for (const row of rows) { + await row.click() + } + + // Remove selected contract hosts from the blocklist. + await menu.getByLabel('remove host addresses from blocklist').click() + await dialog.getByRole('button', { name: 'Remove from blocklist' }).click() + + await openManageListsDialog(page) + await expect(dialog.getByText('The blocklist is empty')).toBeVisible() + await dialog.getByLabel('view allowlist').click() + await expect(dialog.getByText('The allowlist is empty')).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/ContractsAddAllowlist.tsx b/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsAddAllowlist.tsx new file mode 100644 index 000000000..c84c51bae --- /dev/null +++ b/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsAddAllowlist.tsx @@ -0,0 +1,54 @@ +import { Button, Paragraph } from '@siafoundation/design-system' +import { ListChecked16 } from '@siafoundation/react-icons' +import { useCallback, useMemo } from 'react' +import { useDialog } from '../../../contexts/dialog' +import { useContracts } from '../../../contexts/contracts' +import { pluralize } from '@siafoundation/units' +import { useAllowlistUpdate } from '../../../hooks/useAllowlistUpdate' + +export function ContractsAddAllowlist() { + const { multiSelect } = useContracts() + + const publicKeys = useMemo( + () => + Object.entries(multiSelect.selectionMap).map(([_, item]) => item.hostKey), + [multiSelect.selectionMap] + ) + const { openConfirmDialog } = useDialog() + const allowlistUpdate = useAllowlistUpdate() + + const add = useCallback(async () => { + allowlistUpdate(publicKeys, []) + multiSelect.deselectAll() + }, [allowlistUpdate, multiSelect, publicKeys]) + + return ( + + ) +} diff --git a/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsAddBlocklist.tsx b/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsAddBlocklist.tsx new file mode 100644 index 000000000..416370e31 --- /dev/null +++ b/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsAddBlocklist.tsx @@ -0,0 +1,58 @@ +import { Button, Paragraph } from '@siafoundation/design-system' +import { ListChecked16 } from '@siafoundation/react-icons' +import { useCallback, useMemo } from 'react' +import { useDialog } from '../../../contexts/dialog' +import { useContracts } from '../../../contexts/contracts' +import { pluralize } from '@siafoundation/units' +import { useBlocklistUpdate } from '../../../hooks/useBlocklistUpdate' + +export function ContractsAddBlocklist() { + const { multiSelect } = useContracts() + + const hostAddresses = useMemo( + () => + Object.entries(multiSelect.selectionMap).map(([_, item]) => item.hostIp), + [multiSelect.selectionMap] + ) + const { openConfirmDialog } = useDialog() + const blocklistUpdate = useBlocklistUpdate() + + const add = useCallback(async () => { + blocklistUpdate(hostAddresses, []) + multiSelect.deselectAll() + }, [blocklistUpdate, multiSelect, hostAddresses]) + + return ( + + ) +} diff --git a/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsRemoveAllowlist.tsx b/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsRemoveAllowlist.tsx new file mode 100644 index 000000000..cb62e38d6 --- /dev/null +++ b/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsRemoveAllowlist.tsx @@ -0,0 +1,54 @@ +import { Button, Paragraph } from '@siafoundation/design-system' +import { ListChecked16 } from '@siafoundation/react-icons' +import { useCallback, useMemo } from 'react' +import { useDialog } from '../../../contexts/dialog' +import { useContracts } from '../../../contexts/contracts' +import { pluralize } from '@siafoundation/units' +import { useAllowlistUpdate } from '../../../hooks/useAllowlistUpdate' + +export function ContractsRemoveAllowlist() { + const { multiSelect } = useContracts() + + const publicKeys = useMemo( + () => + Object.entries(multiSelect.selectionMap).map(([_, item]) => item.hostKey), + [multiSelect.selectionMap] + ) + const { openConfirmDialog } = useDialog() + const allowlistUpdate = useAllowlistUpdate() + + const remove = useCallback(async () => { + await allowlistUpdate([], publicKeys) + multiSelect.deselectAll() + }, [allowlistUpdate, multiSelect, publicKeys]) + + return ( + + ) +} diff --git a/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsRemoveBlocklist.tsx b/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsRemoveBlocklist.tsx new file mode 100644 index 000000000..702f5acb6 --- /dev/null +++ b/apps/renterd/components/Contracts/ContractsBatchMenu/ContractsRemoveBlocklist.tsx @@ -0,0 +1,58 @@ +import { Button, Paragraph } from '@siafoundation/design-system' +import { ListChecked16 } from '@siafoundation/react-icons' +import { useCallback, useMemo } from 'react' +import { useDialog } from '../../../contexts/dialog' +import { useContracts } from '../../../contexts/contracts' +import { pluralize } from '@siafoundation/units' +import { useBlocklistUpdate } from '../../../hooks/useBlocklistUpdate' + +export function ContractsRemoveBlocklist() { + const { multiSelect } = useContracts() + + const hostAddresses = useMemo( + () => + Object.entries(multiSelect.selectionMap).map(([_, item]) => item.hostIp), + [multiSelect.selectionMap] + ) + const { openConfirmDialog } = useDialog() + const blocklistUpdate = useBlocklistUpdate() + + const remove = useCallback(async () => { + blocklistUpdate([], hostAddresses) + multiSelect.deselectAll() + }, [blocklistUpdate, multiSelect, hostAddresses]) + + return ( + + ) +} diff --git a/apps/renterd/components/Contracts/ContractsBatchMenu/index.tsx b/apps/renterd/components/Contracts/ContractsBatchMenu/index.tsx index e45343bf8..cfa638f61 100644 --- a/apps/renterd/components/Contracts/ContractsBatchMenu/index.tsx +++ b/apps/renterd/components/Contracts/ContractsBatchMenu/index.tsx @@ -1,12 +1,24 @@ import { MultiSelectionMenu } from '@siafoundation/design-system' import { useContracts } from '../../../contexts/contracts' import { ContractsBatchDelete } from './ContractsBatchDelete' +import { ContractsAddBlocklist } from './ContractsAddBlocklist' +import { ContractsAddAllowlist } from './ContractsAddAllowlist' +import { ContractsRemoveBlocklist } from './ContractsRemoveBlocklist' +import { ContractsRemoveAllowlist } from './ContractsRemoveAllowlist' export function ContractsBatchMenu() { const { multiSelect } = useContracts() return ( +
+ + +
+
+ + +
) diff --git a/apps/renterd/components/Hosts/HostsAllowBlockDialog/AllowlistForm.tsx b/apps/renterd/components/Hosts/HostsAllowBlockDialog/AllowlistForm.tsx index 5fa45004d..4cfb83f64 100644 --- a/apps/renterd/components/Hosts/HostsAllowBlockDialog/AllowlistForm.tsx +++ b/apps/renterd/components/Hosts/HostsAllowBlockDialog/AllowlistForm.tsx @@ -104,7 +104,7 @@ export function AllowlistForm() {
{filtered.length ? ( -
+
({ diff --git a/apps/renterd/components/Hosts/HostsAllowBlockDialog/BlocklistForm.tsx b/apps/renterd/components/Hosts/HostsAllowBlockDialog/BlocklistForm.tsx index e4471b6c3..63bc92697 100644 --- a/apps/renterd/components/Hosts/HostsAllowBlockDialog/BlocklistForm.tsx +++ b/apps/renterd/components/Hosts/HostsAllowBlockDialog/BlocklistForm.tsx @@ -153,16 +153,18 @@ export function BlocklistForm() { )} {filtered.length ? ( - ({ - value: address, - label: `${address.slice(0, 20)}...`, - })) || [] - } - onClick={(value) => copyToClipboard(value, 'blocked address')} - onRemove={(value) => blocklistUpdate([], [value])} - /> +
+ ({ + value: address, + label: `${address.slice(0, 20)}...`, + })) || [] + } + onClick={(value) => copyToClipboard(value, 'blocked address')} + onRemove={(value) => blocklistUpdate([], [value])} + /> +
) : isFiltered ? (
diff --git a/apps/renterd/components/Hosts/HostsAllowBlockDialog/index.tsx b/apps/renterd/components/Hosts/HostsAllowBlockDialog/index.tsx index 952acaab8..391711ccc 100644 --- a/apps/renterd/components/Hosts/HostsAllowBlockDialog/index.tsx +++ b/apps/renterd/components/Hosts/HostsAllowBlockDialog/index.tsx @@ -42,8 +42,12 @@ export function HostsAllowBlockDialog({ trigger, open, onOpenChange }: Props) { - Block - Allow + + Block + + + Allow + diff --git a/apps/renterd/hooks/useAllowlistUpdate.tsx b/apps/renterd/hooks/useAllowlistUpdate.tsx index edc5c5c14..2fe630169 100644 --- a/apps/renterd/hooks/useAllowlistUpdate.tsx +++ b/apps/renterd/hooks/useAllowlistUpdate.tsx @@ -4,6 +4,7 @@ import { truncate, } from '@siafoundation/design-system' import { useHostsAllowlistUpdate } from '@siafoundation/renterd-react' +import { pluralize } from '@siafoundation/units' import { useCallback } from 'react' export function useAllowlistUpdate() { @@ -28,17 +29,22 @@ export function useAllowlistUpdate() { if (add.length) { triggerSuccessToast({ title: 'Allowlist updated', - body: `${add - .map((i) => truncate(i, 20)) - .join(', ')} added to allowlist.`, + body: + add.length === 1 + ? `Host ${truncate(add[0], 20)} added to allowlist.` + : `Added ${pluralize(add.length, 'host')} to the allowlist.`, }) } if (remove.length) { triggerSuccessToast({ title: 'Allowlist updated', - body: `${remove - .map((i) => truncate(i, 20)) - .join(', ')} removed from allowlist.`, + body: + remove.length === 1 + ? `Host ${truncate(remove[0], 20)} removed from allowlist.` + : `Removed ${pluralize( + remove.length, + 'host' + )} from the allowlist.`, }) } return true diff --git a/apps/renterd/hooks/useBlocklistUpdate.tsx b/apps/renterd/hooks/useBlocklistUpdate.tsx index 75b7e2b9e..695019b05 100644 --- a/apps/renterd/hooks/useBlocklistUpdate.tsx +++ b/apps/renterd/hooks/useBlocklistUpdate.tsx @@ -4,6 +4,7 @@ import { truncate, } from '@siafoundation/design-system' import { useHostsBlocklistUpdate } from '@siafoundation/renterd-react' +import { pluralize } from '@siafoundation/units' import { useCallback } from 'react' export function useBlocklistUpdate() { @@ -28,17 +29,22 @@ export function useBlocklistUpdate() { if (add.length) { triggerToast({ title: 'Blocklist updated', - body: `${add - .map((i) => truncate(i, 20)) - .join(', ')} added to blocklist.`, + body: + add.length === 1 + ? `Host ${truncate(add[0], 20)} added to blocklist.` + : `Added ${pluralize(add.length, 'host')} to the blocklist.`, }) } if (remove.length) { triggerToast({ title: 'Blocklist updated', - body: `${remove - .map((i) => truncate(i, 20)) - .join(', ')} removed from blocklist.`, + body: + remove.length === 1 + ? `Host ${truncate(remove[0], 20)} removed from blocklist.` + : `Removed ${pluralize( + remove.length, + 'host' + )} from the blocklist.`, }) } return true 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/core/PoolSelected.tsx b/libs/design-system/src/core/PoolSelected.tsx index f77dc9287..fec186fd5 100644 --- a/libs/design-system/src/core/PoolSelected.tsx +++ b/libs/design-system/src/core/PoolSelected.tsx @@ -18,7 +18,7 @@ export function PoolSelected({ options, onClick, onRemove }: Props) {
{options.map((option) => { return ( - +