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
2 changes: 2 additions & 0 deletions .changeset/10859-object-form-properties-bag.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,5 @@ Nothing changes at render time: `SchemaRenderer` hoists every `properties` key o
**The same release's `.changeset/6152-object-form-unmirrored-members.md`** declared members on the flat mirror so that the strict face stopped refusing them on an authored node. On an authored node those members are now written in the bag, where the spec row judges them: every one of them except `buttons`, `defaults` and `subforms`, which the row does not declare. Those three stay authorable in an `object-view`'s `form` slot, which is built from the mirror, and a section there is still judged member by member.

**What did not move.** The TypeScript `ObjectFormSchema` and its zod mirror `ObjectFormSchema` stay published and unchanged in shape. They are the node as `ObjectForm` reads it after the hoist, and as code composes it (`ObjectView`, `RecordFormPage`, `ScreenView`, a host mounting `<ObjectForm schema={…}>`). The object-view `form` slot is still built from the mirror.

**Note, 2026-10-01 (objectui#11168 slice 3, objectui#7759 group C, shipping in this same release).** `layout` is no longer one of the places the bag and the flat mirror differ. `ObjectFormSchema.layout`, on both faces, now takes the row's own `vertical | horizontal` by reference, the `object-form` registration publishes the same two, and the designer offers only those. `inline` and `grid` rendered as `vertical` before they were refused, so nothing that rendered is lost. The other differences listed above stand.
5 changes: 5 additions & 0 deletions .changeset/11168-designer-form-layout-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@object-ui/app-shell': patch
---

The page-block inspector no longer offers `Inline` and `Grid` for an `object-form`'s `layout` (objectui#11168 slice 3, objectui#7759 group C). `@objectstack/spec` 17.5.0 refuses both values, and both rendered as `Vertical`, so the designer was writing values that publish refuses. The two option labels (en, zh) are removed with the options.
9 changes: 9 additions & 0 deletions .changeset/11168-object-form-layout-two-values.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@object-ui/plugin-form': minor
---

`object-form.layout` publishes only `vertical` and `horizontal`. This is objectui#11168 slice 3 and delivers objectui#7759 group C. `@objectstack/spec` 17.5.0 retired `inline` and `grid` from the form layout enum (objectstack#20221). The authored `object-form` arm, which reads the spec row by reference, already refused both. The registration still published them, so the page validator passed values `objectui validate` refuses.

⚠️ This narrows a published input. Measured through the real `SchemaRenderer` before the change, `inline` and `grid` rendered byte-identical to `vertical` on the simple, tabbed, wizard and split layouts. Nothing that rendered is lost. A stored document carrying either value is refused at authoring; write `vertical` (the default) or `horizontal`. The fixed group ships the change as `minor`.

The fold that mapped `inline` / `grid` to `vertical` is retired with them, in `ObjectForm` (simple, drawer and modal routes), `DrawerForm` and `ModalForm`. `layout` now passes through unfolded, and an absent `layout` still draws as `vertical`.
11 changes: 11 additions & 0 deletions .changeset/11168-types-form-layout-mirrors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@object-ui/types': minor
---

The form layout mirrors state the two values the spec and the renderers honour. This is objectui#11168 slice 3 and delivers objectui#7759 group C.

- `ObjectFormSchema.layout`, TS and zod, is the `object-form` row's own enum, taken by reference: `vertical | horizontal`. It used to add `inline` and `grid`. `@objectstack/spec` 17.5.0 retired both, and both rendered as `vertical`. `ObjectViewSchema.form`, which picks this key, narrows with it.
- The zod `FormSchema.layout`, on the `form` node, drops `grid`. The TypeScript declaration and the `form` registration never had it, and the renderer reads only `layout === 'horizontal'`. Its `WiderThanDeclared` ledger row in the mirror-parity test resolves and is struck.
- The `columns` docs on both mirrors no longer say "for grid layout". `columns` sets the field grid's width, whatever the `layout`.

⚠️ This narrows the accept set: `layout: 'inline'` or `'grid'` is now a type error and a zod refusal. The fixed group ships the change as `minor`.
16 changes: 10 additions & 6 deletions apps/console/src/__tests__/registry-inputs-spec-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -760,7 +760,7 @@ const OBJECTUI_11111_LEDGER_CAPS = {
unjudgedBlocks: 4, // objectui#11168: object-gantt, object-map, object-timeline, object-tree
offSpecInputs: 0, // objectui#11168 slice 1 retired action:group.name
unpublishedKeys: 3, // objectui#11168: 3 (action:button endpoint + undoable, action:icon endpoint); objectui#8652: 0 and objectui#8649: 0 (each struck by its landing)
refusedArms: 1, // objectui#11168: object-form (slice 2 narrowed element:definition-list.columns)
refusedArms: 0, // objectui#11168: slice 2 narrowed element:definition-list.columns, slice 3 object-form.layout
memberPins: 0, // objectui#11168 slice 2 pinned element:definition-list.items and element:repeater ×3
} as const;

Expand Down Expand Up @@ -1991,11 +1991,15 @@ const OFF_SPEC_ARM_EXEMPTIONS: Record<string, string> = {
* declares and the renderer compares (`props.columns === 2`); the strings it
* declared before drew one column. The pin is
* `packages/components/src/renderers/basic/__tests__/element-list-inputs-11168.test.tsx`.
*
* objectui#11168 slice 3 took the last entry, `object-form.layout:enum`, the
* same way, and with it objectui#7759 group C: the enum is `vertical` /
* `horizontal`, the two the spec row declares. Measured through the real
* `SchemaRenderer`, `inline` and `grid` rendered byte-identical to
* `vertical`, so nothing that rendered is lost. The pin is
* `packages/plugin-form/src/__tests__/objectFormLayout-11168.test.tsx`.
* The map is EMPTY again.
*/
'object-form.layout:enum': OWED_TO(
'objectui#11168',
'A REFUSED ARM: `object-form.layout` declares the members `inline` and `grid`, which the 17.5.0 contract refuses.',
),
};

// ── the MEMBER direction (objectui#8067) ─────────────────────────────────────
Expand Down Expand Up @@ -5615,6 +5619,6 @@ describe('registry `inputs` vs `@objectstack/spec` ComponentPropsMap (repo-wide)
reasons.filter((reason) => reason.startsWith(`${OWED_PREFIX}${owner}.`)).length,
]),
),
).toEqual({ 'objectui#11168': 8, 'objectui#8652': 0, 'objectui#8649': 0 });
).toEqual({ 'objectui#11168': 7, 'objectui#8652': 0, 'objectui#8649': 0 });
});
});
2 changes: 1 addition & 1 deletion content/docs/plugins/plugin-view.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -604,7 +604,7 @@ derived from the child object's metadata unless you override them
### View tabs

