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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/12108-cel-default-hint.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 2 additions & 1 deletion content/docs/guide/building-crud-app.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
66 changes: 63 additions & 3 deletions packages/components/src/renderers/form/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `renderFieldDescription`). Byte-identical to the `en` pack.
'form.serverDefaultHint': 'Set automatically when saved.',
},
'common.selectOption',
);
Expand Down Expand Up @@ -957,6 +960,42 @@ function withReadonlyHostGroup(labelId: string | undefined, node: React.ReactNod
return <ReadonlyFieldGroup labelId={labelId}>{node}</ReadonlyFieldGroup>;
}

/**
* 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 `<FormDescription>`, the field's text first, because
* `<FormControl>` 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 renderFieldDescription(
description: string | undefined,
serverDefaultHint: string | undefined,
): React.ReactNode {
if (!description && !serverDefaultHint) return null;
return (
<FormDescription>
{description}
{serverDefaultHint && (
<span className={description ? 'block' : undefined} data-server-default-hint="true">
{serverDefaultHint}
</span>
)}
</FormDescription>
);
}

function stripRegisteredFieldProps(type: string, props: RenderFieldProps): RenderFieldProps {
const {
dataSource,
Expand Down Expand Up @@ -2945,6 +2984,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,
Expand All @@ -2958,7 +3002,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).
Expand Down Expand Up @@ -3492,8 +3536,24 @@ ComponentRegistry.register('form',
...(groupLabelId ? { 'aria-labelledby': groupLabelId } : null),
}))}
</FormControl>
{description && (
<FormDescription>{description}</FormDescription>
{renderFieldDescription(
description,
// objectui#12108 — shown exactly while the submit would omit
// the key, so the server fills the field: it is server-owned
// (`serverOwned`, the classifier the required suppression above
// reads) and its live value is empty by `isMissingForRequired`,
// the predicate `omitServerResolvedDefaults` drops a key by. A
// typed value is submitted and wins, so the hint goes with it.
//
// Not on a control that takes no input. The containers send
// `previousValues` in EDIT mode only, so a `mode: 'view'` form
// reads as create here, and every field it draws is disabled:
// an empty stored column there is filled by no save. The same
// gate hides the hint on a locked field of a real create form,
// which is a missing hint, never a wrong one.
serverOwned && !readonly && !fieldDisabled && isMissingForRequired(formField.value)
? t('form.serverDefaultHint')
: undefined,
)}
<FormMessage />
</FormItem>
Expand Down
6 changes: 5 additions & 1 deletion packages/core/src/validation/server-owned-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,6 +228,7 @@ const ar = {
// description, used when the form declares no `description` of its own.
dialogDescriptionFallback: "املأ حقول النموذج ثم أرسل أو ألغِ.",
deniedDescription: "ليس لديك إذن لتعديل هذا الحقل.",
serverDefaultHint: "يُعيَّن تلقائيًا عند الحفظ.",
masterDetail: {
loadingColumns: "جارٍ تحميل الأعمدة…",
subtotal: "المجموع الفرعي",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/de.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions packages/i18n/src/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ const ja = {
// description, used when the form declares no `description` of its own.
dialogDescriptionFallback: "フォームの項目を入力してから、送信またはキャンセルしてください。",
deniedDescription: "このフィールドを編集する権限がありません。",
serverDefaultHint: "保存時に自動で設定されます。",
masterDetail: {
loadingColumns: "列を読み込み中…",
subtotal: "小計",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ const ko = {
// description, used when the form declares no `description` of its own.
dialogDescriptionFallback: "양식 필드를 작성한 다음 제출하거나 취소하세요.",
deniedDescription: "이 필드를 편집할 권한이 없습니다.",
serverDefaultHint: "저장 시 자동으로 설정됩니다.",
masterDetail: {
loadingColumns: "열 로드 중…",
subtotal: "소계",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/pt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ const ru = {
// description, used when the form declares no `description` of its own.
dialogDescriptionFallback: "Заполните поля формы, затем отправьте или отмените.",
deniedDescription: "У вас нет прав на редактирование этого поля.",
serverDefaultHint: "Задаётся автоматически при сохранении.",
masterDetail: {
loadingColumns: "Загрузка столбцов…",
subtotal: "Промежуточный итог",
Expand Down
1 change: 1 addition & 0 deletions packages/i18n/src/locales/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,7 @@ const zh = {
// description, used when the form declares no `description` of its own.
dialogDescriptionFallback: '填写表单字段,然后提交或取消。',
deniedDescription: '您没有此字段的编辑权限。',
serverDefaultHint: '保存时自动设置。',
masterDetail: {
loadingColumns: '正在加载列…',
subtotal: '小计',
Expand Down
Loading
Loading