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 (