Skip to content

Commit 7a5f4c1

Browse files
feat(app-shell): Data pillar — object header buttons, seven flat tabs with a 1000 px fold, flush Records body (objectui#12450) (#12477)
Fixes #12450 Clause-②: no The Data pillar's content column, after the layout skeleton (objectui#12459): the object header gets its two icon buttons, the five power panels become flat tabs that fold back into "Advanced ▾" below a 1000 px content column, and the Records tab loses the framed card and body padding around the list. Part of epic objectui#12446 (decisions 1, 3 and B). ## What changed **Object header** (`DataPillar` in `StudioDesignSurface.tsx`) - Adds an **Object settings** icon button. It opens the Settings panel, so it still works when Settings is folded into Advanced. - Adds a **More actions** icon button with a menu. Both buttons are 32 px and carry an `aria-label`. - **Add field** moves from the header row into the More menu. The artboard has no header Add field. The grid's "+" column header and the form designer's own button still add fields. - More is drawn only when its one entry is offered: a writable package, the object loaded, and the Records or Form tab. **Tab row** - Ruling B (maintainer, 2026-10-15): seven flat tabs — Records, Form, Validations, Hooks, Actions, API, Settings — when the content column is at least 1000 px wide. Narrower, the last five fold into "Advanced ▾", which keeps its objectui#11794 name rule. - A CSS container query decides the fold. The content column is `@container`. The five flat tabs carry `hidden @min-[1000px]:inline-flex` and the trigger carries `@min-[1000px]:hidden`. - So the fold also follows the field inspector: at 1280 px with the inspector open, the column is 740 px and the tabs fold. There is no JS width read. - The rule and its class strings live in the new `dataTabsFold.ts`. - Premise B re-checked on `cf430cd` before building: `advancedDataTabs` held exactly `rules` (Validations), `hooks`, `actions`, `api` and `settings`. **Records body** - The grid view's `main` drops `px-6 py-4` and the list's `rounded-lg border` card. The toolbar, grid and footer run flush with the column. - Error and held-edit strips keep a 24 px inset in that view. Every other view keeps the padded body; the Form tab body is untouched (objectui#12451). **Records toolbar** (`renderStudioGridList`) - The Studio list uses the wide form, `compactToolbar: false`: Hide fields, Filter, Group and Sort as labelled buttons, then Refresh, Search and Add record. - The console list pages keep the bundled Display form (objectui#12423). - Density is turned off, because the artboard has no density control. - The toggles move from the retired bare `show*` spellings to `userActions`. The slot's own `userActions` still wins. **List search button** (`packages/plugin-list`, the toolbar's producer) - The icon-only Search trigger gets an `aria-label`, in the same two-attribute shape as Refresh. Card acceptance 3 asks for one on every icon-only toolbar button. - With a keyword active, the button's accessible name is now "Search: KEYWORD" instead of the bare keyword. - This file is outside the claim's file surface. The dispatch allows a fix at the producer when the producer is in another package. It is one attribute. ## Deviations from the artboard (`data-records.png`) 1. **Toolbar arrangement.** The controls sit in the list's right-aligned rounded cluster. The artboard has left-aligned 28 px outline chips after an "N records" count, then Search and Refresh, a divider, and Add record as a primary-soft chip. - In the cluster Refresh comes before Search, and Add record is a ghost button. - Reason: the toolbar is `ListView`'s (`packages/plugin-list`), not plugin-grid's as the card assumed. Its controls hold `ListView`-internal state: filters, sort, grouping, hidden fields, search keyword and refresh key. A host toolbar cannot drive that state, so suppression alone would remove the controls. - Restyling `ListView`'s toolbar is outside the claimed surface. It is raised as an open question in the dev report. 2. **Grid metrics.** The header row is 48 px on the background surface; the artboard has 36 px on the sidebar surface. Body rows measure 41 px; the artboard has 40 px. - The `#` column is a 40 px cell with the number centred, about 16 px into the column. The artboard has a 48 px column with a 24 px inset, so **card acceptance 1 (title edge = row-number edge at 24 px) is not met**. - A vertical divider follows only the frozen first column. - Lookup cells render as primary links, not 20 px initials avatars, and the autonumber column is not mono. - Reason: these are `ObjectGrid` / data-table internals (`packages/plugin-grid`, `packages/components`). The card's surface admits plugin-grid only for a toolbar option. Same open question. 3. **Footer.** "N records" only, at 8 px by 16 px padding, with no range on the right. The artboard has "N records" plus "1–10 of 10" at 10 px by 24 px. Reason: this is `ListView`'s record-count bar. Same open question. 4. **More button.** It is not drawn on a read-only package, before the object loads, or on the five power panels. Add field is its only entry and is not offered there. 5. **Sample data.** The screenshots use a writable "Acme CRM" base seeded on a local backend. - Under `objectstack dev`, HotCRM's package is read-only and the server refuses to duplicate it (`DUPLICATE_SOURCE_NOT_A_BASE`). A read-only package draws no More button. - So the object, columns and rows differ from the artboard's HotCRM sample. - The shell header (app chip, pillar control with More, Create app) is the skeleton's, and objectui#12452's and objectui#12453's to change. ## Screenshots (1280 px, local console on its own backend) - After, light: `docs/design/studio-redesign/after/12450-data-records-light.png` - After, dark: `docs/design/studio-redesign/after/12450-data-records-dark.png` - The fold, light, 1180 px viewport (940 px column): `docs/design/studio-redesign/after/12450-data-records-folded-light.png` - Before: `docs/design/studio-redesign/after/skeleton-data-records-light.png` and `-dark.png`, from `cf430cd`, as filed by the skeleton change. These were not re-shot. Measured in that browser run (Chromium, 1280 × 900): - With a 1040 px content column, the seven tabs are displayed and the Advanced trigger is not. - With a 940 px column, Records and Form plus Advanced are displayed. - With the field inspector open at 1280 px (a 740 px column), the tabs fold. - Every displayed toolbar button has a visible label or an `aria-label` (Refresh, Search). ## Tests (all at `4c06d3a`, from the worktree root, through the shared verify lock) - `pnpm exec vitest run packages/app-shell/src/views/studio-design/` in two runs: - the 98 files that are not `StudioDesignSurface.*`: `Test Files 98 passed (98)`, `Tests 773 passed (773)`; - the 76 `StudioDesignSurface.*` files: `Test Files 76 passed (76)`, `Tests 511 passed (511)`. - `pnpm exec vitest run packages/plugin-list/`: `Test Files 133 passed (133)`, `Tests 1432 passed (1432)`. Run after the plugin-list change; that file has not changed since. - Tests that read the search trigger, in app-shell and plugin-list (6 files): 214 passed. - **New:** `DataPillar.flatTabs-12450.test.tsx` (9 tests). - It pins the flat state: the order of the seven tabs, the content column as the query container, the five flat tabs' fold classes, the trigger's class, and that both class strings name `DATA_TABS_FOLD_WIDTH`. - It also pins that a flat tab opens its panel while the folded trigger mirrors it, the header buttons' `aria-label`s, More holding Add field, and the read-only control. - **Ablated once:** the flat tabs' `className={DATA_TAB_FLAT_ONLY}` was replaced with `className={undefined}` through `ablation-replace.mjs`. - The anchor went from 1 to 0 and the blob changed. - Result: `Tests 1 failed | 8 passed (9)`, and the failure is the fold-class pin. - It was restored to the HEAD blob, with `git diff HEAD` empty. - **Kept for the folded state, unchanged:** `DataPillar.advancedKeepsName-11794.test.tsx`, green. - **Existing suites whose harness changed, assertions untouched:** - Each step that added a field through the header now opens More first (shared helper `__tests__/dataPillarAddField.ts`). Each step that clicked a power panel by name now clicks its flat tab. - Three suites mock the menu as passthrough buttons, which would now draw a second button of the same name. - Two suites asked "is Add field offered" through the header button's title. They now read the More trigger, which is drawn exactly when Add field is offered. `DataPillar.flatTabs-12450` pins that both ways. - The files: `DataPillar.authorRefusal-11785`, `choiceWithoutOptions-11253`, `designerRegistryPopulated`, `gridProjection`, `heldIncomplete-11786`, `inspectorBelongsToView-11781`, `panelGate`, `picklistServedOptions-10202`, and `StudioDesignSurface.autosaveInFlight-11204`, `autosaveSwitch-11232`, `cancelledLoad-11331`, `draftVersionConflict-11773`, `formFields`, `gridColumns`. - Three of these are on the card's stay-green list: `gridProjection`, `gridColumns` and `inspectorBelongsToView-11781`. All are green. `hiddenSystemFields-11780`, `surfaceRequest` and `listViewInspector-11823` are unchanged and green. - `pnpm --filter @object-ui/app-shell --filter @object-ui/plugin-list type-check`: both echo `type-check` and exit 0. Dependency closure built first. `tsconfig.test.json --listFiles` lists the new test and helper. - eslint on every touched file in both packages: 0 errors. The warnings are existing patterns on untouched lines. - `pnpm --filter @object-ui/plugin-list --filter @object-ui/app-shell build`: exit 0. The `aria-label` is in `plugin-list/dist/index.js`, and the fold classes are in `app-shell/dist/.../dataTabsFold.js`. - Gates, exit 0 each: `check-changeset-presence`, `check-changeset-no-major`, `check:i18n-keys`, `check:i18n-drift`, `check:i18n-designer-parity`, `check:control-bytes`, `check:new-line-citations` (0 new) and `check:test-path-roots`. `check-governed-queue-guard --test` reports NOT GOVERNED. ## Acceptance notes - **Stretched last row (seen, not filed).** In Studio on HotCRM's `crm_account` at 1280 × 900, the grid drew 6 of the 9 rows the footer counts, and the 6th row stretched to the bottom. The before-screenshot from `cf430cd` shows the same shape. The 8-row Acme object does not do it. Not root-caused and not filed. Carrier: none. - **Narrow-screen labels (inference from source, not measured).** Below the `sm` breakpoint, the list toolbar's Filter and Sort buttons hide their labels and carry no `aria-label`, in both toolbar forms. Carrier: none. - **Merge state.** `origin/main` moved three commits after `cf430cd`; none of them touches this diff's files. i18n: `engine.studio.data.objectSettings` and `engine.studio.data.moreActions` are added in en and zh. Changeset: `.changeset/12450-data-pillar-layout.md` (app-shell minor, plugin-list patch). The dev run's session is `session_01SJn1GEkrbbBQkhGC26wEDw` (seat `domain:ui#4`). --- _Generated by [Claude Code](https://claude.ai/code/session_01SJn1GEkrbbBQkhGC26wEDw)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c950345 commit 7a5f4c1

24 files changed

Lines changed: 469 additions & 70 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@object-ui/app-shell': minor
3+
'@object-ui/plugin-list': patch
4+
---
5+
6+
Studio Data pillar layout (objectui#12450, epic objectui#12446).
7+
8+
- **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.
9+
- **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.
10+
- **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.
11+
- **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.
122 KB
Loading
102 KB
Loading
119 KB
Loading

‎packages/app-shell/src/views/metadata-admin/i18n.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3393,6 +3393,8 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
33933393
'engine.studio.hooks.newLabel': 'New hook',
33943394
'engine.studio.data.addFieldTitle': 'Add a field (then set its type and properties on the right)',
33953395
'engine.studio.data.addField': 'Add field',
3396+
'engine.studio.data.objectSettings': 'Object settings',
3397+
'engine.studio.data.moreActions': 'More actions',
33963398
'engine.studio.data.editFieldProps': 'Edit field properties',
33973399
'engine.studio.data.draftObjectTitle': 'Unpublished new object',
33983400
'engine.studio.data.draftObjectHint':
@@ -6544,6 +6546,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
65446546
'engine.studio.hooks.newLabel': '新钩子',
65456547
'engine.studio.data.addFieldTitle': '添加一个字段(随后在右侧设置类型与属性)',
65466548
'engine.studio.data.addField': '添加字段',
6549+
'engine.studio.data.objectSettings': '对象设置',
6550+
'engine.studio.data.moreActions': '更多操作',
65476551
'engine.studio.data.editFieldProps': '编辑字段属性',
65486552
'engine.studio.data.draftObjectTitle': '未发布的新对象',
65496553
'engine.studio.data.draftObjectHint':

‎packages/app-shell/src/views/studio-design/DataPillar.authorRefusal-11785.test.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ vi.mock('@object-ui/react', async (importOriginal) => {
7575
});
7676

7777
import { DataPillar } from './StudioDesignSurface';
78+
import { addFieldFromMoreMenu } from './__tests__/dataPillarAddField';
7879
import { createEmptyDataSource } from './__tests__/emptyDataSource';
7980
import { registerBuiltinInspectors } from '../metadata-admin/inspectors';
8081
import { t, tFormat } from '../metadata-admin/i18n';
@@ -102,7 +103,7 @@ describe('Studio data page — a held Picklist reads as a sentence (objectui#117
102103
</MemoryRouter>,
103104
);
104105
fireEvent.click(await screen.findByRole('button', { name: 'Form' }));
105-
fireEvent.click(await screen.findByTitle(/^Add a field/));
106+
await addFieldFromMoreMenu();
106107
await screen.findByText('Type', { selector: 'label' }, { timeout: 4000 });
107108
const apiName = (controlUnder('API name') as HTMLInputElement).value;
108109
const label = (controlUnder('Label') as HTMLInputElement).value;
@@ -138,7 +139,7 @@ describe('Studio data page — a held Picklist reads as a sentence (objectui#117
138139
</MemoryRouter>,
139140
);
140141
fireEvent.click(await screen.findByRole('button', { name: 'Form' }));
141-
fireEvent.click(await screen.findByTitle(/^Add a field/));
142+
await addFieldFromMoreMenu();
142143
await screen.findByText('Type', { selector: 'label' }, { timeout: 4000 });
143144
const apiName = (controlUnder('API name') as HTMLInputElement).value;
144145
await userEvent.click(controlUnder('Type'));

‎packages/app-shell/src/views/studio-design/DataPillar.choiceWithoutOptions-11253.test.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ vi.mock('@object-ui/react', async (importOriginal) => {
9393
});
9494

9595
import { DataPillar } from './StudioDesignSurface';
96+
import { addFieldFromMoreMenu } from './__tests__/dataPillarAddField';
9697
import { createEmptyDataSource } from './__tests__/emptyDataSource';
9798
import { registerBuiltinInspectors } from '../metadata-admin/inspectors';
9899
import { tFormat } from '../metadata-admin/i18n';
@@ -159,7 +160,7 @@ async function addFieldAndType(typeLabel: RegExp): Promise<string> {
159160
</MemoryRouter>,
160161
);
161162
fireEvent.click(await screen.findByRole('button', { name: 'Form' }));
162-
fireEvent.click(await screen.findByTitle(/^Add a field/));
163+
await addFieldFromMoreMenu();
163164
await screen.findByText('Type', { selector: 'label' }, { timeout: 4000 });
164165
const fieldName = (controlUnder('API name') as HTMLInputElement).value;
165166
expect(fieldName, 'the new field has no API name: the harness is dead').not.toBe('');

‎packages/app-shell/src/views/studio-design/DataPillar.designerRegistryPopulated.test.tsx‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,9 @@ describe('Data pillar consumers with the registries POPULATED (#6846 contrast)',
189189
assertRegistriesPopulated();
190190
renderPillar();
191191

192-
fireEvent.click(await screen.findByRole('button', { name: 'Actions' }));
192+
// objectui#12450 — the flat tab; the passthrough menu draws the folded
193+
// entry as a second button of the same name.
194+
fireEvent.click(await screen.findByTestId('data-tab-flat-actions'));
193195
// `ActionDefaultInspector`'s Name control, carrying the action's identifier.
194196
expect(await screen.findByDisplayValue('send_email')).toBeInTheDocument();
195197
expectNoEmptyStateSentence();
@@ -199,7 +201,9 @@ describe('Data pillar consumers with the registries POPULATED (#6846 contrast)',
199201
assertRegistriesPopulated();
200202
renderPillar();
201203

202-
fireEvent.click(await screen.findByRole('button', { name: 'Hooks' }));
204+
// objectui#12450 — the flat tab; the passthrough menu draws the folded
205+
// entry as a second button of the same name.
206+
fireEvent.click(await screen.findByTestId('data-tab-flat-hooks'));
203207
fireEvent.click(await screen.findByText('Guard'));
204208
// Controls only `HookDefaultInspector` renders…
205209
expect(await screen.findByTestId('hook-name')).toBeInTheDocument();
@@ -214,7 +218,9 @@ describe('Data pillar consumers with the registries POPULATED (#6846 contrast)',
214218
assertRegistriesPopulated();
215219
renderPillar();
216220

217-
fireEvent.click(await screen.findByRole('button', { name: 'Settings' }));
221+
// objectui#12450 — the flat tab; the passthrough menu draws the folded
222+
// entry as a second button of the same name.
223+
fireEvent.click(await screen.findByTestId('data-tab-flat-settings'));
218224
// `ObjectDefaultInspector`'s Name control, carrying the object's name.
219225
expect(await screen.findByTestId('object-name-input')).toHaveValue('showcase_task');
220226
expect(screen.getByTestId('object-access-posture')).toBeInTheDocument();
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectui#12450 — the Data pillar's flat tab row, and the object header's two
5+
* icon buttons.
6+
*
7+
* Decision B of epic objectui#12446 (maintainer, 2026-10-15): seven flat tabs
8+
* — Records, Form, Validations, Hooks, Actions, API, Settings — when the
9+
* content column is 1000 px or wider; narrower, the last five fold back into
10+
* "Advanced ▾". The folded form keeps its own pins in
11+
* `DataPillar.advancedKeepsName-11794.test.tsx`; this file pins the flat form.
12+
*
13+
* What a DOM test can and cannot see: happy-dom evaluates no container query,
14+
* so both forms are in the tree here, exactly as they are in a browser, and
15+
* which one is DISPLAYED is decided by CSS this environment does not run. The
16+
* pins therefore read what decides it — the content column is the query
17+
* container, the five flat tabs carry the class that displays them at the
18+
* fold width and above, the Advanced trigger carries the class that hides it
19+
* there, and both classes name `DATA_TABS_FOLD_WIDTH` — and the browser
20+
* screenshots on the pull request show the two widths rendered.
21+
*
22+
* The REAL `DataPillar`; the records grid and two panels are stubbed, because
23+
* only which panel is open is under test, not what it draws.
24+
*/
25+
26+
import '@testing-library/jest-dom/vitest';
27+
import { afterEach, describe, expect, it, vi } from 'vitest';
28+
import { cleanup, render, screen, waitFor, within } from '@testing-library/react';
29+
import userEvent from '@testing-library/user-event';
30+
import { MemoryRouter } from 'react-router-dom';
31+
32+
const servedObject = {
33+
name: 'showcase_account',
34+
label: 'Account',
35+
fields: { name: { type: 'text', label: 'Account Name' } },
36+
};
37+
38+
const mockClient = {
39+
list: vi.fn(async () => [{ name: 'showcase_account', label: 'Account' }]),
40+
listDrafts: vi.fn(async () => []),
41+
layered: vi.fn(async () => ({ effective: servedObject, code: servedObject })),
42+
getDraft: vi.fn(async () => null),
43+
save: vi.fn(async () => ({ ok: true })),
44+
};
45+
46+
vi.mock('../metadata-admin/useMetadata', async (importOriginal) => {
47+
const mod = await importOriginal<typeof import('../metadata-admin/useMetadata')>();
48+
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
49+
});
50+
51+
vi.mock('./packages-io', async (importOriginal) => {
52+
const mod = await importOriginal<typeof import('./packages-io')>();
53+
return { ...mod, fetchPackages: vi.fn(async () => []) };
54+
});
55+
56+
vi.mock('@object-ui/react', async (importOriginal) => {
57+
const mod = await importOriginal<typeof import('@object-ui/react')>();
58+
return { ...mod, useAdapter: () => ({}) };
59+
});
60+
61+
vi.mock('@object-ui/plugin-view', async (importOriginal) => {
62+
const mod = await importOriginal<Record<string, unknown>>();
63+
return { ...mod, ObjectView: () => <div data-testid="records-panel" /> };
64+
});
65+
66+
vi.mock('./ObjectValidationsPanel', () => ({ ObjectValidationsPanel: () => <div data-testid="rules-panel" /> }));
67+
vi.mock('./ObjectSettingsPanel', () => ({ ObjectSettingsPanel: () => <div data-testid="settings-panel" /> }));
68+
69+
import { DataPillar } from './StudioDesignSurface';
70+
import { SurfaceDeepLinkProvider } from './surfaceDeepLinkChannel';
71+
import {
72+
DATA_CONTENT_CONTAINER,
73+
DATA_TAB_FLAT_ONLY,
74+
DATA_TAB_FOLDED_ONLY,
75+
DATA_TABS_FOLD_WIDTH,
76+
} from './dataTabsFold';
77+
import { t } from '../metadata-admin/i18n';
78+
79+
const FLAT_KEYS = ['rules', 'hooks', 'actions', 'api', 'settings'] as const;
80+
const ROW_NAMES = ['records', 'form', ...FLAT_KEYS].map((k) => t(`engine.studio.data.tab.${k}`, 'en'));
81+
const ADVANCED = t('engine.studio.data.tab.advanced', 'en');
82+
83+
afterEach(() => {
84+
cleanup();
85+
vi.clearAllMocks();
86+
});
87+
88+
async function renderPillar(props: { readOnly?: boolean } = {}) {
89+
render(
90+
<MemoryRouter initialEntries={['/studio/com.example.showcase/data']}>
91+
<SurfaceDeepLinkProvider>
92+
<DataPillar packageId="com.example.showcase" readOnly={props.readOnly} />
93+
</SurfaceDeepLinkProvider>
94+
</MemoryRouter>,
95+
);
96+
await waitFor(() => expect(screen.getByTestId('records-panel')).toBeInTheDocument(), { timeout: 8000 });
97+
}
98+
99+
/** The header's tab row: the `nav` the item header draws its tabs into. */
100+
function tabRow(): HTMLElement {
101+
const nav = screen.getByTestId('data-object-header').querySelector('nav');
102+
expect(nav, 'the object header draws no tab row').not.toBeNull();
103+
return nav as HTMLElement;
104+
}
105+
106+
/** Split a class attribute into its tokens. */
107+
function classesOf(el: Element): string[] {
108+
return (el.getAttribute('class') ?? '').split(/\s+/).filter(Boolean);
109+
}
110+
111+
describe('the Data pillar’s seven flat tabs (objectui#12450)', () => {
112+
it('both class strings name the fold width, so the rule and its constant cannot drift apart', () => {
113+
const query = `@min-[${DATA_TABS_FOLD_WIDTH}px]:`;
114+
expect(DATA_TABS_FOLD_WIDTH).toBe(1000);
115+
expect(DATA_TAB_FLAT_ONLY.split(' ')).toEqual(['hidden', `${query}inline-flex`]);
116+
expect(DATA_TAB_FOLDED_ONLY.split(' ')).toEqual([`${query}hidden`]);
117+
});
118+
119+
it('the tab row holds the seven tabs in the artboard’s order, then the folded trigger', async () => {
120+
await renderPillar();
121+
const row = tabRow();
122+
const buttons = within(row).getAllByRole('button');
123+
expect(buttons.map((b) => b.textContent)).toEqual([...ROW_NAMES, ADVANCED]);
124+
expect(buttons.at(-1)).toBe(screen.getByTestId('data-tabs-advanced'));
125+
});
126+
127+
it('the content column is the query container, and it holds the tab row it measures for', async () => {
128+
await renderPillar();
129+
const column = screen.getByTestId('data-content-column');
130+
expect(classesOf(column)).toContain(DATA_CONTENT_CONTAINER);
131+
expect(column).toContainElement(tabRow());
132+
});
133+
134+
it('the five panels’ flat tabs display only at the fold width and above; the trigger only below it', async () => {
135+
await renderPillar();
136+
for (const key of FLAT_KEYS) {
137+
const tab = screen.getByTestId(`data-tab-flat-${key}`);
138+
for (const cls of DATA_TAB_FLAT_ONLY.split(' ')) expect(classesOf(tab)).toContain(cls);
139+
// `cn` merges the tab's own `inline-flex` away: an unconditional
140+
// display class left beside `hidden` would show the tab at every width.
141+
expect(classesOf(tab)).not.toContain('inline-flex');
142+
}
143+
const trigger = screen.getByTestId('data-tabs-advanced');
144+
expect(classesOf(trigger)).toContain(DATA_TAB_FOLDED_ONLY);
145+
expect(classesOf(trigger)).not.toContain('hidden');
146+
});
147+
148+
it('CONTROL: Records and Form are displayed at every width', async () => {
149+
await renderPillar();
150+
const row = tabRow();
151+
for (const name of ROW_NAMES.slice(0, 2)) {
152+
const tab = within(row).getByRole('button', { name });
153+
expect(classesOf(tab)).toContain('inline-flex');
154+
expect(classesOf(tab)).not.toContain('hidden');
155+
expect(classesOf(tab).some((c) => c.startsWith('@'))).toBe(false);
156+
}
157+
});
158+
159+
it('a flat tab opens its panel and takes the active underline; the folded trigger mirrors it', async () => {
160+
await renderPillar();
161+
const validations = screen.getByTestId('data-tab-flat-rules');
162+
expect(validations).toHaveAttribute('aria-pressed', 'false');
163+
164+
await userEvent.click(validations);
165+
await waitFor(() => expect(screen.getByTestId('rules-panel')).toBeInTheDocument());
166+
expect(validations).toHaveAttribute('aria-pressed', 'true');
167+
// The two forms say the same thing: at the narrower width the trigger is
168+
// the one on screen, and it shows a power panel is open.
169+
expect(screen.getByTestId('data-tabs-advanced')).toHaveAttribute('aria-pressed', 'true');
170+
171+
await userEvent.click(screen.getByTestId('data-tab-flat-settings'));
172+
await waitFor(() => expect(screen.getByTestId('settings-panel')).toBeInTheDocument());
173+
expect(screen.queryByTestId('rules-panel')).toBeNull();
174+
expect(validations).toHaveAttribute('aria-pressed', 'false');
175+
expect(screen.getByTestId('data-tab-flat-settings')).toHaveAttribute('aria-pressed', 'true');
176+
});
177+
});
178+
179+
describe('the object header’s icon buttons (objectui#12450)', () => {
180+
const OBJECT_SETTINGS = t('engine.studio.data.objectSettings', 'en');
181+
const MORE_ACTIONS = t('engine.studio.data.moreActions', 'en');
182+
const ADD_FIELD = t('engine.studio.data.addField', 'en');
183+
184+
it('each is named by an aria-label; Object settings opens the Settings panel', async () => {
185+
await renderPillar();
186+
const header = screen.getByTestId('data-object-header');
187+
const settings = within(header).getByRole('button', { name: OBJECT_SETTINGS });
188+
expect(settings).toHaveAttribute('aria-label', OBJECT_SETTINGS);
189+
expect(within(header).getByRole('button', { name: MORE_ACTIONS })).toHaveAttribute('aria-label', MORE_ACTIONS);
190+
191+
await userEvent.click(settings);
192+
await waitFor(() => expect(screen.getByTestId('settings-panel')).toBeInTheDocument());
193+
expect(screen.getByTestId('data-tab-flat-settings')).toHaveAttribute('aria-pressed', 'true');
194+
});
195+
196+
it('More holds Add field, which adds a field and opens its inspector', async () => {
197+
await renderPillar();
198+
// Add field left the header row: no labelled button for it there now.
199+
const header = screen.getByTestId('data-object-header');
200+
expect(within(header).queryByRole('button', { name: ADD_FIELD })).toBeNull();
201+
202+
await userEvent.click(screen.getByTestId('data-object-more'));
203+
await userEvent.click(await screen.findByRole('menuitem', { name: ADD_FIELD }));
204+
expect(await screen.findByTestId('data-field-inspector', undefined, { timeout: 4000 })).toBeInTheDocument();
205+
});
206+
207+
it('CONTROL: a read-only package keeps Object settings and draws no More, whose one entry it does not offer', async () => {
208+
await renderPillar({ readOnly: true });
209+
expect(screen.getByTestId('data-object-settings')).toBeInTheDocument();
210+
expect(screen.queryByTestId('data-object-more')).toBeNull();
211+
});
212+
});

‎packages/app-shell/src/views/studio-design/DataPillar.gridProjection.test.tsx‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
import '@testing-library/jest-dom/vitest';
2727
import * as React from 'react';
2828
import { describe, it, expect, vi, afterEach } from 'vitest';
29-
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
29+
import { render, screen, cleanup, waitFor } from '@testing-library/react';
3030
import { MemoryRouter } from 'react-router-dom';
3131

3232
/** The object as the SERVER has it: two published, materialised fields. */
@@ -86,6 +86,7 @@ vi.mock('@object-ui/plugin-view', async (importOriginal) => {
8686
});
8787

8888
import { DataPillar } from './StudioDesignSurface';
89+
import { addFieldFromMoreMenu } from './__tests__/dataPillarAddField';
8990
import { SurfaceDeepLinkProvider } from './surfaceDeepLinkChannel';
9091
import { registerBuiltinInspectors } from '../metadata-admin/inspectors';
9192

@@ -119,8 +120,7 @@ describe('DataPillar grid projection (cloud#1652)', () => {
119120
renderPillar();
120121
await waitFor(() => expect(columnsNow()).toContain('book_name'));
121122

122-
const addField = screen.getByRole('button', { name: /添加|add field/i });
123-
fireEvent.click(addField);
123+
await addFieldFromMoreMenu();
124124

125125
// The draft grew a `field_<N>`; the projection must not have.
126126
await waitFor(() => expect(columnsNow()).toContain('book_name'));

0 commit comments

Comments
 (0)