View system
This describes the shipped, as-built view system: the
valem-viewevaluation engine, theViewDefinition/ComponentSpecformat, theEvaluatedViewcontract, and the built-invalem-view-reactrenderer.
- Context
- Architecture Overview
- Maven Module:
valem-view - Default Visibility / ReadOnly Inheritance
- npm Library:
valem-view-react - Built-in UI integration
Context
Valem models expose data and logic through REST/WebSocket/console APIs. A View Definition —
a declarative JSON artifact embedded in ModelSpec — describes how that data is presented as a UI
form: component layout, field bindings, visibility rules, and event handlers. The definition is
renderer-agnostic; the built-in renderer is a React UI backed by Spring. AI agents and other clients
(mobile, Angular, CLI) can implement their own renderers against the same EvaluatedView
REST/console contract.
Two modules implement the feature: valem-view (Java, no Spring) holds the data model and
evaluation engine; valem-view-react (npm library) holds the React component library, hooks,
and TypeScript types. The valem-ui app depends on valem-view-react as a local workspace package.
Architecture Overview
ModelSpec.viewDefinition (JsonNode, raw)
│
│ parsed by valem-view (Maven)
▼
ViewDefinition ─► ViewSpec ─► ComponentSpec[]
┌──────────────────────┐
│ ViewEvaluator │ valem-view/engine
│ │
ViewSpec ──────────►│ + mergedDocument │──► EvaluatedView
metaCache ─────────►│ + metaCache │ └─ EvaluatedComponent[]
ExpressionCache ───►│ + ExpressionCache │ (all dynamics resolved)
└──────────────────────┘
REST (valem-api)
GET /models/{id}/view → EvaluatedView
GET /models/{id}/view/{viewId} → EvaluatedView
Console (valem-console)
{"cmd":"get-view","id":"..."} → EvaluatedView
Built-in renderer (valem-ui + valem-view-react)
<ViewPanel>
<ViewRenderer spec={viewDef} state={mergedDoc} onMutate={mutate} />
└─ <ComponentRenderer component={c} />
├─ <TextField/> <SelectField/> <CountrySelector/> …
├─ <DataTable/> <DataChart/>
├─ <Group/> <SectionList/>
└─ <Button/> <Menu/>
Pluggability contract: ViewDefinition (JSON) is the renderer-agnostic spec.
EvaluatedView (JSON from REST) is the computed snapshot any renderer can consume.
The React library is one implementation; future renderers just need to parse either.
Dependency chain (no circular deps):
valem-view→valem-core(ExpressionCache, ObjectNode, JSONata)valem-service→valem-core+valem-viewvalem-api→valem-service(valem-view transitive)ModelSpec.viewDefinitionstays as rawJsonNodeinvalem-core(same pattern asschema)
Maven Module: valem-view
Package: org.json_kula.valem.view
valem-view/pom.xml dependencies:
valem-corejackson-databindjunit-jupiter,assertj-core(test scope)
view/model package
ViewDefinition, ViewSpec and the supporting records use @JsonCreator factory methods; the
ComponentSpec records bind through their canonical constructors. All of it is read with
FAIL_ON_UNKNOWN_PROPERTIES = false.
Write-time validation. A viewDefinition is validated when a model is created or evolved,
not deferred to first render: ModelSpecValidator enforces (structurally, over the raw JSON)
that view ids are unique, component ids are unique within a view, and defaultView /
sectionList.itemView name existing views; the service additionally parses the definition into
these records. Any failure is a 422 at write time rather than a 500 at render. Views and
components are therefore addressable by id, which is what makes the upsertViews /
upsertComponents evolution tiers (see model-spec/tests-and-evolution.md) well-defined.
ViewDefinition
record ViewDefinition(
String renderer, // optional, reserved; default "builtin"
List<ViewSpec> views,
String defaultView
)
ViewSpec
record ViewSpec(
String id,
String label,
String layout, // vertical | horizontal | grid | tabs | wizard
Integer columns, // for grid layout
List<ComponentSpec> components,
EventHandler onOpen,
EventHandler onClose
)
ComponentSpec — a sealed interface, not a flat record. type is the discriminator
(@JsonTypeInfo(use = NAME, include = EXISTING_PROPERTY, property = "type", visible = true)), so
the JSON stays flat and unchanged while each component type binds to a record carrying only the
fields it actually uses. A badge with a pageSize is not representable.
The interface itself declares only what the generic evaluation pipeline reads; every other field is
reachable exclusively by pattern-matching the concrete record, which is what makes ViewEvaluator’s
switch exhaustive:
sealed interface ComponentSpec permits BasicInputSpec, TextAreaSpec, /* … */ UnknownComponentSpec {
String id();
String type();
default String bind() { return null; } // $.path — also the meta-inheritance anchor
default JsonNode visible() { return null; } // Boolean | String (JSONata) | null → inherit meta
default JsonNode enabled() { return null; } // Boolean | String (JSONata) | null → !readOnly
default JsonNode readOnly() { return null; } // Boolean | String (JSONata) | null → inherit meta
default JsonNode required() { return null; } // Boolean | String (JSONata) | null → `#required`
}
id and type are required on every component — a spec missing either is rejected at parse time
(a 422 at write, not a nameless component at render).
Records are grouped by field shape, not one per type — the same grouping EvaluatedComponent
uses on the output side:
| Record | type values |
Fields beyond id/type/bind/visible |
|---|---|---|
BasicInputSpec |
textField, numericField, currencyField, percentField, passwordField, emailField, phoneNumberField, checkboxField, toggleField, dateField, dateTimeField, timeField, countrySelector |
label, enabled, readOnly, required, placeholder, helperText, tooltip, format, currency, onChange |
TextAreaSpec |
textAreaField, richTextField |
basic-input fields + rows, toolbar |
ChoiceInputSpec |
selectField, radioField, multiSelectField, autocompleteField, comboBox, tagsField |
basic-input fields + options, optionsExpr, optionsUrl, optionsPath, allowCustom, onOpen, onClose |
DependentSelectorSpec |
countryRegionSelector |
basic-input fields + options, dependsOn |
SliderSpec |
sliderField, ratingField, numericStepper |
label, enabled, readOnly, required, helperText, tooltip, min, max, step, onChange |
DateRangeSpec |
dateRangeField |
label, enabled, readOnly, required, bindFrom, bindTo, fromLabel, toLabel, helperText, tooltip, minDate, maxDate, onChange |
FileUploadSpec |
fileUploadField |
label, enabled, readOnly, required, helperText, tooltip, accept, multiple, minFiles, maxFiles, minSize, maxSize, allowedMediaTypes, onChange |
LabelSpec |
label |
label, text |
StaticTextSpec |
staticText |
text, format |
BadgeSpec |
badge, alert, callout |
label, text, variant |
SeparatorLineSpec |
separatorLine, spacer |
size |
ImageSpec |
image |
label, src, alt, width, height, fit |
LinkSpec |
link |
label, href, text, target, icon |
ProgressBarSpec |
progressBar, gauge |
label, min, max, showValue, format, helperText, tooltip |
DataTableSpec |
dataTable |
label, tableColumns, pageSize, tooltip |
DataChartSpec |
dataChart, sparkline |
label, chartType, chartX, chartSeries |
KeyValueListSpec |
keyValueList, summaryList |
label, items, columns, tooltip |
StatTileSpec |
statTile, metric |
label, value, delta, caption, trend, format, currency, variant, icon, tooltip |
JsonViewerSpec |
jsonViewer |
label, collapsed, maxDepth, tooltip |
TracePanelSpec |
explainPanel, auditTimeline |
label, limit, showConstraints, collapsed, tooltip |
ValidationSummarySpec |
validationSummary |
label, pathPrefix, variant, maxItems, emptyText |
EffectStatusSpec |
effectStatus |
label, effectId, errorPath, showRetry, retryLabel, tooltip, onRetry |
ContainerSpec |
group, fieldSet, card, toolbar, buttonGroup, tabs, tabItem, accordion, collapsible, sectionItem |
label, layout, columns, legend, collapsed, components |
SectionListSpec |
sectionList |
label, itemView, canAdd, canRemove, addLabel, removeLabel, layout, columns, components, onChange |
ButtonSpec |
button |
label, enabled, variant, icon, onClick |
MenuSpec |
menu, stepper, breadcrumb |
orientation, menuItems |
UnknownComponentSpec |
anything else | (no fixed shape — see below) |
Why so many type names on so few records. Grouping by field shape is what makes most of the
catalog cheap: a ratingField is a slider’s min/max/step, an alert is a badge’s
label/text/variant, a card is a group with a heading. Only nine of the types added
beyond the original catalog needed a record of their own. It also means the alternative spellings
cost nothing at evaluation time and cannot diverge in behaviour — switching a group to a card
provably cannot change what a view computes, because the evaluator runs the same arm.
The full vocabulary is enumerated three times — ViewComponentTypes (valem-core, write-time
validation), @JsonSubTypes (valem-view, record binding), and KNOWN_COMPONENT_TYPES
(valem-view-react, dispatch). Each drifts silently on its own, so
ViewComponentTypesCoverageTest in valem-view reads all three (including the TypeScript source)
and fails until they agree.
bind is on every record, not only the value-carrying ones, because it is the anchor for the
meta-driven visible/readOnly/required inheritance described below — not just a value locator.
Unknown types. A type none of the built-in records claim binds to UnknownComponentSpec,
which keeps the component’s raw JSON verbatim — a custom type may carry any property at all,
including ones no built-in type declares, and nothing is dropped at parse time. The common fields
the pipeline reads are projected out of that node; everything else is reachable via
property(String), and the record serializes back to exactly the JSON it was parsed from.
ViewEvaluator renders these as a basic input, which is what an unrecognised type has always
resolved to.
classNamewas declared on the old flat record but read by nothing — no evaluator, no renderer. It is not part of any record, nor of the TypeScript mirror. Since specs are stored and served as raw JSON, aclassNamein an existing spec still reaches the client untouched; it simply is not modelled anywhere.
Supporting records:
record OptionSpec(String value, String label)
record ColumnSpec(String field, String header, String format, String width)
record ChartSeriesSpec(String field, String label, String color)
record MenuItemSpec(String label, String targetView, String icon)
record EventHandler(String mutations, String navigate)
// mutations: JSONata → {"$.path": value, ...}
// navigate: view id to activate
Component Type Catalog
Input fields (bind, label, visible, enabled, readOnly, required,
placeholder, helperText, onChange on all):
type |
Extra fields | Notes |
|---|---|---|
textField |
— | single-line text |
textAreaField |
rows |
multiline |
numericField |
— | number input; min/max from meta |
passwordField |
— | masked |
emailField |
— | email validation |
phoneNumberField |
— | built-in country code picker (restcountries.com IDD) |
checkboxField |
— | boolean |
toggleField |
— | boolean switch |
selectField |
options, optionsExpr, optionsUrl, optionsPath |
dropdown |
radioField |
options, optionsExpr |
radio group |
multiSelectField |
options, optionsExpr, optionsUrl, optionsPath |
|
dateField |
— | date picker |
dateTimeField |
— | date + time picker |
timeField |
— | time-only picker (HH:mm) |
sliderField |
min, max, step |
range slider; dragging only updates the local draft — the mutation is sent when the thumb is released (pointer-up / key-up), or on blur/debounce as a backstop |
fileUploadField |
accept, multiple, minFiles/maxFiles, minSize/maxSize, allowedMediaTypes |
POSTs multipart to /blobs; stores BlobRef {$blobId,$mediaType,$bytes} in bound field. minFiles/maxFiles/minSize/maxSize/allowedMediaTypes fall back to a minItems/maxItems/minSize/maxSize/allowedMediaTypes metaDerivation on the bind path when unset on the component (ViewEvaluator meta-cache lookup, same pattern as readOnly/required) |
countrySelector |
— | fetches from restcountries.com automatically |
countryRegionSelector |
dependsOn |
dependsOn = bind path of countrySelector |
currencyField / percentField |
format, currency |
numeric field plus a display convention; the stored value stays a plain number. percent appends % and does not rescale |
richTextField |
rows, toolbar |
stores markdown, not HTML — the value stays readable to derivations and constraints |
autocompleteField / comboBox |
options, optionsExpr, optionsUrl, optionsPath, allowCustom |
filtering select; allowCustom defaults true for comboBox |
tagsField |
options, allowCustom |
array of scalars as chips; writes the whole array in one mutation |
dateRangeField |
bindFrom, bindTo, fromLabel, toLabel, minDate, maxDate |
two paths, not one object bind — each end mutates independently so the audit shows which changed |
ratingField / numericStepper |
min, max, step |
slider affordances; commit on click rather than deferring |
Data output (bind, label, visible on all):
type |
Extra fields | Notes |
|---|---|---|
label |
text |
dynamic text or bound value |
staticText |
text, format |
format: markdown (default, escaped) / text / html (unescaped, opt-in) |
badge |
text, variant |
status indicator. variant is a plain string, not JSONata-capable server-side — ViewEvaluator passes it through unevaluated. The bundled React renderer re-evaluates a JSONata variant expression client-side, so it only resolves correctly through the built-in UI, not through the raw GET /models/{id}/view (or MCP/console) response. |
separatorLine |
— | horizontal rule |
dataTable |
tableColumns, pageSize |
tabular view of array |
dataChart |
chartType, chartX, chartSeries |
recharts chart |
sparkline |
chartType, chartSeries |
axis-less inline trend; first series only |
progressBar |
min, max, showValue, format |
numeric value as filled bar; format: percent (default) or value (75 / 100) |
gauge |
min, max, showValue, format |
the same value as a 180° arc |
alert / callout |
text, variant |
block-level badge; danger/warning render as role="alert" |
spacer |
size |
vertical gap without a rule |
image |
src, alt, width, height, fit |
resolves a bound BlobRef to /blobs/{id}, so an upload and its preview share one path |
link |
href, text, target, icon |
external anchor; _blank gets rel="noopener noreferrer" automatically |
keyValueList / summaryList |
items, columns |
caption/value summary; rows resolved server-side. bind on a row wins over text; format/currency are per row |
statTile / metric |
value, delta, caption, trend, format, currency, variant, icon |
trend is authored, not inferred from the delta’s sign — whether “up” is good is a domain question |
jsonViewer |
collapsed, maxDepth |
bound subtree as JSON; bind to $ for the whole merged document |
explainPanel / auditTimeline |
limit, showConstraints, collapsed |
declaration only — the renderer fetches /explain/{path} or /audit itself (see below) |
validationSummary |
pathPrefix, variant, maxItems, emptyText |
the flag-policy violations that have no field to sit beside |
effectStatus |
effectId, errorPath, showRetry, retryLabel, onRetry |
an effect’s statusPath machine; fully resolved server-side since the status is ordinary model state |
Aggregates:
type |
Key fields | Notes |
|---|---|---|
group |
layout, columns, components |
layout container |
fieldSet |
legend, components |
HTML <fieldset> |
card |
label, layout, components |
group on a titled surface |
toolbar / buttonGroup |
components |
row of actions; ignore layout |
tabs / tabItem |
label, components |
one panel per child, captioned by the child’s label |
accordion / collapsible |
label, collapsed, components |
collapsed is the initial state, not a live binding |
sectionList |
bind, itemView, canAdd, canRemove, labels |
array add/remove |
sectionItem |
bind, components |
single element editor (sub-view); evaluates to an EvaluatedContainer carrying bind |
Actions:
type |
Key fields | Notes |
|---|---|---|
button |
variant, icon, onClick, enabled |
|
menu |
menuItems, orientation |
view navigation |
stepper / breadcrumb |
menuItems, orientation |
the same items as a progression or a trail; position is the active view id, so it survives a reload |
Tabs and wizards hold no model state. Which tab is open is local UI state and deliberately not in the document — it is not something a derivation, a constraint or an audit record should have an opinion about. When the position must survive a reload, use separate views and navigate between them: the active view id is then the step, and a
stepperrenders it.
What the server does not evaluate.
explainPanel,auditTimelineandvalidationSummaryevaluate to a declaration — which path, how many rows — not to data.ViewEvaluatorreceives onlymergedDocument/metaCache/exprCache/constants; it has no access to the trace ring buffer, the audit store or the runtime’s flagged-constraint set, and wiring one in would put an unbounded read inside every view evaluation and everyviewDelta. The renderer fetches those itself, the same division of labour asoptionsUrl.effectStatusandjsonViewerare the exceptions: their data is ordinary model state, so both resolve fully server-side.
view/engine package
EvaluatedComponent — a sealed interface, not a flat record. Each component type
serializes as one of 26 concrete records, each carrying only the fields relevant to that type
(@JsonInclude(NON_NULL), with a BooleanTrueFilter suppressing default true/false
booleans): EvaluatedBasicInput, EvaluatedTextArea, EvaluatedSelectField,
EvaluatedDependentSelector, EvaluatedSlider, EvaluatedDateRange, EvaluatedFileUpload,
EvaluatedLabel, EvaluatedStaticText, EvaluatedBadge, EvaluatedImage, EvaluatedLink,
EvaluatedProgressBar, EvaluatedDataTable, EvaluatedDataChart, EvaluatedKeyValueList,
EvaluatedStatTile, EvaluatedJsonViewer, EvaluatedTracePanel,
EvaluatedValidationSummary, EvaluatedEffectStatus, EvaluatedContainer,
EvaluatedSectionList, EvaluatedButton, EvaluatedMenu, EvaluatedSeparatorLine. For example
EvaluatedBadge carries only id, type, visible, variant, text, label — no
bind/value/enabled. This mirrors the ComponentSpec hierarchy on the input side
one-for-one, minus UnknownComponentSpec (an unrecognised type evaluates to an
EvaluatedBasicInput).
EvaluatedKeyValueList additionally nests EvaluatedKeyValueItem, the only supporting record on
the output side that is itself resolved (each row’s value is read out of the merged document).
EvaluatedContainer covers all ten container types; its bind is populated only by
sectionItem, and is omitted from the JSON otherwise. It carries label because for a card,
a tabItem and a collapsible the label is the visible heading — the tab’s title, the panel’s
summary row — rather than decoration a renderer may drop.
The interface exposes ~25 default methods (returning null/false for fields a given
subtype doesn’t carry) so callers can treat any EvaluatedComponent uniformly:
sealed interface EvaluatedComponent permits EvaluatedBasicInput, EvaluatedTextArea, /* … */ {
String id(); String type();
default String label() { return null; }
default boolean visible() { return true; }
default boolean enabled() { return true; }
default boolean readOnly() { return false; }
default boolean required() { return false; }
default String bind() { return null; }
default JsonNode value() { return null; }
// ...+ options/text/components/tableColumns/chartX/menuItems/variant/min/max/step/
// accept/multiple/minFiles/maxFiles/minSize/maxSize/allowedMediaTypes/showValue/
// format/onClick/onChange/onOpen/onClose, each with a type-appropriate default
}
EvaluatedView:
record EvaluatedView(
String modelId, String viewId, String title, String layout,
List<EvaluatedComponent> components
)
ViewEvaluator — a stateless utility class (private constructor, static methods only):
public final class ViewEvaluator {
public static EvaluatedView evaluate(
String modelId,
ViewSpec view,
ObjectNode mergedDocument,
Map<String, JsonNode> metaCache,
ExpressionCache exprCache
)
// Overload: also binds the model's named constants as $const in every view expression.
public static EvaluatedView evaluate(
String modelId, ViewSpec view, ObjectNode mergedDocument,
Map<String, JsonNode> metaCache, ExpressionCache exprCache,
ObjectNode constants // nullable — null means no $const binding
)
}
After resolving the common dynamics, the evaluator dispatches with a switch pattern-matching
the sealed ComponentSpec, not on the type string. The switch is exhaustive over the permits
list, so a new component record does not compile until it is handled — adding a type can no longer
fall through to the generic input branch unnoticed.
Per-component evaluation steps:
- Resolve
visible→ null: checkmetaCache["$.bind#relevant"](absent → true); bool, else any textual string is evaluated as JSONata (no$required — see step 5’s contrast) - Resolve
readOnly→ null: checkmetaCache["$.bind#read_only"](absent → false); bool/JSONata - Resolve
required→ null: checkmetaCache["$.bind#required"](absent → false); bool/JSONata. There is no JSON-Schema-required-array fallback —ViewEvaluatornever receives the schema (onlymergedDocument/metaCache/exprCache/constants); a spec that relies solely on the schema’srequiredarray (no explicit#requiredmetaDerivation) rendersrequired=falsein the view. - Resolve
enabled→ null:!effectiveReadOnly; bool/JSONata - Resolve
text/value/delta/caption/trend(resolveText/resolveNode) → the string is evaluated as JSONata only if it contains a$; otherwise it is kept as a literal. This is the deliberate asymmetry with the boolean dynamics in steps 1–4, which evaluate any string: the$-gate keeps display literals like"Underweight"or"25 - 29.9"from being parsed as expressions. A bare field reference ("myField") therefore renders verbatim server-side — reference a field through a$function ($string(myField)) or viabind. (The built-in React renderer has no such gate; seeuseJSONataLiteral.) Documented for authors in Field value kinds. - Look up
bindpath inmergedDocument→value - Resolve
options— static list passthrough only.optionsExpr/optionsUrl/optionsPathare never read by the server — they are declared onComponentSpecbut dead from the engine’s perspective; resolution happens entirely client-side (see the Explainability boundary note below). - Recurse into
componentsfor aggregates
Default Visibility / ReadOnly Inheritance
| ComponentSpec field | Null (default) source |
|---|---|
visible |
metaCache["$.bind#relevant"] → false = hidden; absent = visible |
readOnly |
metaCache["$.bind#read_only"] → true = read-only; absent = editable |
required |
metaCache["$.bind#required"] → absent = false. Not derived from the JSON Schema required array — you must add an explicit #required metaDerivation. |
enabled |
!effectiveReadOnly |
The survey example’s issueCategory (driven by relevant and readOnly metaDerivations)
becomes hidden/disabled with zero extra ViewDefinition config.
Wiring.
ModelSpec.viewDefinitionandSpecEvolution.newViewDefinitionare raw nullableJsonNodes invalem-core(same pattern asschema), parsed byvalem-view.ModelServiceexposesgetEvaluatedView(id, viewId);valem-apiserves it viaViewController(GET /models/{id}/view[/{viewId}], 404 throughModelNotFoundException) andvalem-consolevia theget-viewcommand. There is no role/access parameter anywhere in the view evaluation path, consistent with there being no per-field authorization in Valem (see security-model.md).
npm Library: valem-view-react
Location: valem-view-react/ (repo root sibling to valem-ui)
package.json peer dependencies: react ^18, react-dom ^18
Runtime dependencies: jsonata, recharts
Build tooling: Vite in lib mode, TypeScript
Public exports (index.ts):
ViewRenderer— main entry componentViewContext,ViewContextProvider- All component types (re-exported for custom composition)
- TypeScript types:
ViewDefinition,ViewSpec,ComponentSpec,EvaluatedView,EvaluatedComponent,OptionSpec,EventHandler, etc. - The
ComponentSpecvariants (BasicInputSpec,SliderSpec,ContainerSpec, …),KnownComponentSpec,UnknownComponentSpec, plus theisKnownComponent/hasChildComponentsnarrowing guards and theKNOWN_COMPONENT_TYPESlist - Hooks:
useJSONata,useCountries,useRegions
ComponentSpec is a discriminated union here too, mirroring the Java sealed hierarchy
variant-for-variant: same grouping, same field sets, same UnknownComponentSpec fallback (typed
with an index signature, so a custom type may carry any property). Consequences in the renderer:
BaseComponentProps<C>is generic, so each implementation receives only its own variant —SliderFieldtakes aSliderSpec. Readingc.pageSizeinsideBadgeis a compile error.ComponentRenderernarrows withisKnownComponent(c)before dispatching, so the unknown-type branch is explicit rather than adefault:fallthrough, and theswitchoverKnownComponentSpecis exhaustiveness-checked by itsReactElementreturn type — a missing case failstscwith “function lacks ending return statement”.
The one place the mirror is deliberately looser: the TS base carries enabled/readOnly/
required for every variant (matching the Java interface’s default methods), whereas the Java
records only bind them where the type is interactive.
ViewRenderer props:
interface ViewRendererProps {
modelId: string;
viewDef: ViewDefinition; // raw spec — client evaluates dynamics
state: Record<string, unknown>; // merged model state
meta: Record<string, unknown>; // meta cache
onMutate: (mutations: Record<string, unknown>) => Promise<void>;
onNavigate?: (viewId: string) => void;
activeViewId?: string;
}
Rendering strategy (hybrid, not purely client-side):
ViewRenderer/ComponentRenderertake the rawViewDefinition/ComponentSpecand evaluatevisible/enabled/readOnly/required/textJSONata expressions client-side viajsonatanpm, against the fetchedstate+metamaps — this is the primary render path.- The server-evaluated
EvaluatedView(GET /models/{id}/view[/{viewId}]) is also fetched byViewPanelon mount, purely to seed/refresh themetamap (readOnly/visible/required) that the client evaluator reads — the client re-derives the actual displayed values from the raw spec rather than consuming the server’s already-resolvedEvaluatedComponents directly. - After every mutation (
POST /models/{id}/mutations[/patch]with anX-Viewheader), the server returns aviewDelta— aMap<String, EvaluatedComponent>keyed by component id, containing only the components whosebindpath was mutated or re-derived (mutatedPaths ∪ derivedUpdated).ViewPanelapplies this delta to patch local view state optimistically instead of doing a full view re-fetch. See api-reference.md for theMutationResponse.viewDeltashape. - On WebSocket
ChangeEvent:ViewPanelre-fetches state, passes toViewRenderer onClick/onChange: evaluatemutationsJSONata → callonMutate()→ parent POSTs mutation
Explainability boundary (client-side evaluation).
onClick/onChangemutationsJSONata are evaluated in the browser, not on the server, and so do not appear in the server-side derivation/constraint trace (GET /models/{id}/explain/{path}) until the resulting mutation is POSTed (which then runs the full audited pipeline).optionsExpr/optionsUrl/optionsPathare likewise resolved only client-side today —ViewEvaluatordoes not evaluate any of them (see evaluation step 7 above), sooptionsExprcurrently provides no more server-side auditability thanoptionsUrl, and neither is subject to the server’s SSRF controls. Where auditability matters, compute the option-relevant value via aderivationsfield and bind the component to that instead.
Public REST APIs (fetched client-side, cached in module-level maps):
| Component | URL |
|---|---|
countrySelector |
https://restcountries.com/v3.1/all?fields=name,cca2 |
countryRegionSelector |
https://raw.githubusercontent.com/dr5hn/countries-states-cities-database/master/states.json |
phoneNumberField |
https://restcountries.com/v3.1/all?fields=name,cca2,idd |
Source tree:
src/
index.ts
types.ts
format.ts ← formatValue / currencySymbol, for `format` + `currency`
ViewRenderer.tsx
ViewContext.tsx
ComponentRenderer.tsx
hooks/
useJSONata.ts, useCountries.ts, useRegions.ts,
useDeferredMutate.ts, useResolvedOptions.ts
fields/
TextField.tsx, TextAreaField.tsx, RichTextField.tsx, NumericField.tsx,
PasswordField.tsx, EmailField.tsx, CheckboxField.tsx, ToggleField.tsx,
SelectField.tsx, RadioField.tsx, MultiSelectField.tsx,
AutocompleteField.tsx, TagsField.tsx,
DateField.tsx, DateTimeField.tsx, TimeField.tsx, DateRangeField.tsx,
SliderField.tsx, RatingField.tsx, NumericStepper.tsx, FileUploadField.tsx,
CountrySelector.tsx, CountryRegionSelector.tsx, PhoneNumberField.tsx
output/
LabelComponent.tsx, StaticText.tsx, Badge.tsx, Alert.tsx, SeparatorLine.tsx,
ImageComponent.tsx, LinkComponent.tsx,
DataTable.tsx, DataChart.tsx, Sparkline.tsx, ProgressBar.tsx, Gauge.tsx,
KeyValueList.tsx, StatTile.tsx, JsonViewer.tsx,
TracePanel.tsx, ValidationSummary.tsx, EffectStatus.tsx
aggregates/
LayoutContainer.tsx ← the five layouts, shared with ViewRenderer
GroupComponent.tsx, FieldSetComponent.tsx, CardComponent.tsx,
ToolbarComponent.tsx, TabsComponent.tsx, CollapsibleComponent.tsx,
SectionList.tsx, SectionItem.tsx
actions/
ButtonComponent.tsx, MenuComponent.tsx, StepperComponent.tsx
Layouts. ViewSpec.layout has always documented vertical | horizontal | grid | tabs |
wizard, but only the first three were ever rendered — a view asking for tabs silently got a
vertical stack. LayoutContainer implements all five and is shared by ViewRenderer and every
container component, so a view-level layout: "wizard" and a group with the same layout behave
identically.
Two guards keep the renderer honest. renderKnown’s switch is exhaustiveness-checked by
its ReactElement return type (a missing case fails tsc with “function lacks ending return
statement”), and a const _typesInSync: AssertEqual<…> = true in types.ts fails to compile
when KNOWN_COMPONENT_TYPES and the KnownComponentSpec union disagree — the array drives
isKnownComponent’s narrowing while the union drives dispatch, so a type present in one and not
the other passes the guard and then reaches a switch with no case for it.
useJSONataLiteral. The client evaluates text fields that the server leaves alone:
ViewEvaluator only evaluates a string containing $, whereas jsonata("up") parses fine and
resolves to nothing. Fields that are usually a literal and occasionally an expression —
variant, trend, icon, target — go through useJSONataLiteral, which falls back to the
literal, so the same spec renders identically through the raw-spec path and through
GET /models/{id}/view.
Built-in UI integration
In the built-in UI, valem-ui consumes valem-view-react as a workspace package: it fetches a
model’s spec, renders a “View” tab (shown only when spec.viewDefinition is set) via
<ViewRenderer>, and re-fetches state on each WebSocket ChangeEvent. The bundled example specs
(customer-satisfaction-survey.json, order-items-price-total.json, and others) ship a
viewDefinition — see the examples gallery.