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 (