There is no `tabs` key, and `form.layout` has no tabbed value
(`vertical | horizontal | inline | grid`). The tab strip this package ships is
(`vertical | horizontal`). The tab strip this package ships is
the **saved-view** tab bar: declare the views and render `<ViewTabBar>` (or let
a host such as `@object-ui/app-shell` do it):

Expand Down
4 changes: 0 additions & 4 deletions packages/app-shell/src/views/metadata-admin/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,8 +672,6 @@ const ENGINE_STRINGS_EN: Record<string, string> = {
'engine.inspector.pageBlock.field.object-form.layout': 'Layout',
'engine.inspector.pageBlock.option.layout.vertical': 'Vertical',
'engine.inspector.pageBlock.option.layout.horizontal': 'Horizontal',
'engine.inspector.pageBlock.option.layout.inline': 'Inline',
'engine.inspector.pageBlock.option.layout.grid': 'Grid',
'engine.inspector.pageBlock.field.object-form.columns': 'Columns (grid layout)',
'engine.inspector.pageBlock.field.object-form.fields': 'Fields',
'engine.inspector.pageBlock.field.object-form.title': 'Title',
Expand Down Expand Up @@ -3672,8 +3670,6 @@ const ENGINE_STRINGS_ZH: Record<string, string> = {
'engine.inspector.pageBlock.field.object-form.layout': '布局',
'engine.inspector.pageBlock.option.layout.vertical': '纵向',
'engine.inspector.pageBlock.option.layout.horizontal': '横向',
'engine.inspector.pageBlock.option.layout.inline': '行内',
'engine.inspector.pageBlock.option.layout.grid': '网格',
'engine.inspector.pageBlock.field.object-form.columns': '列数(网格布局)',
'engine.inspector.pageBlock.field.object-form.fields': '字段',
'engine.inspector.pageBlock.field.object-form.title': '标题',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,9 @@ export const BLOCK_CONFIG: Record<string, BlockPropField[]> = {
options: [
{ value: 'vertical', label: 'engine.inspector.pageBlock.option.layout.vertical' },
{ value: 'horizontal', label: 'engine.inspector.pageBlock.option.layout.horizontal' },
{ value: 'inline', label: 'engine.inspector.pageBlock.option.layout.inline' },
{ value: 'grid', label: 'engine.inspector.pageBlock.option.layout.grid' },
// `inline` / `grid` are not offered: `@objectstack/spec` 17.5.0 refuses
// both on `object-form.layout`, and they rendered as `vertical`
// (objectui#11168 slice 3, objectui#7759 group C).
],
},
{ name: 'columns', label: 'engine.inspector.pageBlock.field.object-form.columns', kind: 'number', placeholder: { literal: '2' } },
Expand Down
7 changes: 4 additions & 3 deletions packages/plugin-form/src/DrawerForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -662,9 +662,10 @@ export const DrawerForm: React.FC<DrawerFormProps> = ({
: { height: schema.drawerWidth, maxHeight: schema.drawerWidth };
}, [schema.drawerWidth, side]);

const formLayout = (schema.layout === 'vertical' || schema.layout === 'horizontal')
? schema.layout
: 'vertical';
// `vertical | horizontal` on the declared face; the fold that mapped any
// other value to `vertical` was retired with `inline` / `grid`
// (objectui#11168 slice 3, objectui#7759 group C).
const formLayout = schema.layout ?? 'vertical';

// Action buttons live in the drawer's own footer (not inside the form
// renderer). Routing Cancel through the footer lets it call the
Expand Down
7 changes: 4 additions & 3 deletions packages/plugin-form/src/ModalForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -715,9 +715,10 @@ export const ModalForm: React.FC<ModalFormProps> = ({
finalizeClose();
}, [finalizeClose]);

const formLayout = (schema.layout === 'vertical' || schema.layout === 'horizontal')
? schema.layout
: 'vertical';
// `vertical | horizontal` on the declared face; the fold that mapped any
// other value to `vertical` was retired with `inline` / `grid`
// (objectui#11168 slice 3, objectui#7759 group C).
const formLayout = schema.layout ?? 'vertical';

// Build base form schema
// Actions are hidden inside the form renderer — we render them in a sticky footer instead
Expand Down
24 changes: 10 additions & 14 deletions packages/plugin-form/src/ObjectForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -545,13 +545,13 @@ export const ObjectForm: React.FC<ObjectFormComponentProps> = ({
}

if (schema.formType === 'drawer') {
const { layout: _layout, ...drawerRest } = schema;
const drawerLayout = (schema.layout === 'vertical' || schema.layout === 'horizontal') ? schema.layout : undefined;
// `layout` passes through unfolded: it is `vertical | horizontal` on both
// faces since objectui#11168 slice 3 (objectui#7759 group C), and the fold
// that mapped `inline` / `grid` away here is retired with them.
return (
<DrawerForm
schema={{
...drawerRest,
layout: drawerLayout,
...schema,
formType: 'drawer',
sections: schema.sections?.map(s => ({
name: s.name,
Expand Down Expand Up @@ -581,13 +581,11 @@ export const ObjectForm: React.FC<ObjectFormComponentProps> = ({
}

if (schema.formType === 'modal') {
const { layout: _layout2, ...modalRest } = schema;
const modalLayout = (schema.layout === 'vertical' || schema.layout === 'horizontal') ? schema.layout : undefined;
// `layout` passes through unfolded — see the drawer branch above.
return (
<ModalForm
schema={{
...modalRest,
layout: modalLayout,
...schema,
formType: 'modal',
sections: schema.sections?.map(s => ({
name: s.name,
Expand Down Expand Up @@ -1571,12 +1569,10 @@ const SimpleObjectForm: React.FC<{ schema: LocalizedObjectFormSchema; dataSource
);
}

// Convert to FormSchema
// Note: FormSchema currently only supports 'vertical' and 'horizontal' layouts
// Map 'grid' and 'inline' to 'vertical' as fallback
const formLayout = (schema.layout === 'vertical' || schema.layout === 'horizontal')
? schema.layout
: 'vertical';
// Convert to FormSchema. `layout` is `vertical | horizontal` on both faces
// since objectui#11168 slice 3 (objectui#7759 group C); the fold that mapped
// the retired `inline` / `grid` to `vertical` here is retired with them.
const formLayout = schema.layout ?? 'vertical';

// If sections are provided (explicitly, or derived from the object's
// `fieldGroups`) for the simple form, render them as full-width, optionally
Expand Down
136 changes: 136 additions & 0 deletions packages/plugin-form/src/__tests__/objectFormLayout-11168.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* ObjectUI
* Copyright (c) 2024-present ObjectStack Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/

/**
* objectui#11168 slice 3 — `object-form.layout` is `vertical | horizontal` on
* every face, and objectui#7759 group C with it.
*
* `@objectstack/spec` 17.5.0 retired `inline` and `grid` from the form layout
* enum (objectstack#20221). The authored `object-form` arm reads its props
* through the spec row by reference, so it already refused both. Three objectui
* faces still published them: the `object-form` registration, the
* `ObjectFormSchema` mirror (TS and zod), and the `form` node's `FormSchema`
* zod mirror (`grid`). The designer offered both too. Measured through the real
* `SchemaRenderer` before the change, both rendered byte-identical to
* `vertical`: every layout folded them to `vertical`, and the `form` renderer
* reads only `layout === 'horizontal'`. So narrowing loses nothing that
* rendered, and the folds are retired with the values.
*
* The compile-time rows are checked by `tsc -p tsconfig.test.json`.
*/
import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, cleanup, screen, waitFor } from '@testing-library/react';
import { ComponentRegistry } from '@object-ui/core';
import { SchemaRenderer, SchemaRendererProvider } from '@object-ui/react';
import { registerAllFields } from '@object-ui/fields';
import { safeValidateSchema, ObjectFormSchema as ObjectFormMirror, FormSchema as FormMirror } from '@object-ui/types/zod';
import type { ObjectFormSchema, FormSchema } from '@object-ui/types';
import type { ObjectFormProps } from '@objectstack/spec/ui';
// Registers the `form` renderer the object-form draws through, and
// `object-form` itself, at module scope.
import '@object-ui/components';
import '../index';

registerAllFields();
afterEach(cleanup);

type Equal<A, B> = (<T>() => T extends A ? 1 : 2) extends (<T>() => T extends B ? 1 : 2) ? true : false;
type Expect<T extends true> = T;

/** The TS face IS the spec row's enum, so a spec release that moves it moves this type. */
export type _ObjectFormLayoutIsTheRowsEnum = Expect<Equal<ObjectFormSchema['layout'], ObjectFormProps['layout']>>;
export type _FormLayoutIsTwoValues = Expect<Equal<FormSchema['layout'], 'vertical' | 'horizontal' | undefined>>;
// @ts-expect-error — `grid` is retired on the object-form's TS face.
const _retiredGrid: ObjectFormSchema['layout'] = 'grid';
// @ts-expect-error — and so is `inline`.
const _retiredInline: ObjectFormSchema['layout'] = 'inline';

const RETIRED = ['inline', 'grid'] as const;
const LIVE = ['vertical', 'horizontal'] as const;

/** `{ code, path }` of what a parse refused, so a row names WHICH key was refused and HOW. */
const refusal = (result: { success: boolean; error?: { issues: Array<{ code: string; path: PropertyKey[] }> } }) =>
result.success ? [] : result.error!.issues.map((issue) => ({ code: issue.code, path: issue.path.join('.') }));

describe('`object-form.layout` publishes the spec row\'s two values (objectui#11168, objectui#7759 group C)', () => {
it('the registration declares exactly `vertical` and `horizontal`', () => {
const layout = (ComponentRegistry.getConfig('object-form', 'plugin-form')?.inputs ?? []).find(
(input) => input.name === 'layout',
);
expect(layout?.enum).toEqual(['vertical', 'horizontal']);
});

it.each(RETIRED)('`objectui validate` refuses `layout: "%s"` on an authored object-form', (layout) => {
// The acceptance row: the authored arm reads the spec row by reference.
expect(refusal(safeValidateSchema({ type: 'object-form', properties: { objectName: 'contact', layout } }) as never)).toEqual([
{ code: 'invalid_value', path: 'properties.layout' },
]);
});

it.each(LIVE)('LIT CONTROL: `objectui validate` accepts `layout: "%s"`', (layout) => {
expect(safeValidateSchema({ type: 'object-form', properties: { objectName: 'contact', layout } }).success).toBe(true);
});

it.each(RETIRED)('the `ObjectFormSchema` mirror refuses `%s` at `layout`, and accepts both live values', (layout) => {
const node = { type: 'object-form', objectName: 'contact', mode: 'create' };
expect(refusal(ObjectFormMirror.safeParse({ ...node, layout }) as never)).toEqual([
{ code: 'invalid_value', path: 'layout' },
]);
for (const live of LIVE) expect(ObjectFormMirror.safeParse({ ...node, layout: live }).success).toBe(true);
});

it('the `form` node\'s `FormSchema` mirror refuses `grid` — its declaration and registration never had it', () => {
const node = { type: 'form', fields: [] };
expect(refusal(FormMirror.safeParse({ ...node, layout: 'grid' }) as never)).toEqual([
{ code: 'invalid_value', path: 'layout' },
]);
for (const live of LIVE) expect(FormMirror.safeParse({ ...node, layout: live }).success).toBe(true);
expect(ComponentRegistry.getConfig('form')?.inputs?.find((input) => input.name === 'layout')?.enum).toEqual(LIVE);
});

it('the compile-time rows are real values', () => {
expect([_retiredGrid, _retiredInline]).toEqual(['grid', 'inline']);
});
});

describe('with the folds retired, the key still decides label placement (objectui#11168)', () => {
const OBJECT = { name: 'contact', fields: { name: { type: 'text', label: 'Name' } } };

/** The rendered form, ids normalised, once its field label is on screen. */
async function rendered(layout: string | undefined): Promise<string> {
const ds = {
getObjectSchema: vi.fn().mockResolvedValue(OBJECT),
find: vi.fn(async () => ({ data: [] })),
findOne: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
};
render(
<SchemaRendererProvider dataSource={ds as never}>
<SchemaRenderer
schema={{ type: 'object-form', properties: { objectName: 'contact', mode: 'create', ...(layout ? { layout } : {}) } } as never}
/>
</SchemaRendererProvider>,
);
await waitFor(() => expect(screen.getAllByText('Name').length).toBeGreaterThan(0));
const html = document.body.innerHTML
.replace(/(id|for|aria-[a-z]+|name)="[^"]*"/g, '$1=""')
.replace(/«[^»]*»|_r_[0-9a-z]+_|:r[0-9a-z]+:/g, '');
cleanup();
return html;
}

it('an absent `layout` draws exactly what `vertical` draws, and `horizontal` draws something else', async () => {
const vertical = await rendered('vertical');
expect(await rendered(undefined)).toBe(vertical);
// LIT CONTROL: the comparison can tell layouts apart.
expect(await rendered('horizontal')).not.toBe(vertical);
});
});
Loading
Loading