diff --git a/.changeset/12450-data-pillar-layout.md b/.changeset/12450-data-pillar-layout.md new file mode 100644 index 0000000000..c53e1f9e3d --- /dev/null +++ b/.changeset/12450-data-pillar-layout.md @@ -0,0 +1,11 @@ +--- +'@object-ui/app-shell': minor +'@object-ui/plugin-list': patch +--- + +Studio Data pillar layout (objectui#12450, epic objectui#12446). + +- **Seven flat tabs.** Records, Form, Validations, Hooks, Actions, API and Settings sit in one underline tab row when the content column is 1000px or wider. Below that, the last five fold back into "Advanced ▾", whose trigger keeps its own name whichever panel is open. A container query on the content column decides the fold, so it also responds when the field inspector opens beside the column. +- **Object header buttons.** The object header gains an Object settings button, which opens the Settings panel, and a More button. Add field moves from the header row into the More menu. It is also still in the grid's "+" column header and in the form designer. +- **Records toolbar and grid.** The Records tab drops the framed card and the body padding around the list, so the toolbar, grid and footer run flush with the column. Its toolbar is the list's wide form: Hide fields, Filter, Group and Sort as labelled buttons, then Refresh, Search and Add record. There is no density control. +- **List search button name.** The list toolbar's icon-only Search button now has an `aria-label`, like Refresh. When a keyword is active, its accessible name is "Search: KEYWORD" instead of the bare keyword. diff --git a/docs/design/studio-redesign/after/12450-data-records-dark.png b/docs/design/studio-redesign/after/12450-data-records-dark.png new file mode 100644 index 0000000000..d6c9dc2a67 Binary files /dev/null and b/docs/design/studio-redesign/after/12450-data-records-dark.png differ diff --git a/docs/design/studio-redesign/after/12450-data-records-folded-light.png b/docs/design/studio-redesign/after/12450-data-records-folded-light.png new file mode 100644 index 0000000000..c4bde8b1fb Binary files /dev/null and b/docs/design/studio-redesign/after/12450-data-records-folded-light.png differ diff --git a/docs/design/studio-redesign/after/12450-data-records-light.png b/docs/design/studio-redesign/after/12450-data-records-light.png new file mode 100644 index 0000000000..a4f29f2a75 Binary files /dev/null and b/docs/design/studio-redesign/after/12450-data-records-light.png differ diff --git a/packages/app-shell/src/views/metadata-admin/i18n.ts b/packages/app-shell/src/views/metadata-admin/i18n.ts index 586378f59d..e13e2d076b 100644 --- a/packages/app-shell/src/views/metadata-admin/i18n.ts +++ b/packages/app-shell/src/views/metadata-admin/i18n.ts @@ -3394,6 +3394,8 @@ const ENGINE_STRINGS_EN: Record = { 'engine.studio.hooks.newLabel': 'New hook', 'engine.studio.data.addFieldTitle': 'Add a field (then set its type and properties on the right)', 'engine.studio.data.addField': 'Add field', + 'engine.studio.data.objectSettings': 'Object settings', + 'engine.studio.data.moreActions': 'More actions', 'engine.studio.data.editFieldProps': 'Edit field properties', 'engine.studio.data.draftObjectTitle': 'Unpublished new object', 'engine.studio.data.draftObjectHint': @@ -6546,6 +6548,8 @@ const ENGINE_STRINGS_ZH: Record = { 'engine.studio.hooks.newLabel': '新钩子', 'engine.studio.data.addFieldTitle': '添加一个字段(随后在右侧设置类型与属性)', 'engine.studio.data.addField': '添加字段', + 'engine.studio.data.objectSettings': '对象设置', + 'engine.studio.data.moreActions': '更多操作', 'engine.studio.data.editFieldProps': '编辑字段属性', 'engine.studio.data.draftObjectTitle': '未发布的新对象', 'engine.studio.data.draftObjectHint': diff --git a/packages/app-shell/src/views/studio-design/DataPillar.authorRefusal-11785.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.authorRefusal-11785.test.tsx index 2520d2190e..5ce06e0649 100644 --- a/packages/app-shell/src/views/studio-design/DataPillar.authorRefusal-11785.test.tsx +++ b/packages/app-shell/src/views/studio-design/DataPillar.authorRefusal-11785.test.tsx @@ -75,6 +75,7 @@ vi.mock('@object-ui/react', async (importOriginal) => { }); import { DataPillar } from './StudioDesignSurface'; +import { addFieldFromMoreMenu } from './__tests__/dataPillarAddField'; import { createEmptyDataSource } from './__tests__/emptyDataSource'; import { registerBuiltinInspectors } from '../metadata-admin/inspectors'; import { t, tFormat } from '../metadata-admin/i18n'; @@ -102,7 +103,7 @@ describe('Studio data page — a held Picklist reads as a sentence (objectui#117 , ); fireEvent.click(await screen.findByRole('button', { name: 'Form' })); - fireEvent.click(await screen.findByTitle(/^Add a field/)); + await addFieldFromMoreMenu(); await screen.findByText('Type', { selector: 'label' }, { timeout: 4000 }); const apiName = (controlUnder('API name') as HTMLInputElement).value; const label = (controlUnder('Label') as HTMLInputElement).value; @@ -138,7 +139,7 @@ describe('Studio data page — a held Picklist reads as a sentence (objectui#117 , ); fireEvent.click(await screen.findByRole('button', { name: 'Form' })); - fireEvent.click(await screen.findByTitle(/^Add a field/)); + await addFieldFromMoreMenu(); await screen.findByText('Type', { selector: 'label' }, { timeout: 4000 }); const apiName = (controlUnder('API name') as HTMLInputElement).value; await userEvent.click(controlUnder('Type')); diff --git a/packages/app-shell/src/views/studio-design/DataPillar.choiceWithoutOptions-11253.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.choiceWithoutOptions-11253.test.tsx index d5ff9977b7..a0b43696b6 100644 --- a/packages/app-shell/src/views/studio-design/DataPillar.choiceWithoutOptions-11253.test.tsx +++ b/packages/app-shell/src/views/studio-design/DataPillar.choiceWithoutOptions-11253.test.tsx @@ -93,6 +93,7 @@ vi.mock('@object-ui/react', async (importOriginal) => { }); import { DataPillar } from './StudioDesignSurface'; +import { addFieldFromMoreMenu } from './__tests__/dataPillarAddField'; import { createEmptyDataSource } from './__tests__/emptyDataSource'; import { registerBuiltinInspectors } from '../metadata-admin/inspectors'; import { tFormat } from '../metadata-admin/i18n'; @@ -159,7 +160,7 @@ async function addFieldAndType(typeLabel: RegExp): Promise { , ); fireEvent.click(await screen.findByRole('button', { name: 'Form' })); - fireEvent.click(await screen.findByTitle(/^Add a field/)); + await addFieldFromMoreMenu(); await screen.findByText('Type', { selector: 'label' }, { timeout: 4000 }); const fieldName = (controlUnder('API name') as HTMLInputElement).value; expect(fieldName, 'the new field has no API name: the harness is dead').not.toBe(''); diff --git a/packages/app-shell/src/views/studio-design/DataPillar.designerRegistryPopulated.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.designerRegistryPopulated.test.tsx index 0d5450d705..6be440272b 100644 --- a/packages/app-shell/src/views/studio-design/DataPillar.designerRegistryPopulated.test.tsx +++ b/packages/app-shell/src/views/studio-design/DataPillar.designerRegistryPopulated.test.tsx @@ -189,7 +189,9 @@ describe('Data pillar consumers with the registries POPULATED (#6846 contrast)', assertRegistriesPopulated(); renderPillar(); - fireEvent.click(await screen.findByRole('button', { name: 'Actions' })); + // objectui#12450 — the flat tab; the passthrough menu draws the folded + // entry as a second button of the same name. + fireEvent.click(await screen.findByTestId('data-tab-flat-actions')); // `ActionDefaultInspector`'s Name control, carrying the action's identifier. expect(await screen.findByDisplayValue('send_email')).toBeInTheDocument(); expectNoEmptyStateSentence(); @@ -199,7 +201,9 @@ describe('Data pillar consumers with the registries POPULATED (#6846 contrast)', assertRegistriesPopulated(); renderPillar(); - fireEvent.click(await screen.findByRole('button', { name: 'Hooks' })); + // objectui#12450 — the flat tab; the passthrough menu draws the folded + // entry as a second button of the same name. + fireEvent.click(await screen.findByTestId('data-tab-flat-hooks')); fireEvent.click(await screen.findByText('Guard')); // Controls only `HookDefaultInspector` renders… expect(await screen.findByTestId('hook-name')).toBeInTheDocument(); @@ -214,7 +218,9 @@ describe('Data pillar consumers with the registries POPULATED (#6846 contrast)', assertRegistriesPopulated(); renderPillar(); - fireEvent.click(await screen.findByRole('button', { name: 'Settings' })); + // objectui#12450 — the flat tab; the passthrough menu draws the folded + // entry as a second button of the same name. + fireEvent.click(await screen.findByTestId('data-tab-flat-settings')); // `ObjectDefaultInspector`'s Name control, carrying the object's name. expect(await screen.findByTestId('object-name-input')).toHaveValue('showcase_task'); expect(screen.getByTestId('object-access-posture')).toBeInTheDocument(); diff --git a/packages/app-shell/src/views/studio-design/DataPillar.flatTabs-12450.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.flatTabs-12450.test.tsx new file mode 100644 index 0000000000..9316815d72 --- /dev/null +++ b/packages/app-shell/src/views/studio-design/DataPillar.flatTabs-12450.test.tsx @@ -0,0 +1,212 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12450 — the Data pillar's flat tab row, and the object header's two + * icon buttons. + * + * Decision B of epic objectui#12446 (maintainer, 2026-10-15): seven flat tabs + * — Records, Form, Validations, Hooks, Actions, API, Settings — when the + * content column is 1000 px or wider; narrower, the last five fold back into + * "Advanced ▾". The folded form keeps its own pins in + * `DataPillar.advancedKeepsName-11794.test.tsx`; this file pins the flat form. + * + * What a DOM test can and cannot see: happy-dom evaluates no container query, + * so both forms are in the tree here, exactly as they are in a browser, and + * which one is DISPLAYED is decided by CSS this environment does not run. The + * pins therefore read what decides it — the content column is the query + * container, the five flat tabs carry the class that displays them at the + * fold width and above, the Advanced trigger carries the class that hides it + * there, and both classes name `DATA_TABS_FOLD_WIDTH` — and the browser + * screenshots on the pull request show the two widths rendered. + * + * The REAL `DataPillar`; the records grid and two panels are stubbed, because + * only which panel is open is under test, not what it draws. + */ + +import '@testing-library/jest-dom/vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, render, screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; + +const servedObject = { + name: 'showcase_account', + label: 'Account', + fields: { name: { type: 'text', label: 'Account Name' } }, +}; + +const mockClient = { + list: vi.fn(async () => [{ name: 'showcase_account', label: 'Account' }]), + listDrafts: vi.fn(async () => []), + layered: vi.fn(async () => ({ effective: servedObject, code: servedObject })), + getDraft: vi.fn(async () => null), + save: vi.fn(async () => ({ ok: true })), +}; + +vi.mock('../metadata-admin/useMetadata', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) }; +}); + +vi.mock('./packages-io', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, fetchPackages: vi.fn(async () => []) }; +}); + +vi.mock('@object-ui/react', async (importOriginal) => { + const mod = await importOriginal(); + return { ...mod, useAdapter: () => ({}) }; +}); + +vi.mock('@object-ui/plugin-view', async (importOriginal) => { + const mod = await importOriginal>(); + return { ...mod, ObjectView: () =>
}; +}); + +vi.mock('./ObjectValidationsPanel', () => ({ ObjectValidationsPanel: () =>
})); +vi.mock('./ObjectSettingsPanel', () => ({ ObjectSettingsPanel: () =>
})); + +import { DataPillar } from './StudioDesignSurface'; +import { SurfaceDeepLinkProvider } from './surfaceDeepLinkChannel'; +import { + DATA_CONTENT_CONTAINER, + DATA_TAB_FLAT_ONLY, + DATA_TAB_FOLDED_ONLY, + DATA_TABS_FOLD_WIDTH, +} from './dataTabsFold'; +import { t } from '../metadata-admin/i18n'; + +const FLAT_KEYS = ['rules', 'hooks', 'actions', 'api', 'settings'] as const; +const ROW_NAMES = ['records', 'form', ...FLAT_KEYS].map((k) => t(`engine.studio.data.tab.${k}`, 'en')); +const ADVANCED = t('engine.studio.data.tab.advanced', 'en'); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +async function renderPillar(props: { readOnly?: boolean } = {}) { + render( + + + + + , + ); + await waitFor(() => expect(screen.getByTestId('records-panel')).toBeInTheDocument(), { timeout: 8000 }); +} + +/** The header's tab row: the `nav` the item header draws its tabs into. */ +function tabRow(): HTMLElement { + const nav = screen.getByTestId('data-object-header').querySelector('nav'); + expect(nav, 'the object header draws no tab row').not.toBeNull(); + return nav as HTMLElement; +} + +/** Split a class attribute into its tokens. */ +function classesOf(el: Element): string[] { + return (el.getAttribute('class') ?? '').split(/\s+/).filter(Boolean); +} + +describe('the Data pillar’s seven flat tabs (objectui#12450)', () => { + it('both class strings name the fold width, so the rule and its constant cannot drift apart', () => { + const query = `@min-[${DATA_TABS_FOLD_WIDTH}px]:`; + expect(DATA_TABS_FOLD_WIDTH).toBe(1000); + expect(DATA_TAB_FLAT_ONLY.split(' ')).toEqual(['hidden', `${query}inline-flex`]); + expect(DATA_TAB_FOLDED_ONLY.split(' ')).toEqual([`${query}hidden`]); + }); + + it('the tab row holds the seven tabs in the artboard’s order, then the folded trigger', async () => { + await renderPillar(); + const row = tabRow(); + const buttons = within(row).getAllByRole('button'); + expect(buttons.map((b) => b.textContent)).toEqual([...ROW_NAMES, ADVANCED]); + expect(buttons.at(-1)).toBe(screen.getByTestId('data-tabs-advanced')); + }); + + it('the content column is the query container, and it holds the tab row it measures for', async () => { + await renderPillar(); + const column = screen.getByTestId('data-content-column'); + expect(classesOf(column)).toContain(DATA_CONTENT_CONTAINER); + expect(column).toContainElement(tabRow()); + }); + + it('the five panels’ flat tabs display only at the fold width and above; the trigger only below it', async () => { + await renderPillar(); + for (const key of FLAT_KEYS) { + const tab = screen.getByTestId(`data-tab-flat-${key}`); + for (const cls of DATA_TAB_FLAT_ONLY.split(' ')) expect(classesOf(tab)).toContain(cls); + // `cn` merges the tab's own `inline-flex` away: an unconditional + // display class left beside `hidden` would show the tab at every width. + expect(classesOf(tab)).not.toContain('inline-flex'); + } + const trigger = screen.getByTestId('data-tabs-advanced'); + expect(classesOf(trigger)).toContain(DATA_TAB_FOLDED_ONLY); + expect(classesOf(trigger)).not.toContain('hidden'); + }); + + it('CONTROL: Records and Form are displayed at every width', async () => { + await renderPillar(); + const row = tabRow(); + for (const name of ROW_NAMES.slice(0, 2)) { + const tab = within(row).getByRole('button', { name }); + expect(classesOf(tab)).toContain('inline-flex'); + expect(classesOf(tab)).not.toContain('hidden'); + expect(classesOf(tab).some((c) => c.startsWith('@'))).toBe(false); + } + }); + + it('a flat tab opens its panel and takes the active underline; the folded trigger mirrors it', async () => { + await renderPillar(); + const validations = screen.getByTestId('data-tab-flat-rules'); + expect(validations).toHaveAttribute('aria-pressed', 'false'); + + await userEvent.click(validations); + await waitFor(() => expect(screen.getByTestId('rules-panel')).toBeInTheDocument()); + expect(validations).toHaveAttribute('aria-pressed', 'true'); + // The two forms say the same thing: at the narrower width the trigger is + // the one on screen, and it shows a power panel is open. + expect(screen.getByTestId('data-tabs-advanced')).toHaveAttribute('aria-pressed', 'true'); + + await userEvent.click(screen.getByTestId('data-tab-flat-settings')); + await waitFor(() => expect(screen.getByTestId('settings-panel')).toBeInTheDocument()); + expect(screen.queryByTestId('rules-panel')).toBeNull(); + expect(validations).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByTestId('data-tab-flat-settings')).toHaveAttribute('aria-pressed', 'true'); + }); +}); + +describe('the object header’s icon buttons (objectui#12450)', () => { + const OBJECT_SETTINGS = t('engine.studio.data.objectSettings', 'en'); + const MORE_ACTIONS = t('engine.studio.data.moreActions', 'en'); + const ADD_FIELD = t('engine.studio.data.addField', 'en'); + + it('each is named by an aria-label; Object settings opens the Settings panel', async () => { + await renderPillar(); + const header = screen.getByTestId('data-object-header'); + const settings = within(header).getByRole('button', { name: OBJECT_SETTINGS }); + expect(settings).toHaveAttribute('aria-label', OBJECT_SETTINGS); + expect(within(header).getByRole('button', { name: MORE_ACTIONS })).toHaveAttribute('aria-label', MORE_ACTIONS); + + await userEvent.click(settings); + await waitFor(() => expect(screen.getByTestId('settings-panel')).toBeInTheDocument()); + expect(screen.getByTestId('data-tab-flat-settings')).toHaveAttribute('aria-pressed', 'true'); + }); + + it('More holds Add field, which adds a field and opens its inspector', async () => { + await renderPillar(); + // Add field left the header row: no labelled button for it there now. + const header = screen.getByTestId('data-object-header'); + expect(within(header).queryByRole('button', { name: ADD_FIELD })).toBeNull(); + + await userEvent.click(screen.getByTestId('data-object-more')); + await userEvent.click(await screen.findByRole('menuitem', { name: ADD_FIELD })); + expect(await screen.findByTestId('data-field-inspector', undefined, { timeout: 4000 })).toBeInTheDocument(); + }); + + it('CONTROL: a read-only package keeps Object settings and draws no More, whose one entry it does not offer', async () => { + await renderPillar({ readOnly: true }); + expect(screen.getByTestId('data-object-settings')).toBeInTheDocument(); + expect(screen.queryByTestId('data-object-more')).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/views/studio-design/DataPillar.gridProjection.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.gridProjection.test.tsx index fc26101682..18e69c5a92 100644 --- a/packages/app-shell/src/views/studio-design/DataPillar.gridProjection.test.tsx +++ b/packages/app-shell/src/views/studio-design/DataPillar.gridProjection.test.tsx @@ -26,7 +26,7 @@ import '@testing-library/jest-dom/vitest'; import * as React from 'react'; import { describe, it, expect, vi, afterEach } from 'vitest'; -import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; /** The object as the SERVER has it: two published, materialised fields. */ @@ -86,6 +86,7 @@ vi.mock('@object-ui/plugin-view', async (importOriginal) => { }); import { DataPillar } from './StudioDesignSurface'; +import { addFieldFromMoreMenu } from './__tests__/dataPillarAddField'; import { SurfaceDeepLinkProvider } from './surfaceDeepLinkChannel'; import { registerBuiltinInspectors } from '../metadata-admin/inspectors'; @@ -119,8 +120,7 @@ describe('DataPillar grid projection (cloud#1652)', () => { renderPillar(); await waitFor(() => expect(columnsNow()).toContain('book_name')); - const addField = screen.getByRole('button', { name: /添加|add field/i }); - fireEvent.click(addField); + await addFieldFromMoreMenu(); // The draft grew a `field_`; the projection must not have. await waitFor(() => expect(columnsNow()).toContain('book_name')); diff --git a/packages/app-shell/src/views/studio-design/DataPillar.heldIncomplete-11786.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.heldIncomplete-11786.test.tsx index 06a4a9fce2..7e600790cc 100644 --- a/packages/app-shell/src/views/studio-design/DataPillar.heldIncomplete-11786.test.tsx +++ b/packages/app-shell/src/views/studio-design/DataPillar.heldIncomplete-11786.test.tsx @@ -78,6 +78,7 @@ vi.mock('@object-ui/react', async (importOriginal) => { }); import { DataPillar } from './StudioDesignSurface'; +import { addFieldFromMoreMenu } from './__tests__/dataPillarAddField'; import { createEmptyDataSource } from './__tests__/emptyDataSource'; import { registerBuiltinInspectors } from '../metadata-admin/inspectors'; import { t, tFormat } from '../metadata-admin/i18n'; @@ -121,7 +122,7 @@ async function addFieldAs(typeOption: RegExp): Promise<{ name: string; label: st , ); fireEvent.click(await screen.findByRole('button', { name: 'Form' })); - fireEvent.click(await screen.findByTitle(/^Add a field/)); + await addFieldFromMoreMenu(); await screen.findByText('Type', { selector: 'label' }, { timeout: 4000 }); const name = (controlUnder('API name') as HTMLInputElement).value; const label = (controlUnder('Label') as HTMLInputElement).value; @@ -206,7 +207,7 @@ describe('Studio data page — complete bodies are sent as before (objectui#1178 , ); fireEvent.click(await screen.findByRole('button', { name: 'Form' })); - fireEvent.click(await screen.findByTitle(/^Add a field/)); + await addFieldFromMoreMenu(); await waitFor(() => expect(puts.length).toBe(1), { timeout: 4000 }); expect(screen.queryByTestId('studio-held')).toBeNull(); }); @@ -229,7 +230,7 @@ describe('Studio data page — complete bodies are sent as before (objectui#1178 , ); fireEvent.click(await screen.findByRole('button', { name: 'Form' })); - fireEvent.click(await screen.findByTitle(/^Add a field/)); + await addFieldFromMoreMenu(); const strip = await screen.findByTestId('studio-refusal', undefined, { timeout: 4000 }); expect(puts.length).toBe(1); expect(within(strip).getByTestId('studio-refusal-message')).toHaveTextContent( diff --git a/packages/app-shell/src/views/studio-design/DataPillar.inspectorBelongsToView-11781.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.inspectorBelongsToView-11781.test.tsx index 1564973acc..d8af70a9eb 100644 --- a/packages/app-shell/src/views/studio-design/DataPillar.inspectorBelongsToView-11781.test.tsx +++ b/packages/app-shell/src/views/studio-design/DataPillar.inspectorBelongsToView-11781.test.tsx @@ -118,7 +118,9 @@ describe('DataPillar — the field inspector belongs to the view that opened it ])('switching from Form to %s closes it', async (tabKey) => { await openInspectorOnForm(); - fireEvent.click(screen.getByRole('button', { name: L(tabKey) })); + // objectui#12450 — the first match is the flat tab: the passthrough menu + // draws a power panel's folded entry as a second button of the same name. + fireEvent.click(screen.getAllByRole('button', { name: L(tabKey) })[0]); await waitFor(() => expect(rail()).toBeNull()); }); diff --git a/packages/app-shell/src/views/studio-design/DataPillar.panelGate.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.panelGate.test.tsx index 74bef95e2c..bcda3355e5 100644 --- a/packages/app-shell/src/views/studio-design/DataPillar.panelGate.test.tsx +++ b/packages/app-shell/src/views/studio-design/DataPillar.panelGate.test.tsx @@ -135,7 +135,9 @@ async function openRuleGuard() { , ); - fireEvent.click(await screen.findByRole('button', { name: 'Validations' })); + // objectui#12450 — the flat tab; the passthrough menu draws the folded + // entry as a second button of the same name. + fireEvent.click(await screen.findByTestId('data-tab-flat-rules')); fireEvent.click(await screen.findByText('Expression')); return screen.getAllByRole('combobox').find((el) => el.tagName === 'TEXTAREA') as HTMLTextAreaElement; } diff --git a/packages/app-shell/src/views/studio-design/DataPillar.picklistServedOptions-10202.test.tsx b/packages/app-shell/src/views/studio-design/DataPillar.picklistServedOptions-10202.test.tsx index 40c86eebb4..4eb3852967 100644 --- a/packages/app-shell/src/views/studio-design/DataPillar.picklistServedOptions-10202.test.tsx +++ b/packages/app-shell/src/views/studio-design/DataPillar.picklistServedOptions-10202.test.tsx @@ -132,6 +132,7 @@ vi.mock('@object-ui/plugin-view', async (importOriginal) => { vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), dismiss: vi.fn() } })); import { DataPillar } from './StudioDesignSurface'; +import { addFieldFromMoreMenu } from './__tests__/dataPillarAddField'; import { createEmptyDataSource } from './__tests__/emptyDataSource'; const dataSource = createEmptyDataSource(); @@ -178,7 +179,7 @@ describe('Studio data page — a picklist-bound field is saved without its serve it('the draft autosave (an added field) sends no bound field with `options`, and the inline select byte-identical', async () => { renderData(); await waitFor(() => expect(fieldCountShown()).toBe('3 fields'), { timeout: 8000 }); - fireEvent.click(screen.getByTitle(/^Add a field/)); + await addFieldFromMoreMenu(); await outlastDebounce(); await waitFor(() => expect(puts.length).toBeGreaterThan(0), { timeout: 4000 }); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.autosaveInFlight-11204.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.autosaveInFlight-11204.test.tsx index dc63d8571a..11c4dff373 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.autosaveInFlight-11204.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.autosaveInFlight-11204.test.tsx @@ -156,6 +156,7 @@ vi.mock('@object-ui/plugin-view', async (importOriginal) => { vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), dismiss: vi.fn() } })); import { AutomationsPillar, DataPillar, InterfacesPillar } from './StudioDesignSurface'; +import { addFieldFromMoreMenu } from './__tests__/dataPillarAddField'; import { createEmptyDataSource } from './__tests__/emptyDataSource'; import { registerMetadataPreview } from '../metadata-admin/preview-registry'; import { registerMetadataInspector } from '../metadata-admin/inspector-registry'; @@ -270,17 +271,17 @@ describe('Data pillar — an edit taken while an object save is in flight (objec } const fieldNames = (body: Record) => readFields(body.fields).entries.map((e) => e.name); const fieldCountShown = () => screen.getByText(/^\d+ fields$/).textContent; - const addField = () => fireEvent.click(screen.getByTitle(/^Add a field/)); + const addField = () => addFieldFromMoreMenu(); it('the autosave: an added field stays on screen and lands in the next save', async () => { renderData(); await waitFor(() => expect(fieldCountShown()).toBe('2 fields'), { timeout: 8000 }); const release = holdNextSave(); - addField(); + await addField(); await screen.findByTestId('data-autosaving', undefined, { timeout: 8000 }); expect(savesOf('object').map(fieldNames)).toEqual([['title', 'status', 'field_3']]); - addField(); + await addField(); expect(fieldCountShown()).toBe('4 fields'); await release(); @@ -300,7 +301,7 @@ describe('Data pillar — an edit taken while an object save is in flight (objec fireEvent.click(await screen.findByRole('button', { name: 'Reverse columns' }, { timeout: 8000 })); await screen.findByTestId('data-autosaving', undefined, { timeout: 8000 }); expect(savesOf('object').map(fieldNames)).toEqual([['status', 'title']]); - addField(); + await addField(); expect(fieldCountShown()).toBe('3 fields'); await release(); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.autosaveSwitch-11232.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.autosaveSwitch-11232.test.tsx index bec771f8de..bdaaae4d17 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.autosaveSwitch-11232.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.autosaveSwitch-11232.test.tsx @@ -189,6 +189,7 @@ vi.mock('./StudioAiCopilot', () => ({ StudioChatDock: () => null })); vi.mock('../../preview/DraftChangesPanel', () => ({ DraftChangesPanel: () => null })); import { AutomationsPillar, DataPillar, InterfacesPillar, StudioDesignSurface } from './StudioDesignSurface'; +import { ADD_FIELD_OFFERED, addFieldFromMoreMenu } from './__tests__/dataPillarAddField'; import { createEmptyDataSource } from './__tests__/emptyDataSource'; import { registerMetadataPreview } from '../metadata-admin/preview-registry'; import { registerMetadataInspector } from '../metadata-admin/inspector-registry'; @@ -424,7 +425,7 @@ describe('Data pillar — an object switch inside the debounce, the control (obj ); await waitFor(() => expect(screen.getByText(/^\d+ fields$/)).toHaveTextContent('2 fields'), SLOW); - fireEvent.click(screen.getByTitle(/^Add a field/)); + await addFieldFromMoreMenu(); const release = holdLoad('object', 'acme_note'); fireEvent.click(screen.getByRole('button', { name: /Note/ })); await pastDebounce(); @@ -437,7 +438,7 @@ describe('Data pillar — an object switch inside the debounce, the control (obj await pastDebounce(1800); expect(server.saves).toEqual([]); // The control's own control: an edit on the opened object saves to it. - fireEvent.click(screen.getByTitle(/^Add a field/)); + await addFieldFromMoreMenu(); await waitFor(() => expect(server.saves).toHaveLength(1), SLOW); expect(savesOf('object', 'acme_note').map((b) => readFields(b.fields).entries.map((e) => e.name))).toEqual([ ['body', 'field_2'], @@ -675,13 +676,14 @@ describe("the load window: nothing of the previous item is offered, shown or sav const release = holdLoad('object', 'acme_note'); fireEvent.click(screen.getByRole('button', { name: /Note/ })); - const offered = screen.queryByTitle(/^Add a field/); - if (offered) fireEvent.click(offered); + // objectui#12450 — Add field lives in the header's More menu, drawn + // exactly when it is offered. + if (screen.queryByTestId(ADD_FIELD_OFFERED)) await addFieldFromMoreMenu(); await pastDebounce(); expect(savesRead()).toEqual([]); const shownDuringLoad = { fieldCount: screen.queryByText(/^\d+ fields$/) !== null, - addField: screen.queryByTitle(/^Add a field/) !== null, + addField: screen.queryByTestId(ADD_FIELD_OFFERED) !== null, }; await release(); @@ -690,7 +692,7 @@ describe("the load window: nothing of the previous item is offered, shown or sav expect(server.saves).toEqual([]); // The control: a field added after the load saves once, to its own object. - fireEvent.click(screen.getByTitle(/^Add a field/)); + await addFieldFromMoreMenu(); await waitFor(() => expect(server.saves).toHaveLength(1), SLOW); expect(server.saves.map((s) => [s.type, s.name, s.body.label, fieldNamesOf(s.body)])).toEqual([ ['object', 'acme_note', 'Note', ['body', 'field_2']], @@ -744,7 +746,7 @@ describe('a package switch: Automations and Data open nothing of the previous pa await switchToBeta(); await screen.findByRole('button', { name: /Beta item/ }, SLOW); await waitFor(() => expect(screen.getByText(/^\d+ fields$/)).toBeInTheDocument(), SLOW); - fireEvent.click(screen.getByTitle(/^Add a field/)); + await addFieldFromMoreMenu(); await waitFor(() => expect(server.saves).toHaveLength(1), SLOW); expect(server.saves.map((s) => [s.type, s.name, s.packageId, s.body.label, fieldNamesOf(s.body)])).toEqual([ ['object', 'beta_item', PKG_B, 'Beta item', ['name', 'field_2']], diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.cancelledLoad-11331.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.cancelledLoad-11331.test.tsx index e50e4c6640..67d369c96d 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.cancelledLoad-11331.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.cancelledLoad-11331.test.tsx @@ -137,6 +137,7 @@ vi.mock('@object-ui/react', async (importOriginal) => { }); import { DataPillar, InterfacesPillar } from './StudioDesignSurface'; +import { ADD_FIELD_OFFERED } from './__tests__/dataPillarAddField'; import { registerMetadataPreview } from '../metadata-admin/preview-registry'; import { DashboardPreview } from '../metadata-admin/previews/DashboardPreview'; import { createEmptyDataSource, failOnAbsorbedFetchError } from './__tests__/emptyDataSource'; @@ -154,7 +155,9 @@ afterEach(() => { }); const NO_DESIGNER = /cannot be previewed or designed here/; -const ADD_FIELD = 'Add a field (then set its type and properties on the right)'; +// objectui#12450 — "the object opened" reads as "Add field is offered": the +// header's More menu, which holds it, is drawn exactly then. +const ADD_FIELD = ADD_FIELD_OFFERED; /** The pillar's canvas: each pillar renders exactly one `main`. */ function canvas(): HTMLElement { @@ -225,7 +228,7 @@ describe('Data pillar — a new metadata client while the open object loads (obj rerender(dataTree()); await openAccount(); - expect(await screen.findByTitle(ADD_FIELD)).toBeInTheDocument(); + expect(await screen.findByTestId(ADD_FIELD)).toBeInTheDocument(); expect(within(canvas()).queryByText('Loading…')).toBeNull(); // Read once by the cancelled load and once by its replacement. expect(reads('object', 'acme_account')).toBe(2); @@ -237,13 +240,13 @@ describe('Data pillar — a new metadata client while the open object loads (obj await waitFor(() => expect(reads('object', 'acme_account')).toBe(1)); await openAccount(); - expect(await screen.findByTitle(ADD_FIELD)).toBeInTheDocument(); + expect(await screen.findByTestId(ADD_FIELD)).toBeInTheDocument(); expect(within(canvas()).queryByText('Loading…')).toBeNull(); }); it('a new client after the object loaded reads nothing again: the buffer is kept', async () => { const { rerender } = render(dataTree()); - expect(await screen.findByTitle(ADD_FIELD)).toBeInTheDocument(); + expect(await screen.findByTestId(ADD_FIELD)).toBeInTheDocument(); expect(reads('object', 'acme_account')).toBe(1); remintClient(); @@ -256,6 +259,6 @@ describe('Data pillar — a new metadata client while the open object loads (obj }); expect(reads('object', 'acme_account')).toBe(1); - expect(screen.getByTitle(ADD_FIELD)).toBeInTheDocument(); + expect(screen.getByTestId(ADD_FIELD)).toBeInTheDocument(); }); }); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.draftVersionConflict-11773.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.draftVersionConflict-11773.test.tsx index d26248dfe9..ba6ce8fd44 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.draftVersionConflict-11773.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.draftVersionConflict-11773.test.tsx @@ -163,6 +163,7 @@ vi.mock('@object-ui/plugin-view', async (importOriginal) => { vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn(), info: vi.fn(), dismiss: vi.fn() } })); import { DataPillar } from './StudioDesignSurface'; +import { addFieldFromMoreMenu } from './__tests__/dataPillarAddField'; import { createEmptyDataSource } from './__tests__/emptyDataSource'; import { readFields } from '../metadata-admin/previews/object-fields-io'; @@ -200,7 +201,7 @@ async function loaded(editor: HTMLElement, count: string): Promise { /** Add a field and wait until the autosave carrying it has landed. */ async function addFieldAndSave(editor: HTMLElement): Promise { const before = server.saves.length; - fireEvent.click(within(editor).getByTitle(/^Add a field/)); + await addFieldFromMoreMenu(editor); await waitFor(() => expect(server.saves.length).toBe(before + 1), { timeout: 8000 }); await waitFor(() => expect(within(editor).queryByTestId('data-autosaving')).toBeNull(), { timeout: 8000 }); } @@ -226,7 +227,7 @@ async function raceTwoEditors(): Promise<{ a: HTMLElement; b: HTMLElement }> { await loaded(b, '3 fields'); await reorderAndSave(b); // B: status before title expect(serverDraftFields()).toEqual(['status', 'title', 'field_3']); - fireEvent.click(within(a).getByTitle(/^Add a field/)); // A's stale copy + field_4 + await addFieldFromMoreMenu(a); // A's stale copy + field_4 await screen.findByTestId('draft-conflict-dialog', undefined, { timeout: 8000 }); return { a, b }; } @@ -256,7 +257,7 @@ describe('Data pillar — a draft saved elsewhere is not overwritten in silence await loaded(b, '2 fields'); await addFieldAndSave(a); // A: + field_3, the first draft expect(serverDraftFields()).toEqual(['title', 'status', 'field_3']); - fireEvent.click(within(b).getByTitle(/^Add a field/)); // B's first save, built on the published object + await addFieldFromMoreMenu(b); // B's first save, built on the published object await screen.findByTestId('draft-conflict-dialog', undefined, { timeout: 8000 }); const refused = server.saves[server.saves.length - 1]!; expect([refused.ifMatch, refused.ifNoneMatch]).toEqual([undefined, '*']); diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.formFields.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.formFields.test.tsx index 25b2e4b60c..7b32071b3f 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.formFields.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.formFields.test.tsx @@ -141,6 +141,7 @@ vi.mock('@object-ui/plugin-form', async (importOriginal) => { }); import { DataPillar } from './StudioDesignSurface'; +import { addFieldFromMoreMenu } from './__tests__/dataPillarAddField'; beforeEach(() => { dataSource = createDataSource(); @@ -242,7 +243,7 @@ describe('Studio Data pillar form preview — `fields` keeps a stable identity ( await mountSettledFormPreview(); const before = fieldsSnapshots[fieldsSnapshots.length - 1]; - fireEvent.click(screen.getAllByRole('button', { name: /Add field/i })[0]); + await addFieldFromMoreMenu(); await waitFor( () => { diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.gridColumns.test.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.gridColumns.test.tsx index 51f85aa219..1237ff95ab 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.gridColumns.test.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.gridColumns.test.tsx @@ -97,6 +97,7 @@ vi.mock('@object-ui/react', async (importOriginal) => { }); import { DataPillar } from './StudioDesignSurface'; +import { addFieldFromMoreMenu } from './__tests__/dataPillarAddField'; beforeEach(() => { dataSource = createDataSource(); @@ -185,7 +186,7 @@ describe('Studio Data pillar grid — the columns array keeps a stable identity it('a fresh columns array of EQUAL content still refetches — identity-churn liveness', async () => { const before = await mountSettledGrid(); - fireEvent.click(screen.getAllByRole('button', { name: /Add field/i })[0]); + await addFieldFromMoreMenu(); await waitFor(() => expect(dataSource.find.mock.calls.length).toBeGreaterThan(before), { timeout: 4000, diff --git a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx index 49eca97add..12a4c43efd 100644 --- a/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx +++ b/packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx @@ -34,6 +34,7 @@ import { AppMark, type AppMarkApp } from '../../layout/AppMark.js'; import { STUDIO_HEADER, STUDIO_HEADER_ICON_BUTTON, STUDIO_KIND_CHIP } from './studio-tokens.js'; import { StudioInspector } from './StudioInspector.js'; import { StudioItemHeader, StudioTab } from './StudioItemHeader.js'; +import { DATA_CONTENT_CONTAINER, DATA_TAB_FLAT_ONLY, DATA_TAB_FOLDED_ONLY } from './dataTabsFold.js'; import { StudioRail, StudioRailRow } from './StudioRail.js'; import { GridFieldAuthoringProvider, @@ -101,11 +102,13 @@ import { ChevronDown, Lock, Settings, + Settings2, Home as HomeIcon, Shield, ShieldCheck, ShieldQuestion, Menu, + MoreHorizontal, PanelRightClose, PanelRightOpen, type LucideIcon, @@ -4468,11 +4471,26 @@ export function renderStudioGridList(props: { // the channel and dropped the leftover parameter). ...listSchema, viewType: 'grid', - showSearch: true, - showSort: true, - showFilters: true, - showGroup: true, - showHideFields: true, + // objectui#12450 — the Records toolbar the Data pillar's artboard + // draws: Hide fields, Filter, Group and Sort as labelled buttons, + // then Refresh, Search and Add record. That is the list's wide form + // (`compactToolbar: false`, one button per control); the bundled + // Display form the console's list pages default to (objectui#12423) + // would fold Hide fields and Group away. Density is not offered: the + // artboard has no density control. The toggles are spelled in + // `userActions`, the protocol's home for them (the bare `show*` + // spellings this used are retired tombstones, objectui#7924), and + // the slot's own `userActions` — what a view declares — still wins. + compactToolbar: false, + userActions: { + search: true, + sort: true, + filter: true, + group: true, + hideFields: true, + rowHeight: false, + ...(listSchema.userActions as Record | undefined), + }, inlineEdit: true, addDeleteRecordsInline: true, // Fold "+ New" into this toolbar (next to Hide fields/Filter/Group/ @@ -5107,15 +5125,13 @@ export function DataPillar({ // rendered fills the field rail below without a remount. const inspector = useRegisteredMetadataInspector('object'); - // The object-level tabs (Data pillar). A shadcn/HIG segmented control: a - // recessed `bg-muted` track with an elevated `bg-background` pill on the - // active segment — the inverse of the old transparent-track/grey-active - // styling, which read as toolbar chrome rather than a distinct nav layer. + // The object-level tabs (Data pillar): underline tabs in the item header. // objectui#5813 — the 90% path is Records / Form (fields ARE the grid's - // columns, with Add field right beside them, so a separate fields tab would - // ADD a surface, not remove one). The five power tabs keep their panels - // untouched behind one "Advanced" menu — capability stays, the default view - // stops taxing every visit with seven choices (maintainer ruling 2026-08-24). + // columns, so a separate fields tab would ADD a surface, not remove one). + // objectui#12450 — the five power panels are flat tabs beside them when the + // content column has room, and fold back into one "Advanced" menu when it + // does not (maintainer ruling 2026-10-15, decision B of epic objectui#12446; + // the rule lives in `dataTabsFold.ts`). const dataTabs: ReadonlyArray<{ key: typeof viewMode; label: string }> = [ { key: 'grid', label: t('engine.studio.data.tab.records', locale) }, { key: 'form', label: t('engine.studio.data.tab.form', locale) }, @@ -5128,6 +5144,10 @@ export function DataPillar({ { key: 'settings', label: t('engine.studio.data.tab.settings', locale) }, ]; const activeAdvancedTab = advancedDataTabs.find((tabDef) => tabDef.key === viewMode); + // The two views Add field serves; `addField` itself refuses the rest. + const addFieldOffered = Boolean(current) && (viewMode === 'grid' || viewMode === 'form') && !readOnly && objLoaded; + // objectui#12450 — the Records grid draws flush with the content column. + const recordsFlush = Boolean(current) && objLoaded && viewMode === 'grid' && hasBaseline; // The selected object's own icon (from its metadata) — prefer the loaded // draft body, fall back to the rail header. getIcon degrades to Database. @@ -5136,12 +5156,15 @@ export function DataPillar({ ); // objectui#12459 — the object's tab row lives in the item header (the - // skeleton's scaffold): the two primary tabs flat, "Advanced" as the - // overflow it was (objectui#5813). The trigger keeps its own name whichever - // panel is open (objectui#11794): renamed to the panel ("Validations ▾") it - // hid the one word that says sibling panels sit behind it. Where you are - // still shows twice — the trigger takes the active look while one of its - // panels is open, and the menu marks that panel as the checked radio item. + // skeleton's scaffold). objectui#12450 — seven flat tabs at a content column + // of `DATA_TABS_FOLD_WIDTH` and wider; narrower, the five power panels fold + // into "Advanced". Both forms are rendered and a container query on the + // content column displays exactly one of them (see `dataTabsFold.ts`). + // Folded, the trigger keeps its own name whichever panel is open + // (objectui#11794): renamed to the panel ("Validations ▾") it hid the one + // word that says sibling panels sit behind it. Where you are still shows + // twice — the trigger takes the active look while one of its panels is + // open, and the menu marks that panel as the checked radio item. const dataTabRowEl = current ? ( <> {dataTabs.map((tab) => ( @@ -5149,9 +5172,20 @@ export function DataPillar({ {tab.label} ))} + {advancedDataTabs.map((tab) => ( + selectViewMode(tab.key)} + className={DATA_TAB_FLAT_ONLY} + > + {tab.label} + + ))} - + {t('engine.studio.data.tab.advanced', locale)} @@ -5223,7 +5257,7 @@ export function DataPillar({ })} -
+
{/* objectui#12459 — the object header sits inside the content column, over the thing it names, instead of spanning the rail. */} ) : null} - {current && (viewMode === 'grid' || viewMode === 'form') && !readOnly && objLoaded && ( - + {/* objectui#12450 — the header's two icon buttons. Object + settings opens the Settings panel (a shortcut that still + works when that tab is folded into Advanced); More holds + Add field, which left the header row for it. Add field + stays reachable from the grid's own "+" column and the form + designer's button too. More is drawn only when it holds + something: Add field is not offered on a read-only package, + before the object loads, or on the five power panels. */} + {current && ( +
+ + {addFieldOffered && ( + + + + + + + {t('engine.studio.data.addField', locale)} + + + + )} +
)} } tabs={dataTabRowEl} /> -
+ {/* objectui#12450 — the Records grid runs flush with the column: no + body padding and no framed card around the list, so its toolbar, + grid and footer span the column's full width. Every other view + keeps the padded body. */} +
{!current ? ( objectsLoaded && objects.length === 0 ? ( /* Fresh package: the first act is creating an object — say so and @@ -5313,7 +5387,7 @@ export function DataPillar({ locale={locale} // objectui#11785 — "Show me" opens the named field's inspector. onShow={(target) => setFieldSel({ kind: target.kind, id: target.id })} - className="mb-2 px-3 py-1.5 text-[11px]" + className={cn('mb-2 px-3 py-1.5 text-[11px]', recordsFlush && 'mx-6 mt-3')} /> )} {objHeld && ( @@ -5326,7 +5400,7 @@ export function DataPillar({ ? undefined : (target) => setFieldSel({ kind: target.kind, id: target.id }) } - className="mb-2 px-3 py-1.5 text-[11px]" + className={cn('mb-2 px-3 py-1.5 text-[11px]', recordsFlush && 'mx-6 mt-3')} /> )} {!objLoaded ? ( @@ -5397,7 +5471,7 @@ export function DataPillar({ {/* Records grid — fields are the columns. Header "+" adds a field, the * per-column edit affordance opens the field editor, and dragging a * column header reorders the object's fields (all via the context). */} -
+
{ + const where = scope ? within(scope) : screen; + await userEvent.click(await where.findByTestId(ADD_FIELD_OFFERED)); + await userEvent.click(await screen.findByTestId('data-object-add-field')); +} diff --git a/packages/app-shell/src/views/studio-design/dataTabsFold.ts b/packages/app-shell/src/views/studio-design/dataTabsFold.ts new file mode 100644 index 0000000000..2df2c8f67a --- /dev/null +++ b/packages/app-shell/src/views/studio-design/dataTabsFold.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * dataTabsFold — where the Data pillar's five power panels sit: flat in the + * object's tab row, or folded into "Advanced ▾" (objectui#12450, decision B of + * epic objectui#12446). + * + * The maintainer's ruling: seven flat tabs — Records, Form, Validations, + * Hooks, Actions, API, Settings — when the content column is at least + * {@link DATA_TABS_FOLD_WIDTH} px wide; narrower than that, the last five fold + * back into "Advanced ▾", whose trigger keeps its own name whichever panel is + * open (objectui#11794). + * + * The rule is a CSS container query on the pillar's content column, not a JS + * width read: the column narrows when the field inspector opens beside it or + * the copilot dock expands, and a container query answers those moves with no + * resize observer and no re-render. Both forms are in the DOM and exactly one + * of them is displayed at any width, so the folded form keeps its pins + * (`DataPillar.advancedKeepsName-11794.test.tsx`) and the flat form has its + * own (`DataPillar.flatTabs-12450.test.tsx`). + * + * The class strings are literals, not built from the number: Tailwind finds a + * class by scanning source text, so a template literal would generate no CSS. + * The flat-tab pin checks that both strings name {@link DATA_TABS_FOLD_WIDTH}. + */ + +/** The content-column width (px) at and above which the five panels are flat tabs. */ +export const DATA_TABS_FOLD_WIDTH = 1000; + +/** On the pillar's content column: the container the two classes below measure. */ +export const DATA_CONTENT_CONTAINER = '@container'; + +/** On each of the five flat tabs: not displayed below the fold width, an inline flex at and above it. */ +export const DATA_TAB_FLAT_ONLY = 'hidden @min-[1000px]:inline-flex'; + +/** On the "Advanced ▾" trigger: displayed below the fold width, not displayed at and above it. */ +export const DATA_TAB_FOLDED_ONLY = '@min-[1000px]:hidden'; diff --git a/packages/plugin-list/src/ListView.tsx b/packages/plugin-list/src/ListView.tsx index e370377949..4b85345544 100644 --- a/packages/plugin-list/src/ListView.tsx +++ b/packages/plugin-list/src/ListView.tsx @@ -5571,6 +5571,10 @@ export const ListView = React.forwardRef(({ )} data-testid="search-icon-button" title={searchTerm ? `${t('list.search')}: ${searchTerm}` : t('list.search')} + // An icon-only button names itself (objectui#12450), the same + // two-attribute shape as Refresh: `title` alone is a tooltip, + // and with a keyword the button's text was the bare keyword. + aria-label={searchTerm ? `${t('list.search')}: ${searchTerm}` : t('list.search')} > {/* Persisted keyword restored from storage keeps filtering after