Skip to content

Commit e07bd1c

Browse files
committed
fix(database): unify ColumnInfo in shared; wire column registry from typegen
1 parent ea90f8a commit e07bd1c

19 files changed

Lines changed: 591 additions & 494 deletions

File tree

apps/dev-playground/client/src/routes/database.route.tsx

Lines changed: 260 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@ import {
33
Badge,
44
Button,
55
Card,
6+
CardContent,
7+
CardDescription,
8+
CardHeader,
9+
CardTitle,
10+
CreateEntity,
11+
EditEntity,
612
Input,
713
Label,
814
Select,
@@ -16,9 +22,18 @@ import {
1622
TableHead,
1723
TableHeader,
1824
TableRow,
25+
ViewEntity,
1926
} from "@databricks/appkit-ui/react";
2027
import { createFileRoute } from "@tanstack/react-router";
21-
import { useCallback, useEffect, useId, useMemo, useState } from "react";
28+
import {
29+
type FormEvent,
30+
useCallback,
31+
useEffect,
32+
useId,
33+
useMemo,
34+
useState,
35+
} from "react";
36+
import { codeToHtml } from "shiki";
2237

2338
/**
2439
* Database plugin demo: `db.cases` is typed from `config/database/schema.ts`;
@@ -41,44 +56,266 @@ const RISK_BADGE: Record<string, "default" | "secondary" | "destructive"> = {
4156
Low: "default",
4257
};
4358

59+
const CASE_VIEW_FIELDS = [
60+
"case_id",
61+
"entity_name",
62+
"risk_level",
63+
"status",
64+
"assigned_to",
65+
] as const;
66+
67+
const CASE_MUTATION_FIELDS = [
68+
"case_id",
69+
"entity_id",
70+
"entity_name",
71+
"risk_level",
72+
"status",
73+
] as const;
74+
75+
const MANUAL_DB_SNIPPET = `const base =
76+
status === "All" ? db.cases : db.cases.where({ status });
77+
78+
const [rows, count] = await Promise.all([
79+
base.order({ created_at: "desc" }).limit(50).toArray(),
80+
base.count(),
81+
]);
82+
83+
await db.cases.create({
84+
case_id: "CASE-1001",
85+
entity_id: "ENT-5001",
86+
entity_name: "Acme Trading",
87+
risk_level: "Medium",
88+
status: "New",
89+
});
90+
91+
await db.cases.update("CASE-1001", {
92+
status: "Closed",
93+
updated_at: new Date().toISOString(),
94+
});
95+
96+
await db.cases.delete("CASE-1001");`;
97+
98+
const ENTITY_COMPONENTS_SNIPPET = `const [createOpen, setCreateOpen] = useState(false);
99+
const [editingId, setEditingId] = useState<string | null>(null);
100+
101+
<ViewEntity
102+
entity="cases"
103+
fields={["case_id", "entity_name", "risk_level", "status", "assigned_to"]}
104+
order={{ created_at: "desc" }}
105+
limit={8}
106+
onRowClick={(row) => setEditingId(row.case_id)}
107+
/>
108+
109+
<CreateEntity
110+
entity="cases"
111+
fields={["case_id", "entity_id", "entity_name", "risk_level", "status"]}
112+
open={createOpen}
113+
onOpenChange={setCreateOpen}
114+
/>
115+
116+
{editingId && (
117+
<EditEntity
118+
entity="cases"
119+
id={editingId}
120+
fields={["entity_id", "entity_name", "risk_level", "status"]}
121+
open
122+
onOpenChange={(open) => !open && setEditingId(null)}
123+
/>
124+
)}`;
125+
44126
export const Route = createFileRoute("/database")({
45127
component: DatabaseRoute,
46128
});
47129

48130
function DatabaseRoute() {
49131
return (
50132
<div className="min-h-screen bg-background">
51-
<div className="max-w-6xl mx-auto px-6 py-12">
52-
<div className="mb-8">
53-
<h1 className="text-3xl font-bold mb-2">Database Plugin Demo</h1>
133+
<div className="max-w-7xl mx-auto px-6 py-10">
134+
<div className="mb-8 max-w-3xl">
135+
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-muted-foreground mb-3">
136+
Database plugin beta
137+
</div>
138+
<h1 className="text-3xl font-bold tracking-tight mb-3">
139+
Two ways to build on the same typed entity API
140+
</h1>
54141
<p className="text-base text-muted-foreground">
55-
Full CRUD flow against <code>cases</code> via the typed{" "}
56-
<code>db</code> client. Every action hits an auto-generated route at{" "}
57-
<code>/api/database/cases</code>.
142+
Both sections hit the auto-mounted <code>/api/database/cases</code>{" "}
143+
routes. The left side shows hand-built product UI using{" "}
144+
<code>db.cases</code>; the right side shows the schema-driven entity
145+
components that generate the table and forms from metadata.
58146
</p>
59147
</div>
60148

61-
<div className="grid lg:grid-cols-[2fr_1fr] gap-6">
62-
<CaseList />
63-
<CreateCase />
149+
<div className="grid xl:grid-cols-2 gap-6 items-start">
150+
<ManualDbSection />
151+
<EntityComponentsSection />
64152
</div>
65153
</div>
66154
</div>
67155
);
68156
}
69157

70-
function useCases(status: string) {
158+
function CodeBlock({
159+
code,
160+
lang = "typescript",
161+
}: {
162+
code: string;
163+
lang?: string;
164+
}) {
165+
const [html, setHtml] = useState("");
166+
167+
useEffect(() => {
168+
let active = true;
169+
codeToHtml(code, {
170+
lang,
171+
theme: "dark-plus",
172+
}).then((highlighted) => {
173+
if (active) setHtml(highlighted);
174+
});
175+
return () => {
176+
active = false;
177+
};
178+
}, [code, lang]);
179+
180+
return (
181+
<div
182+
className="rounded-md overflow-hidden border bg-zinc-950 [&>pre]:m-0 [&>pre]:max-h-[420px] [&>pre]:overflow-auto [&>pre]:p-4 [&>pre]:text-xs [&>pre]:leading-relaxed"
183+
dangerouslySetInnerHTML={{ __html: html }}
184+
/>
185+
);
186+
}
187+
188+
function CodeDisclosure({
189+
code,
190+
label = "Show snippet",
191+
}: {
192+
code: string;
193+
label?: string;
194+
}) {
195+
const [open, setOpen] = useState(false);
196+
197+
return (
198+
<div className="space-y-3">
199+
<Button
200+
type="button"
201+
variant="outline"
202+
size="sm"
203+
onClick={() => setOpen((value) => !value)}
204+
>
205+
{open ? "Hide snippet" : label}
206+
</Button>
207+
{open && <CodeBlock code={code} />}
208+
</div>
209+
);
210+
}
211+
212+
function ManualDbSection() {
213+
const [refreshToken, setRefreshToken] = useState(0);
214+
const refresh = useCallback(() => setRefreshToken((value) => value + 1), []);
215+
216+
return (
217+
<Card className="overflow-hidden">
218+
<CardHeader className="border-b">
219+
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
220+
Side A
221+
</div>
222+
<CardTitle>Hand-built UI, typed database client</CardTitle>
223+
<CardDescription>
224+
Custom AML case workflow using direct, typed calls like{" "}
225+
<code>db.cases.where(...)</code>, <code>create</code>,{" "}
226+
<code>update</code>, and <code>delete</code>.
227+
</CardDescription>
228+
</CardHeader>
229+
<CardContent className="space-y-5">
230+
<CodeDisclosure code={MANUAL_DB_SNIPPET} />
231+
<CaseList refreshToken={refreshToken} />
232+
<CreateCase onCreated={refresh} />
233+
</CardContent>
234+
</Card>
235+
);
236+
}
237+
238+
function EntityComponentsSection() {
239+
const [createOpen, setCreateOpen] = useState(false);
240+
const [editingId, setEditingId] = useState<string | null>(null);
241+
const [refreshToken, setRefreshToken] = useState(0);
242+
const refresh = useCallback(() => setRefreshToken((value) => value + 1), []);
243+
244+
return (
245+
<Card className="overflow-hidden">
246+
<CardHeader className="border-b">
247+
<div className="text-xs font-semibold uppercase tracking-[0.16em] text-muted-foreground">
248+
Side B
249+
</div>
250+
<CardTitle>Entity components from the same schema</CardTitle>
251+
<CardDescription>
252+
Generic table and mutation dialogs driven by column metadata from{" "}
253+
<code>config/database/schema.ts</code>.
254+
</CardDescription>
255+
</CardHeader>
256+
<CardContent className="space-y-5">
257+
<CodeDisclosure code={ENTITY_COMPONENTS_SNIPPET} />
258+
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border bg-muted/20 p-3">
259+
<div>
260+
<div className="font-medium">Cases entity</div>
261+
<div className="text-sm text-muted-foreground">
262+
Click a row to open the generated edit dialog.
263+
</div>
264+
</div>
265+
<Button onClick={() => setCreateOpen(true)}>
266+
New with component
267+
</Button>
268+
</div>
269+
<ViewEntity
270+
key={refreshToken}
271+
entity="cases"
272+
fields={CASE_VIEW_FIELDS}
273+
order={{ created_at: "desc" }}
274+
limit={8}
275+
onRowClick={(row) => setEditingId(row.case_id)}
276+
/>
277+
<CreateEntity
278+
entity="cases"
279+
fields={CASE_MUTATION_FIELDS}
280+
open={createOpen}
281+
onOpenChange={setCreateOpen}
282+
onSuccess={refresh}
283+
title="Create case with Entity component"
284+
description="The form is generated from database.columns.ts metadata."
285+
/>
286+
{editingId && (
287+
<EditEntity
288+
entity="cases"
289+
id={editingId}
290+
fields={["entity_id", "entity_name", "risk_level", "status"]}
291+
open
292+
onOpenChange={(open) => {
293+
if (!open) setEditingId(null);
294+
}}
295+
onSuccess={refresh}
296+
title={`Edit ${editingId}`}
297+
description="Only editable, non-generated columns are shown."
298+
/>
299+
)}
300+
</CardContent>
301+
</Card>
302+
);
303+
}
304+
305+
function useCases(status: string, refreshToken: number) {
71306
const [data, setData] = useState<Awaited<
72307
ReturnType<typeof db.cases.toArray>
73308
> | null>(null);
74309
const [total, setTotal] = useState<number | null>(null);
75310
const [loading, setLoading] = useState(false);
76311
const [error, setError] = useState<string | null>(null);
77-
const [_tick, setTick] = useState(0);
312+
const [tick, setTick] = useState(0);
78313

79314
const refetch = useCallback(() => setTick((n) => n + 1), []);
80315

81316
useEffect(() => {
317+
void refreshToken;
318+
void tick;
82319
const ctrl = new AbortController();
83320
let active = true;
84321

@@ -109,14 +346,17 @@ function useCases(status: string) {
109346
active = false;
110347
ctrl.abort();
111348
};
112-
}, [status]);
349+
}, [status, refreshToken, tick]);
113350

114351
return { data, total, loading, error, refetch };
115352
}
116353

117-
function CaseList() {
354+
function CaseList({ refreshToken }: { refreshToken: number }) {
118355
const [statusFilter, setStatusFilter] = useState<string>(STATUS_FILTER_ALL);
119-
const { data, total, loading, error, refetch } = useCases(statusFilter);
356+
const { data, total, loading, error, refetch } = useCases(
357+
statusFilter,
358+
refreshToken,
359+
);
120360
const statusFilterId = useId();
121361

122362
const filterLabel = useMemo(
@@ -128,7 +368,7 @@ function CaseList() {
128368
);
129369

130370
return (
131-
<Card className="p-6">
371+
<div>
132372
<div className="flex items-center justify-between gap-4 mb-4">
133373
<div>
134374
<h2 className="text-xl font-semibold">Cases</h2>
@@ -205,7 +445,7 @@ function CaseList() {
205445
</TableBody>
206446
</Table>
207447
</div>
208-
</Card>
448+
</div>
209449
);
210450
}
211451

@@ -299,7 +539,7 @@ function CaseRowItem({
299539
);
300540
}
301541

302-
function CreateCase() {
542+
function CreateCase({ onCreated }: { onCreated: () => void }) {
303543
const [caseId, setCaseId] = useState("");
304544
const [entityId, setEntityId] = useState("");
305545
const [entityName, setEntityName] = useState("");
@@ -319,7 +559,7 @@ function CreateCase() {
319559

320560
const disabled = busy || caseId.trim() === "" || entityId.trim() === "";
321561

322-
const submit = async (e: React.FormEvent) => {
562+
const submit = async (e: FormEvent) => {
323563
e.preventDefault();
324564
if (disabled) return;
325565
setBusy(true);
@@ -336,6 +576,7 @@ function CreateCase() {
336576
setCaseId("");
337577
setEntityId("");
338578
setEntityName("");
579+
onCreated();
339580
} catch (err) {
340581
setMessage({ kind: "err", text: describeError(err) });
341582
} finally {

0 commit comments

Comments
 (0)