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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/26843-form-layout-horizontal-tabbed-page.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@object-ui/components': minor
'@object-ui/plugin-form': minor
'@object-ui/console': minor
---

fix(ui): a form view's `layout: 'horizontal'` draws each label beside its field, a tabbed form keeps it, and the form page draws `type: 'tabbed'` as tabs (#26843)

Clause-②: no
Metadata-protocol: semantic

A form view's `layout: 'horizontal'` reached every console form but drew nothing different, and the form page ignored `type: 'tabbed'`:

- **`layout: 'horizontal'`** now places each field's label in a fixed column beside its control from the `sm` breakpoint up; below `sm` (a phone) the field stays stacked, label above control, as `vertical` draws it. The description and validation message stay under the control. `vertical`, and a form view with no `layout`, draw exactly as before. Before, the form renderer read the key only to align its action row.
- **A tabbed form** (`type: 'tabbed'`) now keeps an authored `layout: 'horizontal'` on every tab. It used to draw its fields `vertical` whatever the form view said.
- **The form page** (`/forms/:name`, and the public `/f/:slug`) now draws a `type: 'tabbed'` form view as one tab per section: `defaultTab` opens first, `tabPosition` places the strip, and a section hidden by its `visibleWhen` draws no tab. A submit carries every section's fields, the closed tabs' included, and a submit refused for a missing field opens the tab that holds it. A tabbed form view with a single section draws it stacked, as before. The page also draws `layout: 'horizontal'` rows. Before, it drew every section stacked and every label above its field.
246 changes: 246 additions & 0 deletions ui/apps/console/src/components/FormPage.tabbedLayout-26843.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,246 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack-ai/objectstack#26843 — the form page draws a form view's
* `type: 'tabbed'` and `layout: 'horizontal'`, which it never read.
*
* - `type: 'tabbed'`: one tab per section, the form view's `defaultTab` open
* first and `tabPosition` placing the strip — the semantics of the app's
* tabbed form. A submit carries every section's fields, the closed tabs'
* included, and a refused submit opens the tab holding the field it names.
* - `layout: 'horizontal'`: each row draws its label beside the control from
* `sm` up, the arrangement the form renderer gives the same key.
*
* CONTROLS: a `type: 'simple'` view, and a tabbed view with one section, draw
* stacked sections and no tab strip; no `layout` draws no grid row.
*/

import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, cleanup, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { createI18n } from '@object-ui/i18n';
import { FormPage, resolveActiveTab, sectionTabKey } from './FormPage';

vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));

const OBJECT_SCHEMA = {
name: 'showcase_ticket',
label: 'Ticket',
fields: {
title: { type: 'text', label: 'Title' },
notes: { type: 'text', label: 'Notes' },
channel: { type: 'text', label: 'Channel' },
},
};

const THREE_SECTIONS = [
{ name: 'main', label: 'Main', fields: ['title'] },
{ name: 'more', label: 'More', fields: ['notes'] },
{ name: 'routing', label: 'Routing', fields: ['channel'] },
];

let submits: Array<{ url: string; body: Record<string, unknown> }> = [];

function stubFetch(form: Record<string, unknown>, objectSchema: unknown = OBJECT_SCHEMA) {
return vi.fn(async (url: string, init?: RequestInit) => {
const path = String(url);
if (init?.method === 'POST') {
submits.push({ url: path, body: JSON.parse(String(init.body)) });
}
const body = path.includes('/meta/view/')
? {
name: 'showcase_ticket.intake',
object: 'showcase_ticket',
viewKind: 'form',
label: 'Intake',
config: { type: 'tabbed', sections: THREE_SECTIONS, ...form },
}
: path.includes('/meta/object/')
? objectSchema
: path.includes('/data/showcase_ticket')
? { object: 'showcase_ticket', id: 't1', record: { id: 't1' } }
: null;
if (!body) throw new Error(`unstubbed fetch: ${path}`);
return {
ok: true,
status: 200,
statusText: 'OK',
json: async () => body,
text: async () => JSON.stringify(body),
} as unknown as Response;
});
}

