Skip to content

Commit fa88fef

Browse files
authored
feat(local-explorer-ui): Add schema editor to data studio (#12760)
1 parent 67ab88b commit fa88fef

File tree

17 files changed

+1871
-74
lines changed

17 files changed

+1871
-74
lines changed

.changeset/young-facts-grin.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@cloudflare/local-explorer-ui": minor
3+
---
4+
5+
Add schema editor to data studio
6+
7+
Adds a visual schema editor to the data studio that allows you to create new database tables and edit existing table schemas. The editor provides column management (add, edit, remove), constraint editing (primary keys, unique constraints), and generates the corresponding SQL statements for review before committing changes.
8+
9+
This is a WIP experimental feature.

packages/local-explorer-ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
"@phosphor-icons/react": "^2.1.10",
3737
"@tailwindcss/vite": "^4.0.15",
3838
"@tanstack/react-router": "^1.158.0",
39+
"immer": "^11.1.4",
3940
"react": "^19.2.0",
4041
"react-dom": "^19.2.0",
4142
"tailwindcss": "^4.0.15"

packages/local-explorer-ui/src/components/studio/Explain/SQLiteExplainTab.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ function describeExplainNode(d: string): {
116116
label: (
117117
<div className="flex items-center">
118118
<strong>SCAN </strong>
119-
<span className="border border-color p-1 mx-2 rounded flex items-center gap-2">
119+
<span className="border border-border p-1 mx-2 rounded flex items-center gap-2">
120120
<TableIcon />
121121
{d.substring("SCAN ".length)}
122122
</span>
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { Button, Dialog, Text } from "@cloudflare/kumo";
2+
import { useState } from "react";
3+
import type { IStudioDriver } from "../../../types/studio";
4+
import type { SubmitEvent } from "react";
5+
6+
interface DropTableConfirmationModalProps {
7+
closeModal: () => void;
8+
driver: IStudioDriver;
9+
isOpen: boolean;
10+
onSuccess?: () => void;
11+
schemaName: string;
12+
tableName: string;
13+
}
14+
15+
export function DropTableConfirmationModal({
16+
closeModal,
17+
driver,
18+
isOpen,
19+
onSuccess,
20+
schemaName,
21+
tableName,
22+
}: DropTableConfirmationModalProps): JSX.Element {
23+
const [challengeInput, setChallengeInput] = useState<string>("");
24+
const [error, setError] = useState<string | null>(null);
25+
const [isDeleting, setIsDeleting] = useState<boolean>(false);
26+
27+
const isValid = challengeInput === tableName;
28+
29+
const handleSubmit = async (e: SubmitEvent<HTMLFormElement>) => {
30+
e.preventDefault();
31+
setIsDeleting(true);
32+
setError(null);
33+
34+
try {
35+
await driver.dropTable(schemaName, tableName);
36+
onSuccess?.();
37+
closeModal();
38+
} catch (err) {
39+
setIsDeleting(false);
40+
setError(err instanceof Error ? err.message : "Failed to delete table");
41+
}
42+
};
43+
44+
return (
45+
<Dialog.Root
46+
onOpenChange={(open: boolean) => {
47+
if (!open) {
48+
closeModal();
49+
}
50+
}}
51+
open={isOpen}
52+
>
53+
<Dialog className="p-6">
54+
<div className="flex items-start justify-between gap-4 mb-4">
55+
{/* @ts-expect-error - Type mismatch due to pnpm monorepo @types/react version conflict */}
56+
<Dialog.Title className="text-lg font-semibold">
57+
Delete table?
58+
</Dialog.Title>
59+
</div>
60+
61+
<form onSubmit={handleSubmit}>
62+
<div className="space-y-4">
63+
{/* @ts-expect-error - Type mismatch due to pnpm monorepo @types/react version conflict */}
64+
<Dialog.Description className="text-kumo-subtle">
65+
Are you sure you want to delete the table{" "}
66+
<strong>{tableName}</strong>? This action cannot be undone.
67+
</Dialog.Description>
68+
69+
<div className="space-y-2">
70+
<Text size="sm">
71+
Type <strong>{tableName}</strong> to confirm
72+
</Text>
73+
<input
74+
autoComplete="off"
75+
autoFocus
76+
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm"
77+
onChange={(e) => setChallengeInput(e.target.value)}
78+
value={challengeInput}
79+
/>
80+
</div>
81+
82+
{error && (
83+
<div className="rounded-md bg-red-50 p-3 text-red-700">
84+
{error}
85+
</div>
86+
)}
87+
</div>
88+
89+
<div className="flex gap-2 justify-end mt-4">
90+
<Button onClick={closeModal} variant="secondary">
91+
Cancel
92+
</Button>
93+
94+
<Button
95+
disabled={!isValid || isDeleting}
96+
loading={isDeleting}
97+
type="submit"
98+
variant="destructive"
99+
>
100+
Delete
101+
</Button>
102+
</div>
103+
</form>
104+
</Dialog>
105+
</Dialog.Root>
106+
);
107+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { cn } from "@cloudflare/kumo";
2+
import type { HTMLAttributes } from "react";
3+
4+
interface SkeletonBlockProps extends HTMLAttributes<HTMLDivElement> {
5+
height?: number | string;
6+
mb?: number;
7+
p?: number;
8+
width?: number | string;
9+
}
10+
11+
export function SkeletonBlock({
12+
className,
13+
height,
14+
mb,
15+
p,
16+
style,
17+
width,
18+
...props
19+
}: SkeletonBlockProps) {
20+
return (
21+
<div
22+
className={cn(
23+
"relative overflow-hidden rounded-md bg-surface-tertiary",
24+
"before:absolute before:inset-0 before:animate-pulse before:bg-linear-to-r before:from-transparent before:via-white/20 before:to-transparent",
25+
className
26+
)}
27+
style={{
28+
height: typeof height === "number" ? `${height}px` : height,
29+
width: typeof width === "number" ? `${width}px` : width,
30+
marginBottom: mb ? `${mb * 4}px` : undefined,
31+
padding: p ? `${p * 4}px` : undefined,
32+
...style,
33+
}}
34+
{...props}
35+
>
36+
{/* Non-breaking space for sizing */}
37+
&nbsp;
38+
</div>
39+
);
40+
}

packages/local-explorer-ui/src/components/studio/TabRegister.tsx

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { BinocularsIcon, TableIcon } from "@phosphor-icons/react";
1+
import { BinocularsIcon, PencilIcon, TableIcon } from "@phosphor-icons/react";
2+
import { StudioCreateUpdateTableTab } from "./Tabs/CreateUpdateTable";
23
import { StudioQueryTab } from "./Tabs/Query";
34
import { StudioTableExplorerTab } from "./Tabs/TableExplorer";
45
import type { Icon } from "@phosphor-icons/react";
@@ -26,7 +27,31 @@ const TableTab: TabDefinition<{
2627
type: "table",
2728
};
2829

29-
const RegisteredTabDefinition = [QueryTab, TableTab];
30+
const EditTableTab: TabDefinition<{
31+
schemaName: string;
32+
tableName: string;
33+
type: "edit-table";
34+
}> = {
35+
icon: PencilIcon,
36+
makeComponent: ({ schemaName, tableName }) => (
37+
<StudioCreateUpdateTableTab schemaName={schemaName} tableName={tableName} />
38+
),
39+
makeIdentifier: (tab) => `edit-table/${tab.schemaName}.${tab.tableName}`,
40+
makeTitle: ({ tableName }) => tableName,
41+
type: "edit-table",
42+
};
43+
44+
const NewTableTab: TabDefinition<{
45+
type: "create-table";
46+
}> = {
47+
icon: PencilIcon,
48+
makeComponent: () => <StudioCreateUpdateTableTab />,
49+
makeIdentifier: () => `create-table`,
50+
makeTitle: () => "Create table",
51+
type: "create-table",
52+
};
53+
54+
const RegisteredTabDefinition = [QueryTab, TableTab, EditTableTab, NewTableTab];
3055

3156
export interface TabDefinition<T extends { type: string }> {
3257
icon: Icon;
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { Button, DropdownMenu } from "@cloudflare/kumo";
2+
import { CopyIcon, TableIcon, TextTIcon } from "@phosphor-icons/react";
3+
import { useCallback } from "react";
4+
import type { IStudioDriver } from "../../../types/studio";
5+
6+
export interface TableTarget {
7+
schemaName: string;
8+
tableName: string;
9+
}
10+
11+
interface TableActionsDropdownProps {
12+
/**
13+
* The currently selected table. When null/undefined, the dropdown trigger is disabled.
14+
*/
15+
currentTable: string | null | undefined;
16+
17+
/**
18+
* The database driver used to perform operations like fetching schema and dropping tables.
19+
*/
20+
driver: IStudioDriver;
21+
22+
/**
23+
* The schema name for the current table.
24+
*
25+
* @default 'main'
26+
*/
27+
schemaName?: string;
28+
}
29+
30+
export function StudioTableActionsDropdown({
31+
currentTable,
32+
driver,
33+
schemaName = "main",
34+
}: TableActionsDropdownProps): JSX.Element {
35+
const handleCopyTableName = useCallback(async (): Promise<void> => {
36+
if (!currentTable) {
37+
return;
38+
}
39+
40+
await window.navigator.clipboard.writeText(currentTable);
41+
}, [currentTable]);
42+
43+
const handleCopyTableSchema = useCallback(async (): Promise<void> => {
44+
if (!currentTable) {
45+
return;
46+
}
47+
48+
const tableSchema = await driver.tableSchema(schemaName, currentTable);
49+
if (!tableSchema.createScript) {
50+
return;
51+
}
52+
53+
await window.navigator.clipboard.writeText(tableSchema.createScript);
54+
}, [currentTable, driver, schemaName]);
55+
56+
return (
57+
<>
58+
<DropdownMenu>
59+
<DropdownMenu.Trigger
60+
render={
61+
<Button
62+
aria-label="Copy"
63+
disabled={!currentTable}
64+
icon={CopyIcon}
65+
shape="square"
66+
/>
67+
}
68+
/>
69+
70+
<DropdownMenu.Content>
71+
<DropdownMenu.Item
72+
className="space-x-2 cursor-pointer"
73+
icon={TextTIcon}
74+
onClick={handleCopyTableName}
75+
>
76+
Copy table name
77+
</DropdownMenu.Item>
78+
79+
<DropdownMenu.Item
80+
className="space-x-2 cursor-pointer"
81+
icon={TableIcon}
82+
onClick={handleCopyTableSchema}
83+
>
84+
Copy table schema
85+
</DropdownMenu.Item>
86+
</DropdownMenu.Content>
87+
</DropdownMenu>
88+
</>
89+
);
90+
}

packages/local-explorer-ui/src/components/studio/Table/Result/EditableCell.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ function InputCellEditor({
9494
{popover &&
9595
createPortal(
9696
<div
97-
className="bg-surface border border-color rounded fixed shadow flex flex-col"
97+
className="bg-surface border border-border rounded fixed shadow flex flex-col"
9898
ref={refs.setFloating}
9999
style={{
100100
...(floatingStyles as React.CSSProperties),

0 commit comments

Comments
 (0)