From 58c76e3dcfdeffe34243c0e1341fb2debfd8e354 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 17:03:26 +0000 Subject: [PATCH 1/7] feat(components): mark the fields a visible update action's patch would require on the edit form An update action with a static patch writes it over the record, and the server runs requiredWhen over the result. The edit form judged the record as it stands, so a field only the action makes required drew no mark and the first press was refused. The form now reads the object's declarative update actions, evaluates each field's requiredWhen over the record merged with the patch, and notes the action under an empty field that comes out required. A heads-up only: Save is never blocked. Co-Authored-By: Claude --- .../src/renderers/form/actionRequirements.ts | 130 ++++++++++++++++++ .../components/src/renderers/form/form.tsx | 80 ++++++++++- ui/packages/i18n/src/locales/ar.ts | 1 + ui/packages/i18n/src/locales/de.ts | 1 + ui/packages/i18n/src/locales/en.ts | 3 + ui/packages/i18n/src/locales/es.ts | 1 + ui/packages/i18n/src/locales/fr.ts | 1 + ui/packages/i18n/src/locales/ja.ts | 1 + ui/packages/i18n/src/locales/ko.ts | 1 + ui/packages/i18n/src/locales/pt.ts | 1 + ui/packages/i18n/src/locales/ru.ts | 1 + ui/packages/i18n/src/locales/zh.ts | 1 + 12 files changed, 219 insertions(+), 3 deletions(-) create mode 100644 ui/packages/components/src/renderers/form/actionRequirements.ts diff --git a/ui/packages/components/src/renderers/form/actionRequirements.ts b/ui/packages/components/src/renderers/form/actionRequirements.ts new file mode 100644 index 00000000000..bfca4e0daa0 --- /dev/null +++ b/ui/packages/components/src/renderers/form/actionRequirements.ts @@ -0,0 +1,130 @@ +/** + * 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. + */ + +/** + * The fields an update action would require, named before it is pressed + * (objectstack#28382). + * + * A declarative update action — `operation: 'update'` with a static `patch` + * (`@objectstack/spec` `ActionSchema.patch`) — writes its patch to the current + * record on the data plane, so the object's `requiredWhen` rules run over the + * record AS THE ACTION LEAVES IT. The edit form evaluates them over the record + * as it stands, so a field that only the action makes required (the showcase + * invoice's "Issued On" under "Submit for Sign-off" → `status: 'sent'`) drew + * no mark, and the first press was always refused. + * + * The form renderer reads the object's declared actions here, once per object, + * through the data source it already has (the same read the form hosts make, + * served from the adapter's schema cache), and keeps the ones whose writes are + * all in `patch`: no `params` (a dialog value is not known before the press), + * and a placement (`locations`) so the action is one a person can see. The + * renderer then evaluates each field's `requiredWhen` over + * `{ ...record, ...patch }`. + * + * It is a heads-up only. Nothing here blocks a save, and the server's refusal + * stays the source of truth for the action itself. + */ + +import * as React from "react" +import type { DataSource } from "@object-ui/types" + +/** One declarative update action, reduced to what the mark needs. */ +export interface PatchingUpdateAction { + /** The action's machine name — its identity for de-duplication. */ + name: string + /** What the mark calls it: the declared label, or the name when none. */ + label: string + /** The static field values the action writes. */ + patch: Record + /** The declared `visible` predicate, if any (evaluated by the form). */ + visible?: unknown + /** The declared capability gate, if any (checked by the form). */ + requiredPermissions?: unknown + /** The action as declared, for `isActionSwitchedOff`. */ + source: object +} + +function isPlainRecord(value: unknown): value is Record { + return value != null && typeof value === "object" && !Array.isArray(value) +} + +/** + * The declared actions whose whole write is a static `patch`. + * + * Kept: `operation: 'update'`, a non-empty `patch` object, no `params` (or an + * empty list), and at least one declared location. Everything else is left + * out, because what it writes cannot be known before it runs: an action that + * collects `params` writes the dialog's values over the patch, and any other + * action type writes through a body this side does not see. A locationless + * action is placed nowhere (`actionRendersAt`), so nobody can press it. + */ +export function patchingUpdateActions(actions: unknown): PatchingUpdateAction[] { + if (!Array.isArray(actions)) return [] + const out: PatchingUpdateAction[] = [] + const seen = new Set() + for (const action of actions) { + if (!isPlainRecord(action)) continue + if (action.operation !== "update") continue + const patch = action.patch + if (!isPlainRecord(patch) || Object.keys(patch).length === 0) continue + if (action.params != null && !(Array.isArray(action.params) && action.params.length === 0)) continue + if (!Array.isArray(action.locations) || action.locations.length === 0) continue + const name = typeof action.name === "string" ? action.name : "" + if (!name || seen.has(name)) continue + seen.add(name) + const label = typeof action.label === "string" && action.label.trim() ? action.label : name + out.push({ + name, + label, + patch, + visible: action.visible, + requiredPermissions: action.requiredPermissions, + source: action, + }) + } + return out +} + +const NONE: PatchingUpdateAction[] = [] + +/** + * The object's declarative update actions, read once per object while + * `enabled` (an EDIT form: on an insert there is no record for an action to + * run on). A data source without `getObjectSchema`, or a failed read, yields + * none, and the form draws no mark. + */ +export function usePatchingUpdateActions( + dataSource: DataSource | null | undefined, + objectName: string | undefined, + enabled: boolean, +): PatchingUpdateAction[] { + const [actions, setActions] = React.useState(NONE) + const canAsk = enabled && !!objectName && typeof dataSource?.getObjectSchema === "function" + + React.useEffect(() => { + if (!canAsk || !dataSource || !objectName) { + setActions(NONE) + return + } + let cancelled = false + Promise.resolve() + .then(() => dataSource.getObjectSchema(objectName)) + .then( + (schema) => patchingUpdateActions((schema as { actions?: unknown } | null | undefined)?.actions), + () => NONE, + ) + .then((found) => { + if (!cancelled) setActions(found.length > 0 ? found : NONE) + }) + return () => { + cancelled = true + } + }, [canAsk, dataSource, objectName]) + + return actions +} diff --git a/ui/packages/components/src/renderers/form/form.tsx b/ui/packages/components/src/renderers/form/form.tsx index b66926d6e88..7d2d6912a82 100644 --- a/ui/packages/components/src/renderers/form/form.tsx +++ b/ui/packages/components/src/renderers/form/form.tsx @@ -6,7 +6,7 @@ * LICENSE file in the root directory of this source tree. */ -import { ComponentRegistry, resolveFieldRuleState, evalFieldPredicate, resolveCascadingOptions, resolveDependsOnFields, CASCADE_OPTION_WIDGET_TYPES, EXPANDABLE_FIELD_TYPES, isValueStillOffered, isMissingForRequired, isServerOwnedValue } from '@object-ui/core'; +import { ComponentRegistry, resolveFieldRuleState, evalFieldPredicate, resolveCascadingOptions, resolveDependsOnFields, CASCADE_OPTION_WIDGET_TYPES, EXPANDABLE_FIELD_TYPES, isValueStillOffered, isMissingForRequired, isServerOwnedValue, isActionSwitchedOff, type FieldRulePredicate } from '@object-ui/core'; import type { FormSchema, FormField as FormFieldConfig, FormFieldTab, FormFieldPane, FieldCondition, SelectOption } from '@object-ui/types'; import { useForm, type RefCallBack } from 'react-hook-form'; import { Form, FormField, FormItem, FormLabel, FormControl, FormMessage, FormDescription } from '../../ui/form'; @@ -42,10 +42,11 @@ import { FullscreenEditor } from '../../custom/fullscreen-editor'; import { CharacterCount } from '../../custom/character-count'; import { cn } from '../../lib/utils'; import React from 'react'; -import { SchemaRendererContext, usePredicateScope, isPermissionError, extractWriteErrorMessage, extractFieldErrors, declaredUserMessage, delegationScopeMessage, unmarkedRefusalLines } from '@object-ui/react'; +import { SchemaRendererContext, usePredicateScope, useCapabilityGate, isPermissionError, extractWriteErrorMessage, extractFieldErrors, declaredUserMessage, delegationScopeMessage, unmarkedRefusalLines } from '@object-ui/react'; import { createSafeTranslation } from '@object-ui/i18n'; import type { RendererHostProps } from '../../lib/renderer-host-props'; import { currencyCodeFieldNames, useUsedCurrencyCodes, withUsedCurrencyCodes } from './usedCurrencyCodes'; +import { usePatchingUpdateActions } from './actionRequirements'; /** * A submit refusal as the banner and the toast draw it: one string as itself, @@ -508,6 +509,10 @@ const useSafeFormTranslation = createSafeTranslation( // 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.', + // objectstack#28382 — the note under an empty field a visible update + // action's static `patch` would make required (see `actionOutcomes`). + // Byte-identical to the `en` pack. + 'form.requiredForAction': 'Required to run “{{action}}”.', }, 'common.selectOption', ); @@ -1211,8 +1216,9 @@ const HORIZONTAL_FIELD_ITEM_CLASS = function renderFieldDescription( description: string | undefined, serverDefaultHint: string | undefined, + actionRequirementHints: readonly string[] = [], ): React.ReactNode { - if (!description && !serverDefaultHint) return null; + if (!description && !serverDefaultHint && actionRequirementHints.length === 0) return null; return ( {description} @@ -1221,6 +1227,15 @@ function renderFieldDescription( {serverDefaultHint} )} + {/* objectstack#28382 — one line per update action that would require + this empty field. Inside the same `` for the + serverDefaultHint's reason: the control's `aria-describedby` names + it, so the note is announced with the field. */} + {actionRequirementHints.map((hint) => ( + + {hint} + + ))} ); } @@ -2336,6 +2351,45 @@ ComponentRegistry.register('form', const codeFieldNames = React.useMemo(() => currencyCodeFieldNames(fields as unknown[]), [fields]); const usedCurrencyCodes = useUsedCurrencyCodes(contextDataSource, schema.objectName, codeFieldNames); + // The record as each visible update action would leave it + // (objectstack#28382). A declarative update action writes its static + // `patch` over the record, and the server runs `requiredWhen` over the + // result — so a field the patch makes required is refused on the first + // press while this form, judging the record as it stands, drew no mark. + // Each entry is `{ ...ruleRecord, ...patch }`, the merge the server makes; + // `renderFormField` evaluates the field's own `requiredWhen` over it and + // notes the action under an empty field that comes out required. + // + // EDIT forms only: an insert has no record for an action to run on. An + // action is kept when it is not switched off, the caller holds its + // `requiredPermissions` (the `action:bar` gate), and its `visible` + // predicate holds over the live record. That predicate fails CLOSED here + // (a rule that cannot be evaluated hides the button's note, never invents + // one), and quietly: the action's own button reports its fault. The note + // is a heads-up only — nothing here blocks a save. + const patchingActions = usePatchingUpdateActions(contextDataSource, schema.objectName, !isCreateForm); + const mayInvokeAction = useCapabilityGate(); + const actionOutcomes = React.useMemo(() => { + if (isCreateForm || patchingActions.length === 0) return [] as Array<{ label: string; record: Record }>; + const out: Array<{ label: string; record: Record }> = []; + for (const action of patchingActions) { + if (isActionSwitchedOff(action.source) || !mayInvokeAction(action.requiredPermissions)) continue; + const visible = action.visible; + if (visible === false) continue; + if ( + visible != null && + visible !== true && + !evalFieldPredicate(visible as FieldRulePredicate, ruleRecord, false, previousRecord, predicateScope, { + warn: false, + }) + ) { + continue; + } + out.push({ label: action.label, record: { ...ruleRecord, ...action.patch } }); + } + return out; + }, [isCreateForm, patchingActions, mayInvokeAction, ruleRecord, previousRecord, predicateScope]); + // Field name → label, for the "select the parent first" gate hint (#2284). const fieldLabelByName = React.useMemo(() => { const m: Record = {}; @@ -3991,6 +4045,26 @@ ComponentRegistry.register('form', serverOwned && !readonly && !fieldDisabled && isMissingForRequired(formField.value) ? t('form.serverDefaultHint') : undefined, + // objectstack#28382 — the update actions this empty field + // would block: its own `requiredWhen` over the record each + // action leaves (`actionOutcomes`). Not while it is required + // already (the asterisk says so), nor on a control the person + // cannot fill. Faults answer `false` and stay quiet: the live + // evaluation above already reported a broken rule. + requiredWhen != null && + !required && + !readonly && + !fieldDisabled && + actionOutcomes.length > 0 && + isMissingForRequired(formField.value) + ? actionOutcomes + .filter((outcome) => + evalFieldPredicate(requiredWhen, outcome.record, false, previousRecord, predicateScope, { + warn: false, + }), + ) + .map((outcome) => t('form.requiredForAction', { action: outcome.label })) + : undefined, )} diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 5cda3ff0950..12a55127843 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -199,6 +199,7 @@ const ar = { dialogDescriptionFallback: "املأ حقول النموذج ثم أرسل أو ألغِ.", deniedDescription: "ليس لديك إذن لتعديل هذا الحقل.", serverDefaultHint: "يُعيَّن تلقائيًا عند الحفظ.", + requiredForAction: "مطلوب لتنفيذ «{{action}}».", maxFileSize: "الحجم الأقصى: {{size}}", masterDetail: { loadingColumns: "جارٍ تحميل الأعمدة…", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index fceef129692..3c7245b402a 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -185,6 +185,7 @@ const de = { 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.", + requiredForAction: "Erforderlich, um „{{action}}“ auszuführen.", maxFileSize: "Maximale Größe: {{size}}", masterDetail: { loadingColumns: "Spalten werden geladen…", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 644ede2e990..5d2459ae0c5 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -265,6 +265,9 @@ const en = { // 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.', + // Under an empty field that a visible update action's static `patch` would + // make required (objectstack#28382). `{{action}}` is the action's label. + requiredForAction: 'Required to run “{{action}}”.', // The size-limit hint the flat `ObjectForm` appends to a `file` or `image` // field's description when the field declares `maxSize` (objectui#12156). // `{{size}}` is the formatted limit ("10.0 MB"); only the words translate. diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 203711f8a25..6d759157d58 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -192,6 +192,7 @@ const es = { 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.", + requiredForAction: "Obligatorio para ejecutar «{{action}}».", maxFileSize: "Tamaño máximo: {{size}}", masterDetail: { loadingColumns: "Cargando columnas…", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index d9e79db72c9..3cb63038b76 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -187,6 +187,7 @@ const fr = { 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.", + requiredForAction: "Requis pour exécuter « {{action}} ».", maxFileSize: "Taille maximale : {{size}}", masterDetail: { loadingColumns: "Chargement des colonnes…", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 062ab9cf899..b9ed6f15d37 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -185,6 +185,7 @@ const ja = { dialogDescriptionFallback: "フォームの項目を入力してから、送信またはキャンセルしてください。", deniedDescription: "このフィールドを編集する権限がありません。", serverDefaultHint: "保存時に自動で設定されます。", + requiredForAction: "「{{action}}」の実行に必須です。", maxFileSize: "最大サイズ: {{size}}", masterDetail: { loadingColumns: "列を読み込み中…", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 9e2026b0e5f..9de1340d4d3 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -185,6 +185,7 @@ const ko = { dialogDescriptionFallback: "양식 필드를 작성한 다음 제출하거나 취소하세요.", deniedDescription: "이 필드를 편집할 권한이 없습니다.", serverDefaultHint: "저장 시 자동으로 설정됩니다.", + requiredForAction: "'{{action}}' 실행에 필요합니다.", maxFileSize: "최대 크기: {{size}}", masterDetail: { loadingColumns: "열 로드 중…", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 7fe40bfb534..63acf347de7 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -187,6 +187,7 @@ const pt = { 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.", + requiredForAction: "Obrigatório para executar «{{action}}».", maxFileSize: "Tamanho máximo: {{size}}", masterDetail: { loadingColumns: "Carregando colunas…", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 8c8d3164ab1..1c21b4bf991 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -192,6 +192,7 @@ const ru = { dialogDescriptionFallback: "Заполните поля формы, затем отправьте или отмените.", deniedDescription: "У вас нет прав на редактирование этого поля.", serverDefaultHint: "Задаётся автоматически при сохранении.", + requiredForAction: "Обязательно для выполнения «{{action}}».", maxFileSize: "Максимальный размер: {{size}}", masterDetail: { loadingColumns: "Загрузка столбцов…", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index ec94d36721e..d15e7a0262a 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -193,6 +193,7 @@ const zh = { dialogDescriptionFallback: '填写表单字段,然后提交或取消。', deniedDescription: '您没有此字段的编辑权限。', serverDefaultHint: '保存时自动设置。', + requiredForAction: '执行「{{action}}」时必填。', maxFileSize: '最大文件大小:{{size}}', masterDetail: { loadingColumns: '正在加载列…', From fae4dd5ac0af27f3fccb65ff51520e6849a66666 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 17:04:38 +0000 Subject: [PATCH 2/7] test(components): pin the edit form's note for fields an update action's patch would require Co-Authored-By: Claude --- .../form-required-for-action-28382.test.tsx | 231 ++++++++++++++++++ 1 file changed, 231 insertions(+) create mode 100644 ui/packages/components/src/renderers/form/__tests__/form-required-for-action-28382.test.tsx diff --git a/ui/packages/components/src/renderers/form/__tests__/form-required-for-action-28382.test.tsx b/ui/packages/components/src/renderers/form/__tests__/form-required-for-action-28382.test.tsx new file mode 100644 index 00000000000..b2f21ada4ff --- /dev/null +++ b/ui/packages/components/src/renderers/form/__tests__/form-required-for-action-28382.test.tsx @@ -0,0 +1,231 @@ +/** + * 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. + */ + +/** + * objectstack#28382 — an EDIT form names the fields a visible update action's + * static `patch` would make required, before the action is pressed. + * + * The reported case, from the showcase: a draft invoice's "Issued On" carries + * `requiredWhen: record.status in ['sent', 'paid']`, and "Submit for Sign-off" + * is `operation: 'update'` + `patch: { status: 'sent' }`. The server runs the + * rule over the record the patch leaves, so the first press was refused with + * "Issued On is required", while the form — judging `status: 'draft'` — drew + * nothing on the field. + * + * The door is the real `form` renderer with a data source in + * `SchemaRendererContext`, which is where it reads the object's actions from. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import React from 'react'; +import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import { ComponentRegistry } from '@object-ui/core'; +import { SchemaRendererContext } from '@object-ui/react'; +// Module scope, not `beforeAll` — see object-ui/no-dynamic-import-in-test-hook. +import '../../../renderers'; +import { patchingUpdateActions } from '../actionRequirements'; + +beforeEach(() => { + if (!(Element.prototype as any).scrollIntoView) { + (Element.prototype as any).scrollIntoView = () => {}; + } +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +const SUBMIT_SIGNOFF = { + name: 'showcase_submit_signoff', + label: 'Submit for Sign-off', + operation: 'update', + patch: { status: 'sent' }, + visible: { dialect: 'cel', source: "has(record.status) && record.status != 'sent'" }, + locations: ['list_item', 'record_header'], +}; + +const FIELDS = [ + { name: 'name', label: 'Invoice Number', type: 'input' }, + { name: 'status', label: 'Status', type: 'input' }, + { + name: 'issued_on', + label: 'Issued On', + type: 'input', + requiredWhen: { dialect: 'cel', source: "record.status in ['sent', 'paid']" }, + }, +]; + +const DRAFT = { id: 'inv1', name: 'INV-1004', status: 'draft', issued_on: null }; + +const NOTE = 'Required to run “Submit for Sign-off”.'; + +function renderInvoiceForm(opts: { + actions?: unknown[]; + record?: Record; + edit?: boolean; + onSubmit?: (data: any) => unknown; +}) { + const record = opts.record ?? DRAFT; + const getObjectSchema = vi.fn(async () => ({ name: 'showcase_invoice', actions: opts.actions ?? [SUBMIT_SIGNOFF] })); + const Form = ComponentRegistry.get('form')!; + const view = render( + +
undefined), + }} + /> + , + ); + return { ...view, getObjectSchema }; +} + +const issuedOnInput = () => screen.getByLabelText(/Issued On/) as HTMLInputElement; +const noteOnIssuedOn = () => + document.querySelector('[data-testid="field:showcase_invoice.issued_on"] [data-required-for-action="true"]'); + +describe('form renderer — fields an update action would require (objectstack#28382)', () => { + it('notes the action under the empty field its patch makes required, on an edit form', async () => { + const { getObjectSchema } = renderInvoiceForm({}); + + await waitFor(() => expect(noteOnIssuedOn()?.textContent).toBe(NOTE)); + expect(getObjectSchema).toHaveBeenCalledWith('showcase_invoice'); + // It is announced with the field: the note sits inside the description the + // control's `aria-describedby` names. + const describedBy = issuedOnInput().getAttribute('aria-describedby') ?? ''; + const described = describedBy + .split(/\s+/) + .map((id) => document.getElementById(id)?.textContent ?? '') + .join(' '); + expect(described).toContain(NOTE); + // Required for the action, not for Save: no asterisk state on the control. + expect(issuedOnInput().getAttribute('aria-required')).toBeNull(); + // Only the field the rule names: the other fields carry no note. + expect(document.querySelectorAll('[data-required-for-action="true"]')).toHaveLength(1); + }); + + it('never blocks Save: the record is written with the field still empty', async () => { + const onSubmit = vi.fn(); + renderInvoiceForm({ onSubmit }); + await waitFor(() => expect(noteOnIssuedOn()).not.toBeNull()); + + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + + await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1)); + expect(onSubmit.mock.calls[0][0]).toMatchObject({ status: 'draft' }); + }); + + it('drops the note once the field holds a value', async () => { + renderInvoiceForm({}); + await waitFor(() => expect(noteOnIssuedOn()).not.toBeNull()); + + fireEvent.change(issuedOnInput(), { target: { value: '2026-10-31' } }); + + await waitFor(() => expect(noteOnIssuedOn()).toBeNull()); + }); + + it('gives way to the asterisk when the record as it stands already requires the field', async () => { + renderInvoiceForm({}); + await waitFor(() => expect(noteOnIssuedOn()).not.toBeNull()); + + // Typing the status the rule names makes the field required for Save + // itself, and hides the action (its `visible` no longer holds). + fireEvent.change(screen.getByLabelText(/Status/), { target: { value: 'paid' } }); + + await waitFor(() => expect(issuedOnInput().getAttribute('aria-required')).toBe('true')); + expect(noteOnIssuedOn()).toBeNull(); + }); + + it('does not read or note anything on a create form', async () => { + const { getObjectSchema } = renderInvoiceForm({ edit: false }); + // Let any effect settle before reading the absence. + await new Promise((r) => setTimeout(r, 20)); + expect(getObjectSchema).not.toHaveBeenCalled(); + expect(noteOnIssuedOn()).toBeNull(); + }); + + it('skips an action whose `visible` does not hold over the record', async () => { + const { getObjectSchema } = renderInvoiceForm({ + actions: [{ ...SUBMIT_SIGNOFF, visible: { dialect: 'cel', source: "record.status == 'approved'" } }], + }); + await waitFor(() => expect(getObjectSchema).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 20)); + expect(noteOnIssuedOn()).toBeNull(); + }); + + it('skips an action that collects params', async () => { + const { getObjectSchema } = renderInvoiceForm({ + actions: [{ ...SUBMIT_SIGNOFF, params: [{ name: 'note', type: 'text' }] }], + }); + await waitFor(() => expect(getObjectSchema).toHaveBeenCalled()); + await new Promise((r) => setTimeout(r, 20)); + expect(noteOnIssuedOn()).toBeNull(); + }); + + it('names every action that would require the field, one line each', async () => { + renderInvoiceForm({ + actions: [ + SUBMIT_SIGNOFF, + { name: 'mark_paid', label: 'Mark Paid', operation: 'update', patch: { status: 'paid' }, locations: ['record_header'] }, + // Writes a status the rule does not name: no line. + { name: 'void_it', label: 'Void', operation: 'update', patch: { status: 'void' }, locations: ['record_header'] }, + ], + }); + + await waitFor(() => + expect( + Array.from(document.querySelectorAll('[data-required-for-action="true"]')).map((n) => n.textContent), + ).toEqual([NOTE, 'Required to run “Mark Paid”.']), + ); + }); +}); + +describe('patchingUpdateActions — which declared actions write only their patch', () => { + it('keeps a placed `operation: update` action with a static patch and no params', () => { + expect(patchingUpdateActions([SUBMIT_SIGNOFF]).map((a) => [a.name, a.label, a.patch])).toEqual([ + ['showcase_submit_signoff', 'Submit for Sign-off', { status: 'sent' }], + ]); + }); + + it('leaves out every action whose write is not known before it runs', () => { + expect( + patchingUpdateActions([ + { ...SUBMIT_SIGNOFF, name: 'a', operation: undefined }, + { ...SUBMIT_SIGNOFF, name: 'b', type: 'api', operation: undefined, patch: undefined, target: '/x' }, + { ...SUBMIT_SIGNOFF, name: 'c', patch: {} }, + { ...SUBMIT_SIGNOFF, name: 'd', params: [{ name: 'p' }] }, + { ...SUBMIT_SIGNOFF, name: 'e', locations: [] }, + { ...SUBMIT_SIGNOFF, name: 'f', locations: undefined }, + null, + 'showcase_submit_signoff', + ]), + ).toEqual([]); + }); + + it('keeps an empty params list, falls back to the name for a label, and de-duplicates by name', () => { + const found = patchingUpdateActions([ + { ...SUBMIT_SIGNOFF, params: [], label: undefined }, + SUBMIT_SIGNOFF, + ]); + expect(found.map((a) => a.label)).toEqual(['showcase_submit_signoff']); + }); + + it('answers none for anything that is not a list', () => { + expect(patchingUpdateActions(undefined)).toEqual([]); + expect(patchingUpdateActions({ actions: [SUBMIT_SIGNOFF] })).toEqual([]); + }); +}); From 6bb12307429bd652c6ff53f66b481769e05a1358 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 17:06:03 +0000 Subject: [PATCH 3/7] chore: changeset for the edit form's required-for-action note Co-Authored-By: Claude --- .changeset/28382-required-for-action-note.md | 17 +++++++++++++++++ .../form-required-for-action-28382.test.tsx | 5 +++-- 2 files changed, 20 insertions(+), 2 deletions(-) create mode 100644 .changeset/28382-required-for-action-note.md diff --git a/.changeset/28382-required-for-action-note.md b/.changeset/28382-required-for-action-note.md new file mode 100644 index 00000000000..3324a68bbfa --- /dev/null +++ b/.changeset/28382-required-for-action-note.md @@ -0,0 +1,17 @@ +--- +'@object-ui/components': patch +'@object-ui/i18n': patch +--- + +feat(components): the edit form names the fields a visible update action would require, before the action is pressed + +Clause-②: yes (widening: `@object-ui/i18n` gains the `form.requiredForAction` key in all ten locales; no accept set moves) +Metadata-protocol: none + +A declarative update action (`operation: 'update'` with a static `patch`) writes its patch over the record, and the server runs the object's `requiredWhen` rules over the result. The edit form judged the record as it stands, so a field that only the action makes required showed nothing, and the first press was refused. In the showcase, "Issued On" on a draft invoice is required once `status` is `sent`, and "Submit for Sign-off" sets `status: 'sent'`. + +The form renderer now reads the object's declared actions on an edit form, through the data source it already has. For each update action that is placed, not switched off, permitted and visible for the record, it evaluates each field's `requiredWhen` over the record merged with the patch. An empty field that comes out required shows "Required to run “Submit for Sign-off”." under the control, inside the description the control's `aria-describedby` names. + +- It is a heads-up only. Save is not blocked, and the field is not marked required for Save. The server's refusal stays the source of truth. +- Actions that collect `params`, and actions whose write is not a static `patch`, are skipped, because what they write is not known before they run. +- A create form reads and marks nothing. diff --git a/ui/packages/components/src/renderers/form/__tests__/form-required-for-action-28382.test.tsx b/ui/packages/components/src/renderers/form/__tests__/form-required-for-action-28382.test.tsx index b2f21ada4ff..df34f3a9dd6 100644 --- a/ui/packages/components/src/renderers/form/__tests__/form-required-for-action-28382.test.tsx +++ b/ui/packages/components/src/renderers/form/__tests__/form-required-for-action-28382.test.tsx @@ -142,8 +142,9 @@ describe('form renderer — fields an update action would require (objectstack#2 renderInvoiceForm({}); await waitFor(() => expect(noteOnIssuedOn()).not.toBeNull()); - // Typing the status the rule names makes the field required for Save - // itself, and hides the action (its `visible` no longer holds). + // Typing a status the rule names makes the field required for Save itself. + // The action is still visible (`paid` is not `sent`) and would still + // require it, but the asterisk already says so: no second note. fireEvent.change(screen.getByLabelText(/Status/), { target: { value: 'paid' } }); await waitFor(() => expect(issuedOnInput().getAttribute('aria-required')).toBe('true')); From 19eac3453f9631d1de99fe4198b719844072582d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 17:08:02 +0000 Subject: [PATCH 4/7] refactor(components): keep the patching-actions read off a synchronous effect setState Co-Authored-By: Claude --- .../src/renderers/form/actionRequirements.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/ui/packages/components/src/renderers/form/actionRequirements.ts b/ui/packages/components/src/renderers/form/actionRequirements.ts index bfca4e0daa0..a086c586e49 100644 --- a/ui/packages/components/src/renderers/form/actionRequirements.ts +++ b/ui/packages/components/src/renderers/form/actionRequirements.ts @@ -103,14 +103,18 @@ export function usePatchingUpdateActions( objectName: string | undefined, enabled: boolean, ): PatchingUpdateAction[] { - const [actions, setActions] = React.useState(NONE) + // The answer is kept beside the source it was read from, so a change of + // data source or object shows none until its own read lands, and nothing is + // set synchronously inside the effect. + const [answer, setAnswer] = React.useState<{ + dataSource: DataSource | null | undefined + objectName: string | undefined + actions: PatchingUpdateAction[] + }>({ dataSource: undefined, objectName: undefined, actions: NONE }) const canAsk = enabled && !!objectName && typeof dataSource?.getObjectSchema === "function" React.useEffect(() => { - if (!canAsk || !dataSource || !objectName) { - setActions(NONE) - return - } + if (!canAsk || !dataSource || !objectName) return let cancelled = false Promise.resolve() .then(() => dataSource.getObjectSchema(objectName)) @@ -119,12 +123,12 @@ export function usePatchingUpdateActions( () => NONE, ) .then((found) => { - if (!cancelled) setActions(found.length > 0 ? found : NONE) + if (!cancelled) setAnswer({ dataSource, objectName, actions: found.length > 0 ? found : NONE }) }) return () => { cancelled = true } }, [canAsk, dataSource, objectName]) - return actions + return canAsk && answer.dataSource === dataSource && answer.objectName === objectName ? answer.actions : NONE } From c56ed27dd702385f46f487365f72e856bff605be Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 17:34:27 +0000 Subject: [PATCH 5/7] test(i18n): move the de quote census by one matched pair for form.requiredForAction Co-Authored-By: Claude --- .../i18n/src/__tests__/de-quote-pairing-3876.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts b/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts index 8160ea5cf48..0d50e1a0d1c 100644 --- a/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts +++ b/ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts @@ -373,7 +373,10 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // `console.serverAction.fillRequiredThenRetry`, the refused action's hint // that names the action to run again („{{action}}“) — one interpolated // span, runtime data. - expect(okSpans, 'correctly paired spans').toBe(103); + // 104 once objectstack-ai/objectstack#28382 added `form.requiredForAction`, + // the edit form's note that names the update action a field is required + // for („{{action}}“) — one interpolated span, runtime data. + expect(okSpans, 'correctly paired spans').toBe(104); }); it('keeps the count identity that replaces the card’s count(„) === count(“)', () => { @@ -448,7 +451,9 @@ describe('objectui#3876 — de pack closes „ with “ and not with a straight // objectstack-ai/objectstack#28241 added `setupAssignments.notFound`, one // more matched pair. 103 / 103 / 0 after objectstack-ai/objectstack#27520 // added `console.serverAction.fillRequiredThenRetry`, one more matched pair. - expect({ open, close, rdq }).toEqual({ open: 103, close: 103, rdq: 0 }); + // 104 / 104 / 0 after objectstack-ai/objectstack#28382 added + // `form.requiredForAction`, one more matched pair. + expect({ open, close, rdq }).toEqual({ open: 104, close: 104, rdq: 0 }); // The durable shape: every „ closed by a “, every surplus “ an English // opener answered by a ”. Survived translating the two English values. expect(close).toBe(open + rdq); From e4ae56a2a9f0455f37d060f5e9845b49af08f669 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 18:03:13 +0000 Subject: [PATCH 6/7] fix(components): name the patch-shape guard so it does not shadow a spec export Co-Authored-By: Claude --- .../components/src/renderers/form/actionRequirements.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/ui/packages/components/src/renderers/form/actionRequirements.ts b/ui/packages/components/src/renderers/form/actionRequirements.ts index a086c586e49..cfe61d8ac12 100644 --- a/ui/packages/components/src/renderers/form/actionRequirements.ts +++ b/ui/packages/components/src/renderers/form/actionRequirements.ts @@ -49,7 +49,7 @@ export interface PatchingUpdateAction { source: object } -function isPlainRecord(value: unknown): value is Record { +function isPatchObject(value: unknown): value is Record { return value != null && typeof value === "object" && !Array.isArray(value) } @@ -68,10 +68,10 @@ export function patchingUpdateActions(actions: unknown): PatchingUpdateAction[] const out: PatchingUpdateAction[] = [] const seen = new Set() for (const action of actions) { - if (!isPlainRecord(action)) continue + if (!isPatchObject(action)) continue if (action.operation !== "update") continue const patch = action.patch - if (!isPlainRecord(patch) || Object.keys(patch).length === 0) continue + if (!isPatchObject(patch) || Object.keys(patch).length === 0) continue if (action.params != null && !(Array.isArray(action.params) && action.params.length === 0)) continue if (!Array.isArray(action.locations) || action.locations.length === 0) continue const name = typeof action.name === "string" ? action.name : "" From 09cb02950eb69ba4327431b778716b923b93f71d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 18:44:55 +0000 Subject: [PATCH 7/7] fix(components): read an object's actions only when a field declares requiredWhen With no requiredWhen on the form there is nothing an action's patch could make required, so the form makes no read beyond the ones its host made. Keeps the hosts' one-schema-read pins. Co-Authored-By: Claude --- .../form-required-for-action-28382.test.tsx | 22 +++++++++++++++++++ .../components/src/renderers/form/form.tsx | 14 +++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/ui/packages/components/src/renderers/form/__tests__/form-required-for-action-28382.test.tsx b/ui/packages/components/src/renderers/form/__tests__/form-required-for-action-28382.test.tsx index df34f3a9dd6..481dd13fe76 100644 --- a/ui/packages/components/src/renderers/form/__tests__/form-required-for-action-28382.test.tsx +++ b/ui/packages/components/src/renderers/form/__tests__/form-required-for-action-28382.test.tsx @@ -159,6 +159,28 @@ describe('form renderer — fields an update action would require (objectstack#2 expect(noteOnIssuedOn()).toBeNull(); }); + it('makes no read when no field declares a `requiredWhen`', async () => { + const getObjectSchema = vi.fn(async () => ({ name: 'showcase_invoice', actions: [SUBMIT_SIGNOFF] })); + const Form = ComponentRegistry.get('form')!; + render( + + rest), + }} + /> + , + ); + // Let any effect settle before reading the absence. + await new Promise((r) => setTimeout(r, 20)); + expect(getObjectSchema).not.toHaveBeenCalled(); + expect(noteOnIssuedOn()).toBeNull(); + }); + it('skips an action whose `visible` does not hold over the record', async () => { const { getObjectSchema } = renderInvoiceForm({ actions: [{ ...SUBMIT_SIGNOFF, visible: { dialect: 'cel', source: "record.status == 'approved'" } }], diff --git a/ui/packages/components/src/renderers/form/form.tsx b/ui/packages/components/src/renderers/form/form.tsx index 7d2d6912a82..6ecbe81e240 100644 --- a/ui/packages/components/src/renderers/form/form.tsx +++ b/ui/packages/components/src/renderers/form/form.tsx @@ -2367,7 +2367,19 @@ ComponentRegistry.register('form', // (a rule that cannot be evaluated hides the button's note, never invents // one), and quietly: the action's own button reports its fault. The note // is a heads-up only — nothing here blocks a save. - const patchingActions = usePatchingUpdateActions(contextDataSource, schema.objectName, !isCreateForm); + // + // Asked only when some field declares a `requiredWhen`: with none, no field + // can come out required under any patch, so the form makes no read beyond + // the ones its host already made. + const declaresRequiredWhen = React.useMemo( + () => fields.some((f) => (f as { requiredWhen?: unknown } | null | undefined)?.requiredWhen != null), + [fields], + ); + const patchingActions = usePatchingUpdateActions( + contextDataSource, + schema.objectName, + !isCreateForm && declaresRequiredWhen, + ); const mayInvokeAction = useCapabilityGate(); const actionOutcomes = React.useMemo(() => { if (isCreateForm || patchingActions.length === 0) return [] as Array<{ label: string; record: Record }>;