Skip to content

Commit f73a00f

Browse files
feat(aurora-portal): implement three-zone DataGrid header for Object Storage (Ceph) (#923)
* feat(aurora-portal): three-zone DataGrid header for Ceph buckets * test(aurora-portal): drive Ceph bulk-empty through Actions popup; plural modal title * feat(aurora-portal): three-zone DataGrid header for Ceph objects * chore(aurora-portal): update translations * chore(aurora-portal): create changeset --------- Co-authored-by: TilmanHaupt <tilman.haupt@sap.com>
1 parent 7b80141 commit f73a00f

14 files changed

Lines changed: 381 additions & 167 deletions

File tree

.changeset/spotty-flowers-serve.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cobaltcore-dev/aurora": minor
3+
---
4+
5+
implement three-zone DataGrid header for Object Storage (Ceph)

packages/aurora/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/ContainerTableView.tsx

Lines changed: 32 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ interface ContainerTableViewProps {
3030
onDeleteError: (bucketName: string, errorMessage: string) => void
3131
selectedContainers: string[]
3232
setSelectedContainers: (containers: string[]) => void
33+
// When false, the selection column (header select-all + per-row checkboxes) is dropped.
34+
hasAnyBulkAction?: boolean
3335
}
3436

3537
export const ContainerTableView = ({
@@ -44,6 +46,7 @@ export const ContainerTableView = ({
4446
onDeleteError,
4547
selectedContainers,
4648
setSelectedContainers,
49+
hasAnyBulkAction = true,
4750
}: ContainerTableViewProps) => {
4851
const { projectId, provider } = useParams({ strict: false })
4952
const { t } = useLingui()
@@ -119,24 +122,30 @@ export const ContainerTableView = ({
119122
)
120123
}
121124

122-
// Define column template — 6 columns: checkbox, name, count, last modified, size, actions menu
123-
const gridColumnTemplate = "40px minmax(200px, 2fr) minmax(100px, 1fr) minmax(180px, 2fr) minmax(100px, 1fr) 60px"
125+
// Column template — drops the leading 40px selection track when bulk actions are unavailable.
126+
// The header and the absolutely-positioned virtual rows must share an identical track string.
127+
const columnCount = hasAnyBulkAction ? 6 : 5
128+
const gridColumnTemplate = hasAnyBulkAction
129+
? "40px minmax(200px, 2fr) minmax(100px, 1fr) minmax(180px, 2fr) minmax(100px, 1fr) 60px"
130+
: "minmax(200px, 2fr) minmax(100px, 1fr) minmax(180px, 2fr) minmax(100px, 1fr) 60px"
124131

125132
return (
126133
<>
127134
<div className="relative">
128135
{/* Table Header with scrollbar padding */}
129136
<div style={{ paddingRight: `${scrollbarWidth}px` }}>
130137
<DataGrid
131-
columns={6}
138+
columns={columnCount}
132139
gridColumnTemplate={gridColumnTemplate}
133140
className="containers"
134141
data-testid="containers-table-header"
135142
>
136143
<DataGridRow>
137-
<DataGridHeadCell>
138-
<Checkbox checked={allSelected} onChange={handleSelectAll} data-testid="select-all-containers" />
139-
</DataGridHeadCell>
144+
{hasAnyBulkAction && (
145+
<DataGridHeadCell>
146+
<Checkbox checked={allSelected} onChange={handleSelectAll} data-testid="select-all-containers" />
147+
</DataGridHeadCell>
148+
)}
140149
<DataGridHeadCell>
141150
<Trans>Bucket Name</Trans>
142151
</DataGridHeadCell>
@@ -159,7 +168,7 @@ export const ContainerTableView = ({
159168
ref={parentRef}
160169
className="overflow-auto"
161170
style={{
162-
height: "calc(100vh - 510px)", // Dynamic height based on viewport
171+
height: "calc(100vh - 490px)", // Dynamic height based on viewport
163172
}}
164173
data-testid="containers-table-body"
165174
>
@@ -211,20 +220,22 @@ export const ContainerTableView = ({
211220
}
212221
}}
213222
>
214-
<DataGridCell
215-
onClick={(e) => e.stopPropagation()}
216-
onKeyDown={(e) => {
217-
if (e.key === "Enter" || e.key === " ") {
218-
e.stopPropagation()
219-
}
220-
}}
221-
>
222-
<Checkbox
223-
checked={isSelected}
224-
onChange={() => handleSelectContainer(container.name)}
225-
data-testid={`select-container-${container.name}`}
226-
/>
227-
</DataGridCell>
223+
{hasAnyBulkAction && (
224+
<DataGridCell
225+
onClick={(e) => e.stopPropagation()}
226+
onKeyDown={(e) => {
227+
if (e.key === "Enter" || e.key === " ") {
228+
e.stopPropagation()
229+
}
230+
}}
231+
>
232+
<Checkbox
233+
checked={isSelected}
234+
onChange={() => handleSelectContainer(container.name)}
235+
data-testid={`select-container-${container.name}`}
236+
/>
237+
</DataGridCell>
238+
)}
228239
<DataGridCell className="min-w-0 overflow-hidden">
229240
<span className="block truncate" title={container.name}>
230241
{container.name}

packages/aurora/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/EmptyBucketsModal.test.tsx

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -127,14 +127,19 @@ describe("EmptyBucketsModal", () => {
127127

128128
test("renders when isOpen is true and buckets are provided", () => {
129129
renderModal()
130-
expect(screen.getByRole("heading", { name: "Empty Buckets" })).toBeInTheDocument()
130+
expect(screen.getByRole("dialog", { name: "Empty Buckets" })).toBeInTheDocument()
131131
})
132132
})
133133

134134
describe("UI elements", () => {
135-
test("renders modal title", () => {
136-
renderModal()
137-
expect(screen.getByRole("heading", { name: "Empty Buckets" })).toBeInTheDocument()
135+
test("renders the plural title when multiple buckets are selected", () => {
136+
renderModal({ buckets: mockBuckets })
137+
expect(screen.getByRole("dialog", { name: "Empty Buckets" })).toBeInTheDocument()
138+
})
139+
140+
test("renders the singular title when a single bucket is selected", () => {
141+
renderModal({ buckets: mockSingleBucket })
142+
expect(screen.getByRole("dialog", { name: "Empty Bucket" })).toBeInTheDocument()
138143
})
139144

140145
test("shows warning message with bucket count (plural)", () => {

packages/aurora/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/EmptyBucketsModal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ export const EmptyBucketsModal = ({ isOpen, buckets, onClose, onComplete }: Empt
7979

8080
return (
8181
<Modal
82-
title={t`Empty Buckets`}
82+
title={<Plural value={totalCount} one="Empty Bucket" other="Empty Buckets" />}
8383
open={isOpen}
8484
onCancel={handleClose}
8585
confirmButtonLabel={isPending ? t`Emptying...` : t`Empty`}

packages/aurora/src/client/routes/_auth/projects/$projectId/storage/-components/Ceph/Containers/index.test.tsx

Lines changed: 56 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,27 @@ describe("CephContainers (index)", () => {
188188
})
189189
})
190190

191+
// The bulk-empty flow now lives in the Zone 3 "Actions" popup: select rows,
192+
// open the (otherwise disabled) Actions menu, then click the singular/plural
193+
// "Empty Bucket(s)" item to open the modal.
194+
const actionsButton = () => screen.getByRole("button", { name: /Actions/i })
195+
196+
const selectOne = async (user: ReturnType<typeof userEvent.setup>) => {
197+
await user.click(screen.getByTestId("simulate-select-bucket"))
198+
await waitFor(() => expect(actionsButton()).toBeEnabled())
199+
}
200+
201+
const selectTwo = async (user: ReturnType<typeof userEvent.setup>) => {
202+
await user.click(screen.getByTestId("simulate-select-two"))
203+
await waitFor(() => expect(actionsButton()).toBeEnabled())
204+
}
205+
206+
const openEmptyModal = async (user: ReturnType<typeof userEvent.setup>, label: "Empty Bucket" | "Empty Buckets") => {
207+
await user.click(actionsButton())
208+
await user.click(await screen.findByText(label))
209+
await waitFor(() => expect(screen.getByTestId("empty-buckets-modal")).toBeInTheDocument())
210+
}
211+
191212
describe("Loading state", () => {
192213
test("shows loading spinner while fetching", () => {
193214
trpcState.isLoading = true
@@ -236,14 +257,14 @@ describe("CephContainers (index)", () => {
236257
expect(screen.getByRole("button", { name: /Create Bucket/i })).toBeInTheDocument()
237258
})
238259

239-
test("renders Empty All button", () => {
260+
test("renders the Actions button", () => {
240261
renderContainers()
241-
expect(screen.getByRole("button", { name: /^Empty All$/i })).toBeInTheDocument()
262+
expect(screen.getByRole("button", { name: /Actions/i })).toBeInTheDocument()
242263
})
243264

244-
test("Empty All button is disabled when no containers are selected", () => {
265+
test("Actions button is disabled when no containers are selected", () => {
245266
renderContainers()
246-
expect(screen.getByRole("button", { name: /^Empty All$/i })).toBeDisabled()
267+
expect(screen.getByRole("button", { name: /Actions/i })).toBeDisabled()
247268
})
248269

249270
test("passes selectedContainers and setSelectedContainers to ContainerTableView", () => {
@@ -304,79 +325,71 @@ describe("CephContainers (index)", () => {
304325
})
305326
})
306327

307-
describe("Empty All button", () => {
308-
test("shows no count when nothing is selected", () => {
328+
describe("Bulk actions menu", () => {
329+
test("Actions button is disabled when nothing is selected", () => {
309330
renderContainers()
310-
expect(screen.getByRole("button", { name: "Empty All" })).toBeInTheDocument()
331+
expect(actionsButton()).toBeDisabled()
311332
})
312333

313-
test("is enabled and shows count after selecting containers", async () => {
334+
test("Actions button becomes enabled after selecting a bucket", async () => {
314335
const user = userEvent.setup()
315336
renderContainers()
316337
await user.click(screen.getByTestId("simulate-select-bucket"))
317-
await waitFor(() => {
318-
expect(screen.getByRole("button", { name: /Empty All \(1\)/i })).toBeEnabled()
319-
})
338+
await waitFor(() => expect(actionsButton()).toBeEnabled())
320339
})
321340

322-
test("count updates as more containers are selected", async () => {
341+
test("shows the singular Empty Bucket item when one bucket is selected", async () => {
323342
const user = userEvent.setup()
324343
renderContainers()
325-
await user.click(screen.getByTestId("simulate-select-two"))
326-
await waitFor(() => {
327-
expect(screen.getByRole("button", { name: /Empty All \(2\)/i })).toBeEnabled()
328-
})
344+
await selectOne(user)
345+
await user.click(actionsButton())
346+
expect(await screen.findByText("Empty Bucket")).toBeInTheDocument()
329347
})
330348

331-
test("returns to disabled with no count after deselecting all", async () => {
349+
test("shows the plural Empty Buckets item when multiple buckets are selected", async () => {
332350
const user = userEvent.setup()
333351
renderContainers()
334-
await user.click(screen.getByTestId("simulate-select-bucket"))
335-
await waitFor(() => expect(screen.getByRole("button", { name: /Empty All \(1\)/i })).toBeEnabled())
352+
await selectTwo(user)
353+
await user.click(actionsButton())
354+
expect(await screen.findByText("Empty Buckets")).toBeInTheDocument()
355+
})
356+
357+
test("Actions button returns to disabled after deselecting all", async () => {
358+
const user = userEvent.setup()
359+
renderContainers()
360+
await selectOne(user)
336361
await user.click(screen.getByTestId("simulate-deselect-all"))
337-
await waitFor(() => {
338-
expect(screen.getByRole("button", { name: "Empty All" })).toBeDisabled()
339-
})
362+
await waitFor(() => expect(actionsButton()).toBeDisabled())
340363
})
341364
})
342365

343-
describe("Empty All modal", () => {
344-
const selectOne = async (user: ReturnType<typeof userEvent.setup>) => {
345-
await user.click(screen.getByTestId("simulate-select-bucket"))
346-
await waitFor(() => expect(screen.getByRole("button", { name: /Empty All \(1\)/i })).toBeEnabled())
347-
}
348-
366+
describe("Bulk empty modal", () => {
349367
test("modal is not visible by default", () => {
350368
renderContainers()
351369
expect(screen.queryByTestId("empty-buckets-modal")).not.toBeInTheDocument()
352370
})
353371

354-
test("clicking Empty All opens the modal", async () => {
372+
test("choosing Empty Bucket from the Actions menu opens the modal", async () => {
355373
const user = userEvent.setup()
356374
renderContainers()
357375
await selectOne(user)
358-
await user.click(screen.getByRole("button", { name: /Empty All \(1\)/i }))
359-
await waitFor(() => {
360-
expect(screen.getByTestId("empty-buckets-modal")).toBeInTheDocument()
361-
})
376+
await openEmptyModal(user, "Empty Bucket")
377+
expect(screen.getByTestId("empty-buckets-modal")).toBeInTheDocument()
362378
})
363379

364380
test("modal receives the selected bucket count", async () => {
365381
const user = userEvent.setup()
366382
renderContainers()
367383
await selectOne(user)
368-
await user.click(screen.getByRole("button", { name: /Empty All \(1\)/i }))
369-
await waitFor(() => {
370-
expect(screen.getByTestId("empty-buckets-modal")).toHaveAttribute("data-bucket-count", "1")
371-
})
384+
await openEmptyModal(user, "Empty Bucket")
385+
expect(screen.getByTestId("empty-buckets-modal")).toHaveAttribute("data-bucket-count", "1")
372386
})
373387

374388
test("closing the modal hides it", async () => {
375389
const user = userEvent.setup()
376390
renderContainers()
377391
await selectOne(user)
378-
await user.click(screen.getByRole("button", { name: /Empty All \(1\)/i }))
379-
await waitFor(() => expect(screen.getByTestId("empty-buckets-modal")).toBeInTheDocument())
392+
await openEmptyModal(user, "Empty Bucket")
380393
await user.click(screen.getByRole("button", { name: "CloseEmptyAll" }))
381394
await waitFor(() => {
382395
expect(screen.queryByTestId("empty-buckets-modal")).not.toBeInTheDocument()
@@ -388,8 +401,7 @@ describe("CephContainers (index)", () => {
388401
const user = userEvent.setup()
389402
renderContainers()
390403
await selectOne(user)
391-
await user.click(screen.getByRole("button", { name: /Empty All \(1\)/i }))
392-
await waitFor(() => expect(screen.getByTestId("empty-buckets-modal")).toBeInTheDocument())
404+
await openEmptyModal(user, "Empty Bucket")
393405
await user.click(screen.getByTestId("simulate-empty-success"))
394406
await waitFor(() => {
395407
expect(getBucketsEmptyCompleteToast).toHaveBeenCalledWith(
@@ -398,17 +410,15 @@ describe("CephContainers (index)", () => {
398410
[],
399411
expect.objectContaining({ onDismiss: expect.any(Function) })
400412
)
401-
expect(screen.getByRole("button", { name: "Empty All" })).toBeDisabled()
413+
expect(actionsButton()).toBeDisabled()
402414
})
403415
})
404416

405417
test("keeps failed buckets selected after partial error", async () => {
406418
const user = userEvent.setup()
407419
renderContainers()
408-
await user.click(screen.getByTestId("simulate-select-two"))
409-
await waitFor(() => expect(screen.getByRole("button", { name: /Empty All \(2\)/i })).toBeEnabled())
410-
await user.click(screen.getByRole("button", { name: /Empty All \(2\)/i }))
411-
await waitFor(() => expect(screen.getByTestId("empty-buckets-modal")).toBeInTheDocument())
420+
await selectTwo(user)
421+
await openEmptyModal(user, "Empty Buckets")
412422
await user.click(screen.getByTestId("simulate-empty-error"))
413423
await waitFor(() => {
414424
// Only bucket-1 failed — it stays selected; bucket-2 succeeded — cleared

0 commit comments

Comments
 (0)