Pattern gallery

Module settings: content generation

A module's params schema rendered whole, with the saved-template picker and the live preview beside it.

What this solves

Every app hand-built its own shape for the same job: a set of output fields, each with its own prompt, character limits and pass/fail rules, plus a handful of research and admin toggles. speedway's setup table and enhancement-product's S2E table solve it with unrelated code, so a field's label, help text and validation message never look the same twice. A schema-driven form closes that gap: one field anatomy, one place the rules live, and the live preview sitting beside it so a customer sees what a rule change actually does before running the job.
Use it when
  • A module's job parameters are a template of output fields, each with its own prompt and pass/fail rulesenhancement-product S2E Apply Enhancements: EnhancementV2Table, a row per field with title, kind, char limits, up to 12 rules and a rich prompt editor
  • The same settings need a customer-facing shape and a wider admin shape drawn from one stateenhancement-product EnhancementToolbar: web search and check-claims are customer-visible; model, preview row count and product type gate on isVAdmin

Rendered

Fixture data; check both themes.
Template
Viewer
ContentThe fields the module writes for every item.
Output fields
required1 row of 24
FieldKindMinimum (chars)Maximum (chars)
Research
patterns/module-content-generation/example.tsxCopy this into a fresh route and it renders as above.
"use client";
import { Button, Card, SchemaForm, Table, type SchemaFormHandle, type SchemaFormValidity, type TableColumn, type XTier } from "@versable-git/ui";
import { useMemo, useRef, useState } from "react";
import fixture from "../../../../../../docs/plan/65-schema-form-fixtures/enhancement.params.schema.json";
import { FILE_CONTEXT, resolveModuleOptions } from "../../components/schema-form/examples/module-stubs";
// Three named starting points plus a blank one, backed by SchemaForm's presets
// API. Picking a template swaps the value wholesale; Reset to template puts
// the active one back once the customer has edited it.
const TEMPLATES: Record<string, Record<string, unknown>> = {
marketing: {
fields: [{ title: "Marketing description", kind: "text", min_chars: 120, max_chars: 320, prompt: "Two sentences about <<Title>> for a shopper comparing brands: <<llm>>" }],
web_search: true,
check_claims: true,
},
spec: {
fields: [
{ title: "Key features", kind: "list", count: 5, min_chars: 0, max_chars: 80, prompt: "<<llm>>" },
{ title: "Fitment notes", kind: "text", min_chars: 0, max_chars: 160, prompt: "State compatible vehicles from <<Brand>> data if present: <<llm>>" },
],
web_search: false,
check_claims: true,
},
blank: { fields: [{ title: "", kind: "text", min_chars: 0, max_chars: 200, prompt: "" }], web_search: true, check_claims: true },
};
const TEMPLATE_LABELS: Record<string, string> = { marketing: "Marketing copy", spec: "Spec sheet", blank: "Blank" };
type PreviewField = { title?: string; kind?: string };
type PreviewRow = Record<string, string>;
const TEXT_FILLERS = ["Ceramic composite, quieter stops with less brake dust.", "Semi-metallic blend built for heat resistance under load.", "Organic compound tuned for a smooth, quiet ride."];
const LIST_FILLERS = ["Low dust, quiet, OE fit", "Ceramic compound, wear indicator", "Direct bolt-on, no modification"];
// The console's stub for what the app would run against unsaved settings: a
// few sample rows, one column per field currently in the form.
function PreviewTable({ value }: { value: Record<string, unknown> | undefined }) {
const fields = Array.isArray(value?.fields) ? (value!.fields as PreviewField[]) : [];
const columns: TableColumn<PreviewRow>[] = useMemo(
() => (fields.length > 0 ? fields.map((f, i) => ({ key: f.title || `field-${i}`, header: f.title || "Untitled field" })) : [{ key: "empty", header: "Preview" }]),
[JSON.stringify(fields.map((f) => [f.title, f.kind]))],
);
const rows: PreviewRow[] = useMemo(() => {
if (fields.length === 0) return [{ empty: "Add a field to preview it here" }, { empty: "" }, { empty: "" }];
return [0, 1, 2].map((i) => Object.fromEntries(fields.map((f, fi) => [f.title || `field-${fi}`, f.kind === "list" ? LIST_FILLERS[i % LIST_FILLERS.length]! : TEXT_FILLERS[i % TEXT_FILLERS.length]!])));
}, [JSON.stringify(fields.map((f) => [f.title, f.kind]))]);
return (
<Card title="Live preview" subtitle="3 sample rows" noAnimate>
<Table columns={columns} rows={rows} rowKey={(_row, i) => i} caption="Preview runs against unsaved settings" density="compact" />
</Card>
);
}
export function ModuleContentGeneration() {
const ref = useRef<SchemaFormHandle>(null);
const [template, setTemplate] = useState<string>("marketing");
const [value, setValue] = useState<Record<string, unknown>>(TEMPLATES.marketing!);
const [tier, setTier] = useState<XTier>("customer");
const [validity, setValidity] = useState<SchemaFormValidity | null>(null);
const [reviewing, setReviewing] = useState(false);
const [result, setResult] = useState<string | null>(null);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-wrap items-center gap-2">
<span className="text-base-content/65 text-xs">Template</span>
{Object.keys(TEMPLATES).map((id) => (
<Button
key={id}
size="xs"
variant={template === id ? "solid" : "ghost"}
color="primary"
shade
onClick={() => {
setTemplate(id);
setValue(TEMPLATES[id]!);
setResult(null);
}}
>
{TEMPLATE_LABELS[id]}
</Button>
))}
<Button size="xs" variant="ghost" shade Icon="Refresh" disabled={!validity?.isDirty} onClick={() => ref.current?.resetToPreset()}>
Reset to template
</Button>
{validity && <span className="text-base-content/65 text-xs">{validity.isDirty ? "edited since the template" : "matches the template"}</span>}
{/* The viewer switch is the page's demo chrome, not a module control; it sits apart so it never reads as a fourth template. */}
<span className="ml-auto flex items-center gap-2">
<span className="text-base-content/65 text-xs">Viewer</span>
{(["customer", "admin"] as const).map((t) => (
<Button key={t} size="xs" variant={tier === t ? "solid" : "ghost"} color="neutral" shade onClick={() => setTier(t)}>
{t.charAt(0).toUpperCase() + t.slice(1)}
</Button>
))}
</span>
</div>
<SchemaForm
ref={ref}
schema={fixture}
value={value}
onChange={setValue}
mode={reviewing ? "review" : "edit"}
tier={tier}
settings={{ module_version: "v3" }}
context={FILE_CONTEXT}
resolveOptions={resolveModuleOptions}
presets={TEMPLATES}
activePreset={template}
aside={<PreviewTable value={value} />}
onValidityChange={setValidity}
onEditSection={() => setReviewing(false)}
/>
<div className="flex items-center gap-2">
<Button
size="sm"
color="primary"
onClick={() => {
const out = ref.current?.submit();
setResult(out ? `Ready to run: ${Object.keys(out).length} settings` : "Fix the errors above");
}}
>
Validate
</Button>
<Button size="sm" variant="ghost" shade onClick={() => setReviewing((r) => !r)}>
{reviewing ? "Back to edit" : "Review"}
</Button>
{result && <span className="text-base-content/65 text-xs">{result}</span>}
</div>
</div>
);
}

