Module settings: content generation
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.TemplateViewer
ContentThe fields the module writes for every item.
Output fields
required1 row of 24
| Field | Kind | Minimum (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 fieldenhancement-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 codedocs/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.