Skip to content

Commit c64ee8c

Browse files
authored
feat(spec): conditional tabs — page:tabs items accept a visibleWhen CEL predicate (#2606) (#2967)
PageTabsProps.items[] gains an optional visibleWhen CEL predicate. When it evaluates FALSE the whole tab (header + panel) is omitted, unlike a child component's own visibleWhen which hides only the panel content and leaves an empty tab header. Binds the same environment as page-component visibleWhen: record + current_user + page.<var>. Per ADR-0089 the key uses the canonical *When name from day one; the deprecated visibility/visibleOn aliases are not accepted on this new surface (no legacy metadata to alias for). Additive, back-compatible → minor. The expression-surface conformance ledger (ADR-0058/0060) classifies the new surface under cel-ui. Renderer landed in objectui#2516. Closes #2606. Refs #2579, ADR-0089.
1 parent ddc2bad commit c64ee8c

4 files changed

Lines changed: 67 additions & 0 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
"@objectstack/spec": minor
3+
---
4+
5+
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.
6+
7+
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).
8+
9+
Additive and back-compatible: items without `visibleWhen` behave exactly as before.

packages/dogfood/test/expression-conformance.ledger.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,11 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [
125125
'ui/view.zod.ts:visibleWhen',
126126
'ui/view.zod.ts:visibleOn',
127127
'ui/component.zod.ts:onSubmit',
128+
// Conditional tabs (framework#2606): `page:tabs` item-level visibility.
129+
// Canonical ADR-0089 `visibleWhen` from day one (no deprecated alias on
130+
// this new surface). Interpreted by the objectui page:tabs renderer to
131+
// omit the whole tab (header + panel) when FALSE.
132+
'ui/component.zod.ts:visibleWhen',
128133
'system/settings-manifest.zod.ts:visible',
129134
],
130135
},

packages/spec/src/ui/component.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,46 @@ describe('PageTabsProps', () => {
7575
it('should reject tabs without items', () => {
7676
expect(() => PageTabsProps.parse({})).toThrow();
7777
});
78+
79+
// Conditional tabs (#2606) — item-level `visibleWhen` (ADR-0089 canonical name).
80+
it('should accept an item-level visibleWhen predicate (bare CEL string → envelope)', () => {
81+
const result = PageTabsProps.parse({
82+
items: [
83+
{ label: 'Contracts', visibleWhen: 'record.status == "customer"', children: [] },
84+
{ label: 'Details', children: [] },
85+
],
86+
});
87+
expect(result.items[0].visibleWhen).toEqual({
88+
dialect: 'cel',
89+
source: 'record.status == "customer"',
90+
});
91+
// Items without the predicate are untouched — additive, back-compatible.
92+
expect(result.items[1].visibleWhen).toBeUndefined();
93+
});
94+
95+
it('should accept an item-level visibleWhen Expression envelope', () => {
96+
const result = PageTabsProps.parse({
97+
items: [
98+
{
99+
label: 'Contracts',
100+
visibleWhen: { dialect: 'cel', source: "page.mode != ''" },
101+
children: [],
102+
},
103+
],
104+
});
105+
expect(result.items[0].visibleWhen).toEqual({ dialect: 'cel', source: "page.mode != ''" });
106+
});
107+
108+
it('does NOT accept the deprecated `visibility` alias on tab items (new surface, canonical key only)', () => {
109+
// ADR-0089 D2 aliases exist for keys with legacy metadata; tab items never
110+
// had a visibility key, so only canonical `visibleWhen` is declared. The
111+
// alias is not folded — it is dropped by parse like any unknown key.
112+
const result = PageTabsProps.parse({
113+
items: [{ label: 'Contracts', visibility: 'record.status == "customer"', children: [] }],
114+
});
115+
expect(result.items[0].visibleWhen).toBeUndefined();
116+
expect('visibility' in result.items[0]).toBe(false);
117+
});
78118
});
79119

80120
describe('PageCardProps', () => {

packages/spec/src/ui/component.zod.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,19 @@ export const PageTabsProps = z.object({
3535
items: z.array(z.object({
3636
label: I18nLabelSchema,
3737
icon: z.string().optional(),
38+
/**
39+
* Conditional tab (CEL, #2606): when the predicate evaluates FALSE the
40+
* whole tab — header *and* panel — is omitted from the strip. This is the
41+
* item-level complement to a child component's own `visibleWhen`, which
42+
* hides only the panel content and would leave an empty tab header behind.
43+
* Binds the same environment as page-component `visibleWhen`: `record` +
44+
* `current_user`, plus page state as `page.<var>` (re-evaluated live).
45+
* Canonical `*When` name per ADR-0089 — this key is new, so the deprecated
46+
* `visibility` / `visibleOn` aliases are NOT accepted on tab items.
47+
*/
48+
visibleWhen: ExpressionInputSchema.optional().describe(
49+
'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).',
50+
),
3851
children: z.array(z.unknown()).describe('Child components')
3952
})),
4053
/** ARIA accessibility */

0 commit comments

Comments
 (0)