Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/adr-0089-visible-when-renderer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@object-ui/react": minor
"@object-ui/types": minor
---

ADR-0089: read the canonical `visibleWhen` conditional-visibility predicate in the form + page renderers.

`@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:

- **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.
- **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.
- **Form renderers** — the `@object-ui/react` `FormRenderer` prefers `visibleWhen` over the `visibleOn` alias. (`ObjectForm`/`form.tsx` already evaluated `visibleWhen`.)
- **Types** — the component base schema (`BaseSchema` / `base.zod`) gains the canonical `visibleWhen`; `visibleOn` is marked `@deprecated`.

Fully back-compat: existing `visibleOn` / `visibility` metadata keeps working through the alias reads.
18 changes: 13 additions & 5 deletions packages/react/src/SchemaRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -272,18 +272,25 @@ export const SchemaRenderer = forwardRef<any, { schema: SchemaNode } & Record<st
newSchema.props = newProps;
}

// Evaluate visibility: visible / visibleOn / visibility / hidden / hiddenOn
// Evaluate visibility: visible / visibleWhen / visibleOn / visibility / hidden / hiddenOn
const shouldHide = (() => {
if (newSchema.visible !== undefined) {
return !evaluator.evaluateCondition(newSchema.visible);
}
// `visibleWhen` is the single canonical conditional-visibility predicate
// across every layer since ADR-0089 (show-when-truthy). The spec folds the
// deprecated `visibleOn` (view) / `visibility` (page) aliases into it at
// parse, so it is checked FIRST; the aliases below remain as a defensive
// read for any raw / un-normalized metadata reaching the renderer.
if (newSchema.visibleWhen !== undefined) {
return !evaluator.evaluateCondition(newSchema.visibleWhen);
}
// @deprecated ADR-0089 → `visibleWhen`.
if (newSchema.visibleOn !== undefined) {
return !evaluator.evaluateCondition(newSchema.visibleOn);
}
// `visibility` is the spec-canonical predicate (PageComponentSchema.visibility,
// an ExpressionInput) — show-when-truthy, same semantics as `visibleOn`. The
// spec→node bridge preserves it verbatim on the node, so this is where it
// finally gates rendering (previously it was inert and leaked as a DOM prop).
// @deprecated ADR-0089 → `visibleWhen` (was PageComponentSchema.visibility,
// an ExpressionInput) — show-when-truthy, same semantics as `visibleOn`.
if (newSchema.visibility !== undefined) {
return !evaluator.evaluateCondition(newSchema.visibility);
}
Expand Down Expand Up @@ -382,6 +389,7 @@ export const SchemaRenderer = forwardRef<any, { schema: SchemaNode } & Record<st
body: _body,
schema: _schema,
visible: _visible,
visibleWhen: _visibleWhen,
visibleOn: _visibleOn,
visibility: _visibility,
hidden: _hidden,
Expand Down
31 changes: 31 additions & 0 deletions packages/react/src/__tests__/SchemaRenderer.expressions.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,37 @@ describe('SchemaRenderer Expression Integration', () => {
});
});

// ADR-0089 — `visibleWhen` is the canonical conditional-visibility predicate;
// `visibleOn` / `visibility` remain as deprecated aliases the renderer still reads.
describe('visibleWhen (ADR-0089 canonical)', () => {
it('shows when the visibleWhen predicate is truthy', () => {
render(
<SchemaRendererContext.Provider value={{ dataSource: { role: 'admin' } }}>
<SchemaRenderer schema={{ type: 'test-component', visibleWhen: '${data.role === "admin"}' }} />
</SchemaRendererContext.Provider>
);
expect(screen.getByTestId('test-component')).toBeInTheDocument();
});

it('hides when the visibleWhen predicate is falsy', () => {
const { container } = render(
<SchemaRendererContext.Provider value={{ dataSource: { role: 'viewer' } }}>
<SchemaRenderer schema={{ type: 'test-component', visibleWhen: '${data.role === "admin"}' }} />
</SchemaRendererContext.Provider>
);
expect(container.innerHTML).toBe('');
});

it('still honors the deprecated `visibility` alias', () => {
const { container } = render(
<SchemaRendererContext.Provider value={{ dataSource: { role: 'viewer' } }}>
<SchemaRenderer schema={{ type: 'test-component', visibility: '${data.role === "admin"}' }} />
</SchemaRendererContext.Provider>
);
expect(container.innerHTML).toBe('');
});
});

describe('disabled expressions', () => {
it('passes disabled=true when disabled expression is true', () => {
render(<SchemaRenderer schema={{ type: 'test-component', disabled: true }} />);
Expand Down
11 changes: 7 additions & 4 deletions packages/react/src/components/form/FormRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,14 @@ function evaluateFieldVisibility(
return false;
}

// Evaluate visibleOn expression
if (fieldConfig.visibleOn) {
// Evaluate the conditional-visibility predicate. `visibleWhen` is canonical
// (ADR-0089); `visibleOn` is the deprecated alias, kept as a fallback for raw
// / un-normalized metadata.
const visiblePredicate = fieldConfig.visibleWhen ?? fieldConfig.visibleOn;
if (visiblePredicate) {
try {
const evaluator = new ExpressionEvaluator({ data: formValues, form: formValues });
return evaluator.evaluateCondition(fieldConfig.visibleOn);
return evaluator.evaluateCondition(visiblePredicate);
} catch {
// On error, default to visible
return true;
Expand Down Expand Up @@ -189,7 +192,7 @@ const FormSectionRenderer: React.FC<FormSectionRendererProps> = ({
const hasConditionalFields = React.useMemo(() => {
return section.fields.some((field) => {
if (typeof field === 'string') return false;
return !!field.dependsOn || !!field.visibleOn;
return !!field.dependsOn || !!field.visibleWhen || !!field.visibleOn;
});
}, [section.fields]);

Expand Down
12 changes: 11 additions & 1 deletion packages/react/src/spec-bridge/__tests__/SpecBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -398,12 +398,22 @@ describe('SpecBridge', () => {
expect(comp.type).toBe('ui.button');
expect(comp.label).toBe('Save');
expect(comp.events).toEqual({ onClick: 'save' });
expect(comp.visibility).toBe('${canEdit}');
// ADR-0089: the deprecated `visibility` alias is folded into canonical `visibleWhen`.
expect(comp.visibleWhen).toBe('${canEdit}');
expect(comp.dataSource).toEqual({ provider: 'api', read: '/api/data' });
expect(comp.responsive).toEqual({ sm: 'hidden' });
expect(comp.aria).toEqual({ label: 'Save record' });
});

it('maps a canonical `visibleWhen` page-component predicate onto the node (ADR-0089)', () => {
const node = bridgePage(
{ regions: [{ components: [{ type: 'ui.button', id: 'b', visibleWhen: '${canEdit}' }] }] },
{},
);
const comp = (node.body as any[])[0].body[0];
expect(comp.visibleWhen).toBe('${canEdit}');
});

it('maps style and responsiveStyles onto the component node', () => {
// Regression: both fields were declared on the PageComponent input type
// but never copied onto the SchemaNode, so a page-authored ADR-0065
Expand Down
10 changes: 9 additions & 1 deletion packages/react/src/spec-bridge/bridges/form-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ interface FormField {
colSpan?: number;
widget?: string;
dependsOn?: string[];
/** Canonical conditional-visibility predicate (ADR-0089). */
visibleWhen?: string;
/** @deprecated ADR-0089 → `visibleWhen`. */
visibleOn?: string;
}

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

return mapped;
}
Expand Down
10 changes: 9 additions & 1 deletion packages/react/src/spec-bridge/bridges/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ interface PageComponent {
style?: Record<string, any>;
responsiveStyles?: Record<string, any>;
className?: string;
/** Canonical conditional-visibility predicate (ADR-0089). */
visibleWhen?: string;
/** @deprecated ADR-0089 → `visibleWhen`. */
visibility?: string;
dataSource?: any;
responsive?: any;
Expand Down Expand Up @@ -83,7 +86,12 @@ function mapComponent(component: PageComponent): SchemaNode {
(node as any).properties = component.properties;
}
if (component.events) node.events = component.events;
if (component.visibility) node.visibility = component.visibility;
// ADR-0089: `visibleWhen` is the canonical conditional-visibility predicate;
// the spec folds the deprecated `visibility` alias into it at parse, so prefer
// it and fall back to `visibility` for raw / un-normalized metadata. SchemaRenderer
// reads `visibleWhen` first (show-when-truthy).
const visiblePredicate = component.visibleWhen ?? component.visibility;
if (visiblePredicate) node.visibleWhen = visiblePredicate;
if (component.dataSource) node.dataSource = component.dataSource;
if (component.responsive) node.responsive = component.responsive;
if (component.aria) node.aria = component.aria;
Expand Down
9 changes: 9 additions & 0 deletions packages/types/src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,18 @@ export interface BaseSchema {
*/
visible?: boolean;

/**
* Canonical conditional-visibility predicate (ADR-0089) — the element is shown
* when this evaluates truthy. The spec folds the deprecated `visibleOn` /
* `visibility` aliases into this key at parse.
* @example "${data.role === 'admin'}"
*/
visibleWhen?: string;

/**
* Expression for conditional visibility.
* Evaluated against the current data context.
* @deprecated ADR-0089 — use `visibleWhen`.
* @example "${data.role === 'admin'}"
*/
visibleOn?: string;
Expand Down
9 changes: 8 additions & 1 deletion packages/types/src/zod/base.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,17 @@ const BaseSchemaCore = z.object({
*/
visible: z.boolean().optional().describe('Visibility control'),

/**
* Canonical conditional-visibility predicate (ADR-0089) — shown when truthy.
* The spec folds the deprecated `visibleOn` / `visibility` aliases into this.
*/
visibleWhen: z.string().optional().describe('Canonical conditional-visibility predicate (ADR-0089)'),

/**
* Conditional visibility expression
* @deprecated ADR-0089 — use `visibleWhen`.
*/
visibleOn: z.string().optional().describe('Expression for conditional visibility'),
visibleOn: z.string().optional().describe('[DEPRECATED → visibleWhen] Expression for conditional visibility'),

/**
* Hidden control
Expand Down
Loading