diff options
Diffstat (limited to 'src/features/surface-host')
| -rw-r--r-- | src/features/surface-host/index.ts | 6 | ||||
| -rw-r--r-- | src/features/surface-host/logic/plan.test.ts | 64 | ||||
| -rw-r--r-- | src/features/surface-host/logic/plan.ts | 43 | ||||
| -rw-r--r-- | src/features/surface-host/logic/table.test.ts | 47 | ||||
| -rw-r--r-- | src/features/surface-host/logic/table.ts | 54 | ||||
| -rw-r--r-- | src/features/surface-host/logic/types.ts | 23 | ||||
| -rw-r--r-- | src/features/surface-host/ui/Stat.svelte | 10 | ||||
| -rw-r--r-- | src/features/surface-host/ui/StatTable.svelte | 21 | ||||
| -rw-r--r-- | src/features/surface-host/ui/SurfaceTable.svelte | 14 | ||||
| -rw-r--r-- | src/features/surface-host/ui/SurfaceView.svelte | 36 |
10 files changed, 282 insertions, 36 deletions
diff --git a/src/features/surface-host/index.ts b/src/features/surface-host/index.ts index afa3127..8f289f1 100644 --- a/src/features/surface-host/index.ts +++ b/src/features/surface-host/index.ts @@ -1,3 +1,9 @@ export { buildInvoke, planSurface } from "./logic/plan"; export type { FieldView, SurfaceRenderPlan } from "./logic/types"; export { default as SurfaceView } from "./ui/SurfaceView.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "surface-host", + description: "Generic renderer for backend-declared surfaces", +} as const; diff --git a/src/features/surface-host/logic/plan.test.ts b/src/features/surface-host/logic/plan.test.ts index 50d6f11..a5727b4 100644 --- a/src/features/surface-host/logic/plan.test.ts +++ b/src/features/surface-host/logic/plan.test.ts @@ -1,6 +1,7 @@ import type { SurfaceField, SurfaceSpec } from "@dispatch/ui-contract"; import { describe, expect, it } from "vitest"; -import { buildInvoke, planSurface } from "./plan"; +import { buildInvoke, groupRenderFields, planSurface } from "./plan"; +import type { FieldView } from "./types"; const makeSpec = (...fields: SurfaceField[]): SurfaceSpec => ({ id: "test-surface", @@ -74,7 +75,7 @@ describe("planSurface", () => { { kind: "button", label: "D", action: { actionId: "d" } }, ), ); - expect(plan.fields.map((f) => f.label)).toEqual(["A", "B", "C", "D"]); + expect(plan.fields.map((f) => ("label" in f ? f.label : null))).toEqual(["A", "B", "C", "D"]); }); it("drops unknown field kinds gracefully", () => { @@ -86,10 +87,11 @@ describe("planSurface", () => { } as SurfaceField), ); expect(plan.fields).toHaveLength(1); - expect(plan.fields[0]?.label).toBe("Known"); + const first = plan.fields[0]; + expect(first && "label" in first ? first.label : null).toBe("Known"); }); - it("drops custom fields (no renderer registered)", () => { + it("carries custom fields through verbatim, preserving order", () => { const plan = planSurface( makeSpec( { kind: "stat", label: "Before", value: "1" }, @@ -97,8 +99,12 @@ describe("planSurface", () => { { kind: "stat", label: "After", value: "2" }, ), ); - expect(plan.fields).toHaveLength(2); - expect(plan.fields.map((f) => f.label)).toEqual(["Before", "After"]); + expect(plan.fields).toHaveLength(3); + expect(plan.fields[1]).toEqual({ + kind: "custom", + rendererId: "chart", + payload: { data: [1, 2, 3] }, + }); }); it("returns empty fields for an empty spec", () => { @@ -106,14 +112,56 @@ describe("planSurface", () => { expect(plan.fields).toEqual([]); }); - it("drops all fields when all are custom", () => { + it("keeps every custom field (render-time decides whether to show each)", () => { const plan = planSurface( makeSpec( { kind: "custom", rendererId: "x", payload: null }, { kind: "custom", rendererId: "y", payload: 42 }, ), ); - expect(plan.fields).toEqual([]); + expect(plan.fields.map((f) => f.kind)).toEqual(["custom", "custom"]); + }); +}); + +describe("groupRenderFields", () => { + const stat = (label: string, value: string): FieldView => ({ kind: "stat", label, value }); + const toggle = (label: string): FieldView => ({ + kind: "toggle", + label, + value: false, + action: { actionId: label }, + }); + + it("coalesces consecutive stats into a single stats group", () => { + const groups = groupRenderFields([stat("a", "1"), stat("b", "2"), stat("c", "3")]); + expect(groups).toHaveLength(1); + expect(groups[0]).toEqual({ + type: "stats", + stats: [ + { kind: "stat", label: "a", value: "1" }, + { kind: "stat", label: "b", value: "2" }, + { kind: "stat", label: "c", value: "3" }, + ], + }); + }); + + it("keeps non-stat fields as standalone groups and preserves order", () => { + const groups = groupRenderFields([stat("a", "1"), toggle("t"), stat("b", "2")]); + expect(groups.map((g) => g.type)).toEqual(["stats", "field", "stats"]); + const first = groups[0]; + const last = groups[2]; + if (first?.type !== "stats" || last?.type !== "stats") throw new Error("bad grouping"); + expect(first.stats.map((s) => s.label)).toEqual(["a"]); + expect(last.stats.map((s) => s.label)).toEqual(["b"]); + }); + + it("starts a new stats run after an interrupting field", () => { + const groups = groupRenderFields([stat("a", "1"), stat("b", "2"), toggle("t"), stat("c", "3")]); + expect(groups.map((g) => g.type)).toEqual(["stats", "field", "stats"]); + }); + + it("returns no groups for an empty field list", () => { + expect(groupRenderFields([])).toEqual([]); }); }); diff --git a/src/features/surface-host/logic/plan.ts b/src/features/surface-host/logic/plan.ts index 5b4530b..769f9f9 100644 --- a/src/features/surface-host/logic/plan.ts +++ b/src/features/surface-host/logic/plan.ts @@ -1,12 +1,14 @@ import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract"; -import type { FieldView, SurfaceRenderPlan } from "./types"; +import type { FieldView, RenderGroup, StatFieldView, SurfaceRenderPlan } from "./types"; -const KNOWN_KINDS = new Set(["toggle", "progress", "selector", "stat", "button"]); +const KNOWN_KINDS = new Set(["toggle", "progress", "selector", "stat", "button", "custom"]); /** * Validate and normalise a SurfaceSpec into a renderable plan. - * Keeps known field kinds in order; drops unknown kinds and `custom` fields - * (no renderer registry yet — graceful skip, never throw). + * Keeps known field kinds in order (including `custom`, carried through verbatim + * for the renderer to dispatch on `rendererId`); drops unknown kinds — graceful + * skip, never throw. Whether a `custom` field actually renders is a RENDER-time + * decision (unknown `rendererId` → skipped there), not a planning one. */ export function planSurface(spec: SurfaceSpec): SurfaceRenderPlan { const fields: FieldView[] = []; @@ -51,12 +53,45 @@ export function planSurface(spec: SurfaceSpec): SurfaceRenderPlan { action: field.action, }); break; + case "custom": + fields.push({ + kind: "custom", + rendererId: field.rendererId, + payload: field.payload, + }); + break; } } return { fields }; } /** + * Coalesce a field list into render groups: maximal runs of consecutive `stat` + * fields become one `stats` group (rendered as a single aligned table), every + * other field stays a standalone `field` group. Order is preserved. Pure. + */ +export function groupRenderFields(fields: readonly FieldView[]): RenderGroup[] { + const groups: RenderGroup[] = []; + let run: StatFieldView[] = []; + const flush = (): void => { + if (run.length > 0) { + groups.push({ type: "stats", stats: run }); + run = []; + } + }; + for (const field of fields) { + if (field.kind === "stat") { + run.push(field); + } else { + flush(); + groups.push({ type: "field", field }); + } + } + flush(); + return groups; +} + +/** * Construct a typed `invoke` client message for an actionable field. * For toggle the payload is the new boolean; for selector the chosen value; * for button the payload is omitted. diff --git a/src/features/surface-host/logic/table.test.ts b/src/features/surface-host/logic/table.test.ts new file mode 100644 index 0000000..e55b3f7 --- /dev/null +++ b/src/features/surface-host/logic/table.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { parseTablePayload } from "./table"; + +describe("parseTablePayload", () => { + it("parses a well-formed table payload", () => { + const data = parseTablePayload({ + columns: ["Name", "Version"], + rows: [ + ["alpha", "1.0"], + ["beta", "2.3"], + ], + }); + expect(data).toEqual({ + columns: ["Name", "Version"], + rows: [ + ["alpha", "1.0"], + ["beta", "2.3"], + ], + }); + }); + + it("coerces numeric and boolean cells to strings", () => { + const data = parseTablePayload({ + columns: ["k", "n", "b"], + rows: [["x", 42, true]], + }); + expect(data?.rows[0]).toEqual(["x", "42", "true"]); + }); + + it("accepts an empty rows array", () => { + expect(parseTablePayload({ columns: ["A"], rows: [] })).toEqual({ columns: ["A"], rows: [] }); + }); + + it.each([ + ["null", null], + ["a number", 7], + ["a string", "nope"], + ["missing columns", { rows: [] }], + ["missing rows", { columns: ["A"] }], + ["non-string column", { columns: [1], rows: [] }], + ["row that is not an array", { columns: ["A"], rows: ["x"] }], + ["cell of unsupported type", { columns: ["A"], rows: [[{ nested: true }]] }], + ["non-finite numeric cell", { columns: ["A"], rows: [[Number.NaN]] }], + ])("returns null for invalid payload: %s", (_label, payload) => { + expect(parseTablePayload(payload)).toBeNull(); + }); +}); diff --git a/src/features/surface-host/logic/table.ts b/src/features/surface-host/logic/table.ts new file mode 100644 index 0000000..027553c --- /dev/null +++ b/src/features/surface-host/logic/table.ts @@ -0,0 +1,54 @@ +/** + * Pure parser for the `rendererId: "table"` custom-field payload. + * + * This is the FRONTEND-side renderer contract for tabular custom fields: a + * backend that wants a table emits a `custom` field with `rendererId: "table"` + * and a payload of `{ columns: string[]; rows: (string|number)[][] }`. Cells are + * coerced to strings. Anything that does not match the shape returns `null`, so + * the renderer gracefully skips it (never throws on hostile/partial data). + */ + +export interface TableData { + readonly columns: readonly string[]; + readonly rows: readonly (readonly string[])[]; +} + +function isStringArray(v: unknown): v is unknown[] { + return Array.isArray(v); +} + +function coerceCell(v: unknown): string | null { + if (typeof v === "string") return v; + if (typeof v === "number" && Number.isFinite(v)) return String(v); + if (typeof v === "boolean") return String(v); + return null; +} + +export function parseTablePayload(payload: unknown): TableData | null { + if (typeof payload !== "object" || payload === null) return null; + const obj = payload as Record<string, unknown>; + + const rawColumns = obj.columns; + const rawRows = obj.rows; + if (!isStringArray(rawColumns) || !isStringArray(rawRows)) return null; + + const columns: string[] = []; + for (const col of rawColumns) { + if (typeof col !== "string") return null; + columns.push(col); + } + + const rows: string[][] = []; + for (const row of rawRows) { + if (!Array.isArray(row)) return null; + const cells: string[] = []; + for (const cell of row) { + const c = coerceCell(cell); + if (c === null) return null; + cells.push(c); + } + rows.push(cells); + } + + return { columns, rows }; +} diff --git a/src/features/surface-host/logic/types.ts b/src/features/surface-host/logic/types.ts index f24438a..d1888a2 100644 --- a/src/features/surface-host/logic/types.ts +++ b/src/features/surface-host/logic/types.ts @@ -38,15 +38,36 @@ export interface ButtonFieldView { readonly action: ActionRef; } +/** + * Normalised view-model for a custom (escape-hatch) field. The plan carries it + * through verbatim; the renderer dispatches on `rendererId` (a renderer KIND, + * never a surface id) and gracefully skips ids it has no renderer for. + */ +export interface CustomFieldView { + readonly kind: "custom"; + readonly rendererId: string; + readonly payload: unknown; +} + /** A normalised field view-model — one entry per renderable field kind. */ export type FieldView = | ToggleFieldView | ProgressFieldView | SelectorFieldView | StatFieldView - | ButtonFieldView; + | ButtonFieldView + | CustomFieldView; /** The output of `planSurface`: the ordered list of renderable fields. */ export interface SurfaceRenderPlan { readonly fields: readonly FieldView[]; } + +/** + * A render group: a maximal run of consecutive `stat` fields (rendered together + * as one aligned label/value table), or a single non-stat field. Grouping is a + * GENERIC presentation rule keyed on field kind — it never inspects a surface id. + */ +export type RenderGroup = + | { readonly type: "stats"; readonly stats: readonly StatFieldView[] } + | { readonly type: "field"; readonly field: Exclude<FieldView, StatFieldView> }; diff --git a/src/features/surface-host/ui/Stat.svelte b/src/features/surface-host/ui/Stat.svelte deleted file mode 100644 index e184dab..0000000 --- a/src/features/surface-host/ui/Stat.svelte +++ /dev/null @@ -1,10 +0,0 @@ -<script lang="ts"> - import type { StatFieldView } from "../logic/types"; - - let { field }: { field: StatFieldView } = $props(); -</script> - -<dl> - <dt>{field.label}</dt> - <dd>{field.value}</dd> -</dl> diff --git a/src/features/surface-host/ui/StatTable.svelte b/src/features/surface-host/ui/StatTable.svelte new file mode 100644 index 0000000..415423f --- /dev/null +++ b/src/features/surface-host/ui/StatTable.svelte @@ -0,0 +1,21 @@ +<script lang="ts"> + import type { StatFieldView } from "../logic/types"; + + // Renders a run of stat fields as one aligned label/value table. Headerless: + // the column semantics aren't known generically, but the two-column layout + // gives the tidy, aligned readout the stats deserve (e.g. extension → version). + let { stats }: { readonly stats: readonly StatFieldView[] } = $props(); +</script> + +<div class="overflow-x-auto"> + <table class="table table-sm"> + <tbody> + {#each stats as stat, i (i)} + <tr> + <th class="font-medium">{stat.label}</th> + <td class="text-right tabular-nums">{stat.value}</td> + </tr> + {/each} + </tbody> + </table> +</div> diff --git a/src/features/surface-host/ui/SurfaceTable.svelte b/src/features/surface-host/ui/SurfaceTable.svelte new file mode 100644 index 0000000..764cc36 --- /dev/null +++ b/src/features/surface-host/ui/SurfaceTable.svelte @@ -0,0 +1,14 @@ +<script lang="ts"> + import Table from "../../../components/Table.svelte"; + import { parseTablePayload } from "../logic/table"; + + let { payload }: { readonly payload: unknown } = $props(); + + // Parse defensively; an unparseable payload yields null → render nothing + // (graceful skip, per the custom-field contract). + const data = $derived(parseTablePayload(payload)); +</script> + +{#if data !== null} + <Table columns={data.columns} rows={data.rows} /> +{/if} diff --git a/src/features/surface-host/ui/SurfaceView.svelte b/src/features/surface-host/ui/SurfaceView.svelte index 4207913..5210e8c 100644 --- a/src/features/surface-host/ui/SurfaceView.svelte +++ b/src/features/surface-host/ui/SurfaceView.svelte @@ -1,10 +1,11 @@ <script lang="ts"> import type { InvokeMessage, SurfaceSpec } from "@dispatch/ui-contract"; - import { planSurface } from "../logic/plan"; + import { groupRenderFields, planSurface } from "../logic/plan"; import Button from "./Button.svelte"; import Progress from "./Progress.svelte"; import Selector from "./Selector.svelte"; - import Stat from "./Stat.svelte"; + import StatTable from "./StatTable.svelte"; + import SurfaceTable from "./SurfaceTable.svelte"; import Toggle from "./Toggle.svelte"; let { @@ -13,21 +14,30 @@ }: { spec: SurfaceSpec; onInvoke: (msg: InvokeMessage) => void } = $props(); const plan = $derived(planSurface(spec)); + // Consecutive stats render together as one aligned table; everything else is + // a standalone widget. Grouping keys on field KIND only — never the surface id. + const groups = $derived(groupRenderFields(plan.fields)); </script> <article> <h2>{spec.title}</h2> - {#each plan.fields as field (field)} - {#if field.kind === "toggle"} - <Toggle {field} surfaceId={spec.id} {onInvoke} /> - {:else if field.kind === "progress"} - <Progress {field} /> - {:else if field.kind === "selector"} - <Selector {field} surfaceId={spec.id} {onInvoke} /> - {:else if field.kind === "stat"} - <Stat {field} /> - {:else if field.kind === "button"} - <Button {field} surfaceId={spec.id} {onInvoke} /> + {#each groups as group, i (i)} + {#if group.type === "stats"} + <StatTable stats={group.stats} /> + {:else if group.field.kind === "toggle"} + <Toggle field={group.field} surfaceId={spec.id} {onInvoke} /> + {:else if group.field.kind === "progress"} + <Progress field={group.field} /> + {:else if group.field.kind === "selector"} + <Selector field={group.field} surfaceId={spec.id} {onInvoke} /> + {:else if group.field.kind === "button"} + <Button field={group.field} surfaceId={spec.id} {onInvoke} /> + {:else if group.field.kind === "custom"} + <!-- Dispatch on rendererId (a renderer KIND, never a surface id); + unknown ids gracefully render nothing. --> + {#if group.field.rendererId === "table"} + <SurfaceTable payload={group.field.payload} /> + {/if} {/if} {/each} </article> |
