Skip to content

Commit b93232c

Browse files
committed
feat(react,types): read canonical visibleWhen in renderers (ADR-0089)
`@objectstack/spec` unifies conditional visibility under the canonical key `visibleWhen`, folding the deprecated `visibleOn` (view form) / `visibility` (page component) aliases into it at parse. Update ObjectUI to read the canonical key: - SchemaRenderer (page): evaluate `visibleWhen` first (show-when-truthy), keep `visibleOn`/`visibility` as defensive reads for raw metadata; strip `visibleWhen` from DOM props. - spec-bridge: page bridge maps `visibleWhen ?? visibility` → node `visibleWhen`; form-view bridge maps `visibleWhen ?? visibleOn` → the ObjectForm view-level predicate slot. - react FormRenderer: prefer `visibleWhen` over the `visibleOn` alias. - types: component base (`BaseSchema` / `base.zod`) gains canonical `visibleWhen`; `visibleOn` marked `@deprecated`. Fully back-compat: existing `visibleOn`/`visibility` metadata keeps working via the alias reads. Addresses objectstack-ai/objectstack#2904. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017fU25GeYSPKq9eEURsQffF
1 parent 7d618f8 commit b93232c

9 files changed

Lines changed: 112 additions & 13 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@object-ui/react": minor
3+
"@object-ui/types": minor
4+
---
5+
6+
ADR-0089: read the canonical `visibleWhen` conditional-visibility predicate in the form + page renderers.
7+
8+
`@objectstack/spec` now unifies conditional visibility under a single canonical key, `visibleWhen`, and folds the deprecated `visibleOn` (view form) / `visibility` (page component) aliases into it at parse. This updates ObjectUI to read the canonical key:
9+
10+
- **Page renderer** (`SchemaRenderer`) — evaluates `visibleWhen` first (show-when-truthy), then the deprecated `visibleOn` / `visibility` as a defensive read for raw / un-normalized metadata. `visibleWhen` is stripped from DOM props.
11+
- **Spec→node bridges** — the page bridge maps a component's `visibleWhen ?? visibility` onto the node's canonical `visibleWhen`; the form-view bridge maps a field's `visibleWhen ?? visibleOn` onto the ObjectForm view-level predicate slot.
12+
- **Form renderers** — the `@object-ui/react` `FormRenderer` prefers `visibleWhen` over the `visibleOn` alias. (`ObjectForm`/`form.tsx` already evaluated `visibleWhen`.)
13+
- **Types** — the component base schema (`BaseSchema` / `base.zod`) gains the canonical `visibleWhen`; `visibleOn` is marked `@deprecated`.
14+
15+
Fully back-compat: existing `visibleOn` / `visibility` metadata keeps working through the alias reads.

‎packages/react/src/SchemaRenderer.tsx‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -272,18 +272,25 @@ export const SchemaRenderer = forwardRef<any, { schema: SchemaNode } & Record<st
272272
newSchema.props = newProps;
273273
}
274274

275-
// Evaluate visibility: visible / visibleOn / visibility / hidden / hiddenOn
275+
// Evaluate visibility: visible / visibleWhen / visibleOn / visibility / hidden / hiddenOn
276276
const shouldHide = (() => {
277277
if (newSchema.visible !== undefined) {
278278
return !evaluator.evaluateCondition(newSchema.visible);
279279
}
280+
// `visibleWhen` is the single canonical conditional-visibility predicate
281+
// across every layer since ADR-0089 (show-when-truthy). The spec folds the
282+
// deprecated `visibleOn` (view) / `visibility` (page) aliases into it at
283+
// parse, so it is checked FIRST; the aliases below remain as a defensive
284+
// read for any raw / un-normalized metadata reaching the renderer.
285+
if (newSchema.visibleWhen !== undefined) {
286+
return !evaluator.evaluateCondition(newSchema.visibleWhen);
287+
}
288+
// @deprecated ADR-0089 → `visibleWhen`.
280289
if (newSchema.visibleOn !== undefined) {
281290
return !evaluator.evaluateCondition(newSchema.visibleOn);
282291
}
283-
// `visibility` is the spec-canonical predicate (PageComponentSchema.visibility,
284-
// an ExpressionInput) — show-when-truthy, same semantics as `visibleOn`. The
285-
// spec→node bridge preserves it verbatim on the node, so this is where it
286-
// finally gates rendering (previously it was inert and leaked as a DOM prop).
292+
// @deprecated ADR-0089 → `visibleWhen` (was PageComponentSchema.visibility,
293+
// an ExpressionInput) — show-when-truthy, same semantics as `visibleOn`.
287294
if (newSchema.visibility !== undefined) {
288295
return !evaluator.evaluateCondition(newSchema.visibility);
289296
}
@@ -382,6 +389,7 @@ export const SchemaRenderer = forwardRef<any, { schema: SchemaNode } & Record<st
382389
body: _body,
383390
schema: _schema,
384391
visible: _visible,
392+
visibleWhen: _visibleWhen,
385393
visibleOn: _visibleOn,
386394
visibility: _visibility,
387395
hidden: _hidden,

‎packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,37 @@ describe('SchemaRenderer Expression Integration', () => {
8383
});
8484
});
8585

