From dff2014c6b3d3d64bef893086a19c55ba11b8e90 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 13:47:23 +0000 Subject: [PATCH] =?UTF-8?q?feat(components):=20page:tabs=20honors=20item-l?= =?UTF-8?q?evel=20visibleWhen=20=E2=80=94=20conditional=20tabs=20(framewor?= =?UTF-8?q?k#2606)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filters page:tabs items by their visibleWhen CEL predicate: FALSE removes the whole tab (header + panel), not just the panel content. Evaluation binds the same environment as page-component visibleWhen (record fields bare and via record./data., user/current_user, page.) 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; the user's own selection is restored if the tab reappears. Canonical ADR-0089 key only — the deprecated visibility/visibleOn aliases are not read on this new surface. Items without visibleWhen behave as before. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FmuuXkuW3JH9LYXHHRnoQV --- .changeset/page-tabs-item-visible-when.md | 16 ++ .../__tests__/page-tabs-visibility.test.tsx | 173 ++++++++++++++++++ .../src/renderers/layout/containers.tsx | 68 ++++++- 3 files changed, 253 insertions(+), 4 deletions(-) create mode 100644 .changeset/page-tabs-item-visible-when.md create mode 100644 packages/components/src/__tests__/page-tabs-visibility.test.tsx diff --git a/.changeset/page-tabs-item-visible-when.md b/.changeset/page-tabs-item-visible-when.md new file mode 100644 index 000000000..71f94aab9 --- /dev/null +++ b/.changeset/page-tabs-item-visible-when.md @@ -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.`) 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. diff --git a/packages/components/src/__tests__/page-tabs-visibility.test.tsx b/packages/components/src/__tests__/page-tabs-visibility.test.tsx new file mode 100644 index 000000000..26b257a68 --- /dev/null +++ b/packages/components/src/__tests__/page-tabs-visibility.test.tsx @@ -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.`), + * 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 ( + + ); +} + +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( + , + ); + 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( + , + ); + 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( + + + + , + ); + 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( + + + , + ); + 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( + + + + , + ); + + // 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( + , + ); + expect(getByText('Contracts')).toBeTruthy(); + }); +}); diff --git a/packages/components/src/renderers/layout/containers.tsx b/packages/components/src/renderers/layout/containers.tsx index c4a1ec21a..465e66d27 100644 --- a/packages/components/src/renderers/layout/containers.tsx +++ b/packages/components/src/renderers/layout/containers.tsx @@ -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'; @@ -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[]; } @@ -305,7 +313,7 @@ const collectRelatedLists = (nodes: any, acc: any[] = []): any[] => { const PageTabsRenderer: React.FC = ({ 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'; @@ -325,6 +333,43 @@ const PageTabsRenderer: React.FC = ({ 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.` — 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 @@ -421,6 +466,18 @@ const PageTabsRenderer: React.FC = ({ 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(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', @@ -445,8 +502,11 @@ const PageTabsRenderer: React.FC = ({ schema, className, ...props }) => { return ( { + setSelectedValue(v); + onTabChange?.(v); + }} orientation={isVertical ? 'vertical' : 'horizontal'} className={cn(className, isVertical && 'flex gap-4 w-full')} {...designer}