types/inspectorSchema — schema-driven Inspector form model
Source:
src/types/inspectorSchema.ts· 541 lines · React + zod integration
Purpose
Before this module, InspectorForms.tsx hardcoded a separate React form per entity type (Lane / Junction / Parking / Signal / StopSign). Adding an entity or a field touched five JSX files.
inspectorSchema introduces a data-driven model — one EntitySchema describes a panel, and the generic SchemaForm component renders any of them.
Design notes (verbatim from source comments):
- Adapter pattern over field path. A naive
entity[fieldName] = valuecannot expressleftWidth(uniform width across every leftSample) orleftBoundaryType(head element of a nested types array). Each FieldDef ships explicitread/writeadapters. - Double-generic typing
<TEntity, TFormValues>.nameis constrained tokeyof TFormValues, so the compiler catches typos. - Validation gate preserved.
validationis the zod schema fed straight touseForm, retaining the LaneForm onChange behaviour.
Public API
| Symbol | Kind | Summary |
|---|---|---|
NumberFieldDef<...> | interface | Numeric input field |
EnumFieldDef<...> | interface | Enumerated select |
FieldDef<TEntity, TFormValues, TKey> | union | NumberFieldDef | EnumFieldDef |
AnyFieldDef<TEntity, TFormValues> | type | Distributed FieldDef union |
ReadOnlyDef<TEntity> | interface | Read-only derived row |
EntitySchema<TEntity, TFormValues> | interface | Full Inspector panel schema |
fieldBuilder<TEntity, TFormValues>() | fn | Curried builder (TKey inferred) |
LaneInspectorSchema | const | Lane panel schema |
formValuesFromEntity | fn | Project entity → form values via read |
diffFormAgainstEntity | fn | Field pairs that drift from entity |
shouldPersistForm | fn | True when there is at least one diff |
applyFormValuesToEntity | fn | Apply form values via write, mark _userOverrides |
Detailed entries
NumberFieldDef<TEntity, TFormValues, TKey>
export interface NumberFieldDef<
TEntity extends MapEntity,
TFormValues,
TKey extends keyof TFormValues,
> {
kind: 'number';
name: TKey;
label: string;
section: string;
min?: number;
max?: number;
step?: number;
/** Pull form-side value out of the (post-derive) entity. */
read: (entity: TEntity) => TFormValues[TKey];
/** Apply form-side value back into the entity (with derivations). */
write: (entity: TEntity, value: TFormValues[TKey]) => TEntity;
/**
* Entity field paths "owned" by this form field. Tagged into
* `_userOverrides` after a successful write; derive rules whose
* `owns` overlap will skip on later geometry edits. Defaults to `[name]`.
*/
overridesPaths?: readonly string[];
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Source: inspectorSchema.ts:62-86.
EnumFieldDef<TEntity, TFormValues, TKey>
export interface EnumFieldDef<...> {
kind: 'enum';
name: TKey;
label: string;
section: string;
options: readonly string[];
enumCategory?: EnumCategory; // for getEnumLabel
read: (entity: TEntity) => TFormValues[TKey];
write: (entity: TEntity, value: TFormValues[TKey]) => TEntity;
overridesPaths?: readonly string[];
}2
3
4
5
6
7
8
9
10
11
enumCategory lets the <Select> show localised labels while keeping the wire value untouched — single i18n hook.
FieldDef & AnyFieldDef
export type FieldDef<...> = NumberFieldDef<...> | EnumFieldDef<...>;
export type AnyFieldDef<TEntity, TFormValues> = {
[K in keyof TFormValues]-?: FieldDef<TEntity, TFormValues, K>;
}[keyof TFormValues];2
3
4
5
AnyFieldDef is the distributed union. Without distribution the array literal would widen read / write to "the union of every possible field type", defeating per-key narrowing.
ReadOnlyDef<TEntity>
export interface ReadOnlyDef<TEntity extends MapEntity> {
kind: 'readonly';
label: string;
section: string;
compute: (entity: TEntity) => React.ReactNode;
}2
3
4
5
6
No form binding, no validation — display only. Used for Length (m), predecessorIds lists, etc. compute may return any React node, e.g. <LaneRefList> with click-to-jump links.
EntitySchema<TEntity, TFormValues>
export interface EntitySchema<
TEntity extends MapEntity,
TFormValues extends Record<string, unknown>,
> {
id: string;
fields: ReadonlyArray<AnyFieldDef<TEntity, TFormValues>>;
readonly: ReadonlyArray<ReadOnlyDef<TEntity>>;
validation: ZodType<TFormValues, TFormValues>;
sectionOrder: ReadonlyArray<string>;
}2
3
4
5
6
7
8
9
10
id— Reactkeyfor force-remount on entity-type switchfields— editable rowsreadonly— display-only rowsvalidation— zod resolver passed touseFormsectionOrder— explicit section render order; unlisted sections render last
fieldBuilder<TEntity, TFormValues>()
export function fieldBuilder<TEntity, TFormValues>() {
return {
field<TKey extends keyof TFormValues>(
def: FieldDef<TEntity, TFormValues, TKey>,
): FieldDef<TEntity, TFormValues, TKey> {
return def;
},
};
}2
3
4
5
6
7
8
9
Why curried? defineField<TEntity, TFormValues, TKey>(def) would force the caller to supply TKey because TS cannot infer three generics from one argument when two are explicit. Pinning TEntity, TFormValues once at builder level leaves TKey to be inferred from the literal name.
const F = fieldBuilder<LaneEntity, LaneFormValues>();
F.field({ kind: 'number', name: 'speedLimit', read, write, ... });2
LaneInspectorSchema
export const LaneInspectorSchema: EntitySchema<LaneEntity, LaneFormValues> = {
id: 'lane',
validation: laneSchema,
sectionOrder: ['Attributes', 'Boundaries', 'Topology'],
fields: [
LaneField.field({ kind: 'enum', name: 'type', label: 'Type', section: 'Attributes', ... }),
LaneField.field({ kind: 'enum', name: 'turn', ... }),
LaneField.field({ kind: 'enum', name: 'direction', ... }),
LaneField.field({ kind: 'number', name: 'speedLimit', ... }),
LaneField.field({ kind: 'number', name: 'leftWidth', read: readLeftWidth, write: writeLeftWidth, ... }),
LaneField.field({ kind: 'number', name: 'rightWidth', ... }),
LaneField.field({ kind: 'enum', name: 'leftBoundaryType', read: readLeftBoundary, write: writeLeftBoundary, ... }),
LaneField.field({ kind: 'enum', name: 'rightBoundaryType', ... }),
],
readonly: [
{ kind: 'readonly', label: 'ID', section: 'Attributes', compute: e => e.id },
{ kind: 'readonly', label: 'Length', section: 'Boundaries', compute: e => `${(e.length ?? 0).toFixed(2)} m` },
{ kind: 'readonly', label: 'L Virtual', ... },
{ kind: 'readonly', label: 'Junction', section: 'Topology',
compute: e => createElement(LaneRef, { id: e.junctionId }) },
{ kind: 'readonly', label: 'Predecessors', section: 'Topology',
compute: e => createElement(LaneRefList, { ids: e.predecessorIds }) },
// ... 9 topology rows total
],
};2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Nine editable fields + 12 readonly rows (ID, Length, plus seven topology lists). Behaviourally identical to the previous LaneForm.tsx, with a km/h alias linked to the stored m/s speed limit.
Lane adapters
Module-private:
function applySampleWidth(samples, width, totalLength): LaneSampleAssociation[] {
if (samples.length === 0) {
return [
{ s: 0, width },
{ s: Math.max(0, totalLength), width },
];
}
return samples.map((sample) => ({ s: sample.s, width }));
}
const readLeftWidth = (e: LaneEntity): number => e.leftSamples[0]?.width ?? DEFAULT_LANE_HALF_WIDTH;
const writeLeftWidth = (e: LaneEntity, width: number | undefined): LaneEntity => {
const next = width ?? DEFAULT_LANE_HALF_WIDTH;
return { ...e, leftSamples: applySampleWidth(e.leftSamples, next, e.length ?? 0) };
};2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
leftWidth is not a LaneEntity field — it is a virtual "uniform width applied to every leftSample". The adapter expands a scalar into the array on write and grabs the first sample on read.
Schema-generic helpers
formValuesFromEntity(schema, entity): TFormValues
export function formValuesFromEntity(schema, entity): TFormValues {
const result = {} as Record<string, unknown>;
for (const field of schema.fields) {
result[field.name as string] = field.read(entity);
}
return result as TFormValues;
}2
3
4
5
6
7
Projects the entity through all read adapters into a react-hook-form initial values object.
diffFormAgainstEntity(schema, current, entity): Array<[key, value]>
Returns the [key, nextValue] pairs where current[key] differs from formValuesFromEntity(schema, entity)[key]. Used as the updateEntity gate.
shouldPersistForm(schema, formValues, entity): boolean
diffFormAgainstEntity(...).length > 0.
applyFormValuesToEntity(schema, entity, values): TEntity
export function applyFormValuesToEntity(schema, entity, values): TEntity {
let next = entity;
for (const field of schema.fields) {
const key = field.name as keyof TFormValues;
if (key in values) {
const v = values[key];
const prevValue = field.read(next);
next = field.write(next, v);
const newValue = field.read(next);
if (prevValue !== newValue) {
const paths = field.overridesPaths ?? [String(field.name)];
for (const path of paths) {
next = markUserOverride(next, path);
}
}
}
}
return next;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Applies each write and only marks _userOverrides when the value actually changed — so a re-seed from formValuesFromEntity does not promote auto-derived values into manual overrides.
Side effects
applyFormValuesToEntitycallsmarkUserOverride(fromcore/elements/derive, pure).- Otherwise pure.
Test coverage
Integration tests in src/components/layout/panels/__tests__/SchemaForm.test.tsx (when present) check Lane schema parity with the legacy LaneForm.
Consumers
src/components/layout/panels/SchemaForm.tsx— generic renderersrc/components/layout/panels/InspectorForms.tsx+src/components/layout/panels/InspectorForms/— selectsLaneInspectorSchemaforentityType === 'lane'src/components/layout/WorkspaceLayout.tsx+src/components/layout/WorkspaceLayout/lazyPanels.tsx— Dockview container and lazy-panel assembly
Source map
| Lines | Content |
|---|---|
| 36–58 | imports |
| 62–86 | NumberFieldDef |
| 88–109 | EnumFieldDef |
| 111–115 | FieldDef |
| 117–126 | AnyFieldDef |
| 130–138 | ReadOnlyDef |
| 142–171 | EntitySchema |
| 173–199 | fieldBuilder |
| 202–247 | Lane adapters |
| 251–435 | LaneInspectorSchema |
| 437–456 | formValuesFromEntity |
| 458–481 | diffFormAgainstEntity |
| 483–493 | shouldPersistForm |
| 495–540 | applyFormValuesToEntity |
See also
entities—MapEntity/LaneEntityapollo— Lane sourceenumLabels— consumed viaenumCategorysrc/lib/schemas.ts—laneSchema,LaneFormValues, option arrayscore/elements/derive—markUserOverridesrc/components/layout/panels/SchemaForm.tsx— actual renderer