86+
// ADR-0089 — `visibleWhen` is the canonical conditional-visibility predicate;
87+
// `visibleOn` / `visibility` remain as deprecated aliases the renderer still reads.
88+
describe('visibleWhen (ADR-0089 canonical)', () => {
89+
it('shows when the visibleWhen predicate is truthy', () => {
90+
render(
91+
<SchemaRendererContext.Provider value={{ dataSource: { role: 'admin' } }}>
92+
<SchemaRenderer schema={{ type: 'test-component', visibleWhen: '${data.role === "admin"}' }} />
93+
</SchemaRendererContext.Provider>
94+
);
95+
expect(screen.getByTestId('test-component')).toBeInTheDocument();
96+
});
97+
98+
it('hides when the visibleWhen predicate is falsy', () => {
99+
const { container } = render(
100+
<SchemaRendererContext.Provider value={{ dataSource: { role: 'viewer' } }}>
101+
<SchemaRenderer schema={{ type: 'test-component', visibleWhen: '${data.role === "admin"}' }} />
102+
</SchemaRendererContext.Provider>
103+
);
104+
expect(container.innerHTML).toBe('');
105+
});
106+
107+
it('still honors the deprecated `visibility` alias', () => {
108+
const { container } = render(
109+
<SchemaRendererContext.Provider value={{ dataSource: { role: 'viewer' } }}>
110+
<SchemaRenderer schema={{ type: 'test-component', visibility: '${data.role === "admin"}' }} />
111+
</SchemaRendererContext.Provider>
112+
);
113+
expect(container.innerHTML).toBe('');
114+
});
115+
});
116+
86117
describe('disabled expressions', () => {
87118
it('passes disabled=true when disabled expression is true', () => {
88119
render(<SchemaRenderer schema={{ type: 'test-component', disabled: true }} />);

‎packages/react/src/components/form/FormRenderer.tsx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -152,11 +152,14 @@ function evaluateFieldVisibility(
152152
return false;
153153
}
154154

155-
// Evaluate visibleOn expression
156-
if (fieldConfig.visibleOn) {
155+
// Evaluate the conditional-visibility predicate. `visibleWhen` is canonical
156+
// (ADR-0089); `visibleOn` is the deprecated alias, kept as a fallback for raw
157+
// / un-normalized metadata.
158+
const visiblePredicate = fieldConfig.visibleWhen ?? fieldConfig.visibleOn;
159+
if (visiblePredicate) {
157160
try {
158161
const evaluator = new ExpressionEvaluator({ data: formValues, form: formValues });
159-
return evaluator.evaluateCondition(fieldConfig.visibleOn);
162+
return evaluator.evaluateCondition(visiblePredicate);
160163
} catch {
161164
// On error, default to visible
162165
return true;
@@ -189,7 +192,7 @@ const FormSectionRenderer: React.FC<FormSectionRendererProps> = ({
189192
const hasConditionalFields = React.useMemo(() => {
190193
return section.fields.some((field) => {
191194
if (typeof field === 'string') return false;
192-
return !!field.dependsOn || !!field.visibleOn;
195+
return !!field.dependsOn || !!field.visibleWhen || !!field.visibleOn;
193196
});
194197
}, [section.fields]);
195198

‎packages/react/src/spec-bridge/__tests__/SpecBridge.test.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -398,12 +398,22 @@ describe('SpecBridge', () => {
398398
expect(comp.type).toBe('ui.button');
399399
expect(comp.label).toBe('Save');
400400
expect(comp.events).toEqual({ onClick: 'save' });
401-
expect(comp.visibility).toBe('${canEdit}');
401+
// ADR-0089: the deprecated `visibility` alias is folded into canonical `visibleWhen`.
402+
expect(comp.visibleWhen).toBe('${canEdit}');
402403
expect(comp.dataSource).toEqual({ provider: 'api', read: '/api/data' });
403404
expect(comp.responsive).toEqual({ sm: 'hidden' });
404405
expect(comp.aria).toEqual({ label: 'Save record' });
405406
});
406407

408+
it('maps a canonical `visibleWhen` page-component predicate onto the node (ADR-0089)', () => {
409+
const node = bridgePage(
410+
{ regions: [{ components: [{ type: 'ui.button', id: 'b', visibleWhen: '${canEdit}' }] }] },
411+
{},
412+
);
413+
const comp = (node.body as any[])[0].body[0];
414+
expect(comp.visibleWhen).toBe('${canEdit}');
415+
});
416+
407417
it('maps style and responsiveStyles onto the component node', () => {
408418
// Regression: both fields were declared on the PageComponent input type
409419
// but never copied onto the SchemaNode, so a page-authored ADR-0065

‎packages/react/src/spec-bridge/bridges/form-view.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ interface FormField {
2020
colSpan?: number;
2121
widget?: string;
2222
dependsOn?: string[];
23+
/** Canonical conditional-visibility predicate (ADR-0089). */
24+
visibleWhen?: string;
25+
/** @deprecated ADR-0089 → `visibleWhen`. */
2326
visibleOn?: string;
2427
}
2528

@@ -57,7 +60,12 @@ function mapField(field: FormField): Record<string, any> {
5760
if (field.colSpan != null) mapped.colSpan = field.colSpan;
5861
if (field.widget) mapped.widget = field.widget;
5962
if (field.dependsOn) mapped.dependsOn = field.dependsOn;
60-
if (field.visibleOn) mapped.visibleOn = field.visibleOn;
63+
// ADR-0089: `visibleWhen` is the canonical view-form-field visibility predicate
64+
// (the spec folds the deprecated `visibleOn` into it at parse). Prefer it and
65+
// fall back to `visibleOn` for raw / un-normalized metadata. The ObjectForm
66+
// renderer reads this view-level predicate from the node's `visibleOn` slot.
67+
const visiblePredicate = field.visibleWhen ?? field.visibleOn;
68+
if (visiblePredicate) mapped.visibleOn = visiblePredicate;
6169

6270
return mapped;
6371
}

‎packages/react/src/spec-bridge/bridges/page.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ interface PageComponent {
1818
style?: Record<string, any>;
1919
responsiveStyles?: Record<string, any>;
2020
className?: string;
21+
/** Canonical conditional-visibility predicate (ADR-0089). */
22+
visibleWhen?: string;
23+
/** @deprecated ADR-0089 → `visibleWhen`. */
2124
visibility?: string;
2225
dataSource?: any;
2326
responsive?: any;
@@ -83,7 +86,12 @@ function mapComponent(component: PageComponent): SchemaNode {
8386
(node as any).properties = component.properties;
8487
}
8588
if (component.events) node.events = component.events;
86-
if (component.visibility) node.visibility = component.visibility;
89+
// ADR-0089: `visibleWhen` is the canonical conditional-visibility predicate;
90+
// the spec folds the deprecated `visibility` alias into it at parse, so prefer
91+
// it and fall back to `visibility` for raw / un-normalized metadata. SchemaRenderer
92+
// reads `visibleWhen` first (show-when-truthy).
93+
const visiblePredicate = component.visibleWhen ?? component.visibility;
94+
if (visiblePredicate) node.visibleWhen = visiblePredicate;
8795
if (component.dataSource) node.dataSource = component.dataSource;
8896
if (component.responsive) node.responsive = component.responsive;
8997
if (component.aria) node.aria = component.aria;

‎packages/types/src/base.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,9 +106,18 @@ export interface BaseSchema {
106106
*/
107107
visible?: boolean;
108108

109+
/**
110+
* Canonical conditional-visibility predicate (ADR-0089) — the element is shown
111+
* when this evaluates truthy. The spec folds the deprecated `visibleOn` /
112+
* `visibility` aliases into this key at parse.
113+
* @example "${data.role === 'admin'}"
114+
*/
115+
visibleWhen?: string;
116+
109117
/**
110118
* Expression for conditional visibility.
111119
* Evaluated against the current data context.
120+
* @deprecated ADR-0089 — use `visibleWhen`.
112121
* @example "${data.role === 'admin'}"
113122
*/
114123
visibleOn?: string;

‎packages/types/src/zod/base.zod.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,10 +99,17 @@ const BaseSchemaCore = z.object({
9999
*/
100100
visible: z.boolean().optional().describe('Visibility control'),
101101

102+
/**
103+
* Canonical conditional-visibility predicate (ADR-0089) — shown when truthy.
104+
* The spec folds the deprecated `visibleOn` / `visibility` aliases into this.
105+
*/
106+
visibleWhen: z.string().optional().describe('Canonical conditional-visibility predicate (ADR-0089)'),
107+
102108
/**
103109
* Conditional visibility expression
110+
* @deprecated ADR-0089 — use `visibleWhen`.
104111
*/
105-
visibleOn: z.string().optional().describe('Expression for conditional visibility'),
112+
visibleOn: z.string().optional().describe('[DEPRECATED → visibleWhen] Expression for conditional visibility'),
106113

107114
/**
108115
* Hidden control

0 commit comments

Comments
 (0)