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
9 changes: 9 additions & 0 deletions .changeset/page-tabs-item-visible-when.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@objectstack/spec": minor
---

Conditional tabs (#2606): `page:tabs` items accept an optional `visibleWhen` CEL predicate. When it evaluates FALSE the whole tab — header **and** panel — is omitted from the tab strip, unlike a child component's own `visibleWhen`, which hides only the panel content and leaves an empty tab header behind. The predicate binds the same environment as page-component `visibleWhen` (`record` + `current_user`, plus page state as `page.<var>`) and is re-evaluated live when page variables change.

Per ADR-0089 the key uses the canonical `*When` name from day one — the deprecated `visibility` / `visibleOn` aliases are **not** accepted on tab items (this surface is new; there is no legacy metadata to alias for).

Additive and back-compatible: items without `visibleWhen` behave exactly as before.
5 changes: 5 additions & 0 deletions packages/dogfood/test/expression-conformance.ledger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [
'ui/view.zod.ts:visibleWhen',
'ui/view.zod.ts:visibleOn',
'ui/component.zod.ts:onSubmit',
// Conditional tabs (framework#2606): `page:tabs` item-level visibility.
// Canonical ADR-0089 `visibleWhen` from day one (no deprecated alias on
// this new surface). Interpreted by the objectui page:tabs renderer to
// omit the whole tab (header + panel) when FALSE.
'ui/component.zod.ts:visibleWhen',
'system/settings-manifest.zod.ts:visible',
],
},
Expand Down
40 changes: 40 additions & 0 deletions packages/spec/src/ui/component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,46 @@ describe('PageTabsProps', () => {
it('should reject tabs without items', () => {
expect(() => PageTabsProps.parse({})).toThrow();
});

// Conditional tabs (#2606) — item-level `visibleWhen` (ADR-0089 canonical name).
it('should accept an item-level visibleWhen predicate (bare CEL string → envelope)', () => {
const result = PageTabsProps.parse({
items: [
{ label: 'Contracts', visibleWhen: 'record.status == "customer"', children: [] },
{ label: 'Details', children: [] },
],
});
expect(result.items[0].visibleWhen).toEqual({
dialect: 'cel',
source: 'record.status == "customer"',
});
// Items without the predicate are untouched — additive, back-compatible.
expect(result.items[1].visibleWhen).toBeUndefined();
});

it('should accept an item-level visibleWhen Expression envelope', () => {
const result = PageTabsProps.parse({
items: [
{
label: 'Contracts',
visibleWhen: { dialect: 'cel', source: "page.mode != ''" },
children: [],
},
],
});
expect(result.items[0].visibleWhen).toEqual({ dialect: 'cel', source: "page.mode != ''" });
});

it('does NOT accept the deprecated `visibility` alias on tab items (new surface, canonical key only)', () => {
// ADR-0089 D2 aliases exist for keys with legacy metadata; tab items never
// had a visibility key, so only canonical `visibleWhen` is declared. The
// alias is not folded — it is dropped by parse like any unknown key.
const result = PageTabsProps.parse({
items: [{ label: 'Contracts', visibility: 'record.status == "customer"', children: [] }],
});
expect(result.items[0].visibleWhen).toBeUndefined();
expect('visibility' in result.items[0]).toBe(false);
});
});

describe('PageCardProps', () => {
Expand Down
13 changes: 13 additions & 0 deletions packages/spec/src/ui/component.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,19 @@ export const PageTabsProps = z.object({
items: z.array(z.object({
label: I18nLabelSchema,
icon: z.string().optional(),
/**
* Conditional tab (CEL, #2606): when the predicate evaluates FALSE the
* whole tab — header *and* panel — is omitted from the strip. This is the
* item-level complement to a child component's own `visibleWhen`, which
* hides only the panel content and would leave an empty tab header behind.
* Binds the same environment as page-component `visibleWhen`: `record` +
* `current_user`, plus page state as `page.<var>` (re-evaluated live).
* Canonical `*When` name per ADR-0089 — this key is new, so the deprecated
* `visibility` / `visibleOn` aliases are NOT accepted on tab items.
*/
visibleWhen: ExpressionInputSchema.optional().describe(
'Visibility predicate (CEL) — the whole tab (header + panel) is omitted when FALSE; the renderer falls back to the first visible tab when the active one is hidden. Binds `record`, `current_user`, `page.<var>`. ADR-0089 canonical name (`visibility`/`visibleOn` aliases are not accepted here).',
),
children: z.array(z.unknown()).describe('Child components')
})),
/** ARIA accessibility */
Expand Down