Where it ships

  • enhancement-product/frontend/src/app/jobs/v2/components/enhancement/enhancements.table.tsx:45-352EnhancementV2Table, the direct ancestor of this module: per-row title, kind, min/max chars, a rules array of four types, and a variable-aware prompt field
  • enhancement-product/frontend/src/app/jobs/v2/components/enhancement/enhancement-toolbar.tsx:135-274EnhancementToolbar's admin-only fields (moduleVersion, previewRowCount, llmModel, productType) map onto this schema's advanced section, tier-gated instead of branched in app code
  • docs/plan/65-schema-form-spec.md section 5.1the params shape this fixture commits, and the slots the app supplies: the live preview table and the saved-template picker

App-specific: The template picker here is a hardcoded record; a real console fetches saved templates by name and posts new ones back, per enhancement-product's save/update/save-as-new toolbar (enhancement-template-toolbar.tsx:51-77), which SchemaForm's presets API doesn't manage on its own. The preview table is a stub with three canned rows; a real preview streams per cell against the file's actual rows and can report a run that produced nothing usable (s2ig:175-185), which is app logic sitting beside SchemaForm, not inside it. model and product_type resolve against this playground's stub options; a real app wires resolveOptions to /models and /product-types.

@versable-git/ui · composites proven in the apps