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

Conditional tabs (framework#2606): the `page:tabs` renderer honors an item-level
`visibleWhen` CEL predicate — when it evaluates FALSE the WHOLE tab (header +
panel) is omitted from the 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
fields bare and via `record.`/`data.`, `user`/`current_user`, and page state as
`page.<var>`) and re-evaluates live as page variables change. The strip is now
controlled: when the ACTIVE tab's predicate flips false, selection falls back to
the first visible tab instead of leaving a blank panel, and the user's own
selection is restored if the tab becomes visible again. Canonical ADR-0089 key
only — the deprecated `visibility`/`visibleOn` aliases are not read on this new
surface. Items without `visibleWhen` behave exactly as before.
173 changes: 173 additions & 0 deletions packages/components/src/__tests__/page-tabs-visibility.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* Conditional tabs (framework#2606): a `page:tabs` item's `visibleWhen` CEL
* predicate removes the WHOLE tab (header + panel) when FALSE — unlike a
* child component's own `visibleWhen`, which hides only the panel content and
* leaves an empty tab header behind. Proves:
* 1. A statically-false predicate omits the tab header entirely.
* 2. Predicates re-evaluate LIVE against page variables (`page.<var>`),
* both as bare CEL strings and as the `{ dialect, source }` Expression
* envelope the spec emits at parse.
* 3. When the ACTIVE tab is hidden, the strip falls back to the first
* visible tab — no blank panel.
* 4. Items without `visibleWhen` are untouched (back-compat), and only the
* canonical ADR-0089 key is read — the deprecated `visibility` alias is
* NOT honored on this new surface.
*/

import { describe, it, expect, beforeAll } from 'vitest';
import { render, act, fireEvent } from '@testing-library/react';
import React from 'react';
import { SchemaRenderer, PageVariablesProvider, usePageVariables } from '@object-ui/react';

beforeAll(async () => {
await import('../renderers');
}, 30000);

/** Writer that flips a page variable on click — stands in for an interactive element. */
function Writer({ name, value }: { name: string; value: any }) {
const { setVariable } = usePageVariables();
return (
<button type="button" onClick={() => setVariable(name, value)}>
write
</button>
);
}

const tabsSchema = (items: any[]) => ({ type: 'page:tabs', id: 'tabs', items });

const textChild = (content: string) => [
{ type: 'element:text', properties: { content } },
];

describe('page:tabs item visibleWhen (framework#2606)', () => {
it('omits the whole tab (header) when the predicate is statically false', () => {
const { queryByText, getByText } = render(
<SchemaRenderer
schema={tabsSchema([
{ label: 'Details', value: 'details', children: textChild('DETAILS BODY') },
{ label: 'Related', value: 'related', children: [] },
{ label: 'Contracts', value: 'contracts', visibleWhen: '1 == 2', children: [] },
])}
/>,
);
expect(getByText('Details')).toBeTruthy();
expect(getByText('Related')).toBeTruthy();
expect(queryByText('Contracts')).toBeNull(); // header gone, not just the panel
expect(getByText('DETAILS BODY')).toBeTruthy(); // first tab still active
});

it('keeps items without visibleWhen untouched (back-compat)', () => {
const { getByText } = render(
<SchemaRenderer
schema={tabsSchema([
{ label: 'Details', value: 'details', children: [] },
{ label: 'Related', value: 'related', children: [] },
])}
/>,
);
expect(getByText('Details')).toBeTruthy();
expect(getByText('Related')).toBeTruthy();
});

it('re-evaluates LIVE against page variables — a hidden tab appears when its predicate flips true', () => {
const { queryByText, getByText } = render(
<PageVariablesProvider definitions={[{ name: 'mode', type: 'string', source: 'w' }]}>
<Writer name="mode" value="customer" />
<SchemaRenderer
schema={tabsSchema([
{ label: 'Details', value: 'details', children: [] },
{ label: 'Related', value: 'related', children: [] },
{
label: 'Contracts',
value: 'contracts',
visibleWhen: "page.mode == 'customer'",
children: [],
},
])}
/>
</PageVariablesProvider>,
);
expect(queryByText('Contracts')).toBeNull();
act(() => {
fireEvent.click(getByText('write'));
});
expect(getByText('Contracts')).toBeTruthy();
});

it('accepts the { dialect, source } Expression envelope the spec emits at parse', () => {
const { queryByText, getByText } = render(
<PageVariablesProvider definitions={[{ name: 'mode', type: 'string' }]}>
<SchemaRenderer
schema={tabsSchema([
{ label: 'Details', value: 'details', children: [] },
{ label: 'Related', value: 'related', children: [] },
{
label: 'Contracts',
value: 'contracts',
visibleWhen: { dialect: 'cel', source: "page.mode == 'customer'" },
children: [],
},
])}
/>
</PageVariablesProvider>,
);
expect(getByText('Details')).toBeTruthy();
expect(queryByText('Contracts')).toBeNull();
});

it('falls back to the first visible tab when the ACTIVE tab is hidden — no blank panel', () => {
const { getByText, queryByText } = render(
<PageVariablesProvider definitions={[{ name: 'stage', type: 'string', source: 'w' }]}>
<Writer name="stage" value="closed" />
<SchemaRenderer
schema={tabsSchema([
{ label: 'Details', value: 'details', children: textChild('DETAILS BODY') },
{
label: 'Contracts',
value: 'contracts',
visibleWhen: "page.stage != 'closed'",
children: textChild('CONTRACTS BODY'),
},
{ label: 'Related', value: 'related', children: [] },
])}
/>
</PageVariablesProvider>,
);

// Activate the conditional tab while it is visible (Radix triggers
// activate on mousedown, not click).
act(() => {
fireEvent.mouseDown(getByText('Contracts'), { button: 0 });
});
expect(getByText('CONTRACTS BODY')).toBeTruthy();

// Flip its predicate false — the whole tab vanishes and the strip falls
// back to the first visible tab instead of leaving a blank panel.
act(() => {
fireEvent.click(getByText('write'));
});
expect(queryByText('Contracts')).toBeNull();
expect(queryByText('CONTRACTS BODY')).toBeNull();
expect(getByText('DETAILS BODY')).toBeTruthy();
});

it('does NOT honor the deprecated `visibility` alias on tab items (canonical key only, ADR-0089)', () => {
const { getByText } = render(
<SchemaRenderer
schema={tabsSchema([
{ label: 'Details', value: 'details', children: [] },
// Alias is ignored → the tab stays visible (the spec strips the key
// at parse anyway; the renderer must not resurrect it).
{ label: 'Contracts', value: 'contracts', visibility: '1 == 2', children: [] },
])}
/>,
);
expect(getByText('Contracts')).toBeTruthy();
});
});
68 changes: 64 additions & 4 deletions packages/components/src/renderers/layout/containers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

import React from 'react';
import { ComponentRegistry, ExpressionEvaluator, getRecordDisplayName } from '@object-ui/core';
import { useRecordContext, useAction, usePredicateScope } from '@object-ui/react';
import { useRecordContext, useAction, usePredicateScope, usePageVariables } from '@object-ui/react';
import { renderChildren, cn } from '../../lib/utils';
import { LazyIcon } from '../../lib/lazy-icon';
import { RelatedCountStore, useRelatedCountVersion } from '../../hooks/related-count-store';
Expand Down Expand Up @@ -257,6 +257,14 @@ interface PageTabsItem {
* Numbers >= 1000 are shortened to `1.2k`-style.
*/
count?: number | string;
/**
* Conditional tab (framework#2606): CEL predicate — when it evaluates FALSE
* the whole tab (header + panel) is omitted from the strip. Canonical
* ADR-0089 key only; the deprecated `visibility`/`visibleOn` aliases are
* not read on this surface. Accepts a bare CEL string or the normalized
* `{ dialect, source }` Expression envelope the spec emits at parse.
*/
visibleWhen?: string | boolean | { dialect?: string; source?: string };
children: any[];
}

Expand Down Expand Up @@ -305,7 +313,7 @@ const collectRelatedLists = (nodes: any, acc: any[] = []): any[] => {
const PageTabsRenderer: React.FC<any> = ({ schema, className, ...props }) => {
const { designer } = splitDesignerProps(props);
const { language } = useObjectTranslation();
const items: PageTabsItem[] = schema?.items || [];
const rawItems: PageTabsItem[] = schema?.items || [];
// Tab visual style lives at `properties.type` ('line'|'card'|'pill') — the
// outer `schema.type` is always 'page:tabs' (the component dispatch key).
const type: 'line' | 'card' | 'pill' = schema?.properties?.type || schema?.tabStyle || 'line';
Expand All @@ -325,6 +333,43 @@ const PageTabsRenderer: React.FC<any> = ({ schema, className, ...props }) => {
const ctx = useRecordContext();
const parentId = ctx?.data?.id;
const ds: any = ctx?.dataSource;

// Conditional tabs (framework#2606): an item-level `visibleWhen` CEL
// predicate removes the ENTIRE tab (header + panel) when FALSE — unlike a
// child component's own `visibleWhen`, which hides only the panel content
// and leaves an empty tab header behind. Same binding environment as
// page-component `visibleWhen`: record fields (bare and via `record.` /
// `data.`), `user`/`current_user`, and page state as `page.<var>` — so tabs
// appear/disappear live as page variables change. Canonical key only
// (ADR-0089): the deprecated `visibility`/`visibleOn` aliases are not read
// on this new surface.
const predicateScope = usePredicateScope();
const { variables: pageVariables } = usePageVariables();
const recordData: any = ctx?.data;
const isItemVisible = (it: PageTabsItem): boolean => {
if (it?.visibleWhen === undefined || it?.visibleWhen === null) return true;
const evaluator = new ExpressionEvaluator({
...(recordData && typeof recordData === 'object' ? recordData : {}),
...predicateScope,
current_user: (predicateScope as any)?.user,
record: recordData,
data: recordData,
page: pageVariables,
});
// evaluateCondition is fail-open (unparseable predicate → visible) — the
// same semantics SchemaRenderer applies to component-level `visibleWhen`.
return evaluator.evaluateCondition(it.visibleWhen);
};
const visibleFlags = rawItems.map(isItemVisible);
// Keep the filtered array's identity stable while visibility is unchanged
// (usePageVariables returns a fresh object per render outside a Page), so
// the count-probe memo/effect below don't re-walk on unrelated renders.
const visibleKey = visibleFlags.join(',');
const items = React.useMemo(
() => rawItems.filter((_, i) => visibleFlags[i]),
// eslint-disable-next-line react-hooks/exhaustive-deps
[rawItems, visibleKey],
);
// Subscribe to the store version so badges re-render on invalidation /
// remote count updates, and RE-PROBE freshly-invalidated keys (#2269): the
// version is a dep of the probe effect below, so an invalidation (deleted
Expand Down Expand Up @@ -421,6 +466,18 @@ const PageTabsRenderer: React.FC<any> = ({ schema, className, ...props }) => {
// Host callback on tab switch (app-shell writes `?tab=` with replace).
const onTabChange: ((value: string) => void) | undefined = (schema as any)?.onTabChange;

// Controlled active tab so a CONDITIONAL tab can vanish under the user
// (framework#2606): when the active tab's `visibleWhen` flips FALSE (a page
// variable or record change), fall back to the first visible tab instead of
// leaving Radix pointing at a value with no trigger/panel — a blank content
// area. The user's own selection is kept whenever it is still visible, and
// restored semantics for `?tab=` (`defaultTab`) are unchanged.
const [selectedValue, setSelectedValue] = React.useState<string | undefined>(defaultValue);
const activeValue =
(selectedValue && itemsWithValue.some((it) => it.value === selectedValue)
? selectedValue
: undefined) ?? itemsWithValue[0]?.value ?? '';

const listClass = cn(
isVertical && 'flex-col h-auto items-stretch p-1',
type === 'card' && 'bg-transparent gap-1',
Expand All @@ -445,8 +502,11 @@ const PageTabsRenderer: React.FC<any> = ({ schema, className, ...props }) => {

return (
<Tabs
defaultValue={defaultValue}
onValueChange={onTabChange}
value={activeValue}
onValueChange={(v) => {
setSelectedValue(v);
onTabChange?.(v);
}}
orientation={isVertical ? 'vertical' : 'horizontal'}
className={cn(className, isVertical && 'flex gap-4 w-full')}
{...designer}
Expand Down
Loading