function renderPage() {
return render(
<MemoryRouter initialEntries={['/forms/showcase_ticket.intake']}>
<Routes>
<Route path="/forms/:name" element={<FormPage mode="internal" />} />
</Routes>
</MemoryRouter>,
);
}

const input = (name: string) => document.querySelector(`[name="${name}"]`) as HTMLInputElement | null;
const tab = (key: string) => screen.queryByTestId(`form-tab:${key}`);
const panel = (key: string) => screen.queryByTestId(`form-tab-panel:${key}`);
const isOpen = (key: string) => tab(key)?.getAttribute('data-state') === 'active';

beforeEach(() => {
submits = [];
createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

describe('FormPage draws a tabbed form view as tabs (#26843)', () => {
it('one tab per section, named by its heading, every panel mounted', async () => {
vi.stubGlobal('fetch', stubFetch({}));
renderPage();
await waitFor(() => expect(tab('main')).not.toBeNull());
const strip = screen.getByRole('tablist');
expect(within(strip).getAllByRole('tab').map((t) => t.textContent)).toEqual(['Main', 'More', 'Routing']);
// No stacked section card beside the strip.
expect(document.querySelector('form section')).toBeNull();
// Force-mounted: a closed tab's control exists, only CSS-hidden. (The
// widgets are lazy, so they arrive after the strip.)
await waitFor(() => {
for (const name of ['title', 'notes', 'channel']) expect(input(name), name).not.toBeNull();
});
expect(panel('more')?.getAttribute('data-state')).toBe('inactive');
expect(panel('more')?.className).toContain('data-[state=inactive]:hidden');
// With no `defaultTab`, the first tab opens.
expect(isOpen('main')).toBe(true);
});

it('the form view\'s defaultTab opens first', async () => {
vi.stubGlobal('fetch', stubFetch({ defaultTab: 'routing' }));
renderPage();
await waitFor(() => expect(tab('routing')).not.toBeNull());
expect(isOpen('routing')).toBe(true);
expect(isOpen('main')).toBe(false);
});

it('a tab click switches the open panel', async () => {
vi.stubGlobal('fetch', stubFetch({}));
renderPage();
await waitFor(() => expect(tab('more')).not.toBeNull());
await userEvent.click(tab('more')!);
expect(isOpen('more')).toBe(true);
expect(panel('main')?.getAttribute('data-state')).toBe('inactive');
});

it('tabPosition: left draws a vertical strip', async () => {
vi.stubGlobal('fetch', stubFetch({ tabPosition: 'left' }));
renderPage();
await waitFor(() => expect(tab('main')).not.toBeNull());
expect(screen.getByRole('tablist').getAttribute('aria-orientation')).toBe('vertical');
});

it('a submit carries every section\'s fields, the closed tabs\' included', async () => {
vi.stubGlobal('fetch', stubFetch({ defaults: { channel: 'web' } }));
renderPage();
await waitFor(() => expect(input('title')).not.toBeNull());
await userEvent.type(input('title')!, 'Printer jam');
await userEvent.click(tab('more')!);
await userEvent.type(input('notes')!, 'Third floor');
// `routing` is never opened: its field carries the form view's default.
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
await waitFor(() => expect(submits).toHaveLength(1));
expect(submits[0].body).toMatchObject({ title: 'Printer jam', notes: 'Third floor', channel: 'web' });
});

it('a refused submit opens the tab holding the field it names', async () => {
const schema = {
...OBJECT_SCHEMA,
fields: { ...OBJECT_SCHEMA.fields, channel: { type: 'text', label: 'Channel', required: true } },
};
vi.stubGlobal('fetch', stubFetch({}, schema));
renderPage();
await waitFor(() => expect(input('title')).not.toBeNull());
expect(isOpen('main')).toBe(true);
await userEvent.click(screen.getByRole('button', { name: 'Submit' }));
await waitFor(() => expect(isOpen('routing')).toBe(true));
expect(submits).toHaveLength(0);
});

it('CONTROL: a single-section tabbed view draws its section stacked, no strip', async () => {
vi.stubGlobal('fetch', stubFetch({ sections: [THREE_SECTIONS[0]] }));
renderPage();
await waitFor(() => expect(input('title')).not.toBeNull());
expect(screen.queryByRole('tablist')).toBeNull();
expect(document.querySelectorAll('form section')).toHaveLength(1);
});

it('CONTROL: a simple view draws every section stacked, no strip', async () => {
vi.stubGlobal('fetch', stubFetch({ type: 'simple', defaultTab: 'routing' }));
renderPage();
await waitFor(() => expect(input('title')).not.toBeNull());
expect(screen.queryByRole('tablist')).toBeNull();
expect([...document.querySelectorAll('form section h2')].map((h) => h.textContent)).toEqual([
'Main',
'More',
'Routing',
]);
});
});

describe('FormPage draws layout: horizontal rows (#26843)', () => {
const LABELS: Record<string, string> = { title: 'Title', notes: 'Notes', channel: 'Channel' };
/** The row a field's `<label>` sits in. */
const row = (name: string) => screen.getByText(LABELS[name]).closest('label')!.parentElement!;

it('each row puts its label beside the control at sm, stacked below', async () => {
vi.stubGlobal('fetch', stubFetch({ type: 'simple', layout: 'horizontal' }));
renderPage();
await waitFor(() => expect(input('title')).not.toBeNull());
const classes = row('title').className.split(/\s+/);
expect(classes).toContain('sm:grid');
expect(classes).toContain('sm:[&>:not(label)]:col-start-2');
// Below sm nothing changes: every class the row carries is sm:-prefixed.
expect(classes.filter((c) => !c.startsWith('sm:'))).toEqual([]);
expect(row('title').firstElementChild?.tagName).toBe('LABEL');
// The label still names its control.
expect(screen.getByLabelText('Title')).toBe(input('title'));
});

it('a tabbed view keeps it on every tab', async () => {
vi.stubGlobal('fetch', stubFetch({ layout: 'horizontal' }));
renderPage();
await waitFor(() => expect(input('notes')).not.toBeNull());
for (const name of ['title', 'notes', 'channel']) {
expect(row(name).className.split(/\s+/), name).toContain('sm:grid');
}
});

it('CONTROL: no layout, and vertical — no grid row', async () => {
for (const layout of [undefined, 'vertical']) {
vi.stubGlobal('fetch', stubFetch({ type: 'simple', ...(layout ? { layout } : {}) }));
renderPage();
await waitFor(() => expect(input('title')).not.toBeNull());
expect(row('title').className.split(/\s+/), String(layout)).not.toContain('sm:grid');
cleanup();
}
});
});

describe('the tab rules (#26843)', () => {
it('a section\'s tab is its name, else its heading, else its position — TabbedForm\'s rule', () => {
expect(sectionTabKey({ name: 'main', label: 'Main' }, 0)).toBe('main');
expect(sectionTabKey({ label: 'Main' }, 0)).toBe('Main');
expect(sectionTabKey({}, 2)).toBe('tab-2');
});

it('the open tab is the pick, else defaultTab, else the first — each only if drawn', () => {
const drawn = ['a', 'b', 'c'];
expect(resolveActiveTab(drawn, 'c', 'b')).toBe('c');
expect(resolveActiveTab(drawn, null, 'b')).toBe('b');
expect(resolveActiveTab(drawn, 'gone', 'b')).toBe('b');
expect(resolveActiveTab(drawn, null, 'hidden')).toBe('a');
expect(resolveActiveTab([], null, 'b')).toBeUndefined();
});
});
Loading
Loading