Skip to content

Commit 78a252d

Browse files
authored
Various local explorer UI fixes (#13562)
1 parent a610749 commit 78a252d

9 files changed

Lines changed: 157 additions & 114 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
"@cloudflare/local-explorer-ui": patch
3+
---
4+
5+
Local Explorer UI refinements
6+
7+
- Add scrolling to the D1 table selector, instead of cutting off the table list
8+
- Show table headers in R2 empty states
9+
- Persist the `delimiter` search param when navigating to R2 object details
10+
- Hide breadcrumb path segments when viewing R2 objects in ungrouped mode

packages/local-explorer-ui/src/__e2e__/d1/d1-database.spec.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ describe("D1 Database Studio", () => {
3838
await selectTableButton.click();
3939

4040
// Should show the users table from seed data
41-
const usersOption = page.getByRole("option", { name: "users" });
41+
const usersOption = page.getByRole("menuitem", { name: "users" });
4242
const isUsersVisible = await usersOption.isVisible();
4343
expect(isUsersVisible).toBe(true);
4444
});
@@ -192,7 +192,9 @@ describe("D1 Database Studio", () => {
192192

193193
await openTableSelector();
194194

195-
const productsOption = page.getByRole("option", { name: "products" });
195+
const productsOption = page.getByRole("menuitem", {
196+
name: "products",
197+
});
196198
const isProductsVisible = await productsOption.isVisible();
197199
expect(isProductsVisible).toBe(true);
198200
});

packages/local-explorer-ui/src/__e2e__/do/durable-object.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,9 @@ describe("Durable Objects", () => {
103103

104104
await openTableSelector();
105105

106-
const productsOption = page.getByRole("option", { name: "products" });
106+
const productsOption = page.getByRole("menuitem", {
107+
name: "products",
108+
});
107109
const isProductsVisible = await productsOption.isVisible();
108110
expect(isProductsVisible).toBe(true);
109111
});

packages/local-explorer-ui/src/__e2e__/r2/r2-bucket.spec.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,45 @@ describe("R2 Bucket", () => {
212212
await waitForText("images/");
213213
await waitForText("documents/");
214214
});
215+
216+
test("delimiter param persists when navigating to an object and back", async ({
217+
expect,
218+
}) => {
219+
await navigateToR2Bucket("my-bucket");
220+
await waitForTableRows(1);
221+
222+
// Switch to ungrouped mode (sets delimiter=false in URL)
223+
await page.getByRole("button", { name: /Grouped|Ungrouped/ }).click();
224+
await page
225+
.getByRole("menuitem", { name: "Ungrouped", exact: true })
226+
.click();
227+
await waitForTableRows(1);
228+
await waitForText("images/logo.svg");
229+
230+
// Navigate to an object
231+
const objectLink = page
232+
.getByRole("link", { name: "images/logo.svg" })
233+
.first();
234+
await objectLink.click();
235+
await waitForText("Object Details");
236+
237+
// URL should still contain delimiter=false
238+
expect(page.url()).toContain("delimiter=false");
239+
240+
// Navigate back via the bucket breadcrumb
241+
const bucketBreadcrumb = page
242+
.locator('nav[aria-label="breadcrumb"]')
243+
.getByRole("link", { name: "my-bucket" })
244+
.first();
245+
await bucketBreadcrumb.click();
246+
await waitForTableRows(1);
247+
248+
// Should still be in ungrouped mode — full keys visible, no directories
249+
await waitForText("images/logo.svg");
250+
251+
// The toolbar button should still show "Ungrouped"
252+
await waitForText("Ungrouped");
253+
});
215254
});
216255

