Skip to content

Commit 6af79c1

Browse files
fix(console): every console form entry point renders the form view's buttons, layout and defaults, and a tabbed form with subforms opens on its defaultTab (#27027)
<!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ83Xfyz6um6gNWtmJ2QTcNH3)_ Refs #26843 (items buttons, layout, defaults, defaultTab, tabPosition) Clause-②: no Metadata-protocol: semantic Skills-docs: none — the skills and docs already teach these keys as rendered (`content/docs/protocol/objectui/layout-dsl.mdx` for `defaultTab` / `tabPosition`, the spec's property help for `buttons` / `defaults` / `layout`); this diff makes the console do what they say. ## 维护者速读 - 改了什么:控制台三个表单入口(整页新建/编辑、新建/编辑弹窗、表单页 `/forms/:name` 与公开表单 `/f/:slug`)现在都按表单视图里声明的 `buttons`、`layout`、`defaults` 渲染;带子表的分页签表单按 `defaultTab` / `tabPosition` 打开。 - 为什么改:这些键能保存、能发布,但各入口用自己写死的 `showSubmit: true`、按钮文案和 `layout: 'vertical'` 盖掉了作者的值,作者看不到任何效果(ADR-0049 声明即生效)。 - 风险与回滚:只改 UI 渲染层,不动 spec 和服务端;表单视图没写这些键时行为不变(每组测试都有对照用例)。回滚即 revert 本 PR。 - 先看:`ui/packages/app-shell/src/utils/recordFormNavigation.ts`(`resolveFormViewChrome` 的优先级与 `defaults` 合并缓存)、`ui/packages/app-shell/src/console/AppContent.tsx`(新建弹窗那一处)、`ui/packages/plugin-form/src/ModalForm.tsx`(子表分支补 `formType`)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/app-shell/src/utils/__tests__/formViewChrome-26843.test.ts packages/plugin-form/src/__tests__/formViewTabKeysAndButtons-26843.test.tsx`,预期全部通过。 - 需要你做的:无,席位落地。 ## What changes One resolver, `resolveFormViewChrome` (`ui/packages/app-shell/src/utils/recordFormNavigation.ts`), maps the selected form view's keys onto the form props, and the full-page record form and the New / Edit dialog both spread it in place of their hard-coded sets: - `buttons.submit` / `buttons.cancel`: an authored `show` wins over the host's `true`; an authored `label` (an `I18nLabel`, resolved against the UI language) over the host's label. `buttons.reset` is not read: no host draws a reset button. - `layout`: the authored value when the spec's enum allows it, else `'vertical'`. - `defaults`, on a create only: merged under the values the host already carries (URL prefill on the page; the `formLink` pre-link or the opener's seeded values in the dialog), which win per field. Before, any prefill dropped `defaults` wholesale, and the dialog never read them. The merged seed is cached on the metadata payload and on the host values by content, so it keeps its identity across renders (the forms re-seed on a new `initialValues`), with no dependency on a memoised identity (ui/AGENTS.md #10). Precedence, as measured: no flat `showSubmit` / `submitText` / `showCancel` / `cancelText` key is author-written. `FormViewSchema` is strict and refuses them, and a census over `examples/**` and `packages/**` finds the one `submitText` on a page block (`project-workspace.page.ts`, an `object-master-detail-form` node), none on a form view. So the hosts' flat values are console defaults, and the authored form-view keys win. The form page (`ui/apps/console/src/components/FormPage.tsx`) is its own renderer and reads the same keys locally: `readFormButton` for the submit / cancel label and visibility (the public route still draws no Cancel), and `readPrefill` takes the form view's `defaults` on a create, over the object's field defaults and under a `?prefill_` param. The tab keys: - `resolveFormViewLayout` copies `defaultTab` / `tabPosition` beside `contentLayout: 'tabbed'`, and `ModalForm`'s tabbed arm hands them to the form renderer (`defaultFieldTab` / `fieldTabsPosition`). - `ModalForm`'s subforms arm now passes `formType` (tabbed when the content layout is), the tab keys, `layout` and `showSubmit` to `MasterDetailForm`, and hands `onCancel` only when Cancel is shown. - `ObjectForm`'s master-detail branch forwards `defaultTab` / `tabPosition` / `layout`, and `onCancel` only when `showCancel` is not `false`; `MasterDetailForm` passes the three keys to its header `ObjectForm`. Not in this PR (the seat's split): `buttons.reset` and the drawer keys (`drawerSide` / `drawerWidth`), which stay on #26843 for an implement-or-retire spec card. ## Tests New, each block with a CONTROL where the key is absent: - `ui/packages/app-shell/src/utils/__tests__/formViewChrome-26843.test.ts`: the resolver's precedence, the per-locale label, the refused `layout: 'grid'` and `tabPosition` values, the per-field merge and its identity, edit reads no defaults. - `ui/packages/app-shell/src/views/RecordFormPage.formViewChrome-26843.test.tsx`: the page hands the authored chrome to `ObjectForm`; a URL prefill wins per field. - `ui/packages/app-shell/src/console/__tests__/AppContent.formViewChrome-26843.test.tsx`: the dialog hands the authored chrome and tab keys to `ModalForm`; a `formLink` pre-link wins per field and the seed has one identity across renders; the real dialog draws the authored label and no Cancel. - `ui/packages/plugin-form/src/__tests__/formViewTabKeysAndButtons-26843.test.tsx`: real renders. A tabbed form with subforms opens on `defaultTab` with a vertical strip for `tabPosition: 'left'`; the modal tabbed arm and its subforms arm likewise; master-detail Cancel and Save follow `showCancel` / `showSubmit`. - `ui/apps/console/src/components/FormPage.formViewChrome-26843.test.tsx`: authored labels and hidden buttons on the internal and public routes, `defaults` on a create, a `prefill_` param wins, an edit reads the record. ## Acceptance notes - `layout: 'horizontal'` now reaches every form the console opens, but the shared form renderer's only reading of it is the action row's alignment (`sm:justify-end` against `sm:justify-start`). The dialog draws its buttons in its own footer, which is always end-aligned, and the form page's action row is always end-aligned too. So in those two places the key arrives and nothing is drawn differently. What `horizontal` should draw (labels beside inputs, as the spec's "Field layout direction" reads) is a renderer-contract question for the seat; it is listed in the report. - `TabbedForm` hard-codes `layout: 'vertical'` on the form it builds, so `horizontal` on a tabbed form view is dropped there. The file is outside this card's surface; it is listed in the report. - A master-detail form with `showSubmit: false` hides its whole action bar, Cancel included, as before. - `packages/spec/liveness/view.json` rows for `buttons` and `defaults` cite `RecordFormPage` as forwarding them; it forwarded them, but then set its own flat keys over `buttons`, and the New dialog and the form page read neither. The ledger is not edited here (`packages/spec/**` is out of bounds); the stale evidence is listed in the report. ## Verification Head `7e4a1249` (after merging `origin/main` at `3271a083`), all under `scripts/pm/os-verify-lock.sh`: <details><summary>Affected-package tests and type-checks</summary> - `pnpm --dir ui exec vitest run packages/plugin-form/`: Test Files 204 passed, Tests 2422 passed / 1 skipped. - app-shell (every `RecordFormPage*`, `AppContent*` and `utils` test): Test Files 81 passed / 1 skipped, Tests 949 passed / 8 skipped. - every `FormPage*` test in `ui/apps/console`: Test Files 34 passed, Tests 328 passed. - `type-check` for `@object-ui/plugin-form`, `@object-ui/app-shell` and `@object-ui/console`: exit 0 (the console one after building its closure, `--filter '@object-ui/console^...'`). </details> <details><summary>Ablations (each restored, blob equal to HEAD, `git diff HEAD` empty)</summary> Driven through `scripts/ablation-replace.mjs`; every mutation landed on disk and turned its test red: - RecordFormPage hard-codes its chrome again: 4 failed. - the New dialog hard-codes its chrome again: 6 failed. - ModalForm's tabbed arm drops `defaultFieldTab`: 1 failed. - ModalForm's subforms arm drops `formType`: 3 failed. - ObjectForm's master-detail branch drops `defaultTab` / `tabPosition`: 1 failed. - MasterDetailForm's header drops `defaultTab`: 2 failed. - ObjectForm's master-detail branch hands `onCancel` regardless: 1 failed. - FormPage's submit label ignores `buttons`: 2 failed. - FormPage's load ignores `defaults`: 2 failed. - the resolver rebuilds the merged seed per call: 2 failed. Two first attempts were no-ops the tool refused (a replacement containing its anchor, an ambiguous anchor); both were re-run on a different anchor, and the counts above are the re-runs. </details> <details><summary>Derived gates</summary> `node scripts/pm/dispatch-gates.mjs --ran` on head `7e4a1249`: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN. Every one exited 0; the last two lines are run in addition to the derived 34. - `node scripts/check-adr-0087-registration.mjs --base origin/main`: exit 0 - `node scripts/check-adr-0087-registration.mjs --self-test`: exit 0 - `node scripts/check-changeset-no-major.mjs --base origin/main`: exit 0 - `node scripts/check-closing-keyword-parity.mjs`: exit 0 - `node scripts/check-closing-keyword-parity.mjs --self-test`: exit 0 - `node scripts/check-comment-mask-corpus.mjs`: exit 0 - `node scripts/check-empty-changeset.mjs --base origin/main`: exit 0 - `node scripts/check-empty-changeset.mjs --self-test`: exit 0 - `node scripts/check-sdui-manifest.mjs`: exit 0 - `node scripts/check-sdui-manifest.mjs --self-test`: exit 0 - `node scripts/pm/release-rehearsal-clone.mjs --self-test`: exit 0 - `node scripts/release-pending-publish.mjs --self-test`: exit 0 - `pnpm --dir ui exec vitest run scripts/`: exit 0 - `pnpm --filter @objectstack/spec run check:duration-unit-keys`: exit 0 - `pnpm check:changeset-gate-self-tests`: exit 0 - `pnpm check:closed-ceremony-text`: exit 0 - `pnpm check:cross-package-test-inputs`: exit 0 - `pnpm check:dispatcher-error-vocabulary`: exit 0 - `pnpm check:driver-memory-census`: exit 0 - `pnpm check:engine-double-contract`: exit 0 - `pnpm check:gitlink-declared`: exit 0 - `pnpm check:nul-bytes`: exit 0 - `pnpm check:pm-changeset-deadline-census`: exit 0 - `pnpm check:published-files`: exit 0 - `pnpm check:query-options-erasure`: exit 0 - `pnpm check:refd-timer-probe`: exit 0 - `pnpm check:skill-identifier-liveness`: exit 0 - `pnpm check:test-source-alias`: exit 0 - `pnpm check:tier-file-adoption`: exit 0 - `pnpm check:type-check-coverage`: exit 0 - `pnpm check:type-check-debt`: exit 0 - `pnpm check:undeclared-dep-imports`: exit 0 - `pnpm check:watch-hint-literal`: exit 0 - `pnpm check:where-matcher`: exit 0 - `pnpm --dir ui check:spec-symbols`: exit 0 - `pnpm --dir ui check:new-line-citations`: exit 0 </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes (CI). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JQwxbabN2bfLWeaoNk6Jue --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f558e14 commit 6af79c1

