From e3b3ff2a7fc264295f62c329cf39d543b7535cf4 Mon Sep 17 00:00:00 2001 From: Alex Freska Date: Fri, 22 Nov 2024 13:34:09 -0500 Subject: [PATCH] feat(renterd): contracts batch manage blocklist and allowlist --- .changeset/shy-dingos-roll.md | 5 ++ apps/renterd-e2e/src/fixtures/hosts.ts | 20 ++++- apps/renterd-e2e/src/specs/contracts.spec.ts | 75 ++++++++++++++++++- .../ContractsAddAllowlist.tsx | 54 +++++++++++++ .../ContractsAddBlocklist.tsx | 58 ++++++++++++++ .../ContractsRemoveAllowlist.tsx | 54 +++++++++++++ .../ContractsRemoveBlocklist.tsx | 58 ++++++++++++++ .../Contracts/ContractsBatchMenu/index.tsx | 12 +++ .../HostsAllowBlockDialog/AllowlistForm.tsx | 2 +- .../HostsAllowBlockDialog/BlocklistForm.tsx | 22 +++--- .../Hosts/HostsAllowBlockDialog/index.tsx | 8 +- apps/renterd/hooks/useAllowlistUpdate.tsx | 18 +++-- apps/renterd/hooks/useBlocklistUpdate.tsx | 18 +++-- libs/design-system/src/core/PoolSelected.tsx | 2 +- 14 files changed, 378 insertions(+), 28 deletions(-) create mode 100644 .changeset/shy-dingos-roll.md create mode 100644 apps/renterd/components/Contracts/ContractsBatchMenu/ContractsAddAllowlist.tsx create mode 100644 apps/renterd/components/Contracts/ContractsBatchMenu/ContractsAddBlocklist.tsx create mode 100644 apps/renterd/components/Contracts/ContractsBatchMenu/ContractsRemoveAllowlist.tsx create mode 100644 apps/renterd/components/Contracts/ContractsBatchMenu/ContractsRemoveBlocklist.tsx 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 984c7f44d..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('bulk 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('bulk 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/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/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 ( - +