217256
describe("object detail page", () => {

packages/local-explorer-ui/src/__e2e__/utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,7 +398,7 @@ export async function openTableSelector(): Promise<void> {
398398
.first();
399399
await tableSelector.click();
400400

401-
await page.waitForSelector('[role="listbox"]', {
401+
await page.waitForSelector('[role="menu"]', {
402402
state: "visible",
403403
timeout: 5_000,
404404
});

packages/local-explorer-ui/src/components/R2ObjectTable.tsx

Lines changed: 18 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Button, Checkbox, DropdownMenu, Table } from "@cloudflare/kumo";
1+
import { Button, Checkbox, DropdownMenu, Table, Text } from "@cloudflare/kumo";
22
import {
33
DotsThreeIcon,
44
DownloadIcon,
@@ -275,21 +275,6 @@ export function R2ObjectTable({
275275
onSelectionChange(newSelection);
276276
}
277277

278-
if (items.length === 0) {
279-
return (
280-
<div className="flex flex-col items-center justify-center space-y-2 p-12 text-center text-kumo-subtle">
281-
<h2 className="text-2xl font-medium">
282-
{currentPrefix
283-
? "No objects in this directory"
284-
: "No objects in this bucket"}
285-
</h2>
286-
<p className="text-sm font-light">
287-
Upload an object using the button above.
288-
</p>
289-
</div>
290-
);
291-
}
292-
293278
return (
294279
<div className="overflow-hidden rounded-lg border border-kumo-fill">
295280
<Table>
@@ -322,6 +307,22 @@ export function R2ObjectTable({
322307
</Table.Header>
323308

324309
<Table.Body>
310+
{items.length === 0 ? (
311+
<Table.Row>
312+
<Table.Cell colSpan={6} className="py-20! text-center">
313+
<div className="flex flex-col items-center justify-center gap-1">
314+
<Text size="sm" bold>
315+
{currentPrefix
316+
? "No objects in this directory"
317+
: "No objects in this bucket"}
318+
</Text>
319+
<Text variant="secondary" size="xs">
320+
Upload an object using the button above.
321+
</Text>
322+
</div>
323+
</Table.Cell>
324+
</Table.Row>
325+
) : null}
325326
{items.map((item) => {
326327
if (item.type === "directory") {
327328
const displayName = getDisplayName(item.prefix, currentPrefix);
@@ -399,6 +400,7 @@ export function R2ObjectTable({
399400
<Link
400401
to="/r2/$bucketName/object/$"
401402
params={{ bucketName, _splat: key }}
403+
search={(prev) => prev}
402404
className="flex items-center gap-2 text-kumo-default no-underline hover:text-kumo-link"
403405
>
404406
<FileIcon size={16} className="text-kumo-subtle" />
Lines changed: 40 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
import { Select } from "@cloudflare/kumo/primitives/select";
1+
import { DropdownMenu } from "@cloudflare/kumo";
22
import {
33
CaretUpDownIcon,
44
CheckIcon,
55
PlusIcon,
66
TableIcon,
77
} from "@phosphor-icons/react";
88
import { useNavigate } from "@tanstack/react-router";
9-
import { useCallback, useState } from "react";
9+
import { useCallback } from "react";
1010
import type { StudioRef } from "./studio";
1111
import type { RefObject } from "react";
1212

@@ -22,14 +22,9 @@ export function TableSelect({
2222
selectedTable,
2323
}: TableSelectProps): JSX.Element {
2424
const navigate = useNavigate();
25-
const [open, setOpen] = useState(false);
2625

2726
const handleTableChange = useCallback(
28-
(tableName: string | null) => {
29-
if (tableName === null) {
30-
return;
31-
}
32-
27+
(tableName: string) => {
3328
void navigate({
3429
search: (prev) => ({ ...prev, table: tableName }),
3530
to: ".",
@@ -39,77 +34,49 @@ export function TableSelect({
3934
);
4035

4136
const handleCreateTable = useCallback((): void => {
42-
setOpen(false);
4337
studioRef.current?.openCreateTableTab();
4438
}, [studioRef]);
4539

4640
return (
47-
<Select.Root
48-
key="table-select"
49-
onOpenChange={setOpen}
50-
onValueChange={handleTableChange}
51-
open={open}
52-
value={selectedTable}
53-
>
54-
<Select.Trigger className="-mx-1.5 inline-flex cursor-pointer items-center gap-1 rounded-md border-none bg-transparent p-2 text-sm text-kumo-default transition-colors hover:bg-kumo-fill data-popup-open:bg-kumo-fill">
55-
{selectedTable ? <Select.Value /> : "Select table"}
56-
<Select.Icon>
57-
<CaretUpDownIcon className="h-3.5 w-3.5 text-kumo-subtle" />
58-
</Select.Icon>
59-
</Select.Trigger>
60-
61-
<Select.Portal>
62-
<Select.Positioner
63-
align="start"
64-
alignItemWithTrigger={false}
65-
className="z-100"
66-
side="bottom"
67-
sideOffset={4}
68-
>
69-
<Select.Popup className="max-h-72 min-w-36 overflow-hidden rounded-lg border border-kumo-fill bg-kumo-base shadow-[0_4px_12px_rgba(0,0,0,0.15)] transition-[opacity,transform] duration-150 data-ending-style:-translate-y-1 data-ending-style:opacity-0 data-starting-style:-translate-y-1 data-starting-style:opacity-0">
70-
<div className="p-1">
71-
<button
72-
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-kumo-default transition-colors outline-none select-none hover:bg-kumo-elevated"
73-
onClick={handleCreateTable}
74-
type="button"
75-
>
76-
<span className="flex w-4 items-center">
77-
<PlusIcon className="h-3.5 w-3.5" />
78-
</span>
79-
Create table
80-
</button>
81-
</div>
41+
<DropdownMenu>
42+
<DropdownMenu.Trigger
43+
render={
44+
<button
45+
className="-mx-1.5 inline-flex cursor-pointer items-center gap-1 rounded-md border-none bg-transparent p-2 text-sm text-kumo-default transition-colors hover:bg-kumo-fill data-[popup-open]:bg-kumo-fill"
46+
type="button"
47+
/>
48+
}
49+
>
50+
{selectedTable ?? "Select table"}
51+
<CaretUpDownIcon className="h-3.5 w-3.5 text-kumo-subtle" />
52+
</DropdownMenu.Trigger>
8253

83-
<div className="mx-1 border-t border-kumo-fill" />
54+
<DropdownMenu.Content
55+
className="max-h-72 overflow-y-auto"
56+
style={{ zIndex: 50 }}
57+
>
58+
<DropdownMenu.Item icon={PlusIcon} onClick={handleCreateTable}>
59+
Create table
60+
</DropdownMenu.Item>
8461

85-
<Select.List className="p-1">
86-
{tables.length > 0 ? (
87-
tables.map((table) => {
88-
const Icon =
89-
selectedTable === table.value ? CheckIcon : TableIcon;
62+
<DropdownMenu.Separator />
9063

91-
return (
92-
<Select.Item
93-
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-sm text-kumo-default transition-colors outline-none select-none data-highlighted:bg-kumo-elevated"
94-
key={table.value}
95-
value={table.value}
96-
>
97-
<span className="flex w-4 items-center">
98-
<Icon className="h-3.5 w-3.5" />
99-
</span>
100-
<Select.ItemText>{table.label}</Select.ItemText>
101-
</Select.Item>
102-
);
103-
})
104-
) : (
105-
<span className="flex w-full items-center justify-center gap-2 px-2 py-1.5 text-sm text-kumo-subtle">
106-
No tables
107-
</span>
108-
)}
109-
</Select.List>
110-
</Select.Popup>
111-
</Select.Positioner>
112-
</Select.Portal>
113-
</Select.Root>
64+
{tables.length > 0 ? (
65+
tables.map((table) => (
66+
<DropdownMenu.Item
67+
icon={selectedTable === table.value ? CheckIcon : TableIcon}
68+
key={table.value}
69+
onClick={() => handleTableChange(table.value)}
70+
>
71+
{table.label}
72+
</DropdownMenu.Item>
73+
))
74+
) : (
75+
<span className="flex w-full items-center justify-center gap-2 px-2 py-1.5 text-sm text-kumo-subtle">
76+
No tables
77+
</span>
78+
)}
79+
</DropdownMenu.Content>
80+
</DropdownMenu>
11481
);
11582
}

packages/local-explorer-ui/src/routes/r2/$bucketName/index.tsx

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -317,9 +317,10 @@ function BucketView(): JSX.Element {
317317
}
318318

319319
// Build breadcrumb items
320-
const pathSegments = search.prefix
321-
? search.prefix.split("/").filter(Boolean)
322-
: [];
320+
const pathSegments =
321+
directoryView && search.prefix
322+
? search.prefix.split("/").filter(Boolean)
323+
: [];
323324
const breadcrumbItems = [
324325
<Link
325326
className="text-kumo-default no-underline hover:text-kumo-link"
@@ -341,7 +342,7 @@ function BucketView(): JSX.Element {
341342
search={{ prefix: segmentPrefix }}
342343
to="/r2/$bucketName"
343344
>
344-
{segment}
345+
{segment}/
345346
</Link>
346347
);
347348
}),

0 commit comments

Comments
 (0)