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
19 changes: 19 additions & 0 deletions .changeset/26843-form-view-keys-every-entry-point.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
'@object-ui/plugin-form': minor
'@object-ui/console': minor
---

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)

Clause-②: no
Metadata-protocol: semantic

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:

- **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.
- **`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`.
- **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.
- **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.
- **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.

`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.
158 changes: 158 additions & 0 deletions ui/apps/console/src/components/FormPage.formViewChrome-26843.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack-ai/objectstack#26843 — the form page renders the form view's
* `buttons` and `defaults`, which it never read.
*
* - `buttons.submit` / `buttons.cancel`: an authored `label` replaces the
* page's own, an authored `show: false` removes the button. The public
* route still draws no Cancel at all.
* - `defaults`: a create opens with them, over the object's own field
* defaults and under a `?prefill_` param; an edit never reads them.
*
* CONTROL: a form view authoring neither keeps the page's own `Submit` /
* `Cancel` and opens blank.
*/

import { describe, expect, it, vi, afterEach } from 'vitest';
import { render, screen, waitFor, cleanup } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { createI18n } from '@object-ui/i18n';
import { FormPage, readFormButton, readPrefill } from './FormPage';

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

const OBJECT_SCHEMA = {
name: 'showcase_inquiry',
fields: {
title: { type: 'text', label: 'Title' },
channel: { type: 'text', label: 'Channel', defaultValue: 'phone' },
},
};

function viewBody(form: Record<string, unknown>) {
return {
name: 'showcase_inquiry.intake',
object: 'showcase_inquiry',
viewKind: 'form',
label: 'Intake',
config: { type: 'simple', sections: [{ fields: ['title', 'channel'] }], ...form },
};
}

function stubFetch(form: Record<string, unknown>) {
return vi.fn(async (url: string) => {
const path = String(url);
const body = path.includes('/meta/view/')
? viewBody(form)
: path.includes('/meta/object/')
? OBJECT_SCHEMA
: path.includes('/forms/intake')
? { slug: 'intake', object: 'showcase_inquiry', form: viewBody(form).config, objectSchema: OBJECT_SCHEMA }
: path.includes('/data/showcase_inquiry/r1')
? { record: { id: 'r1', title: 'Stored', channel: 'email' } }
: 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 renderAt(url: string) {
createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false });
return render(
<MemoryRouter initialEntries={[url]}>
<Routes>
<Route path="/forms/:name" element={<FormPage mode="internal" />} />
<Route path="/f/:slug" element={<FormPage mode="public" />} />
</Routes>
</MemoryRouter>,
);
}

const input = (name: string) => document.querySelector(`[name="${name}"]`) as HTMLInputElement | null;

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

describe('FormPage renders the form view\'s buttons (#26843)', () => {
it('internal: the authored submit label shows, and a hidden cancel is gone', async () => {
vi.stubGlobal('fetch', stubFetch({ buttons: { submit: { label: 'File inquiry' }, cancel: { show: false } } }));
renderAt('/forms/showcase_inquiry.intake');
expect(await screen.findByRole('button', { name: 'File inquiry' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Cancel' })).toBeNull();
});

it('internal: an authored cancel label shows; a hidden submit is gone', async () => {
vi.stubGlobal('fetch', stubFetch({ buttons: { submit: { show: false }, cancel: { label: 'Back' } } }));
const { container } = renderAt('/forms/showcase_inquiry.intake');
expect(await screen.findByRole('button', { name: 'Back' })).toBeInTheDocument();
expect(container.querySelector('button[type="submit"]')).toBeNull();
});

it('public: the authored submit label shows, and there is still no Cancel', async () => {
vi.stubGlobal('fetch', stubFetch({ buttons: { submit: { label: 'Send' }, cancel: { label: 'Back' } } }));
renderAt('/f/intake');
expect(await screen.findByRole('button', { name: 'Send' })).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Back' })).toBeNull();
});

it('CONTROL: no `buttons` — the page\'s own Submit and Cancel', async () => {
vi.stubGlobal('fetch', stubFetch({}));
renderAt('/forms/showcase_inquiry.intake');
expect(await screen.findByRole('button', { name: 'Submit' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
});
});

describe('FormPage seeds a create with the form view\'s defaults (#26843)', () => {
it('a create opens with them, over the object\'s own field default', async () => {
vi.stubGlobal('fetch', stubFetch({ defaults: { title: 'Website lead', channel: 'web' } }));
renderAt('/forms/showcase_inquiry.intake');
await waitFor(() => expect(input('title')?.value).toBe('Website lead'));
expect(input('channel')?.value).toBe('web');
});

it('a `prefill_` param still wins per field', async () => {
vi.stubGlobal('fetch', stubFetch({ defaults: { title: 'Website lead', channel: 'web' } }));
renderAt('/forms/showcase_inquiry.intake?prefill_channel=chat');
await waitFor(() => expect(input('channel')?.value).toBe('chat'));
expect(input('title')?.value).toBe('Website lead');
});

it('an edit reads the stored record, never the defaults', async () => {
vi.stubGlobal('fetch', stubFetch({ defaults: { title: 'Website lead', channel: 'web' } }));
renderAt('/forms/showcase_inquiry.intake?recordId=r1');
await waitFor(() => expect(input('title')?.value).toBe('Stored'));
expect(input('channel')?.value).toBe('email');
});

it('CONTROL: no defaults — the object\'s own field default, else blank', async () => {
vi.stubGlobal('fetch', stubFetch({}));
renderAt('/forms/showcase_inquiry.intake');
await waitFor(() => expect(input('channel')?.value).toBe('phone'));
expect(input('title')?.value).toBe('');
});
});

describe('the two helpers, directly', () => {
it('readFormButton: shown unless `show: false`; a per-locale label resolves', () => {
expect(readFormButton(undefined, 'en')).toStrictEqual({ show: true, label: '' });
expect(readFormButton({ show: false }, 'en').show).toBe(false);
expect(readFormButton({ label: { en: 'Send', 'zh-CN': '发送' } }, 'zh-CN').label).toBe('发送');
});

it('readPrefill: defaults only for a field the form draws, and never on an edit', () => {
const fields = [{ name: 'title' }, { name: 'channel', defaultValue: 'phone' }] as any;
const search = new URLSearchParams();
expect(readPrefill(fields, search, null, { title: 'T', unknown_field: 'x' })).toStrictEqual({ title: 'T', channel: 'phone' });
expect(readPrefill(fields, search, { title: 'Stored' }, { title: 'T' })).toStrictEqual({ title: 'Stored', channel: 'phone' });
});
});
46 changes: 41 additions & 5 deletions ui/apps/console/src/components/FormPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1254,6 +1254,12 @@ export function readLoadedRecord(
* then write;
* 3. an explicit `?prefill_<field>=` query param, which wins over both.
*
* A CREATE also reads the form view's own `defaults` (objectstack-ai/objectstack#26843),
* the author's opening values for this form: they sit over source 1, since a
* form view is narrower than its object, and under source 3, since a `prefill_`
* param outranks every default. An edit never reads them: the stored record is
* the truth there.
*
* Point 3 is the ruling: "explicit `prefill_` params override the loaded
* record's values for the fields they name … record values fill the rest". A
* producer that forwards `?recordId=` AND `?prefill_x=` in one URL is
Expand Down Expand Up @@ -1331,6 +1337,7 @@ export function readPrefill(
fields: RenderableField[],
search: URLSearchParams,
record?: Record<string, unknown> | null,
formDefaults?: Record<string, unknown> | null,
): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const f of fields) {
Expand All @@ -1343,6 +1350,10 @@ export function readPrefill(
} else if (f.defaultValue !== undefined && !isRuntimeDefault(f.defaultValue)) {
out[f.name] = f.defaultValue;
}
// The form view's `defaults`, on a create only — see the docblock.
if (!record && formDefaults && Object.prototype.hasOwnProperty.call(formDefaults, f.name)) {
out[f.name] = formDefaults[f.name];
}
// `hasOwnProperty` rather than a truthiness/undefined test: a stored null
// or empty string is a real value on an edit form, and must beat the
// default.
Expand All @@ -1355,6 +1366,20 @@ export function readPrefill(
return out;
}

/**
* One `FormView.buttons` entry as this page draws it (objectstack-ai/objectstack#26843):
* shown unless the author wrote `show: false`, and the authored label — an
* `I18nLabel`, resolved against the visitor's language — or `''` when there is
* none, so the caller's own label shows. `buttons.reset` is never read: this
* page draws no reset button.
*/
export function readFormButton(
config: { show?: boolean; label?: unknown } | undefined,
language: string,
): { show: boolean; label: string } {
return { show: config?.show !== false, label: pickLocalized(config?.label, language) };
}

/**
* Drop the keys a CREATE payload must leave to the producer (objectui#5883).
*
Expand Down Expand Up @@ -2261,7 +2286,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
// build needs no language and the load does not re-run on a switch.
const sections = buildSections(result.form, result.objectSchema);
const allFields = sections.flatMap((s) => s.fields);
setValues(readPrefill(allFields, search, result.record));
setValues(readPrefill(allFields, search, result.record, result.form?.defaults));
})
.catch((e) => {
if (!cancelled) {
Expand All @@ -2284,6 +2309,15 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
[loaded, language],
);

/**
* The form view's `buttons` (objectstack-ai/objectstack#26843), which this
* page never read: an authored `show: false` removes the button, an authored
* `label` replaces this page's own. The public route draws no Cancel,
* authored or not — see {@link FormPageProps.cancelPath}.
*/
const submitButton = readFormButton(loaded?.form?.buttons?.submit, language);
const cancelButton = readFormButton(loaded?.form?.buttons?.cancel, language);

const behavior: EffectiveSubmitBehavior = resolveSubmitBehavior(
mode,
loaded?.form?.submitBehavior,
Expand Down Expand Up @@ -2460,7 +2494,7 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
// means back to the record as loaded — NOT to blank, which would
// stage a wipe of every field on the next save.
const allFields = sections.flatMap((s) => s.fields);
setValues(readPrefill(allFields, search, loaded.record));
setValues(readPrefill(allFields, search, loaded.record, loaded.form?.defaults));
break;
}
case 'next-record':
Expand Down Expand Up @@ -2706,21 +2740,22 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
</div>
)}
<div className="flex justify-end gap-2">
{mode === 'internal' && (
{mode === 'internal' && cancelButton.show && (
<button
type="button"
onClick={handleCancel}
disabled={submitting}
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"
>
{t('common.cancel')}
{cancelButton.label || t('common.cancel')}
</button>
)}
{/* Blocked while an upload is in flight (objectui#10167): a file's
value only becomes a `sys_file` id once the adapter settles, so a
submit that beats it writes an empty field and still reports
success. The widget contract exists for this — see
`FieldInputProps.onUploadingChange`. */}
{submitButton.show && (
<button
type="submit"
disabled={submitting || isUploading}
Expand All @@ -2730,8 +2765,9 @@ export function FormPage({ mode, recordPath, cancelPath }: FormPageProps) {
? t('publicForm.submitting')
: isUploading
? t('fields.file.uploading')
: t('publicForm.submit')}
: submitButton.label || t('publicForm.submit')}
</button>
)}
</div>
</form>
</div>
Expand Down
34 changes: 22 additions & 12 deletions ui/packages/app-shell/src/console/AppContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { buildExpressionUser } from '../providers/expressionUser.js';
import { useTrackRouteAsRecent } from '../hooks/useTrackRouteAsRecent.js';
import { useHomePath } from '../hooks/useHomePath.js';
import { useSignedInUserLocale } from '../hooks/useUserLocale.js';
import { resolveRecordFormTarget, resolveRecordFormModalLayout, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget, recordFormCreateValues, readRecordFormCreateValues, RECORD_FORM_CREATE_VALUES_STATE } from '../utils/recordFormNavigation.js';
import { resolveRecordFormTarget, resolveRecordFormModalLayout, resolveFormViewChrome, selectRecordFormView, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget, recordFormCreateValues, readRecordFormCreateValues, RECORD_FORM_CREATE_VALUES_STATE } from '../utils/recordFormNavigation.js';
import { deriveRecordSurface, deriveRecordFlowSurface } from '@object-ui/plugin-view';
import { RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM } from '../urlParams.js';
import { appAddressSegment, appRouteSegment, canonicalAppPathname, matchAppBySegment } from '../utils/appRoute.js';
Expand Down Expand Up @@ -322,7 +322,7 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
// worse answer than the literal it replaced.
const homePath = useHomePath();
const previewDrafts = usePreviewDrafts();
const { t } = useObjectTranslation();
const { t, language } = useObjectTranslation();
const { objectLabel, objectPluralLabel } = useObjectLabel();


Expand Down Expand Up @@ -1500,9 +1500,6 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
// history entry carries. Each is handed as the object it is,
// never a merge rebuilt per render: the form re-seeds whenever
// `initialValues` is a new object.
...(!editingRecord && (formLinkValues ?? overlayCreateValues)
? { initialValues: formLinkValues ?? overlayCreateValues }
: {}),
// Honor the object's DEFAULT FORM VIEW: curated sections (field
// selection + order + grouping), `contentLayout: 'tabbed'` when the
// view is tabbed, inline child collections (master-detail), and its
Expand Down Expand Up @@ -1542,7 +1539,6 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
: t('form.createTitle', { object: objectLabel(formObjectDef as any) }),
open: isDialogOpen,
onOpenChange: (open: boolean) => { if (!open) closeRecordForm(); },
layout: 'vertical',
// objectui#6514 — the same declared-key gate `RecordFormPage`
// runs, through the same helper. This read `f.visible`, a key
// `FieldSchema` refuses by name, so nothing an author could
Expand All @@ -1563,12 +1559,26 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
: [],
onSuccess: handleRecordFormSuccess,
onCancel: handleDialogCancel,
showSubmit: true,
showCancel: true,
submitText: editingRecord
? t('form.update', { defaultValue: 'Update' })
: t('form.create', { defaultValue: 'Create' }),
cancelText: t('common.cancel'),
// objectstack-ai/objectstack#26843 — the selected form view's
// `buttons`, `layout` and `defaults` over this dialog's own
// defaults (`resolveFormViewChrome` states the precedence). A
// create's opening values: the `formLink` pre-link
// (objectstack-ai/objectstack#2604 D3, refresh-safe), else the
// values its opener's history entry carries (objectui#12291),
// over the form view's `defaults`; each handed on as the object
// it is, as the form re-seeds on a new `initialValues`.
...resolveFormViewChrome(
selectRecordFormView(formObjectDef as any, editingRecord ? 'edit' : 'create'),
{
flow: editingRecord ? 'edit' : 'create',
language,
submitText: editingRecord
? t('form.update', { defaultValue: 'Update' })
: t('form.create', { defaultValue: 'Create' }),
cancelText: t('common.cancel'),
createValues: editingRecord ? undefined : formLinkValues ?? overlayCreateValues,
},
),
}}
dataSource={dataSource}
/>
Expand Down
Loading
Loading