From 26c889ec3bdf4e8d2f5919e6efa22e58af004090 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 04:49:43 +0000 Subject: [PATCH 1/3] feat(components,i18n): a create form says when the server will fill a field (objectui#12108) A field whose defaultValue is a CEL expression or a runtime token is left empty on a create form, and the submit omits it so the server resolves the default at insert. The form said nothing about it: the field opened empty, or on "Select an option". The form renderer now draws "Set automatically when saved." in the field's description slot while the field is server-owned (isServerOwnedValue, the classifier the create-mode required suppression already reads) and its live value is empty by isMissingForRequired, the predicate omitServerResolvedDefaults drops the key by. A typed value removes it. It is not drawn on a control that takes no input, so a view-mode form, which the renderer reads as create, shows none. One locale key, form.serverDefaultHint, in all ten packs. Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz Co-authored-by: Claude --- .../components/src/renderers/form/form.tsx | 73 ++++- packages/i18n/src/locales/ar.ts | 1 + packages/i18n/src/locales/de.ts | 1 + packages/i18n/src/locales/en.ts | 5 + packages/i18n/src/locales/es.ts | 1 + packages/i18n/src/locales/fr.ts | 1 + packages/i18n/src/locales/ja.ts | 1 + packages/i18n/src/locales/ko.ts | 1 + packages/i18n/src/locales/pt.ts | 1 + packages/i18n/src/locales/ru.ts | 1 + packages/i18n/src/locales/zh.ts | 1 + .../serverDefaultHint-12108.test.tsx | 274 ++++++++++++++++++ 12 files changed, 357 insertions(+), 4 deletions(-) create mode 100644 packages/plugin-form/src/__tests__/serverDefaultHint-12108.test.tsx diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index 58a99c8367..4c5a1ffeec 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -363,6 +363,9 @@ const useSafeFormTranslation = createSafeTranslation( // not be evaluated (see `handleSubmit`). Byte-identical to the `en` pack. 'form.visibleWhenFaulted': "Can't submit: the visibleWhen rule of {{fields}} could not be evaluated. The rule must be fixed before this form can be submitted.", + // objectui#12108 — the hint under an empty field the server fills at insert + // (see `FieldDescription`). Byte-identical to the `en` pack. + 'form.serverDefaultHint': 'Set automatically when saved.', }, 'common.selectOption', ); @@ -957,6 +960,45 @@ function withReadonlyHostGroup(labelId: string | undefined, node: React.ReactNod return {node}; } +/** + * The help line under one field: its authored `description`, and the + * "set automatically when saved" hint when the caller passes one + * (objectui#12108). + * + * Why the hint exists: a CREATE form leaves a server-owned control EMPTY on + * purpose. `@object-ui/plugin-form`'s `schemaDefaults.ts` does not seed a CEL + * envelope or a runtime token (the client cannot evaluate it), and the submit + * omits the key so the server resolves the declared default at insert. With + * nothing here the user saw an empty control, or "Select an option", and no + * sign that a value was coming. The caller decides WHEN; this only draws it. + * + * Both go inside the ONE ``, the field's text first, because + * `` names that element's id in the control's `aria-describedby`: + * the hint is announced with the field, and no second element reuses the id. + * Drawn here, under the control, rather than as a placeholder: a registered + * widget reads its placeholder off the field metadata, not off the host, and + * the date, time, boolean and user widgets draw none at all. + */ +function FieldDescription({ + description, + serverDefaultHint, +}: { + description?: string; + serverDefaultHint?: string; +}): React.ReactElement | null { + if (!description && !serverDefaultHint) return null; + return ( + + {description} + {serverDefaultHint && ( + + {serverDefaultHint} + + )} + + ); +} + function stripRegisteredFieldProps(type: string, props: RenderFieldProps): RenderFieldProps { const { dataSource, @@ -2945,6 +2987,11 @@ ComponentRegistry.register('form', // dialect the server enforces (requiredWhen / readonlyWhen), so // the UX and the persisted verdict agree. A field with no rules // resolves to its static flags unchanged. + // + // `serverOwned` is read twice below — by the required suppression here + // and by the "set on save" hint under the control (objectui#12108) — so + // it is computed once: one classifier, one answer per field. + const serverOwned = isServerOwnedValue(field, isCreateForm); const ruleState = resolveFieldRuleState( { visibleWhen, readonlyWhen, requiredWhen }, ruleRecord, @@ -2958,7 +3005,7 @@ ComponentRegistry.register('form', // producer) nor a `requiredWhen` predicate resolving TRUE against the // live record, which used to re-require it here with nothing the user // could type to unblock the submit (#4085). - serverOwnedValue: isServerOwnedValue(field, isCreateForm), + serverOwnedValue: serverOwned, }, previousRecord, // The host shell's predicate scope — `current_user` and friends (#6010). @@ -3492,9 +3539,27 @@ ComponentRegistry.register('form', ...(groupLabelId ? { 'aria-labelledby': groupLabelId } : null), }))} - {description && ( - {description} - )} + )} diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index a570412560..7392bd0b1b 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -228,6 +228,7 @@ const ar = { // description, used when the form declares no `description` of its own. dialogDescriptionFallback: "املأ حقول النموذج ثم أرسل أو ألغِ.", deniedDescription: "ليس لديك إذن لتعديل هذا الحقل.", + serverDefaultHint: "يُعيَّن تلقائيًا عند الحفظ.", masterDetail: { loadingColumns: "جارٍ تحميل الأعمدة…", subtotal: "المجموع الفرعي", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 5c10a3df16..8d06541732 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -192,6 +192,7 @@ const de = { // description, used when the form declares no `description` of its own. dialogDescriptionFallback: "Füllen Sie die Formularfelder aus und senden Sie ab oder brechen Sie ab.", deniedDescription: "Sie haben keinen Bearbeitungszugriff auf dieses Feld.", + serverDefaultHint: "Wird beim Speichern automatisch gesetzt.", masterDetail: { loadingColumns: "Spalten werden geladen…", subtotal: "Zwischensumme", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 7dd5ff540b..7f43e58deb 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -271,6 +271,11 @@ const en = { // The hint under a field the caller may read but not write, shown when the // field declares no description of its own (objectui#11071). deniedDescription: 'You do not have edit access to this field.', + // The hint under an empty field on a create form whose default the server + // resolves at insert — a CEL expression or a runtime token the form cannot + // evaluate, so it leaves the field empty and omits it (objectui#12108). + // Shown only while the field is empty: a typed value is saved instead. + serverDefaultHint: 'Set automatically when saved.', // The master-detail form's own chrome (objectui#11071): the collection // placeholder while its columns resolve, the document totals stack // (`{{rate}}` is the header's tax rate), the row editor's title diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 424818f0a7..4b0b34ccdc 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -202,6 +202,7 @@ const es = { // description, used when the form declares no `description` of its own. dialogDescriptionFallback: "Complete los campos del formulario y luego envíe o cancele.", deniedDescription: "No tienes permiso para editar este campo.", + serverDefaultHint: "Se establece automáticamente al guardar.", masterDetail: { loadingColumns: "Cargando columnas…", subtotal: "Subtotal", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index 73b4a1ed9d..f151dd7bd2 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -198,6 +198,7 @@ const fr = { // description, used when the form declares no `description` of its own. dialogDescriptionFallback: "Remplissez les champs du formulaire, puis envoyez ou annulez.", deniedDescription: "Vous n'avez pas l'autorisation de modifier ce champ.", + serverDefaultHint: "Défini automatiquement lors de l'enregistrement.", masterDetail: { loadingColumns: "Chargement des colonnes…", subtotal: "Sous-total", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 9f1ac20a6a..bbfaeefa82 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -192,6 +192,7 @@ const ja = { // description, used when the form declares no `description` of its own. dialogDescriptionFallback: "フォームの項目を入力してから、送信またはキャンセルしてください。", deniedDescription: "このフィールドを編集する権限がありません。", + serverDefaultHint: "保存時に自動で設定されます。", masterDetail: { loadingColumns: "列を読み込み中…", subtotal: "小計", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index 39ea95d5ac..6929d930a0 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -192,6 +192,7 @@ const ko = { // description, used when the form declares no `description` of its own. dialogDescriptionFallback: "양식 필드를 작성한 다음 제출하거나 취소하세요.", deniedDescription: "이 필드를 편집할 권한이 없습니다.", + serverDefaultHint: "저장 시 자동으로 설정됩니다.", masterDetail: { loadingColumns: "열 로드 중…", subtotal: "소계", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 3bd4de0aa4..23ad4a8261 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -197,6 +197,7 @@ const pt = { // description, used when the form declares no `description` of its own. dialogDescriptionFallback: "Preencha os campos do formulário e depois envie ou cancele.", deniedDescription: "Você não tem permissão para editar este campo.", + serverDefaultHint: "Definido automaticamente ao salvar.", masterDetail: { loadingColumns: "Carregando colunas…", subtotal: "Subtotal", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index 45c7a66fad..4e073ae89c 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -212,6 +212,7 @@ const ru = { // description, used when the form declares no `description` of its own. dialogDescriptionFallback: "Заполните поля формы, затем отправьте или отмените.", deniedDescription: "У вас нет прав на редактирование этого поля.", + serverDefaultHint: "Задаётся автоматически при сохранении.", masterDetail: { loadingColumns: "Загрузка столбцов…", subtotal: "Промежуточный итог", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index d5f861068c..911ad983ee 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -205,6 +205,7 @@ const zh = { // description, used when the form declares no `description` of its own. dialogDescriptionFallback: '填写表单字段,然后提交或取消。', deniedDescription: '您没有此字段的编辑权限。', + serverDefaultHint: '保存时自动设置。', masterDetail: { loadingColumns: '正在加载列…', subtotal: '小计', diff --git a/packages/plugin-form/src/__tests__/serverDefaultHint-12108.test.tsx b/packages/plugin-form/src/__tests__/serverDefaultHint-12108.test.tsx new file mode 100644 index 0000000000..1610686b20 --- /dev/null +++ b/packages/plugin-form/src/__tests__/serverDefaultHint-12108.test.tsx @@ -0,0 +1,274 @@ +/** + * 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#12108 — a create form says when the server will fill a field. + * + * A `defaultValue` that is a CEL Expression envelope (or a runtime token such + * as `NOW()`) is an instruction the server resolves at insert. The client + * cannot evaluate it, so `schemaDefaults.ts` leaves the control EMPTY and the + * submit omits the key (#4047 / #4068 / #4069). Until this card the field + * opened empty, or on "Select an option", with nothing saying a value was + * coming. The form renderer now draws "Set automatically when saved." under it, + * in the field's description slot, while the field is empty. + * + * Pinned here, through the public `ObjectForm` entry in every layout it + * routes to, with the real registered field widgets: + * + * 1. the hint on a CEL default (text, date, select, number) and on a + * runtime token, in every layout. Not a boolean: the renderer seeds an + * unsupplied two-state control `false` (cloud#972), so it is never empty + * and the submit sends that value — no hint is the true answer there; + * 2. CONTROL: a literal default is seeded as today, and carries no hint; + * a field with no default carries none either; + * 3. the hint follows the live value — a typed value removes it; + * 4. it is announced: the control's `aria-describedby` names the element; + * 5. edit and view forms never show it; + * 6. the measured required behaviour: a REQUIRED field with a CEL default + * does not block Save, shows no required marker, and is omitted from the + * create payload, so the server fills it. The server half is + * `ObjectQL.insert`, which resolves defaults before it validates + * (objectstack `engine.ts`, the `[#4633]` note in `validateData`). + */ + +import React from 'react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { registerAllFields } from '@object-ui/fields'; +import { ObjectForm } from '../ObjectForm'; + +registerAllFields(); + +const HINT = 'Set automatically when saved.'; + +/** The canonical wire shape of a CEL default, as the server serves it. */ +const cel = (source: string) => ({ dialect: 'cel', source }); + +const STAGES = [ + { label: 'Prospect', value: 'prospect' }, + { label: 'Won', value: 'won' }, +]; + +const OBJECT_SCHEMA = { + name: 'deal', + fields: { + // No default: the field the user must fill, and the no-hint control. + title: { type: 'text', label: 'Title', required: true }, + // CEL defaults across the widget families the card names. + code: { type: 'text', label: 'Code', defaultValue: cel("'D-' + string(1)") }, + close_date: { type: 'date', label: 'Close date', required: true, defaultValue: cel('today()') }, + stage: { type: 'select', label: 'Stage', required: true, defaultValue: cel("'prospect'"), options: STAGES }, + amount: { type: 'number', label: 'Amount', defaultValue: cel('0') }, + // A runtime token: the same classifier, so the same hint. + reminded_at: { type: 'datetime', label: 'Reminded at', defaultValue: 'NOW()' }, + // An authored description beside a CEL default: both are drawn. + region: { + type: 'text', + label: 'Region', + description: 'Sales region.', + defaultValue: cel("'emea'"), + }, + // CONTROL: a literal default is seeded, exactly as before. + status: { + type: 'select', + label: 'Status', + required: true, + defaultValue: 'open', + options: [ + { label: 'Open', value: 'open' }, + { label: 'Closed', value: 'closed' }, + ], + }, + }, +}; + +/** The fields whose default the server resolves. */ +const SERVER_FILLED = ['code', 'close_date', 'stage', 'amount', 'reminded_at', 'region']; +const NOT_SERVER_FILLED = ['title', 'status']; + +const SECTIONS = [{ name: 'main', label: 'Main', fields: Object.keys(OBJECT_SCHEMA.fields) }]; + +const makeDS = (record?: Record) => + ({ + getObjectSchema: vi.fn().mockResolvedValue(OBJECT_SCHEMA), + create: vi.fn().mockResolvedValue({ id: 'd1' }), + update: vi.fn().mockResolvedValue({ id: 'd1' }), + findOne: vi.fn().mockResolvedValue(record ?? { id: 'd1' }), + find: vi.fn().mockResolvedValue({ data: [], total: 0 }), + }) as any; + +/** + * Every layout `ObjectForm` routes a create form to. The sectioned layouts get + * the one section; the flat form builds its fields from the object schema. + */ +const LAYOUTS: Array<[string, Record]> = [ + ['ObjectForm (flat)', {}], + ['ObjectForm (sections)', { sections: SECTIONS }], + ['ModalForm', { formType: 'modal', open: true, sections: SECTIONS }], + ['DrawerForm', { formType: 'drawer', open: true, sections: SECTIONS }], + ['TabbedForm', { formType: 'tabbed', sections: SECTIONS }], + ['SplitForm', { formType: 'split', sections: SECTIONS }], + ['WizardForm', { formType: 'wizard', sections: SECTIONS }], +]; + +const renderForm = (ds: any, extra: Record) => + render( + , + ); + +const fieldRow = (name: string) => document.body.querySelector(`[data-field="${name}"]`); +const hintOf = (name: string) => fieldRow(name)?.querySelector('[data-server-default-hint]') ?? null; + +const awaitRows = () => + waitFor(() => { + for (const name of Object.keys(OBJECT_SCHEMA.fields)) { + if (!fieldRow(name)) throw new Error(`${name} not rendered`); + } + }); + +const marksRequired = (name: string) => + (fieldRow(name)?.querySelectorAll('[data-required-marker]').length ?? 0) > 0; + +const submit = () => { + const form = document.body.querySelector('form') as HTMLFormElement; + fireEvent.submit(form); +}; + +beforeEach(() => vi.clearAllMocks()); +afterEach(() => cleanup()); + +describe.each(LAYOUTS)('%s — the "set on save" hint (objectui#12108)', (_name, extra) => { + it('draws the hint under every empty field the server fills, and under no other', async () => { + renderForm(makeDS(), extra); + await awaitRows(); + + for (const name of SERVER_FILLED) { + expect(hintOf(name)?.textContent, name).toBe(HINT); + } + for (const name of NOT_SERVER_FILLED) { + expect(hintOf(name), name).toBeNull(); + } + }); + + it('CONTROL: seeds a literal default as before, with no hint', async () => { + renderForm(makeDS(), extra); + await awaitRows(); + await waitFor(() => + expect(fieldRow('status')?.querySelector('[data-testid="select-trigger-status"]')?.textContent).toContain('Open'), + ); + expect(hintOf('status')).toBeNull(); + }); + + it('keeps the select placeholder and adds the hint below it', async () => { + renderForm(makeDS(), extra); + await awaitRows(); + const trigger = fieldRow('stage')?.querySelector('[data-testid="select-trigger-stage"]'); + expect(trigger?.textContent).toContain('Select an option'); + expect(hintOf('stage')?.textContent).toBe(HINT); + }); +}); + +describe('the hint follows the live value and reaches assistive tech (objectui#12108)', () => { + it('goes away once the user types a value, and comes back when it is cleared', async () => { + renderForm(makeDS(), {}); + await awaitRows(); + const input = fieldRow('code')!.querySelector('input')!; + + fireEvent.change(input, { target: { value: 'D-77' } }); + await waitFor(() => expect(hintOf('code')).toBeNull()); + + fireEvent.change(input, { target: { value: '' } }); + await waitFor(() => expect(hintOf('code')?.textContent).toBe(HINT)); + }); + + it('is part of the description the control names in aria-describedby', async () => { + renderForm(makeDS(), {}); + await awaitRows(); + const input = fieldRow('code')!.querySelector('input')!; + const ids = (input.getAttribute('aria-describedby') ?? '').split(/\s+/).filter(Boolean); + const described = ids.map((id) => document.getElementById(id)?.textContent ?? '').join(' '); + expect(described).toContain(HINT); + }); + + it('draws an authored description first, and the hint after it in the same element', async () => { + renderForm(makeDS(), {}); + await awaitRows(); + const hint = hintOf('region')!; + const description = hint.parentElement!; + expect(description.textContent).toBe(`Sales region.${HINT}`); + expect(hint.className).toContain('block'); + expect(fieldRow('region')!.querySelectorAll(`[id="${description.id}"]`)).toHaveLength(1); + }); +}); + +describe('edit and view forms never show the hint (objectui#12108)', () => { + const STORED = { id: 'd1', title: 'Big deal', status: 'open' }; + + it.each([ + ['ObjectForm (flat)', {}], + ['ModalForm', { formType: 'modal', open: true, sections: SECTIONS }], + ])('%s in edit mode', async (_name, extra) => { + render( + , + ); + await awaitRows(); + expect(document.body.querySelectorAll('[data-server-default-hint]')).toHaveLength(0); + }); + + it.each([ + ['ObjectForm (flat)', {}], + ['ModalForm', { formType: 'modal', open: true, sections: SECTIONS }], + ])('%s in view mode — the renderer reads view as create, and the disabled gate holds', async (_name, extra) => { + render( + , + ); + await waitFor(() => expect(fieldRow('title')).not.toBeNull()); + expect(document.body.querySelectorAll('[data-server-default-hint]')).toHaveLength(0); + }); +}); + +/** + * The measurement the card asks for: does a REQUIRED field with a CEL default + * block Save? It does not, in any container — `isRequiredInForm` (the builders) + * and `resolveFieldRuleState`'s `serverOwnedValue` (the renderer) lower the + * rule on create, and the payload omits the key so `applyFieldDefaults` fills + * it. `createDefaults.test.tsx` pins the same rule on `text` fields; this adds + * the date and select widgets the card measured on, in every layout but the + * wizard (its submit is the last step's button, pinned by its own suite). + */ +describe.each(LAYOUTS.filter(([name]) => name !== 'WizardForm'))( + '%s — a required field with a CEL default does not block Save (objectui#12108)', + (_name, extra) => { + it('submits with close_date and stage left empty, unmarked, and omitted from the payload', async () => { + const ds = makeDS(); + renderForm(ds, extra); + await awaitRows(); + + expect(marksRequired('close_date')).toBe(false); + expect(marksRequired('stage')).toBe(false); + expect(marksRequired('title')).toBe(true); + + fireEvent.change(fieldRow('title')!.querySelector('input')!, { target: { value: 'Big deal' } }); + submit(); + + await waitFor(() => expect(ds.create).toHaveBeenCalled()); + const payload = ds.create.mock.calls[0][1]; + expect(payload).toMatchObject({ title: 'Big deal', status: 'open' }); + for (const name of SERVER_FILLED) expect(payload, name).not.toHaveProperty(name); + }); + }, +); From ad59f8b003b3687563f8c4744050dfcda306e9fe Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 04:53:02 +0000 Subject: [PATCH 2/3] docs(plugin-form,core): document the "set on save" hint, and add its changeset (objectui#12108) The plugin-form README's create-form table and the CRUD guide now say a field left empty for the server carries "Set automatically when saved.", and when it does not (edit, a control that takes no input, a boolean). The classifier's consumer list in server-owned-value.ts and the schemaDefaults.ts docblocks name the hint as the reader it now has. The README's six cross-file line addresses are re-cited by symbol or quoted text, as AGENTS.md #11 asks of a touched file. Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz Co-authored-by: Claude --- .changeset/12108-cel-default-hint.md | 16 +++++++ content/docs/guide/building-crud-app.md | 3 +- .../core/src/validation/server-owned-value.ts | 6 ++- packages/plugin-form/README.md | 42 ++++++++++++++----- packages/plugin-form/src/schemaDefaults.ts | 12 +++++- 5 files changed, 64 insertions(+), 15 deletions(-) create mode 100644 .changeset/12108-cel-default-hint.md diff --git a/.changeset/12108-cel-default-hint.md b/.changeset/12108-cel-default-hint.md new file mode 100644 index 0000000000..10d1b685ed --- /dev/null +++ b/.changeset/12108-cel-default-hint.md @@ -0,0 +1,16 @@ +--- +'@object-ui/components': minor +'@object-ui/i18n': minor +--- + +A create form says when the server will fill a field: "Set automatically when saved." under a field whose `defaultValue` the server resolves at insert, while the field is empty (objectui#12108). + +A `defaultValue` that is a CEL expression (`{ dialect: 'cel', source: 'today()' }`) or a runtime token (`NOW()`, `current_user`) is an instruction, not a value. The client cannot evaluate it, so a create form leaves the field empty and leaves it out of the submit, and the server fills it at insert. Until now the field opened empty, or on "Select an option", with nothing saying a value was coming. + +- **Where it shows.** In the field's description slot, after any `description` the field declares, in every object-form layout (`ObjectForm`, `ModalForm`, `DrawerForm`, `TabbedForm`, `SplitForm`, `WizardForm`), under any field widget: the form draws it, not the widget. The control's `aria-describedby` already names that element, so a screen reader reads the hint with the field. +- **When it shows.** Exactly while the submit would leave the key out: the field is server-owned (`isServerOwnedValue`, the classifier that already lowers `required` on such a field) and its value is empty. A value the user types removes the hint and is saved instead. +- **When it does not.** On an edit form; on a control that takes no input (disabled or read-only), which also keeps it off a `mode: 'view'` form; and on a boolean, which the form starts at `false`, so it is never empty. +- **Required fields.** Measured unchanged: a `required` field with a CEL default does not block Save on a create form, shows no required marker, and is left out of the submit so the server fills it. +- **Locale key.** `form.serverDefaultHint`, in all ten packs. `TranslationKeys` gains it. + +**Clause-②: yes (widening)**: one locale key added to `@object-ui/i18n`'s published packs, which widens `TranslationKeys`. No export, prop or schema key is added. diff --git a/content/docs/guide/building-crud-app.md b/content/docs/guide/building-crud-app.md index adbd2802d2..bd7ccfdc66 100644 --- a/content/docs/guide/building-crud-app.md +++ b/content/docs/guide/building-crud-app.md @@ -311,7 +311,8 @@ declares — the `status` and `priority` fields above start on `Todo` and `Medium`, already submittable, rather than empty next to a required marker. Only static defaults are seeded: a `defaultValue` that is a runtime token (`'NOW()'`, `'current_user'`) or a CEL expression is an instruction the server -resolves at insert time, so the form leaves that field empty and lets it. In +resolves at insert time, so the form leaves that field empty and lets it, with +*Set automatically when saved.* under the field while it stays empty. In **edit** mode nothing is seeded — the form shows the record as stored. Values you pass as `initialData` / `initialValues` outrank a schema default. diff --git a/packages/core/src/validation/server-owned-value.ts b/packages/core/src/validation/server-owned-value.ts index 290fb5bc26..e3cb4c914a 100644 --- a/packages/core/src/validation/server-owned-value.ts +++ b/packages/core/src/validation/server-owned-value.ts @@ -73,7 +73,11 @@ function isExpressionEnvelope(v: unknown): boolean { * 2. the create-mode static `required` rule (#4069) — * `isRequiredInForm` in `@object-ui/plugin-form`; * 3. the create-mode `requiredWhen` rule (#4085) — - * {@link isServerOwnedValue}, read by `resolveFieldRuleState`. + * {@link isServerOwnedValue}, read by `resolveFieldRuleState`; + * 4. the "Set automatically when saved." hint (objectui#12108) — the form + * renderer reads the same {@link isServerOwnedValue} answer it hands to + * `resolveFieldRuleState`, so the hint and the lowered `required` are + * one verdict. */ export function isRuntimeDefault(v: unknown): boolean { return isRuntimeDefaultToken(v) || isExpressionEnvelope(v); diff --git a/packages/plugin-form/README.md b/packages/plugin-form/README.md index 224ec430c7..109ebecbc5 100644 --- a/packages/plugin-form/README.md +++ b/packages/plugin-form/README.md @@ -265,7 +265,7 @@ what `buildValidationRules` emits for an object field of type `email`. **Why the array spelling fails silently.** The only reader of this key is the basic form renderer, which spreads it into the rule object handed to react-hook-form — `const rules: any = { ...validation }` -(`packages/components/src/renderers/form/form.tsx:1652`). Spreading an **array** +(`renderFormField` in `packages/components/src/renderers/form/form.tsx`). Spreading an **array** into an object literal produces numeric keys (`{ '0': …, '1': … }`), which react-hook-form does not recognise: every rule is dropped, nothing throws, and the form looks validated while validating nothing. @@ -281,8 +281,8 @@ option list marks `default: true`. Every object-form container | Declared | Create form opens with | Why | |---|---|---| | `defaultValue: 'draft'` (any static literal) | `draft`, preselected and submittable | the value is known; making the user pick it is busywork, and on a status-like field every wrong option is one click away | -| `defaultValue: 'NOW()'` / `'current_user'` (a runtime token) | empty | the token is an *instruction*, not a value. The server resolves it at insert — but only for fields that arrive empty, so seeding the literal text would suppress it | -| `defaultValue: cel\`today()\`` (an Expression envelope) | empty | same reason: the server evaluates it per insert | +| `defaultValue: 'NOW()'` / `'current_user'` (a runtime token) | empty, with *Set automatically when saved.* under it | the token is an *instruction*, not a value. The server resolves it at insert — but only for fields that arrive empty, so seeding the literal text would suppress it | +| `defaultValue: cel\`today()\`` (an Expression envelope) | empty, with *Set automatically when saved.* under it | same reason: the server evaluates it per insert | | an option's `default: true`, and no `defaultValue` | that option, preselected and submittable | the server stores the marked option when the field is omitted; see below | | nothing | empty | no default is invented | @@ -290,6 +290,24 @@ option list marks `default: true`. Every object-form container default — a lookup prefill or a "duplicate this record" seed is the more specific instruction. +**The "set on save" hint (objectui#12108).** A field left empty for the server +is not left unexplained: the form renderer draws *Set automatically when saved.* +(locale key `form.serverDefaultHint`) in the field's description slot, after any +`description` the field declares, and the control's `aria-describedby` already +names that element. It is drawn exactly while the submit would omit the key — the +field is server-owned (`isServerOwnedValue` from `@object-ui/core`, the classifier +the `required` suppression below reads) and its value is empty by +`isMissingForRequired`, the predicate `omitServerResolvedDefaults` drops a key by. +So a value the user types removes it, and the hint never names the value, since the +client cannot evaluate the expression. It is not drawn: + +- on an edit form, where the default was resolved at insert; +- on a control that takes no input (disabled or read-only). The containers send + `previousValues` in edit mode only, so the renderer reads a `mode: 'view'` form + as create, and this is what keeps the hint off it; +- on a boolean. The renderer starts an unsupplied two-state control at `false` + (cloud#972), so the field is never empty and the submit sends that value. + An option's `default: true` is read the way the server's insert path reads it (`ObjectQL.applyFieldDefaults`, which falls back to the marked option), so the form preselects exactly what omitting the field would store: @@ -344,8 +362,9 @@ remind_at: Field.datetime({ requiredWhen: 'record.status == "scheduled"', defaul The required marker and `aria-required` go with the rule in the create case, since one verdict drives all three — in that mode the user genuinely is not -required to provide the value. Showing what the server *will* supply, as a -non-authoritative preview, is a separate follow-up. +required to provide the value. The hint above says *that* the server supplies it; +showing *what* it will supply, as a non-authoritative preview, is a separate +follow-up. The two halves of that verdict are importable, so a host with its own form renderer applies the same rule rather than re-deriving it (objectui#6059): @@ -1044,7 +1063,8 @@ only — the basic `form` node has no `mode` (retired by objectui#10286, see A bare `form` never fetches or saves by itself: it has no object name and no query, so there is nothing for it to call an adapter *with*. It collects values and hands them to your `onSubmit`, which the renderer awaits -(`packages/components/src/renderers/form/form.tsx:1428`). Any persistence is +(`await onSubmitProp(formData)` in the submit handler of +`packages/components/src/renderers/form/form.tsx`). Any persistence is whatever that function does: ```typescript @@ -1075,9 +1095,9 @@ a JSON metadata document cannot carry one. Metadata pages take the `object-form` route above. What the adapter on the context still does for a bare `form` is supply the -**field widgets**: the renderer reads it at `form.tsx:1004` +**field widgets**: the renderer reads it (`const contextDataSource = schemaCtx?.dataSource ?? null`) and passes it down -per field at `:2061`, which is how a lookup or cascading select loads its +per field (`dataSource: contextDataSource`), which is how a lookup or cascading select loads its options. Nothing else about the form is wired to it. ### Keys that look like wiring but are not @@ -1087,7 +1107,7 @@ nothing: | Written on a form schema | What actually happens | |---|---| -| `dataSource` | **Discarded.** The basic form strips it in both directions — `dataSource: _dataSource` at `form.tsx:304` (`stripRendererOnlyProps`) and `:2168` — so it never reaches a widget and never reaches the DOM. The adapter the fields receive is the context one | +| `dataSource` | **Discarded.** The basic form strips it in both directions — `dataSource: _dataSource` in `stripRendererOnlyProps` and again in the renderer's own props destructure — so it never reaches a widget and never reaches the DOM. The adapter the fields receive is the context one | | `resource` | **Never read.** It is not declared on `FormSchema` or `ObjectFormSchema` at all. It used to exist elsewhere in the protocol — on `CRUDSchema`, where `CRUDBuilder` set it — but objectui#5373 retired both under ADR-0049, so today the key names nothing anywhere in this package's surface, and no form renderer reads it under any spelling | Both survived compilation while `FormSchema` and `ObjectFormSchema` extended a @@ -1103,11 +1123,11 @@ anything. > A top-level `dataSource` **does** mean something on a schema node, but it is not > an adapter: it is the spec's element **binding** (`PageComponentSchema.dataSource`, > objectstack#6953) — a descriptor such as `{ object: 'users' }` — resolved by -> `useElementDataSource` (`packages/react/src/hooks/useElementDataSource.ts:139`). +> `useElementDataSource` (`packages/react/src/hooks/useElementDataSource.ts`). > `object-form` passes through that gate, and honours the binding's `object` key > only. Handing that slot a live adapter is rejected on purpose: the predicate > refuses any value carrying a `find` method -> (`packages/core/src/data-scope/element-data-source.ts:131`), so an adapter written +> (`isElementDataSourceConfig` in `packages/core/src/data-scope/element-data-source.ts`), so an adapter written > there is ignored rather than mistaken for a binding. Pass adapters through the > provider above. diff --git a/packages/plugin-form/src/schemaDefaults.ts b/packages/plugin-form/src/schemaDefaults.ts index a8807e5e63..846a756314 100644 --- a/packages/plugin-form/src/schemaDefaults.ts +++ b/packages/plugin-form/src/schemaDefaults.ts @@ -68,6 +68,12 @@ * the engine resolves. `ObjectForm` had been seeding them verbatim — that is * fixed here along with the missing-seeding half. * + * Left empty is not left unexplained: the form renderer in + * `@object-ui/components` draws "Set automatically when saved." under such a + * control while it stays empty (objectui#12108). It is drawn there, not seeded + * here as a placeholder, because the renderer is where the live value and the + * one description slot every field type shares both live. + * * ONE token is additionally RESOLVED (not seeded literally) when the caller * threads a {@link SeedContext}: `current_user`, whose engine-side resolution * is "the acting user's id" — a value this very session knows exactly. That is @@ -223,8 +229,10 @@ export function isCreateFormMode( * * Note this drops the required MARKER (and `aria-required`) too, since both are * driven by this one boolean — which is the honest reading: in create mode the - * user really is not required to provide the value. Surfacing what the server - * WILL supply is issue #4069's option B, a separate follow-up card. + * user really is not required to provide the value. The form renderer says + * THAT the server supplies it — "Set automatically when saved." under the + * empty control (objectui#12108), keyed on the same classifier. Previewing + * WHAT it will supply is issue #4069's option B, still a separate card. * * The CONDITIONAL spelling (`requiredWhen`) reaches the same verdict, ruled * identically in #4085 — but it cannot be decided here, because it is resolved From 662a0ba4408f7de23eddc3cc03b4293f40be2385 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 04:58:07 +0000 Subject: [PATCH 3/3] refactor(components): draw the field help line with a render function (objectui#12108) The help line under a field was a module-level `FieldDescription` component, which added a react-refresh/only-export-components warning to form.tsx. It is now `renderFieldDescription`, the shape of `withReadonlyHostGroup` beside it; the DOM it draws is unchanged. Claude-Session: https://claude.ai/code/session_01AswpQDLCKiZos2jCXknwKz Co-authored-by: Claude --- .../components/src/renderers/form/form.tsx | 27 ++++++++----------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/packages/components/src/renderers/form/form.tsx b/packages/components/src/renderers/form/form.tsx index 4c5a1ffeec..7996a31dd3 100644 --- a/packages/components/src/renderers/form/form.tsx +++ b/packages/components/src/renderers/form/form.tsx @@ -364,7 +364,7 @@ const useSafeFormTranslation = createSafeTranslation( 'form.visibleWhenFaulted': "Can't submit: the visibleWhen rule of {{fields}} could not be evaluated. The rule must be fixed before this form can be submitted.", // objectui#12108 — the hint under an empty field the server fills at insert - // (see `FieldDescription`). Byte-identical to the `en` pack. + // (see `renderFieldDescription`). Byte-identical to the `en` pack. 'form.serverDefaultHint': 'Set automatically when saved.', }, 'common.selectOption', @@ -979,13 +979,10 @@ function withReadonlyHostGroup(labelId: string | undefined, node: React.ReactNod * widget reads its placeholder off the field metadata, not off the host, and * the date, time, boolean and user widgets draw none at all. */ -function FieldDescription({ - description, - serverDefaultHint, -}: { - description?: string; - serverDefaultHint?: string; -}): React.ReactElement | null { +function renderFieldDescription( + description: string | undefined, + serverDefaultHint: string | undefined, +): React.ReactNode { if (!description && !serverDefaultHint) return null; return ( @@ -3539,8 +3536,8 @@ ComponentRegistry.register('form', ...(groupLabelId ? { 'aria-labelledby': groupLabelId } : null), }))} - + serverOwned && !readonly && !fieldDisabled && isMissingForRequired(formField.value) + ? t('form.serverDefaultHint') + : undefined, + )} )}