diff --git a/apps/dev-playground/.gitignore b/apps/dev-playground/.gitignore index bc5b8f669..ab33c2f05 100644 --- a/apps/dev-playground/.gitignore +++ b/apps/dev-playground/.gitignore @@ -10,4 +10,5 @@ config/database/schema.ts config/database/migrations/ # Auto-generated from config/database/schema.ts by the database vite plugin -shared/appkit-types/database.d.ts \ No newline at end of file +shared/appkit-types/database.d.ts +shared/appkit-types/database.columns.ts \ No newline at end of file diff --git a/apps/dev-playground/client/src/routes/database.route.tsx b/apps/dev-playground/client/src/routes/database.route.tsx index f0992e1c9..3db1e67cf 100644 --- a/apps/dev-playground/client/src/routes/database.route.tsx +++ b/apps/dev-playground/client/src/routes/database.route.tsx @@ -3,6 +3,12 @@ import { Badge, Button, Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + CreateEntity, + EditEntity, Input, Label, Select, @@ -16,9 +22,18 @@ import { TableHead, TableHeader, TableRow, + ViewEntity, } from "@databricks/appkit-ui/react"; import { createFileRoute } from "@tanstack/react-router"; -import { useCallback, useEffect, useId, useMemo, useState } from "react"; +import { + type FormEvent, + useCallback, + useEffect, + useId, + useMemo, + useState, +} from "react"; +import { codeToHtml } from "shiki"; /** * Database plugin demo: `db.cases` is typed from `config/database/schema.ts`; @@ -41,6 +56,73 @@ const RISK_BADGE: Record = { Low: "default", }; +const CASE_VIEW_FIELDS = [ + "case_id", + "entity_name", + "risk_level", + "status", + "assigned_to", +] as const; + +const CASE_MUTATION_FIELDS = [ + "case_id", + "entity_id", + "entity_name", + "risk_level", + "status", +] as const; + +const MANUAL_DB_SNIPPET = `const base = + status === "All" ? db.cases : db.cases.where({ status }); + +const [rows, count] = await Promise.all([ + base.order({ created_at: "desc" }).limit(50).toArray(), + base.count(), +]); + +await db.cases.create({ + case_id: "CASE-1001", + entity_id: "ENT-5001", + entity_name: "Acme Trading", + risk_level: "Medium", + status: "New", +}); + +await db.cases.update("CASE-1001", { + status: "Closed", + updated_at: new Date().toISOString(), +}); + +await db.cases.delete("CASE-1001");`; + +const ENTITY_COMPONENTS_SNIPPET = `const [createOpen, setCreateOpen] = useState(false); +const [editingId, setEditingId] = useState(null); + + setEditingId(row.case_id)} +/> + + + +{editingId && ( + !open && setEditingId(null)} + /> +)}`; + export const Route = createFileRoute("/database")({ component: DatabaseRoute, }); @@ -48,37 +130,192 @@ export const Route = createFileRoute("/database")({ function DatabaseRoute() { return (
-
-
-

Database Plugin Demo

+
+
+
+ Database plugin beta +
+

+ Two ways to build on the same typed entity API +

- Full CRUD flow against cases via the typed{" "} - db client. Every action hits an auto-generated route at{" "} - /api/database/cases. + Both sections hit the auto-mounted /api/database/cases{" "} + routes. The left side shows hand-built product UI using{" "} + db.cases; the right side shows the schema-driven entity + components that generate the table and forms from metadata.

