Skip to content

Commit

Permalink
feat(renterd): keys batch operations
Browse files Browse the repository at this point in the history
  • Loading branch information
alexfreska committed Oct 16, 2024
1 parent 69c3652 commit 6b8ec55
Show file tree
Hide file tree
Showing 23 changed files with 360 additions and 78 deletions.
5 changes: 5 additions & 0 deletions .changeset/calm-flies-punch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@siafoundation/e2e': minor
---

Add getTextInputValueByName method.
5 changes: 5 additions & 0 deletions .changeset/happy-comics-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'renterd': minor
---

The key management table now supports multiselect and batch deletion.
6 changes: 6 additions & 0 deletions .changeset/selfish-parrots-applaud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'hostd': minor
'renterd': minor
---

The onboarding wizard is now bottom right aligned.
5 changes: 5 additions & 0 deletions .changeset/strong-cherries-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@siafoundation/e2e': minor
---

Add toggleColumnVisibility view preferences method.
4 changes: 2 additions & 2 deletions apps/hostd/components/OnboardingBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function OnboardingBar() {

if (maximized) {
return (
<div className="z-20 fixed bottom-5 left-1/2 -translate-x-1/2 flex justify-center">
<div className="z-20 fixed bottom-5 right-5 flex justify-center">
<Panel className="w-[400px] flex flex-col max-h-[600px]">
<ScrollArea>
<div className="flex justify-between items-center px-3 py-2 border-b border-gray-200 dark:border-graydark-300">
Expand Down Expand Up @@ -232,7 +232,7 @@ export function OnboardingBar() {
)
}
return (
<div className="z-30 fixed bottom-5 left-1/2 -translate-x-1/2 flex justify-center">
<div className="z-30 fixed bottom-5 right-5 flex justify-center">
<Button
onClick={() => setMaximized(true)}
size="large"
Expand Down
33 changes: 4 additions & 29 deletions apps/renterd-e2e/src/fixtures/contracts.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,18 @@
import { Page, expect } from '@playwright/test'
import { Page } from '@playwright/test'

export async function getContractRowById(page: Page, id: string) {
export function getContractRowById(page: Page, id: string) {
return page.getByTestId('contractsTable').getByTestId(id)
}

export async function getContractsSummaryRow(page: Page) {
export function getContractsSummaryRow(page: Page) {
return page
.getByTestId('contractsTable')
.locator('thead')
.getByRole('row')
.nth(1)
}

export async function getContractRowByIndex(page: Page, index: number) {
export function getContractRowByIndex(page: Page, index: number) {
return page
.getByTestId('contractsTable')
.locator('tbody')
Expand All @@ -27,28 +27,3 @@ export async function getContractRows(page: Page) {
.getByRole('row')
.all()
}

export async function toggleColumnVisibility(
page: Page,
name: string,
visible: boolean
) {
await page.getByLabel('configure view').click()
const configureView = page.getByRole('dialog')
const columnToggle = configureView.getByRole('checkbox', {
name,
})

if (visible) {
await expect(columnToggle).toBeVisible()
if (!(await columnToggle.isChecked())) {
await columnToggle.click()
}
} else {
await expect(columnToggle).toBeHidden()
if (await columnToggle.isChecked()) {
await columnToggle.click()
}
}
await page.getByLabel('configure view').click()
}
31 changes: 3 additions & 28 deletions apps/renterd-e2e/src/fixtures/hosts.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { Locator, Page, expect } from '@playwright/test'
import { Locator, Page } from '@playwright/test'

export async function getHostRowById(page: Page, id: string) {
export function getHostRowById(page: Page, id: string) {
return page.getByTestId('hostsTable').getByTestId(id)
}

export async function getHostsSummaryRow(page: Page) {
export function getHostsSummaryRow(page: Page) {
return page.getByTestId('hostsTable').locator('thead').getByRole('row').nth(1)
}

Expand Down Expand Up @@ -33,28 +33,3 @@ export async function openHostContextMenu(page: Page, name: string) {
export async function openRowHostContextMenu(row: Locator) {
await row.getByTestId('actions').getByRole('button').first().click()
}

export async function toggleColumnVisibility(
page: Page,
name: string,
visible: boolean
) {
await page.getByLabel('configure view').click()
const configureView = page.getByRole('dialog')
const columnToggle = configureView.getByRole('checkbox', {
name,
})

if (visible) {
await expect(columnToggle).toBeVisible()
if (!(await columnToggle.isChecked())) {
await columnToggle.click()
}
} else {
await expect(columnToggle).toBeHidden()
if (await columnToggle.isChecked()) {
await columnToggle.click()
}
}
await page.getByLabel('configure view').click()
}
41 changes: 41 additions & 0 deletions apps/renterd-e2e/src/fixtures/keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { Page, expect } from '@playwright/test'
import { navigateToKeys } from './navigate'
import { getTextInputValueByName } from '@siafoundation/e2e'

export function getKeyRowById(page: Page, id: string) {
return page.getByTestId('keysTable').getByTestId(id)
}

export function getKeysSummaryRow(page: Page) {
return page.getByTestId('keysTable').locator('thead').getByRole('row').nth(1)
}

export function getKeyRowByIndex(page: Page, index: number) {
return page
.getByTestId('keysTable')
.locator('tbody')
.getByRole('row')
.nth(index)
}

export async function getKeyRows(page: Page) {
return page.getByTestId('keysTable').locator('tbody').getByRole('row').all()
}

export async function createKey(page: Page): Promise<string> {
await navigateToKeys({ page })
await page.getByTestId('navbar').getByText('Create keypair').click()
const dialog = page.getByRole('dialog')
const accessKeyId = await getTextInputValueByName(page, 'name')
await dialog.getByRole('button', { name: 'Create' }).click()
const row = getKeyRowById(page, accessKeyId)
await expect(dialog).toBeHidden()
await expect(row).toBeVisible()
return accessKeyId
}

export async function openKeyContextMenu(page: Page, key: string) {
const selector = page.getByTestId(key).getByLabel('key context menu')
await expect(selector).toBeVisible()
await selector.click()
}
8 changes: 8 additions & 0 deletions apps/renterd-e2e/src/fixtures/navigate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,11 @@ export async function navigateToHosts({ page }: { page: Page }) {
await page.getByTestId('sidenav').getByLabel('Hosts').click()
await expect(page.getByTestId('navbar').getByText('Hosts')).toBeVisible()
}

export async function navigateToKeys({ page }: { page: Page }) {
await page
.getByTestId('sidenav')
.getByLabel('S3 authentication keypairs')
.click()
await expect(page.getByTestId('navbar').getByText('Keys')).toBeVisible()
}
2 changes: 1 addition & 1 deletion apps/renterd-e2e/src/specs/contracts.spec.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { test, expect } from '@playwright/test'
import { toggleColumnVisibility } from '@siafoundation/e2e'
import { navigateToContracts } from '../fixtures/navigate'
import { afterTest, beforeTest } from '../fixtures/beforeTest'
import {
getContractRowByIndex,
getContractRows,
getContractsSummaryRow,
toggleColumnVisibility,
} from '../fixtures/contracts'

test.beforeEach(async ({ page }) => {
Expand Down
56 changes: 56 additions & 0 deletions apps/renterd-e2e/src/specs/keys.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { test, expect } from '@playwright/test'
import { afterTest, beforeTest } from '../fixtures/beforeTest'
import {
createKey,
getKeyRowById,
getKeyRowByIndex,
openKeyContextMenu,
} from '../fixtures/keys'

test.beforeEach(async ({ page }) => {
await beforeTest(page)
})

test.afterEach(async () => {
await afterTest()
})

test('create and delete a key', async ({ page }) => {
const key = await createKey(page)
const row = getKeyRowById(page, key)
await openKeyContextMenu(page, key)
await page.getByRole('menu').getByText('Delete').click()
const dialog = page.getByRole('dialog')
await dialog.getByRole('button', { name: 'Delete' }).click()
await expect(row).toBeHidden()
})

test('batch delete multiple keys', async ({ page }) => {
// Create 3 keys. Note: 1 already exists.
const key1 = await createKey(page)
const key2 = await createKey(page)
const key3 = await createKey(page)
const row1 = getKeyRowById(page, key1)
const row2 = getKeyRowById(page, key2)
const row3 = getKeyRowById(page, key3)

// There are 4 keys total. Get the first and last row.
const rowIdx0 = getKeyRowByIndex(page, 0)
const rowIdx3 = getKeyRowByIndex(page, 3)

// Select all 4 keys.
await rowIdx0.getByLabel('select key').click()
await rowIdx3.getByLabel('select key').click({ modifiers: ['Shift'] })

// Delete all 4 keys.
const menu = page.getByLabel('key multiselect menu')
await menu.getByLabel('delete selected keys').click()
const dialog = page.getByRole('dialog')
await dialog.getByRole('button', { name: 'Delete' }).click()
await expect(row1).toBeHidden()
await expect(row2).toBeHidden()
await expect(row3).toBeHidden()
await expect(
page.getByText('There are no S3 authentication keypairs yet.')
).toBeVisible()
})
13 changes: 9 additions & 4 deletions apps/renterd/components/Keys/KeyContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,19 @@ export function KeyContextMenu({ s3Key, contentProps, buttonProps }: Props) {
if (response.error) {
triggerErrorToast({ title: 'Error deleting key', body: response.error })
} else {
triggerSuccessToast({ title: `Key ${s3Key} removed` })
triggerSuccessToast({ title: `Key ${s3Key} deleted` })
}
}, [settingsS3.data, s3Key, settingsS3Update])

return (
<DropdownMenu
trigger={
<Button variant="ghost" icon="hover" {...buttonProps}>
<Button
aria-label="key context menu"
icon="hover"
size="none"
{...buttonProps}
>
<CaretDown16 />
</Button>
}
Expand All @@ -76,12 +81,12 @@ export function KeyContextMenu({ s3Key, contentProps, buttonProps }: Props) {
onSelect={() => {
openConfirmDialog({
title: `Delete key ${truncate(s3Key, 15)}`,
action: 'Remove',
action: 'Delete',
variant: 'red',
body: (
<div className="flex flex-col gap-1">
<Paragraph size="14">
Are you sure you would like to remove the following key?
Are you sure you would like to delete the following key?
</Paragraph>
<Paragraph size="14" font="mono">
{truncate(s3Key, 80)}
Expand Down
80 changes: 80 additions & 0 deletions apps/renterd/components/Keys/KeysBatchMenu/KeysBatchDelete.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import {
Button,
Paragraph,
triggerSuccessToast,
triggerErrorToast,
} from '@siafoundation/design-system'
import { Delete16 } from '@siafoundation/react-icons'
import {
useSettingsS3,
useSettingsS3Update,
} from '@siafoundation/renterd-react'
import { useCallback, useMemo } from 'react'
import { omit } from '@technically/lodash'
import { useDialog } from '../../../contexts/dialog'
import { useKeys } from '../../../contexts/keys'

export function KeysBatchDelete() {
const { selectionMap, deselect } = useKeys()

const ids = useMemo(
() => Object.entries(selectionMap).map(([_, item]) => item.id),
[selectionMap]
)
const keys = useMemo(
() => Object.entries(selectionMap).map(([_, item]) => item.key),
[selectionMap]
)
const { openConfirmDialog } = useDialog()
const settingsS3 = useSettingsS3()
const settingsS3Update = useSettingsS3Update()
const deleteKeys = useCallback(async () => {
if (!settingsS3.data) {
triggerErrorToast({ title: 'Error deleting key' })
return
}
const newKeys = omit(settingsS3.data?.authentication.v4Keypairs, keys)
const response = await settingsS3Update.put({
payload: {
...settingsS3.data,
authentication: {
...settingsS3.data.authentication,
v4Keypairs: newKeys,
},
},
})
deselect(ids)
if (response.error) {
triggerErrorToast({ title: 'Error deleting keys', body: response.error })
} else {
triggerSuccessToast({ title: `Keys deleted` })
}
}, [settingsS3.data, settingsS3Update, deselect, keys, ids])

return (
<Button
aria-label="delete selected keys"
tip="Delete selected keys"
onClick={() => {
openConfirmDialog({
title: `Delete keys`,
action: 'Delete',
variant: 'red',
body: (
<div className="flex flex-col gap-1">
<Paragraph size="14">
Are you sure you would like to delete the{' '}
{ids.length.toLocaleString()} selected keys?
</Paragraph>
</div>
),
onConfirm: async () => {
deleteKeys()
},
})
}}
>
<Delete16 />
</Button>
)
}
Loading

0 comments on commit 6b8ec55

Please sign in to comment.