Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/12450-data-pillar-layout.md
Original file line number Diff line number Diff line change
@@ -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.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
4 changes: 4 additions & 0 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3394,6 +3394,8 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'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':
Expand Down Expand Up @@ -6546,6 +6548,8 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'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':
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -102,7 +103,7 @@ describe('Studio data page — a held Picklist reads as a sentence (objectui#117
</MemoryRouter>,
);
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;
Expand Down Expand Up @@ -138,7 +139,7 @@ describe('Studio data page — a held Picklist reads as a sentence (objectui#117
</MemoryRouter>,
);
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'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -159,7 +160,7 @@ async function addFieldAndType(typeLabel: RegExp): Promise<string> {
</MemoryRouter>,
);
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('');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<typeof import('../metadata-admin/useMetadata')>();
return { ...mod, useMetadataClient: () => mockClient, useMetadataTypes: () => ({ entries: [] }) };
});

vi.mock('./packages-io', async (importOriginal) => {
const mod = await importOriginal<typeof import('./packages-io')>();
return { ...mod, fetchPackages: vi.fn(async () => []) };
});

vi.mock('@object-ui/react', async (importOriginal) => {
const mod = await importOriginal<typeof import('@object-ui/react')>();
return { ...mod, useAdapter: () => ({}) };
});

vi.mock('@object-ui/plugin-view', async (importOriginal) => {
const mod = await importOriginal<Record<string, unknown>>();
return { ...mod, ObjectView: () => <div data-testid="records-panel" /> };
});

vi.mock('./ObjectValidationsPanel', () => ({ ObjectValidationsPanel: () => <div data-testid="rules-panel" /> }));
vi.mock('./ObjectSettingsPanel', () => ({ ObjectSettingsPanel: () => <div data-testid="settings-panel" /> }));

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(
<MemoryRouter initialEntries={['/studio/com.example.showcase/data']}>
<SurfaceDeepLinkProvider>
<DataPillar packageId="com.example.showcase" readOnly={props.readOnly} />
</SurfaceDeepLinkProvider>
</MemoryRouter>,
);
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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down Expand Up @@ -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';

Expand Down Expand Up @@ -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_<N>`; the projection must not have.
await waitFor(() => expect(columnsNow()).toContain('book_name'));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -121,7 +122,7 @@ async function addFieldAs(typeOption: RegExp): Promise<{ name: string; label: st
</MemoryRouter>,
);
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;
Expand Down Expand Up @@ -206,7 +207,7 @@ describe('Studio data page — complete bodies are sent as before (objectui#1178
</MemoryRouter>,
);
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();
});
Expand All @@ -229,7 +230,7 @@ describe('Studio data page — complete bodies are sent as before (objectui#1178
</MemoryRouter>,
);
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(
Expand Down
Loading
Loading