-
- - +
+ +
); } -function useCases(status: string) { +function CodeBlock({ + code, + lang = "typescript", +}: { + code: string; + lang?: string; +}) { + const [html, setHtml] = useState(""); + + useEffect(() => { + let active = true; + codeToHtml(code, { + lang, + theme: "dark-plus", + }).then((highlighted) => { + if (active) setHtml(highlighted); + }); + return () => { + active = false; + }; + }, [code, lang]); + + return ( +
+ ); +} + +function CodeDisclosure({ + code, + label = "Show snippet", +}: { + code: string; + label?: string; +}) { + const [open, setOpen] = useState(false); + + return ( +
+ + {open && } +
+ ); +} + +function ManualDbSection() { + const [refreshToken, setRefreshToken] = useState(0); + const refresh = useCallback(() => setRefreshToken((value) => value + 1), []); + + return ( + + +
+ Side A +
+ Hand-built UI, typed database client + + Custom AML case workflow using direct, typed calls like{" "} + db.cases.where(...), create,{" "} + update, and delete. + +
+ + + + + +
+ ); +} + +function EntityComponentsSection() { + const [createOpen, setCreateOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [refreshToken, setRefreshToken] = useState(0); + const refresh = useCallback(() => setRefreshToken((value) => value + 1), []); + + return ( + + +
+ Side B +
+ Entity components from the same schema + + Generic table and mutation dialogs driven by column metadata from{" "} + config/database/schema.ts. + +
+ + +
+
+
Cases entity
+
+ Click a row to open the generated edit dialog. +
+
+ +
+ setEditingId(row.case_id)} + /> + + {editingId && ( + { + if (!open) setEditingId(null); + }} + onSuccess={refresh} + title={`Edit ${editingId}`} + description="Only editable, non-generated columns are shown." + /> + )} +
+
+ ); +} + +function useCases(status: string, refreshToken: number) { const [data, setData] = useState > | null>(null); const [total, setTotal] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - const [_tick, setTick] = useState(0); + const [tick, setTick] = useState(0); const refetch = useCallback(() => setTick((n) => n + 1), []); useEffect(() => { + void refreshToken; + void tick; const ctrl = new AbortController(); let active = true; @@ -109,14 +346,17 @@ function useCases(status: string) { active = false; ctrl.abort(); }; - }, [status]); + }, [status, refreshToken, tick]); return { data, total, loading, error, refetch }; } -function CaseList() { +function CaseList({ refreshToken }: { refreshToken: number }) { const [statusFilter, setStatusFilter] = useState(STATUS_FILTER_ALL); - const { data, total, loading, error, refetch } = useCases(statusFilter); + const { data, total, loading, error, refetch } = useCases( + statusFilter, + refreshToken, + ); const statusFilterId = useId(); const filterLabel = useMemo( @@ -128,7 +368,7 @@ function CaseList() { ); return ( - +

Cases

@@ -205,7 +445,7 @@ function CaseList() {
- +
); } @@ -299,7 +539,7 @@ function CaseRowItem({ ); } -function CreateCase() { +function CreateCase({ onCreated }: { onCreated: () => void }) { const [caseId, setCaseId] = useState(""); const [entityId, setEntityId] = useState(""); const [entityName, setEntityName] = useState(""); @@ -319,7 +559,7 @@ function CreateCase() { const disabled = busy || caseId.trim() === "" || entityId.trim() === ""; - const submit = async (e: React.FormEvent) => { + const submit = async (e: FormEvent) => { e.preventDefault(); if (disabled) return; setBusy(true); @@ -336,6 +576,7 @@ function CreateCase() { setCaseId(""); setEntityId(""); setEntityName(""); + onCreated(); } catch (err) { setMessage({ kind: "err", text: describeError(err) }); } finally { diff --git a/knip.json b/knip.json index 2d234091e..fddd23d6f 100644 --- a/knip.json +++ b/knip.json @@ -21,6 +21,7 @@ "packages/appkit/src/plugins/agents/tools/index.ts", "packages/appkit/src/plugins/agents/from-plugin.ts", "packages/appkit/src/plugins/agents/load-agents.ts", + "packages/appkit-ui/src/react/database/**", "template/**", "tools/**", "docs/**", diff --git a/packages/appkit-ui/src/js/database/client.test.ts b/packages/appkit-ui/src/js/database/client.test.ts index bc1b38870..84a66c657 100644 --- a/packages/appkit-ui/src/js/database/client.test.ts +++ b/packages/appkit-ui/src/js/database/client.test.ts @@ -18,6 +18,7 @@ type UserClient = { first: () => Promise; find: (id: string | number) => Promise; count: () => Promise; + columns: () => Promise; create: (data: Partial) => Promise; update: (id: string | number, patch: Partial) => Promise; upsert: ( @@ -133,6 +134,18 @@ describe("createDatabaseClient — find/count", () => { }); }); +describe("createDatabaseClient — columns", () => { + test("requires generated column metadata instead of fetching _columns", async () => { + const { db, fetchSpy } = setup([]); + const typed = db as unknown as { missing_columns_entity: UserClient }; + + await expect(typed.missing_columns_entity.columns()).rejects.toThrow( + /Column metadata for database entity "missing_columns_entity" is not registered/, + ); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + describe("createDatabaseClient — mutations", () => { test("create() POSTs JSON body", async () => { const { db, fetchSpy } = setup([ diff --git a/packages/appkit-ui/src/js/database/client.ts b/packages/appkit-ui/src/js/database/client.ts index a69281809..6a4dfb1cd 100644 --- a/packages/appkit-ui/src/js/database/client.ts +++ b/packages/appkit-ui/src/js/database/client.ts @@ -1,3 +1,4 @@ +import { getRegisteredColumns } from "./column-registry"; import { DatabaseHTTPError } from "./errors"; import type { ApplyIncludes, @@ -108,6 +109,15 @@ export function createDatabaseClient( const json = await readJson<{ count: number }>(res); return json.count; }, + columns: async (signal) => { + void signal; + const cached = getRegisteredColumns(entity); + if (cached) return [...cached]; + throw new Error( + `Column metadata for database entity "${entity}" is not registered. ` + + "Enable appKitDatabaseTypesPlugin() or run appkit db types generate so generated database.columns.ts is loaded.", + ); + }, create: async (data, signal) => { const res = await fetchImpl(`${baseUrl}/${entity}`, { @@ -126,7 +136,7 @@ export function createDatabaseClient( body: JSON.stringify(patch), signal, }); - // 404 → null like `find()` so callers distinguish missing rows from bad responses. + // 404 → null like `find()`. if (res.status === 404) return null; return readJson(res); }, @@ -179,7 +189,7 @@ function normalizeBaseUrl(value: string): string { async function readJson(res: Response): Promise { if (!res.ok) throw await buildError(res); if (res.status === 204) return undefined as T; - // Empty body → typed error (JSON.parse("") throws SyntaxError, not DatabaseHTTPError). + // Empty body throws a typed error instead of a raw SyntaxError. const text = await res.text(); if (text === "") { throw new DatabaseHTTPError( diff --git a/packages/appkit-ui/src/js/database/column-registry.ts b/packages/appkit-ui/src/js/database/column-registry.ts new file mode 100644 index 000000000..94ff8f9d6 --- /dev/null +++ b/packages/appkit-ui/src/js/database/column-registry.ts @@ -0,0 +1,22 @@ +import type { ColumnInfo } from "./types"; + +/** + * Process-wide map populated by the typegen-emitted `database.columns.ts`. + * `db..columns()` reads here; the database plugin no longer relies on + * a runtime `_columns` metadata route. + */ +let registered: Record = {}; + +/** Called by generated code at module load. */ +export function registerDatabaseColumns( + columns: Record, +): void { + registered = columns; +} + +/** Internal — `client.columns()` only. */ +export function getRegisteredColumns( + entity: string, +): readonly ColumnInfo[] | undefined { + return registered[entity]; +} diff --git a/packages/appkit-ui/src/js/database/index.ts b/packages/appkit-ui/src/js/database/index.ts index 37c271165..3c3cdd855 100644 --- a/packages/appkit-ui/src/js/database/index.ts +++ b/packages/appkit-ui/src/js/database/index.ts @@ -1,7 +1,9 @@ export { createDatabaseClient, db } from "./client"; +export { registerDatabaseColumns } from "./column-registry"; export { DatabaseHTTPError } from "./errors"; export type { ApplyIncludes, + ColumnInfo, DatabaseClient, DatabaseClientConfig, DatabaseEntityKey, diff --git a/packages/appkit-ui/src/js/database/types.ts b/packages/appkit-ui/src/js/database/types.ts index f66c081db..d76dc3f10 100644 --- a/packages/appkit-ui/src/js/database/types.ts +++ b/packages/appkit-ui/src/js/database/types.ts @@ -66,6 +66,10 @@ export type WhereInput = { /** Sort directive for `.order(...)`. */ export type OrderInput = { [K in keyof TRow]?: "asc" | "desc" }; +// Single source of truth in `shared` so server, typegen, and browser don't drift. +import type { ColumnInfo } from "shared"; +export type { ColumnInfo }; + /** Related row shape: single `{ row }` or `{ row }[]` from the registry. */ export type RelatedRow< TIncludes, @@ -128,6 +132,8 @@ export interface EntityClient< first(signal?: AbortSignal): Promise; find(id: string | number, signal?: AbortSignal): Promise; count(signal?: AbortSignal): Promise; + /** Column metadata for auto-rendered forms. Registered from generated typegen output. */ + columns(signal?: AbortSignal): Promise; create(data: TInsert, signal?: AbortSignal): Promise; /** diff --git a/packages/appkit-ui/src/react/database/__tests__/entity-form.test.ts b/packages/appkit-ui/src/react/database/__tests__/entity-form.test.ts new file mode 100644 index 000000000..fd4e6f826 --- /dev/null +++ b/packages/appkit-ui/src/react/database/__tests__/entity-form.test.ts @@ -0,0 +1,167 @@ +import { describe, expect, test } from "vitest"; +import type { ColumnInfo } from "@/js"; +import { + coerceFormValues, + filterCreateColumns, + filterEditColumns, + getDefaultValues, + toPatchPayload, +} from "../entity-form"; + +const columns: ColumnInfo[] = [ + { + name: "id", + type: "number", + nullable: false, + primaryKey: true, + hasDefault: true, + generated: true, + }, + { + name: "email", + type: "string", + nullable: false, + primaryKey: false, + hasDefault: false, + generated: false, + }, + { + name: "nickname", + type: "string", + nullable: true, + primaryKey: false, + hasDefault: false, + generated: false, + }, + { + name: "is_admin", + type: "boolean", + nullable: false, + primaryKey: false, + hasDefault: true, + generated: false, + }, + { + name: "score", + type: "number", + nullable: true, + primaryKey: false, + hasDefault: false, + generated: false, + }, + { + name: "tags", + type: "json", + nullable: true, + primaryKey: false, + hasDefault: false, + generated: false, + }, + { + name: "created_at", + type: "date", + nullable: false, + primaryKey: false, + hasDefault: true, + generated: true, + }, +]; + +describe("filterCreateColumns", () => { + test("hides generated columns by default", () => { + const out = filterCreateColumns(columns).map((c) => c.name); + expect(out).toEqual(["email", "nickname", "is_admin", "score", "tags"]); + }); + + test("respects an explicit `fields` allowlist (still drops generated)", () => { + const out = filterCreateColumns(columns, ["email", "id", "score"]).map( + (c) => c.name, + ); + expect(out).toEqual(["email", "score"]); + }); +}); + +describe("filterEditColumns", () => { + test("hides generated AND primary-key columns by default", () => { + const out = filterEditColumns(columns).map((c) => c.name); + expect(out).toEqual(["email", "nickname", "is_admin", "score", "tags"]); + }); +}); + +describe("getDefaultValues", () => { + test("fills nullable columns with null and required text with empty string", () => { + const defaults = getDefaultValues(columns); + expect(defaults).toEqual({ + id: "", + email: "", + nickname: null, + is_admin: false, + score: null, + tags: null, + created_at: "", + }); + }); + + test("merges base values without overwriting them", () => { + const defaults = getDefaultValues(columns, { + email: "alice@x", + score: 42, + }); + expect(defaults.email).toBe("alice@x"); + expect(defaults.score).toBe(42); + // Untouched columns still get their type-appropriate default. + expect(defaults.is_admin).toBe(false); + expect(defaults.nickname).toBeNull(); + }); +}); + +describe("coerceFormValues", () => { + test("parses JSON fields from textarea strings", () => { + const out = coerceFormValues(columns, { tags: '{"a":1}' }); + expect(out.tags).toEqual({ a: 1 }); + }); + + test("converts empty JSON textarea to null", () => { + const out = coerceFormValues(columns, { tags: " " }); + expect(out.tags).toBeNull(); + }); + + test("throws on invalid JSON with the column name in the message", () => { + expect(() => coerceFormValues(columns, { tags: "not-json" })).toThrowError( + /Invalid JSON for tags/, + ); + }); + + test("coerces empty number to null", () => { + const out = coerceFormValues(columns, { score: "" }); + expect(out.score).toBeNull(); + }); + + test("nullable empty string becomes null; required empty string stays empty", () => { + const out = coerceFormValues(columns, { nickname: "", email: "" }); + expect(out.nickname).toBeNull(); + expect(out.email).toBe(""); + }); + + test("ignores keys that are not declared columns", () => { + const out = coerceFormValues(columns, { unknown: "x" }); + expect("unknown" in out).toBe(false); + }); +}); + +describe("toPatchPayload", () => { + test("only includes columns flagged dirty by react-hook-form", () => { + const coerced = { email: "a@x", nickname: "Al", score: 7 }; + const patch = toPatchPayload( + coerced, + { email: true, nickname: false }, + columns, + ); + expect(patch).toEqual({ email: "a@x" }); + }); + + test("non-dirty fields are not echoed back even if present in coerced", () => { + const patch = toPatchPayload({ score: 7 }, {}, columns); + expect(patch).toEqual({}); + }); +}); diff --git a/packages/appkit-ui/src/react/database/__tests__/view-entity.test.tsx b/packages/appkit-ui/src/react/database/__tests__/view-entity.test.tsx new file mode 100644 index 000000000..8775b650e --- /dev/null +++ b/packages/appkit-ui/src/react/database/__tests__/view-entity.test.tsx @@ -0,0 +1,63 @@ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import type { DatabaseEntityKey } from "@/js"; +import { ViewEntity } from "../view-entity"; + +function mockFetch(rows: unknown[]): void { + vi.stubGlobal( + "fetch", + vi.fn(async () => jsonResponse(rows)), + ); +} + +function jsonResponse(body: unknown): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const CASES: DatabaseEntityKey = "cases"; + +describe("", () => { + test("renders a table with one row per fetched entry and auto-derives headers from data keys", async () => { + mockFetch([ + { case_id: "CASE-1", status: "New", risk_score: 42 }, + { case_id: "CASE-2", status: "Pending", risk_score: null }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByText("CASE-1")).toBeDefined(); + }); + expect(screen.getByText("Case Id")).toBeDefined(); + expect(screen.getByText("Status")).toBeDefined(); + expect(screen.getByText("Risk Score")).toBeDefined(); + expect(screen.getByText("CASE-2")).toBeDefined(); + }); + + test("renders an empty state when the server returns no rows", async () => { + mockFetch([]); + render(); + await waitFor(() => { + expect(screen.getByText(/No data available/i)).toBeDefined(); + }); + }); + + test("honors `fields` allow-list for rendered columns", async () => { + mockFetch([{ case_id: "CASE-1", status: "New", risk_score: 1 }]); + + render(); + + await waitFor(() => { + expect(screen.getByText("CASE-1")).toBeDefined(); + }); + expect(screen.queryByText("Risk Score")).toBeNull(); + }); +}); diff --git a/packages/appkit-ui/src/react/database/entity-form.tsx b/packages/appkit-ui/src/react/database/entity-form.tsx new file mode 100644 index 000000000..e6afaa930 --- /dev/null +++ b/packages/appkit-ui/src/react/database/entity-form.tsx @@ -0,0 +1,291 @@ +import type { Ref } from "react"; +import { type Control, Controller } from "react-hook-form"; +import type { ColumnInfo } from "@/js"; +import { formatFieldLabel } from "../lib/format"; +import { Input } from "../ui/input"; +import { Label } from "../ui/label"; +import { Textarea } from "../ui/textarea"; + +// --------------------------------------------------------------------------- +// Form utilities — pure functions for transforming column metadata into form +// defaults, coercing textarea values, and building PATCH payloads. +// --------------------------------------------------------------------------- + +/** Columns shown on create: not generated (serial PKs, defaultNow, etc.). */ +export function filterCreateColumns( + columns: ColumnInfo[], + fields?: readonly string[], +): ColumnInfo[] { + let out = columns.filter((c) => !c.generated); + if (fields?.length) { + const allow = new Set(fields); + out = out.filter((c) => allow.has(c.name)); + } + return out; +} + +/** Columns shown on edit: not generated, not primary key. */ +export function filterEditColumns( + columns: ColumnInfo[], + fields?: readonly string[], +): ColumnInfo[] { + let out = columns.filter((c) => !c.generated && !c.primaryKey); + if (fields?.length) { + const allow = new Set(fields); + out = out.filter((c) => allow.has(c.name)); + } + return out; +} + +/** + * Default form values for the given columns. Merges optional `base` (defaults + * or a loaded row subset) then fills missing keys with type-appropriate blanks. + */ +export function getDefaultValues( + columns: ColumnInfo[], + base?: Record | null, +): Record { + const out: Record = { ...(base ?? {}) }; + for (const c of columns) { + if (out[c.name] !== undefined) continue; + if (c.type === "boolean") out[c.name] = false; + else if (c.nullable) out[c.name] = null; + else out[c.name] = ""; + } + return out; +} + +/** + * Build insert/update payload from raw form values; parses JSON fields from + * textarea strings. + */ +export function coerceFormValues( + columns: ColumnInfo[], + draft: Record, +): Record { + const out: Record = {}; + for (const col of columns) { + if (!(col.name in draft)) continue; + let v = draft[col.name]; + if (col.type === "json" && typeof v === "string") { + const t = v.trim(); + if (t === "") v = null; + else { + try { + v = JSON.parse(t) as unknown; + } catch { + throw new Error(`Invalid JSON for ${col.name}`); + } + } + } + if (col.type === "number" || col.type === "bigint") { + if (v === "" || v === undefined) v = null; + } + if (col.type === "string" || col.type === "uuid") { + if (v === "") v = col.nullable ? null : v; + } + out[col.name] = v; + } + return out; +} + +/** + * Build a PATCH body from coerced values and react-hook-form `dirtyFields` + * (flat object shape). + */ +export function toPatchPayload( + coerced: Record, + dirtyFields: Partial>, + columns: ColumnInfo[], +): Record { + const patch: Record = {}; + for (const col of columns) { + if (dirtyFields[col.name] === true) patch[col.name] = coerced[col.name]; + } + return patch; +} + +// --------------------------------------------------------------------------- +// EntityFormFields — schema-driven form inputs wired to react-hook-form. +// --------------------------------------------------------------------------- + +export interface EntityFormFieldsProps { + idPrefix: string; + columns: ColumnInfo[]; + control: Control>; + disabled?: boolean; + readOnlyNames?: ReadonlySet; +} + +/** + * Schema-driven inputs wired to react-hook-form. One control per `ColumnInfo`; + * parents filter columns via `filterCreateColumns` / `filterEditColumns`. + */ +export function EntityFormFields({ + idPrefix, + columns, + control, + disabled, + readOnlyNames, +}: EntityFormFieldsProps) { + return ( +
+ {columns.map((col) => ( + ( + + )} + /> + ))} +
+ ); +} + +function EntityField({ + col, + id, + value, + onChange, + onBlur, + inputRef, + disabled, + readOnly, +}: { + col: ColumnInfo; + id: string; + value: unknown; + onChange: (v: unknown) => void; + onBlur: () => void; + inputRef: Ref; + disabled?: boolean; + readOnly: boolean; +}) { + const label = formatFieldLabel(col.name); + const ro = readOnly || disabled; + + if (col.type === "boolean") { + const checked = Boolean(value); + return ( +
+ } + id={id} + type="checkbox" + className="h-4 w-4 rounded border" + checked={checked} + onChange={(e) => onChange(e.target.checked)} + onBlur={onBlur} + disabled={ro} + /> + +
+ ); + } + + if (col.type === "json") { + const text = + value === null || value === undefined + ? "" + : typeof value === "string" + ? value + : JSON.stringify(value, null, 2); + return ( +
+ +