Skip to content

Commit 4bc963c

Browse files
feat(components): the edit form names the fields a visible update action would require, before it is pressed (#28459)
<!-- claude-projects-attribution: {"github_login":"os-sales"} --> _Requested by **Sales** · [project thread](https://claude.ai/code/project/chan_01D5bWFY3rFzgJcytD5gD4Uu?thread=cmsg_01D5bWFY3rFzgJcytD5gD4Uu4iEg7dMGWavC1JjYSAwAZj)_ Fixes #28382 Clause-②: yes (widening: `@object-ui/i18n` gains the `form.requiredForAction` key in all ten locales; no accept set moves) Metadata-protocol: none Branch note: `claude/project-thread-3lhyz5` is the branch the platform assigned to this card thread (claim 6546347620 names it), not the repo's `claude/issue-N-slug` convention. ## 维护者速读 - **改了什么**:编辑记录时,如果某个可见的「update + 静态 patch」动作执行后会让一个空字段变成必填,表单就在该字段下写一行提示,例如 showcase 草稿发票的「Issued On」下显示 “Required to run “Submit for Sign-off”.”。 - **为什么改**:服务端在动作的 patch 合并后的记录上跑 `requiredWhen`,而表单只看当前记录,所以第一次点「Submit for Sign-off」总会被拒(`Issued On is required`),事前没有任何提示。 - **风险与回滚**:纯提示,不拦保存、不改字段的必填状态、不改服务端;只在编辑表单生效,读对象 schema 走已有的数据源缓存。回滚即撤销本 PR。 - **先看**:`ui/packages/components/src/renderers/form/form.tsx` 的 `actionOutcomes` 块和 `renderFieldDescription` 调用处;`ui/packages/components/src/renderers/form/actionRequirements.ts`(哪些动作计入)。 - **两分钟验证**:`cd ui && pnpm exec vitest run packages/components/src/renderers/form/__tests__/form-required-for-action-28382.test.tsx`,预期 12 条全过;或在 showcase 打开一张 draft 发票点 Edit,Issued On 下出现该提示。 - **需要你做的**:无,席位落地。 ## What changes Measured on `main` `f91ab5a0` before any code, with the showcase (`objectstack dev --seed-admin`) and the console (`vite`, proxied): | | On `main` | This PR | |:--|:--|:--| | `POST /api/v1/actions/showcase_invoice/showcase_submit_signoff` on draft INV-1004 (`issued_on` empty) | `400 VALIDATION_FAILED`, "Issued On is required" | unchanged: the server stays the source of truth | | Edit Invoice dialog on the same draft | "Issued On" shows nothing | "Issued On" shows "Required to run “Submit for Sign-off”." under the control | - **`actionRequirements.ts` (new, in the form renderer's directory).** `patchingUpdateActions` keeps a declared action only when its whole write is known before it runs: `operation: 'update'`, a non-empty static `patch`, no `params` (an empty list is fine), and at least one location (a locationless action is placed nowhere, per `actionRendersAt`). `usePatchingUpdateActions` reads the object's actions once per object through the data source the renderer already has (`getObjectSchema`, the read every form host makes and the adapter caches), on edit forms only. - **`form.tsx`.** `actionOutcomes` is the record each kept action would leave: `{ ...ruleRecord, ...patch }`, the merge the server makes. An action is dropped when it is switched off, when the caller lacks its `requiredPermissions` (the `action:bar` gate), or when its `visible` predicate does not hold over the live record. That predicate fails closed and quietly, because the action's own button reports its fault. In `renderFormField`, a field with a `requiredWhen` that is not required now, is editable and is empty gets one line per action whose record makes it required. The line sits inside the field's `FormDescription`, so the control's `aria-describedby` announces it. - Nothing about Save changes: the field gets no asterisk and no `aria-required`, and the submit-time required check reads the same live verdict as before. - **i18n.** `form.requiredForAction` in all ten locales, plus the renderer's provider-less fallback row (byte-identical to `en`). ## Tests - `form-required-for-action-28382.test.tsx` (new, 12 tests) through the real `form` renderer with a data source in `SchemaRendererContext`: the note on an edit form and its `aria-describedby` delivery; Save still writes with the field empty; the note goes when the field is filled; it gives way to the asterisk when the record as it stands requires the field; a create form reads nothing; an action whose `visible` does not hold, or that collects `params`, adds nothing; two actions give two lines and an unrelated patch none; and `patchingUpdateActions` unit cases. - Ablation (fix committed first; `scripts/ablation-replace.mjs`, each leg restored with `git diff HEAD` empty): - `actionOutcomes.length > 0 &&` to `< 0`: 5 tests red (every case that expects a note). - `visible !== true &&` to `false &&`: 1 red, the `visible` skip case. - `!required &&` to `true &&`: 1 red, the asterisk case. - `packages/components/src/renderers/form/` + `packages/i18n/` (one run): 177 of 178 files passed; the only red was the German quote census below (2 tests). After moving it, `packages/i18n/` reran: 94/94 files, 1438 passed, 13 skipped. - Moved pin: `ui/packages/i18n/src/__tests__/de-quote-pairing-3876.test.ts`. The German value „{{action}}“ is one more matched pair, so its census goes from 103 to 104 (both literals), with the history comment extended. - `turbo run type-check --filter=@object-ui/components --filter=@object-ui/i18n`: exit 0. eslint on each changed file: 0 errors. `form.tsx` has the same warning count as on `main` (58). ## Acceptance notes - Heads-up only, as the card asks: Save is never blocked, and the server's refusal stays the source of truth for the action. - The renderer reads the actions itself, not through a new `FormSchema` key, so every form host (modal, drawer, page, tabbed, wizard) gets the note with no host or `@object-ui/types` change. This is the same pattern the renderer already uses for the record-currency used codes. - Not covered, by design: an action that collects `params` (the dialog's values are unknown before the press), and any non-declarative action (`api` / `flow` / `script` bodies). The optional "say what is missing on the button" half of the card is not done here: the button lives in the record header, which another card holds. - Live check: the after screenshot shows the note under "Issued On" on the showcase's Edit Invoice dialog. ## Verification - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 34 commands; on head `c56ed27d` all 34 ran with exit 0, and `--ran` reconciles 34 derived, 34 run, 0 NOT-MEASURED (the full list is in the os-dev-report on the card). `node scripts/check-sdui-manifest.mjs` exit 0. `pnpm --dir ui check:i18n-keys`, `check:i18n-dead-keys` and `check:i18n-drift` exit 0. - No open pull request touches `ui/packages/components/src/renderers/form/**` or `ui/packages/i18n/src/locales/**` at this head. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (not touched) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. (per-file eslint locally; the repo-wide run is CI's) - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. (the 维护者速读 section is the seat's required Chinese summary) - [x] The *Before Submitting* list in CONTRIBUTING.md is done. --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgXCPFJQiBaT5tcJurf)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgXCPFJQiBaT5tcJurf --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8e2d56d commit 4bc963c

15 files changed

Lines changed: 513 additions & 5 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@object-ui/components': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
feat(components): the edit form names the fields a visible update action would require, before the action is pressed
7+
8+
Clause-②: yes (widening: `@object-ui/i18n` gains the `form.requiredForAction` key in all ten locales; no accept set moves)
9+
Metadata-protocol: none
10+
11+
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'`.
12+
13+
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.
14+
15+
- 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.
16+
- 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.
17+
- A create form reads and marks nothing.
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectstack#28382 — an EDIT form names the fields a visible update action's
11+
* static `patch` would make required, before the action is pressed.
12+
*
13+
* The reported case, from the showcase: a draft invoice's "Issued On" carries
14+
* `requiredWhen: record.status in ['sent', 'paid']`, and "Submit for Sign-off"
15+
* is `operation: 'update'` + `patch: { status: 'sent' }`. The server runs the
16+
* rule over the record the patch leaves, so the first press was refused with
17+
* "Issued On is required", while the form — judging `status: 'draft'` — drew
18+
* nothing on the field.
19+
*
20+
* The door is the real `form` renderer with a data source in
21+
* `SchemaRendererContext`, which is where it reads the object's actions from.
22+
*/
23+
24+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
25+
import React from 'react';
26+
import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react';
27+
import { ComponentRegistry } from '@object-ui/core';
28+
import { SchemaRendererContext } from '@object-ui/react';
29+
// Module scope, not `beforeAll` — see object-ui/no-dynamic-import-in-test-hook.
30+
import '../../../renderers';
31+
import { patchingUpdateActions } from '../actionRequirements';
32+
33+
beforeEach(() => {
34+
if (!(Element.prototype as any).scrollIntoView) {
35+
(Element.prototype as any).scrollIntoView = () => {};
36+
}
37+
});
38+
39+
afterEach(() => {
40+
cleanup();
41+
vi.restoreAllMocks();
42+
});
43+
44+
const SUBMIT_SIGNOFF = {
45+
name: 'showcase_submit_signoff',
46+
label: 'Submit for Sign-off',
47+
operation: 'update',
48+
patch: { status: 'sent' },
49+
visible: { dialect: 'cel', source: "has(record.status) && record.status != 'sent'" },
50+
locations: ['list_item', 'record_header'],
51+
};
52+
53+
const FIELDS = [
54+
{ name: 'name', label: 'Invoice Number', type: 'input' },
55+
{ name: 'status', label: 'Status', type: 'input' },
56+
{
57+
name: 'issued_on',
58+
label: 'Issued On',
59+
type: 'input',
60+
requiredWhen: { dialect: 'cel', source: "record.status in ['sent', 'paid']" },
61+
},
62+
];
63+
64+
const DRAFT = { id: 'inv1', name: 'INV-1004', status: 'draft', issued_on: null };
65+
66+
const NOTE = 'Required to run “Submit for Sign-off”.';
67+
68+
function renderInvoiceForm(opts: {
69+
actions?: unknown[];
70+
record?: Record<string, unknown>;
71+
edit?: boolean;
72+
onSubmit?: (data: any) => unknown;
73+
}) {
74+
const record = opts.record ?? DRAFT;
75+
const getObjectSchema = vi.fn(async () => ({ name: 'showcase_invoice', actions: opts.actions ?? [SUBMIT_SIGNOFF] }));
76+
const Form = ComponentRegistry.get('form')!;
77+
const view = render(
78+
<SchemaRendererContext.Provider value={{ dataSource: { getObjectSchema } } as any}>
79+
<Form
80+
schema={{
81+
type: 'form',
82+
objectName: 'showcase_invoice',
83+
showSubmit: true,
84+
showCancel: false,
85+
submitLabel: 'Save',
86+
defaultValues: record,
87+
...(opts.edit === false ? {} : { previousValues: record }),
88+
fields: FIELDS,
89+
onSubmit: opts.onSubmit ?? (() => undefined),
90+
}}
91+
/>
92+
</SchemaRendererContext.Provider>,
93+
);
94+
return { ...view, getObjectSchema };
95+
}
96+
97+
const issuedOnInput = () => screen.getByLabelText(/Issued On/) as HTMLInputElement;
98+
const noteOnIssuedOn = () =>
99+
document.querySelector('[data-testid="field:showcase_invoice.issued_on"] [data-required-for-action="true"]');
100+
101+
describe('form renderer — fields an update action would require (objectstack#28382)', () => {
102+
it('notes the action under the empty field its patch makes required, on an edit form', async () => {
103+
const { getObjectSchema } = renderInvoiceForm({});
104+
105+
await waitFor(() => expect(noteOnIssuedOn()?.textContent).toBe(NOTE));
106+
expect(getObjectSchema).toHaveBeenCalledWith('showcase_invoice');
107+
// It is announced with the field: the note sits inside the description the
108+
// control's `aria-describedby` names.
109+
const describedBy = issuedOnInput().getAttribute('aria-describedby') ?? '';
110+
const described = describedBy
111+
.split(/\s+/)
112+
.map((id) => document.getElementById(id)?.textContent ?? '')
113+
.join(' ');
114+
expect(described).toContain(NOTE);
115+
// Required for the action, not for Save: no asterisk state on the control.
116+
expect(issuedOnInput().getAttribute('aria-required')).toBeNull();
117+
// Only the field the rule names: the other fields carry no note.
118+
expect(document.querySelectorAll('[data-required-for-action="true"]')).toHaveLength(1);
119+
});
120+
121+
it('never blocks Save: the record is written with the field still empty', async () => {
122+
const onSubmit = vi.fn();
123+
renderInvoiceForm({ onSubmit });
124+
await waitFor(() => expect(noteOnIssuedOn()).not.toBeNull());
125+
126+
fireEvent.click(screen.getByRole('button', { name: 'Save' }));
127+
128+
await waitFor(() => expect(onSubmit).toHaveBeenCalledTimes(1));
129+
expect(onSubmit.mock.calls[0][0]).toMatchObject({ status: 'draft' });
130+
});
131+
132+
it('drops the note once the field holds a value', async () => {
133+
renderInvoiceForm({});
134+
await waitFor(() => expect(noteOnIssuedOn()).not.toBeNull());
135+
136+
fireEvent.change(issuedOnInput(), { target: { value: '2026-10-31' } });
137+
138+
await waitFor(() => expect(noteOnIssuedOn()).toBeNull());
139+
});
140+
141+
it('gives way to the asterisk when the record as it stands already requires the field', async () => {
142+
renderInvoiceForm({});
143+
await waitFor(() => expect(noteOnIssuedOn()).not.toBeNull());
144+
145+
// Typing a status the rule names makes the field required for Save itself.
146+
// The action is still visible (`paid` is not `sent`) and would still
147+
// require it, but the asterisk already says so: no second note.
148+
fireEvent.change(screen.getByLabelText(/Status/), { target: { value: 'paid' } });
149+
150+
await waitFor(() => expect(issuedOnInput().getAttribute('aria-required')).toBe('true'));
151+
expect(noteOnIssuedOn()).toBeNull();
152+
});
153+
154+
it('does not read or note anything on a create form', async () => {
155+
const { getObjectSchema } = renderInvoiceForm({ edit: false });
156+
// Let any effect settle before reading the absence.
157+
await new Promise((r) => setTimeout(r, 20));
158+
expect(getObjectSchema).not.toHaveBeenCalled();
159+
expect(noteOnIssuedOn()).toBeNull();
160+
});
161+
162+
it('makes no read when no field declares a `requiredWhen`', async () => {
163+
const getObjectSchema = vi.fn(async () => ({ name: 'showcase_invoice', actions: [SUBMIT_SIGNOFF] }));
164+
const Form = ComponentRegistry.get('form')!;
165+
render(
166+
<SchemaRendererContext.Provider value={{ dataSource: { getObjectSchema } } as any}>
167+
<Form
168+
schema={{
169+
type: 'form',
170+
objectName: 'showcase_invoice',
171+
defaultValues: DRAFT,
172+
previousValues: DRAFT,
173+
fields: FIELDS.map(({ requiredWhen: _rule, ...rest }: any) => rest),
174+
}}
175+
/>
176+
</SchemaRendererContext.Provider>,
177+
);
178+
// Let any effect settle before reading the absence.
179+
await new Promise((r) => setTimeout(r, 20));
180+
expect(getObjectSchema).not.toHaveBeenCalled();
181+
expect(noteOnIssuedOn()).toBeNull();
182+
});
183+
184+
it('skips an action whose `visible` does not hold over the record', async () => {
185+
const { getObjectSchema } = renderInvoiceForm({
186+
actions: [{ ...SUBMIT_SIGNOFF, visible: { dialect: 'cel', source: "record.status == 'approved'" } }],
187+
});
188+
await waitFor(() => expect(getObjectSchema).toHaveBeenCalled());
189+
await new Promise((r) => setTimeout(r, 20));
190+
expect(noteOnIssuedOn()).toBeNull();
191+
});
192+
193+
it('skips an action that collects params', async () => {
194+
const { getObjectSchema } = renderInvoiceForm({
195+
actions: [{ ...SUBMIT_SIGNOFF, params: [{ name: 'note', type: 'text' }] }],
196+
});
197+
await waitFor(() => expect(getObjectSchema).toHaveBeenCalled());
198+
await new Promise((r) => setTimeout(r, 20));
199+
expect(noteOnIssuedOn()).toBeNull();
200+
});
201+
202+
it('names every action that would require the field, one line each', async () => {
203+
renderInvoiceForm({
204+
actions: [
205+
SUBMIT_SIGNOFF,
206+
{ name: 'mark_paid', label: 'Mark Paid', operation: 'update', patch: { status: 'paid' }, locations: ['record_header'] },
207+
// Writes a status the rule does not name: no line.
208+
{ name: 'void_it', label: 'Void', operation: 'update', patch: { status: 'void' }, locations: ['record_header'] },
209+
],
210+
});
211+
212+
await waitFor(() =>
213+
expect(
214+
Array.from(document.querySelectorAll('[data-required-for-action="true"]')).map((n) => n.textContent),
215+
).toEqual([NOTE, 'Required to run “Mark Paid”.']),
216+
);
217+
});
218+
});
219+
220+
describe('patchingUpdateActions — which declared actions write only their patch', () => {
221+
it('keeps a placed `operation: update` action with a static patch and no params', () => {
222+
expect(patchingUpdateActions([SUBMIT_SIGNOFF]).map((a) => [a.name, a.label, a.patch])).toEqual([
223+
['showcase_submit_signoff', 'Submit for Sign-off', { status: 'sent' }],
224+
]);
225+
});
226+
227+
it('leaves out every action whose write is not known before it runs', () => {
228+
expect(
229+
patchingUpdateActions([
230+
{ ...SUBMIT_SIGNOFF, name: 'a', operation: undefined },
231+
{ ...SUBMIT_SIGNOFF, name: 'b', type: 'api', operation: undefined, patch: undefined, target: '/x' },
232+
{ ...SUBMIT_SIGNOFF, name: 'c', patch: {} },
233+
{ ...SUBMIT_SIGNOFF, name: 'd', params: [{ name: 'p' }] },
234+
{ ...SUBMIT_SIGNOFF, name: 'e', locations: [] },
235+
{ ...SUBMIT_SIGNOFF, name: 'f', locations: undefined },
236+
null,
237+
'showcase_submit_signoff',
238+
]),
239+
).toEqual([]);
240+
});
241+
242+
it('keeps an empty params list, falls back to the name for a label, and de-duplicates by name', () => {
243+
const found = patchingUpdateActions([
244+
{ ...SUBMIT_SIGNOFF, params: [], label: undefined },
245+
SUBMIT_SIGNOFF,
246+
]);
247+
expect(found.map((a) => a.label)).toEqual(['showcase_submit_signoff']);
248+
});
249+
250+
it('answers none for anything that is not a list', () => {
251+
expect(patchingUpdateActions(undefined)).toEqual([]);
252+
expect(patchingUpdateActions({ actions: [SUBMIT_SIGNOFF] })).toEqual([]);
253+
});
254+
});

0 commit comments

Comments
 (0)