Module settings: attribute normalization
What this solves
Attribute normalization asks a different question in every app that has tried it: which attributes should a part type's raw catalog values fold into. A table is the easy part; seeding its rows from the file's own distinct part types and fetching each row's options at render time is what every app hand-rolled or skipped. Here the schema names both moves,
x-seed-from for the rows and x-options-from with a per-row {row.part_type} placeholder for the fetch, so the console never writes bespoke grid code to get there.Use it when
- A forced single choice needs an explicit tick, not a default that is already correctspeedway's normalize vocabulary card, one real taxonomy option but unchecked until acknowledged, an explicit owner ruling (NewJobForm.tsx:816-830)
- Per-record attribute assignment needs a large vocabulary plus a sense of how much of the record already qualifieswalmart-mvp's legacy TaxonomyColumnPicker computes a RequiredAttributes chip list per leaf from an asterisk marker convention (TaxonomyColumnPicker.tsx:375-402), the closest analog before this schema's async per-row multiselect
Rendered
Fixture data; check both themes.Viewer
Attributes per part type0 rows
| Part type | Attributes | |
|---|---|---|
Nothing to seed from yet | ||
Coverage
Raw values found per part typeWaiting on the file's part types.
Coverage is read from the file; the run rewrites the raw values into the chosen attributes.
patterns/module-attribute-normalization/example.tsxCopy this into a fresh route and it renders as above.
"use client";import { Button, Card, Progress, SchemaForm, type SchemaFormHandle, type XTier } from "@versable-git/ui";import { useRef, useState } from "react";import fixture from "../../../../../../docs/plan/65-schema-form-fixtures/attribute-normalization.params.schema.json";import { FILE_CONTEXT, resolveModuleOptions } from "../../components/schema-form/examples/module-stubs";type PerPartTypeRow = { part_type?: string; attributes?: string[] };// A stand-in for the run's own coverage read: a real console derives this// percent from the file's raw values (context["item.coverage(attributes.raw)"]// feeds the same number into the form's per-row hint), this card just hashes// the row's part type so every row shows a different, stable bar.function coveragePercent(partType: string): number { let sum = 0; for (let i = 0; i < partType.length; i++) sum += partType.charCodeAt(i); return 40 + (sum % 55);}function CoverageSummary({ value }: { value: Record<string, unknown> | undefined }) { const rows = Array.isArray(value?.per_part_type) ? (value!.per_part_type as PerPartTypeRow[]) : []; return ( <Card title="Coverage" subtitle="Raw values found per part type" noAnimate> <div className="flex flex-col gap-3"> {rows.length === 0 ? ( <span className="text-base-content/65 text-sm">Waiting on the file's part types.</span> ) : ( rows.map((row, i) => ( <div key={row.part_type ?? i} className="flex flex-col gap-1"> <span className="text-sm">{row.part_type}</span> <Progress value={coveragePercent(row.part_type ?? "")} label color="primary" size="sm" aria-label={`${row.part_type ?? "row"} coverage`} /> </div> )) )} <p className="text-base-content/65 text-xs"> Coverage is read from the file; the run rewrites the raw values into the chosen attributes. </p> </div> </Card> );}export function ModuleAttributeNormalization() { const ref = useRef<SchemaFormHandle>(null); const [value, setValue] = useState<Record<string, unknown>>(); const [tier, setTier] = useState<XTier>("customer"); 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">Viewer</span> {(["customer", "admin"] as const).map((t) => ( <Button key={t} size="xs" variant={tier === t ? "solid" : "ghost"} color="primary" shade onClick={() => setTier(t)}> {t.charAt(0).toUpperCase() + t.slice(1)} </Button> ))} </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} onEditSection={() => setReviewing(false)} /> <CoverageSummary value={value} /> <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
speedway/app/components/NewJobForm.tsx:816-830the normalize taxonomy checkbox, deliberately unchecked despite one real option, an explicit owner ruling from 2026-07-31; the schema's x-acknowledge keeps the same unchecked-until-chosen rulewalmart-mvp/frontend/src/features/catalog/TaxonomyColumnPicker.tsx:375-402RequiredAttributes, a chip list computed per taxonomy leaf from a marker convention in the spec string; the legacy app's closest analog to a per-part-type attribute list, and both apps mark their attribute surfaces deprecated or crude in their own docsdocs/plan/65-schema-form-spec.md section 5.3the params shape this fixture commits, and the two vocabulary entries it introduces first: x-seed-from for the table's rows, x-hint-from for the per-row coverage line
App-specific: The fixture stubs three things a real console must supply: the file's distinct part types (context["item.distinct(part.type)"]), the per-row coverage hint x-hint-from reads (context["item.coverage(attributes.raw)"]), and the attribute options per part type (resolveOptions answering /attributes?part_type=...). The coverage card beside the form is app logic, not SchemaForm's: a real console reads the same raw-value coverage the run will act on, this fixture only fakes a percent from the row's own part-type string. The taxonomy list (/taxonomies) is a small closed set the module owns; the console never authors it.