13 files changed

Lines changed: 1101 additions & 34 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/plugin-form': minor
3+
'@object-ui/console': minor
4+
---
5+
6+
fix(console): every console form entry point renders the form view's `buttons`, `layout` and `defaults`, and a tabbed form with subforms opens on its `defaultTab` (#26843)
7+
8+
Clause-②: no
9+
Metadata-protocol: semantic
10+
11+
A form view's `buttons`, `layout` and `defaults` saved and published, but the console set its own values over them, depending on which entry point opened the form:
12+
13+
- **The full-page record form** (`/new`, `/record/:id/edit`) and **the New / Edit dialog** set `showSubmit: true`, their own submit and cancel labels and `layout: 'vertical'`, so an authored `buttons.submit.label`, `buttons.*.show: false` or `layout: 'horizontal'` did nothing. Both now read them from the form view the flow selects. An authored label may be a per-locale map. The New dialog also ignored `defaults`; a create there now opens with them.
14+
- **`defaults` and the host's own opening values merge per field.** A URL prefill, a parent pre-link or a seeded calendar day still wins for the fields it names, and the form view's `defaults` fill the rest. Before, a create carrying any prefill dropped `defaults` entirely. An edit never reads `defaults`.
15+
- **The form page** (`/forms/:name`, and the public `/f/:slug`) never read `buttons` or `defaults`. An authored submit or cancel label now shows, `show: false` removes the button (the public form still draws no Cancel), and a create opens with `defaults`, over the object's own field defaults and under a `?prefill_` param.
16+
- **A tabbed form with subforms** opened on its first tab with the strip on top. `ObjectForm` now hands `defaultTab` / `tabPosition` (and `layout`) through `MasterDetailForm` to the header form. The modal's tabbed layout reads both keys, and its subforms arm now renders the header tabbed instead of stacked.
17+
- **A hidden Cancel on a master-detail form** (`showCancel: false`, or `buttons.cancel.show: false`) is no longer drawn: the action bar draws Cancel only when it is handed `onCancel`, and both form arms now hand none.
18+
19+
`buttons.reset` and the drawer keys (`drawerSide` / `drawerWidth`) are not covered: no console entry point draws a reset button or opens a form view as a drawer.
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack-ai/objectstack#26843 — the form page renders the form view's
5+
* `buttons` and `defaults`, which it never read.
6+
*
7+
* - `buttons.submit` / `buttons.cancel`: an authored `label` replaces the
8+
* page's own, an authored `show: false` removes the button. The public
9+
* route still draws no Cancel at all.
10+
* - `defaults`: a create opens with them, over the object's own field
11+
* defaults and under a `?prefill_` param; an edit never reads them.
12+
*
13+
* CONTROL: a form view authoring neither keeps the page's own `Submit` /
14+
* `Cancel` and opens blank.
15+
*/
16+
17+
import { describe, expect, it, vi, afterEach } from 'vitest';
18+
import { render, screen, waitFor, cleanup } from '@testing-library/react';
19+
import { MemoryRouter, Route, Routes } from 'react-router-dom';
20+
import { createI18n } from '@object-ui/i18n';
21+
import { FormPage, readFormButton, readPrefill } from './FormPage';
22+
23+
vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
24+
25+
const OBJECT_SCHEMA = {
26+
name: 'showcase_inquiry',
27+
fields: {
28+
title: { type: 'text', label: 'Title' },
29+
channel: { type: 'text', label: 'Channel', defaultValue: 'phone' },
30+
},
31+
};
32+
33+
function viewBody(form: Record<string, unknown>) {
34+
return {
35+
name: 'showcase_inquiry.intake',
36+
object: 'showcase_inquiry',
37+
viewKind: 'form',
38+
label: 'Intake',
39+
config: { type: 'simple', sections: [{ fields: ['title', 'channel'] }], ...form },
40+
};
41+
}
42+
43+
function stubFetch(form: Record<string, unknown>) {
44+
return vi.fn(async (url: string) => {
45+
const path = String(url);
46+
const body = path.includes('/meta/view/')
47+
? viewBody(form)
48+
: path.includes('/meta/object/')
49+
? OBJECT_SCHEMA
50+
: path.includes('/forms/intake')
51+
? { slug: 'intake', object: 'showcase_inquiry', form: viewBody(form).config, objectSchema: OBJECT_SCHEMA }
52+
: path.includes('/data/showcase_inquiry/r1')
53+
? { record: { id: 'r1', title: 'Stored', channel: 'email' } }
54+
: null;
55+
if (!body) throw new Error(`unstubbed fetch: ${path}`);
56+
return {
57+
ok: true,
58+
status: 200,
59+
statusText: 'OK',
60+
json: async () => body,
61+
text: async () => JSON.stringify(body),
62+
} as unknown as Response;
63+
});
64+
}
65+
66+
function renderAt(url: string) {
67+
createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
68+
return render(
69+
<MemoryRouter initialEntries={[url]}>
70+
<Routes>
71+
<Route path="/forms/:name" element={<FormPage mode="internal" />} />
72+
<Route path="/f/:slug" element={<FormPage mode="public" />} />
73+
</Routes>
74+
</MemoryRouter>,
75+
);
76+
}
77+
78+
const input = (name: string) => document.querySelector(`[name="${name}"]`) as HTMLInputElement | null;
79+
80+
afterEach(() => {
81+
cleanup();
82+
vi.unstubAllGlobals();
83+
});
84+
85+
describe('FormPage renders the form view\'s buttons (#26843)', () => {
86+
it('internal: the authored submit label shows, and a hidden cancel is gone', async () => {
87+
vi.stubGlobal('fetch', stubFetch({ buttons: { submit: { label: 'File inquiry' }, cancel: { show: false } } }));
88+
renderAt('/forms/showcase_inquiry.intake');
89+
expect(await screen.findByRole('button', { name: 'File inquiry' })).toBeInTheDocument();
90+
expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
91+
});
92+
93+
it('internal: an authored cancel label shows; a hidden submit is gone', async () => {
94+
vi.stubGlobal('fetch', stubFetch({ buttons: { submit: { show: false }, cancel: { label: 'Back' } } }));
95+
const { container } = renderAt('/forms/showcase_inquiry.intake');
96+
expect(await screen.findByRole('button', { name: 'Back' })).toBeInTheDocument();
97+
expect(container.querySelector('button[type="submit"]')).toBeNull();
98+
});
99+
100+
it('public: the authored submit label shows, and there is still no Cancel', async () => {
101+
vi.stubGlobal('fetch', stubFetch({ buttons: { submit: { label: 'Send' }, cancel: { label: 'Back' } } }));
102+
renderAt('/f/intake');
103+
expect(await screen.findByRole('button', { name: 'Send' })).toBeInTheDocument();
104+
expect(screen.queryByRole('button', { name: 'Back' })).toBeNull();
105+
});
106+
107+
it('CONTROL: no `buttons` — the page\'s own Submit and Cancel', async () => {
108+
vi.stubGlobal('fetch', stubFetch({}));
109+
renderAt('/forms/showcase_inquiry.intake');
110+
expect(await screen.findByRole('button', { name: 'Submit' })).toBeInTheDocument();
111+
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
112+
});
113+
});
114+
115+
describe('FormPage seeds a create with the form view\'s defaults (#26843)', () => {
116+
it('a create opens with them, over the object\'s own field default', async () => {
117+
vi.stubGlobal('fetch', stubFetch({ defaults: { title: 'Website lead', channel: 'web' } }));
118+
renderAt('/forms/showcase_inquiry.intake');
119+
await waitFor(() => expect(input('title')?.value).toBe('Website lead'));
120+
expect(input('channel')?.value).toBe('web');
121+
});
122+
123+
it('a `prefill_` param still wins per field', async () => {
124+
vi.stubGlobal('fetch', stubFetch({ defaults: { title: 'Website lead', channel: 'web' } }));
125+
renderAt('/forms/showcase_inquiry.intake?prefill_channel=chat');
126+
await waitFor(() => expect(input('channel')?.value).toBe('chat'));
127+
expect(input('title')?.value).toBe('Website lead');
128+
});
129+
130+
it('an edit reads the stored record, never the defaults', async () => {
131+
vi.stubGlobal('fetch', stubFetch({ defaults: { title: 'Website lead', channel: 'web' } }));
132+
renderAt('/forms/showcase_inquiry.intake?recordId=r1');
133+
await waitFor(() => expect(input('title')?.value).toBe('Stored'));
134+
expect(input('channel')?.value).toBe('email');
135+
});
136+
137+
it('CONTROL: no defaults — the object\'s own field default, else blank', async () => {
138+
vi.stubGlobal('fetch', stubFetch({}));
139+
renderAt('/forms/showcase_inquiry.intake');
140+
await waitFor(() => expect(input('channel')?.value).toBe('phone'));
141+
expect(input('title')?.value).toBe('');
142+
});
143+
});
144+
145+
describe('the two helpers, directly', () => {
146+
it('readFormButton: shown unless `show: false`; a per-locale label resolves', () => {
147+
expect(readFormButton(undefined, 'en')).toStrictEqual({ show: true, label: '' });
148+
expect(readFormButton({ show: false }, 'en').show).toBe(false);
149+
expect(readFormButton({ label: { en: 'Send', 'zh-CN': '发送' } }, 'zh-CN').label).toBe('发送');
150+
});
151+
152+
it('readPrefill: defaults only for a field the form draws, and never on an edit', () => {
153+
const fields = [{ name: 'title' }, { name: 'channel', defaultValue: 'phone' }] as any;
154+
const search = new URLSearchParams();
155+
expect(readPrefill(fields, search, null, { title: 'T', unknown_field: 'x' })).toStrictEqual({ title: 'T', channel: 'phone' });
156+
expect(readPrefill(fields, search, { title: 'Stored' }, { title: 'T' })).toStrictEqual({ title: 'Stored', channel: 'phone' });
157+
});
158+
});

‎ui/apps/console/src/components/FormPage.tsx‎

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1254,6 +1254,12 @@ export function readLoadedRecord(
12541254
* then write;
12551255
* 3. an explicit `?prefill_<field>=` query param, which wins over both.
12561256
*
1257+
* A CREATE also reads the form view's own `defaults` (objectstack-ai/objectstack#26843),
1258+
* the author's opening values for this form: they sit over source 1, since a
1259+
* form view is narrower than its object, and under source 3, since a `prefill_`
1260+
* param outranks every default. An edit never reads them: the stored record is
1261+
* the truth there.
1262+
*
12571263
* Point 3 is the ruling: "explicit `prefill_` params override the loaded
12581264
* record's values for the fields they name … record values fill the rest". A
12591265
* producer that forwards `?recordId=` AND `?prefill_x=` in one URL is
@@ -1331,6 +1337,7 @@ export function readPrefill(
13311337
fields: RenderableField[],
13321338
search: URLSearchParams,
13331339
record?: Record<string, unknown> | null,
1340+
formDefaults?: Record<string, unknown> | null,
13341341
): Record<string, unknown> {
13351342
const out: Record<string, unknown> = {};
13361343
for (const f of fields) {
@@ -1343,6 +1350,10 @@ export function readPrefill(
13431350
} else if (f.defaultValue !== undefined && !isRuntimeDefault(f.defaultValue)) {
13441351
out[f.name] = f.defaultValue;
13451352
}
1353+
// The form view's `defaults`, on a create only — see the docblock.
1354+
if (!record && formDefaults && Object.prototype.hasOwnProperty.call(formDefaults, f.name)) {
1355+
out[f.name] = formDefaults[f.name];
1356+
}
13461357
// `hasOwnProperty` rather than a truthiness/undefined test: a stored null
13471358
// or empty string is a real value on an edit form, and must beat the
13481359
// default.
@@ -1355,6 +1366,20 @@ export function readPrefill(
13551366
return out;
13561367
}
13571368

1369+
/**
1370+
* One `FormView.buttons` entry as this page draws it (objectstack-ai/objectstack#26843):
1371+
* shown unless the author wrote `show: false`, and the authored label — an
1372+
* `I18nLabel`, resolved against the visitor's language — or `''` when there is
1373+
* none, so the caller's own label shows. `buttons.reset` is never read: this
1374+
* page draws no reset button.
1375+
*/
1376+
export function readFormButton(
1377+
config: { show?: boolean; label?: unknown } | undefined,
1378+
language: string,
1379+
): { show: boolean; label: string } {
1380+
return { show: config?.show !== false, label: pickLocalized(config?.label, language) };
1381+
}
1382+
13581383
/**
13591384
* Drop the keys a CREATE payload must leave to the producer (objectui#5883).
13601385
*
@@ -2261,7 +2286,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
22612286
// build needs no language and the load does not re-run on a switch.
22622287
const sections = buildSections(result.form, result.objectSchema);
22632288
const allFields = sections.flatMap((s) => s.fields);
2264-
setValues(readPrefill(allFields, search, result.record));
2289+
setValues(readPrefill(allFields, search, result.record, result.form?.defaults));
22652290
})
22662291
.catch((e) => {
22672292
if (!cancelled) {
@@ -2284,6 +2309,15 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
22842309
[loaded, language],
22852310
);
22862311

2312+
/**
2313+
* The form view's `buttons` (objectstack-ai/objectstack#26843), which this
2314+
* page never read: an authored `show: false` removes the button, an authored
2315+
* `label` replaces this page's own. The public route draws no Cancel,
2316+
* authored or not — see {@link FormPageProps.cancelPath}.
2317+
*/
2318+
const submitButton = readFormButton(loaded?.form?.buttons?.submit, language);
2319+
const cancelButton = readFormButton(loaded?.form?.buttons?.cancel, language);
2320+
22872321
const behavior: EffectiveSubmitBehavior = resolveSubmitBehavior(
22882322
mode,
22892323
loaded?.form?.submitBehavior,
@@ -2460,7 +2494,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
24602494
// means back to the record as loaded — NOT to blank, which would
24612495
// stage a wipe of every field on the next save.
24622496
const allFields = sections.flatMap((s) => s.fields);
2463-
setValues(readPrefill(allFields, search, loaded.record));
2497+
setValues(readPrefill(allFields, search, loaded.record, loaded.form?.defaults));
24642498
break;
24652499
}
24662500
case 'next-record':
@@ -2706,21 +2740,22 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
27062740
</div>
27072741
)}
27082742
<div className="flex justify-end gap-2">
2709-
{mode === 'internal' && (
2743+
{mode === 'internal' && cancelButton.show && (
27102744
<button
27112745
type="button"
27122746
onClick={handleCancel}
27132747
disabled={submitting}
27142748
className="rounded-md border bg-background px-4 py-2 text-sm font-medium shadow-sm hover:bg-accent hover:text-accent-foreground disabled:opacity-50"
27152749
>
2716-
{t('common.cancel')}
2750+
{cancelButton.label || t('common.cancel')}
27172751
</button>
27182752
)}
27192753
{/* Blocked while an upload is in flight (objectui#10167): a file's
27202754
value only becomes a `sys_file` id once the adapter settles, so a
27212755
submit that beats it writes an empty field and still reports
27222756
success. The widget contract exists for this — see
27232757
`FieldInputProps.onUploadingChange`. */}
2758+
{submitButton.show && (
27242759
<button
27252760
type="submit"
27262761
disabled={submitting || isUploading}
@@ -2730,8 +2765,9 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
27302765
? t('publicForm.submitting')
27312766
: isUploading
27322767
? t('fields.file.uploading')
2733-
: t('publicForm.submit')}
2768+
: submitButton.label || t('publicForm.submit')}
27342769
</button>
2770+
)}
27352771
</div>
27362772
</form>
27372773
</div>

‎ui/packages/app-shell/src/console/AppContent.tsx‎

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import { buildExpressionUser } from '../providers/expressionUser.js';
3434
import { useTrackRouteAsRecent } from '../hooks/useTrackRouteAsRecent.js';
3535
import { useHomePath } from '../hooks/useHomePath.js';
3636
import { useSignedInUserLocale } from '../hooks/useUserLocale.js';
37-
import { resolveRecordFormTarget, resolveRecordFormModalLayout, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget, recordFormCreateValues, readRecordFormCreateValues, RECORD_FORM_CREATE_VALUES_STATE } from '../utils/recordFormNavigation.js';
37+
import { resolveRecordFormTarget, resolveRecordFormModalLayout, resolveFormViewChrome, selectRecordFormView, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget, recordFormCreateValues, readRecordFormCreateValues, RECORD_FORM_CREATE_VALUES_STATE } from '../utils/recordFormNavigation.js';
3838
import { deriveRecordSurface, deriveRecordFlowSurface } from '@object-ui/plugin-view';
3939
import { RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM } from '../urlParams.js';
4040
import { appAddressSegment, appRouteSegment, canonicalAppPathname, matchAppBySegment } from '../utils/appRoute.js';
@@ -322,7 +322,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
322322
// worse answer than the literal it replaced.
323323
const homePath = useHomePath();
324324
const previewDrafts = usePreviewDrafts();
325-
const { t } = useObjectTranslation();
325+
const { t, language } = useObjectTranslation();
326326
const { objectLabel, objectPluralLabel } = useObjectLabel();
327327

328328

@@ -1500,9 +1500,6 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
15001500
// history entry carries. Each is handed as the object it is,
15011501
// never a merge rebuilt per render: the form re-seeds whenever
15021502
// `initialValues` is a new object.
1503-
...(!editingRecord && (formLinkValues ?? overlayCreateValues)
1504-
? { initialValues: formLinkValues ?? overlayCreateValues }
1505-
: {}),
15061503
// Honor the object's DEFAULT FORM VIEW: curated sections (field
15071504
// selection + order + grouping), `contentLayout: 'tabbed'` when the
15081505
// view is tabbed, inline child collections (master-detail), and its
@@ -1542,7 +1539,6 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
15421539
: t('form.createTitle', { object: objectLabel(formObjectDef as any) }),
15431540
open: isDialogOpen,
15441541
onOpenChange: (open: boolean) => { if (!open) closeRecordForm(); },
1545-
layout: 'vertical',
15461542
// objectui#6514 — the same declared-key gate `RecordFormPage`
15471543
// runs, through the same helper. This read `f.visible`, a key
15481544
// `FieldSchema` refuses by name, so nothing an author could
@@ -1563,12 +1559,26 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
15631559
: [],
15641560
onSuccess: handleRecordFormSuccess,
15651561
onCancel: handleDialogCancel,
1566-
showSubmit: true,
1567-
showCancel: true,
1568-
submitText: editingRecord
1569-
? t('form.update', { defaultValue: 'Update' })
1570-
: t('form.create', { defaultValue: 'Create' }),
1571-
cancelText: t('common.cancel'),
1562+
// objectstack-ai/objectstack#26843 — the selected form view's
1563+
// `buttons`, `layout` and `defaults` over this dialog's own
1564+
// defaults (`resolveFormViewChrome` states the precedence). A
1565+
// create's opening values: the `formLink` pre-link
1566+
// (objectstack-ai/objectstack#2604 D3, refresh-safe), else the
1567+
// values its opener's history entry carries (objectui#12291),
1568+
// over the form view's `defaults`; each handed on as the object
1569+
// it is, as the form re-seeds on a new `initialValues`.
1570+
...resolveFormViewChrome(
1571+
selectRecordFormView(formObjectDef as any, editingRecord ? 'edit' : 'create'),
1572+
{
1573+
flow: editingRecord ? 'edit' : 'create',
1574+
language,
1575+
submitText: editingRecord
1576+
? t('form.update', { defaultValue: 'Update' })
1577+
: t('form.create', { defaultValue: 'Create' }),
1578+
cancelText: t('common.cancel'),
1579+
createValues: editingRecord ? undefined : formLinkValues ?? overlayCreateValues,
1580+
},
1581+
),
15721582
}}
15731583
dataSource={dataSource}
15741584
/>

0 commit comments

Comments
 (0)