Skip to content

Commit c580081

Browse files
fix(ui): judge requiredWhen as a transition gate on the console form page, the kanban drop prompt and the Edit wizard (#30301)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBY37urno69QP7HQ4DEeF5Fw)_ Fixes #30220 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:三处入口都改为按引擎的方式判 `requiredWhen`(转移闸门):控制台表单页(`/forms/:name?recordId=` 编辑)、看板拖放提示、编辑向导的最终提交检查。记录本来就处在闸门内且缺值时,不再拦住无关编辑、不再弹窗索要该字段;把记录移入闸门、清空已有值的编辑照旧拦截。向导按你在 #30220 上裁决的 A 实现:已存记录只交给存量判定,实时判定仍不绑定 `previous`。 - 为什么改:#30216 只修了记录编辑表单;同一条已激活但缺签字件的合同,在表单页保存报「Required: Signed Copy」,看板拖到别的优先级列弹窗索要签字件,编辑向导最后一步提交被拒,而 REST `PATCH` 本身返回 200。 - 风险与回滚:只改客户端判定,读的都是 #30216 已有的存量判定(`requiredWhenAtRest` / `requiredWhenRestsOnStoredRecord`),没有第二套规则;最坏情况是客户端放行、服务端拒绝,拒绝信息照常显示。回滚即还原本 PR。 - 先看:`ui/packages/plugin-form/src/WizardForm.tsx`(`gateFinalSubmit` 新增的 `isEditWizard && !field.required && requiredWhenRestsOnStoredRecord(...)` 分支)、`ui/apps/console/src/components/FormPage.tsx`(`resolveRowState` 多了 `name: field.name`)、`ui/packages/plugin-kanban/src/requiredWhenPrompt.ts`(多了 `name`)。 - 两分钟验证:`pnpm --dir ui exec vitest run packages/plugin-form/src/wizardSkipValidation.test.tsx apps/console/src/components/FormPage.conditionalRules.test.tsx packages/plugin-kanban/src/requiredWhenPrompt.test.ts`,预期全绿,其中三个「…already inside the gate…」用例通过。 - 需要你做的:无,席位落地。 ## What changes The engine's rule (`packages/objectql/src/validation/rule-validator.ts`, the "ADR-0113 non-regression: reject iff the MERGED state violates AND the PRE state complied" block, `preViolated`) refuses an update only when the merged record violates `requiredWhen` and the stored row complied. #30216 mirrored that in `@object-ui/core` `resolveFieldRuleState`: given `previous` (the stored row) and `statics.name`, a stored row already inside the gate without the value resolves `required: false` / `requiredWhenAtRest: true`. A caller that passes no `name` keeps the plain verdict, and the stored-row check is exported alone as `requiredWhenRestsOnStoredRecord`. This PR makes the three remaining doors read it: - **Console form page** (`ui/apps/console/src/components/FormPage.tsx`, `resolveRowState`): it already passed the stored record (`loaded.record`, the `GET /data/:object/:id` row) as `previous`; it now passes `name: field.name`. The asterisk, `aria-required` and the page's own submit check (`findSubmitRefusals`) all read this one verdict, so all three follow. A create form passes no `previous` and is unchanged. - **Kanban drop prompt** (`ui/packages/plugin-kanban/src/requiredWhenPrompt.ts`, `collectRequiredWhenPromptFields`): it already passed the pre-drop record as `previous`; it now passes `name`. A drop of a record already inside the gate without the value issues the plain single-key `PATCH` with no dialog; a drop that moves a compliant record into the gate still opens the "Complete required fields" dialog. - **Edit wizard** (`ui/packages/plugin-form/src/WizardForm.tsx`, `gateFinalSubmit`), as ruled on #30220 (option A, ruling record 6656113078): on an Edit wizard, after the live verdict, a field is skipped when `requiredWhenRestsOnStoredRecord(field.requiredWhen, name, persistedRecord, predicateScope)` answers true and the field is not statically required. The live `resolveFieldRuleState` call still binds no `previous`, so objectui#8069's option-D refusal (no new binding pipeline) and its accepted `visibleWhen` residual are unchanged; `persistedRecord` (the record as first read, the same one the step forms receive as `previousValues`) reaches only the stored-row check, which can only relax a `requiredWhen`, reports no fault, and counts an unevaluable predicate as compliant. The gate's note says so next to the option-D note. No new rule and no change in `@object-ui/core`. No engine-side door: the engine already implements the gate (the `preViolated` check), so there is no `domain:server` half. ## Why #30216 fixed the record Edit form only. The same record (a contract activated before its signed-copy rule existed) was still refused by the console form page ("Required: Signed Copy"), prompted for the signed copy by a kanban drop into another priority column, and refused by an Edit wizard's final submit, while REST `PATCH` answers 200 for these writes. ## Tests - `FormPage.conditionalRules.test.tsx` (the file's existing harness, new top-level describe, real `FormPage` on `/forms/:name?recordId=`): a record already activated with no signed copy shows no required marker and saves an unrelated edit (one `PATCH`, body carries the edit). CONTROL: clearing the value of an activated record that held it is refused (marker shown, `toast.error`, no write). - `requiredWhenPrompt.test.ts` (new top-level describe): a record already inside the gate dropped into another column of an unrelated grouping collects nothing. CONTROL: a draft dropped into `activated` still collects `signed_copy`. - `wizardSkipValidation.test.tsx` (the cross-step gate's harness, new top-level describe, real `WizardForm` in edit mode, `allowSkip`, the field on the skipped step so only the gate judges it): an activated record with no signed copy saves an unrelated edit (one `update`). CONTROL: a draft moved to `activated` without the copy is refused, no `update`, and the wizard lands on the step holding the field. ## Acceptance notes - `Clause-②: no`: `resolveRowState`, `collectRequiredWhenPromptFields` and `WizardForm` keep their signatures and props; only the verdict for a stored row already inside the gate changes. - The wizard leg has unit pins and an ablation but no browser leg: the dispatch asked for browser legs on the kanban and form page doors, which were taken in round 1. - The after-leg form page shot shows the post-submit record page under the fresh boot's "Set the default currency" dialog; the "Submitted" toast and the `PATCH ... -> 200` reading are the evidence. ## Verification Head: `4af42bb2` (base `origin/main` merged at `1013ef11`). Round 2 (the wizard, ruled option A) added `99f59f62` (the fix) and `fe2c28f0` (the changeset) plus two merges of `origin/main`; the readings below say which head they were taken on. <details><summary>Round 2: the Edit wizard (tests, ablation, type-check)</summary> ``` pnpm --dir ui exec vitest run --maxWorkers=2 packages/plugin-form/ packages/core/src/evaluator/ packages/plugin-kanban/ apps/console/src/components/FormPage.conditionalRules.test.tsx (on fe2c28f, under os-verify-lock) Test Files 330 passed (330) Tests 3578 passed | 28 skipped (3606) ``` Re-run on `4af42bb2`: `wizardSkipValidation.test.tsx`, `wizardVisibleWhenFault-8069.test.tsx` (the option-D residual pins, unchanged and green), `FormPage.conditionalRules.test.tsx`, `requiredWhenPrompt.test.ts`: Test Files 4 passed (4), Tests 54 passed (54). ``` WizardForm.tsx: " isEditWizard &&" -> " false &&" (the stored-row skip disabled) ablation-replace: ok mutation landed: anchor 1 -> 0, blob 4ae849c -> 6fedd3263db6 × saves an unrelated edit to a record already inside the gate without the value Tests 1 failed | 8 passed (9) ablation-replace: ok restored: blob == HEAD (4ae849c) and `git diff HEAD` is empty ``` The CONTROL stayed green under the ablation (it pins the gate holding). `WizardForm` is imported by relative path from the test, so no `dist/` is involved. ``` pnpm --filter @object-ui/plugin-form --filter @object-ui/console --filter @object-ui/plugin-kanban run type-check ui/packages/plugin-kanban type-check: Done ui/packages/plugin-form type-check: Done (tsc --noEmit && tsc -p tsconfig.test.json) ui/apps/console type-check: Done os-verify-lock: VERDICT command-exit 0 ``` `npx eslint src/WizardForm.tsx src/wizardSkipValidation.test.tsx` (plugin-form): 0 errors. </details> <details><summary>Browser check on a dev boot (showcase, fresh, built from this branch at origin/main 31260ca + this diff; Vite console serving src)</summary> Seeded by API as admin: writable package `com.qa.rwgate`, object `rwgate_contract` (status draft/activated, priority low/high, `signed_copy` text) with three rows inserted BEFORE the rule existed, then the object re-saved with `signed_copy.requiredWhen: record.status == 'activated'`; a kanban grouped by priority (default list), a kanban grouped by status (`by_status`), a simple form view, an app. Engine control on the same boot (REST): `PATCH` the activated row with no signed copy `{priority: high}` answered 200; `PATCH` the draft row `{status: activated}` answered 400 `VALIDATION_FAILED` ("Signed Copy is required"). | Leg | Before (fix removed from src, Vite restarted `--force`, served code checked) | After (this branch) | |:--|:--|:--| | Form page, Edit of the activated row with no signed copy, rename, Submit | label `Signed Copy*`, refused "Required: Signed Copy", no write | label `Signed Copy`, `PATCH /data/rwgate_contract/0zgY… -> 200`, "Submitted" | | Form page CONTROL, clear the signed copy of a compliant activated row | refused, no write | refused, no write (label `Signed Copy*`) | | Kanban by priority, drag the activated row with no signed copy Low to High | "Complete required fields" dialog, no write | no dialog, `PATCH ... -> 200`, card in High | | Kanban by status CONTROL, drag the draft row Draft to Activated | dialog asks for Signed Copy | dialog asks for Signed Copy, no write | Shots: `/mnt/project-files/ui-orders/30220-shots/` (`before-1..4`, `after-1..4`, one per leg). The boot and the Vite server were stopped after the run (health 000). </details> <details><summary>Round 1: targeted tests, type-check, lint (on 4febaa7)</summary> ``` pnpm --dir ui exec vitest run --maxWorkers=2 packages/plugin-kanban/ packages/core/src/evaluator/ (+ every FormPage*.test.tsx and every apps/console/src/__tests__ file naming FormPage / resolveRowState, 50 files) Test Files 157 passed (157) Tests 1462 passed | 27 skipped (1489) os-verify-lock: VERDICT command-exit 0 ``` Re-run on the merged head 4febaa7: `FormPage.conditionalRules.test.tsx`, `requiredWhenPrompt.test.ts`, `ObjectKanban.requiredWhenPrompt.test.tsx`: Test Files 3 passed (3), Tests 47 passed (47). ``` pnpm --filter @object-ui/console --filter @object-ui/plugin-kanban run type-check ui/packages/plugin-kanban type-check: Done (tsc --noEmit && tsc -p tsconfig.test.json) ui/apps/console type-check: Done (tsconfig include: src, so the test file is compiled) os-verify-lock: VERDICT command-exit 0 ``` `npx eslint` on the four touched files from their package dirs: 0 errors (26 pre-existing warnings in FormPage.tsx, none on touched lines). </details> <details><summary>Round 1: ablation per site, form page and kanban (fix removed, pin goes red, restored to HEAD)</summary> ``` FormPage.tsx: anchor " name: field.name," deleted ablation-replace: ok mutation landed: anchor 1 -> 0, blob 75c5f18 -> af3d441afb12 × saves an unrelated edit to a record already inside the gate without the value Tests 1 failed | 24 passed (25) ablation-replace: ok restored: blob == HEAD (75c5f18) and `git diff HEAD` is empty requiredWhenPrompt.ts: "readonly: def.readonly === true, name }" -> "readonly: def.readonly === true }" ablation-replace: ok mutation landed: anchor 1 -> 0, blob c09359b -> c283327bf78e × asks for nothing when the stored record already rests inside the gate without the value Tests 1 failed | 13 passed (14) ablation-replace: ok restored: blob == HEAD (c09359b) and `git diff HEAD` is empty ``` Both CONTROL cases stayed green under the ablation, as predicted: they pin the gate holding, which the plain verdict also gives. Both subjects are imported by relative path from their tests, so no `dist/` is involved. </details> <details><summary>Derived gates (dispatch-gates --commands on 4af42bb, 35 families, all run)</summary> ``` node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-sdui-manifest.mjs :: exit 0 node scripts/check-sdui-manifest.mjs --self-test :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --dir ui exec vitest run scripts/ :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:skill-identifier-liveness :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` `dispatch-gates --ran`: "35 derived famil(ies) accounted for — 35 run, 0 NOT-MEASURED (a DERIVED zero — all 35 recorded an exit code and none of them is 3)". Round 1 ran the same 35 on 4febaa7, all exit 0. </details> ## 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; eslint on the touched files reports 0 errors. - [x] 4. One issue, one branch off an up-to-date `main` (merged in twice), 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: 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 维护者速读 block is the required Chinese summary). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufziwFKBXQchtNMQYZSX <!-- signed: domain:ui#4 · os-support-ai · session_01WGufziwFKBXQchtNMQYZSX --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent df4c06e commit c580081

7 files changed

Lines changed: 264 additions & 3 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@object-ui/console': patch
3+
'@object-ui/plugin-kanban': patch
4+
'@object-ui/plugin-form': patch
5+
---
6+
7+
fix(ui): the console form page Edit, the kanban drop prompt and the Edit wizard judge a field's `requiredWhen` as the engine does, a transition gate against the stored record
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
- **`@object-ui/console` form page (`/forms/:name?recordId=`):** an Edit no longer refuses a record that already sits inside a `requiredWhen` gate without the value (for example a contract activated before its signed-copy rule existed). The field shows no required marker there, and an unrelated edit saves, as REST `PATCH` already accepts. Still refused: the edit that moves a record into the gate without the value, and clearing the value of a record that held it. A create form enforces as before.
13+
- **`@object-ui/plugin-kanban` drop prompt:** dropping a card no longer asks for a field the engine lets rest. A record already inside the gate without the value moves between columns with no dialog; a drop that moves a compliant record into the gate still asks for the value first.
14+
- **`@object-ui/plugin-form` Edit wizard:** the final submit's cross-step check no longer refuses a record that already sits inside a `requiredWhen` gate without the value; it asks `requiredWhenRestsOnStoredRecord` about the record as first read. The record reaches only that stored-record check, so a `visibleWhen` reading `previous` is still unevaluable at that check, as before. Moving a compliant record into the gate is still refused, and a Create wizard enforces as before.
15+
16+
All three read the stored-record verdict `resolveFieldRuleState` already returns (`requiredWhenAtRest`, or its exported check `requiredWhenRestsOnStoredRecord`); no second rule.

‎ui/apps/console/src/components/FormPage.conditionalRules.test.tsx‎

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -695,3 +695,78 @@ describe('objectui#5627 — the keys reach the rows, in their own slots', () =>
695695
expect(resolveRowState(byName.owned, values, {}, false).required).toBe(true);
696696
});
697697
});
698+
699+
// ─── 5. requiredWhen on an EDIT is the engine's transition gate ──────────
700+
701+
/**
702+
* objectstack#30220 — an Edit through `/forms/:name?recordId=` judges
703+
* `requiredWhen` as objectql does: an update is refused only when the merged
704+
* record violates the rule AND the stored row complied (ADR-0113
705+
* non-regression). `resolveRowState` hands `resolveFieldRuleState` the stored
706+
* row AND the field's column `name`, so a record already inside the gate
707+
* without the value saves an unrelated edit. The CONTROL is the gate still
708+
* holding: clearing the value of a record that complied is refused.
709+
*/
710+
describe('objectstack#30220 — FormPage judges requiredWhen on an Edit as a transition gate', () => {
711+
const SIGNED_WHEN_ACTIVATED = withRules({
712+
notes: { label: 'Notes', requiredWhen: "record.status == 'activated'" },
713+
});
714+
const SECTIONS = [{ label: 'Basics', fields: ['priority', 'title', 'status', 'notes'] }];
715+
const writes = () => calls.filter((c) => c.method === 'POST' || c.method === 'PATCH');
716+
717+
function renderEdit(stored: Record<string, unknown>) {
718+
return renderInternal(SECTIONS, {
719+
objectSchema: SIGNED_WHEN_ACTIVATED,
720+
query: '?recordId=c-1',
721+
extraRoutes: [
722+
{
723+
match: '/data/showcase_task/c-1',
724+
body: { object: 'showcase_task', id: 'c-1', record: { id: 'c-1', ...stored } },
725+
},
726+
{
727+
method: 'PATCH',
728+
match: '/data/showcase_task/c-1',
729+
body: { object: 'showcase_task', id: 'c-1', record: { id: 'c-1', ...stored } },
730+
},
731+
],
732+
});
733+
}
734+
735+
it('saves an unrelated edit to a record already inside the gate without the value', async () => {
736+
const user = userEvent.setup();
737+
const { container } = renderEdit({ priority: 'low', title: 'Old', status: 'activated' });
738+
await awaitForm();
739+
// At rest: neither the marker nor the control says required.
740+
expect(controlFor(container, 'notes')).not.toBeRequired();
741+
expect(labelFor(container, 'notes').textContent).not.toContain('*');
742+
743+
const title = screen.getByLabelText('Title');
744+
await user.clear(title);
745+
await user.type(title, 'Renamed');
746+
await user.click(screen.getByRole('button', { name: /submit/i }));
747+
await waitFor(() => expect(writes()).toHaveLength(1));
748+
expect(writes()[0].method).toBe('PATCH');
749+
expect((writes()[0].body as Record<string, unknown>).title).toBe('Renamed');
750+
});
751+
752+
it('CONTROL: clearing the value of a record that complied is still refused', async () => {
753+
const { toast } = await import('sonner');
754+
vi.mocked(toast.error).mockClear();
755+
const user = userEvent.setup();
756+
const { container } = renderEdit({
757+
priority: 'low',
758+
title: 'Old',
759+
status: 'activated',
760+
notes: 'signed.pdf',
761+
});
762+
await awaitForm();
763+
expect(controlFor(container, 'notes')).toBeRequired();
764+
765+
await user.clear(screen.getByLabelText(/^Notes/));
766+
expect(controlFor(container, 'notes')).toBeRequired();
767+
expect(labelFor(container, 'notes').textContent).toContain('*');
768+
await user.click(screen.getByRole('button', { name: /submit/i }));
769+
await waitFor(() => expect(vi.mocked(toast.error)).toHaveBeenCalled());
770+
expect(writes()).toHaveLength(0);
771+
});
772+
});

‎ui/apps/console/src/components/FormPage.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1146,6 +1146,13 @@ export function resolveRowState(
11461146
{ field: { defaultValue: field.defaultValue } },
11471147
isCreateForm,
11481148
),
1149+
// The column of `previous` that holds this field's stored value, so an
1150+
// EDIT judges `requiredWhen` as the engine's transition gate: a stored
1151+
// row already inside the gate without the value rests there, and an
1152+
// unrelated edit saves (objectstack#30220, the verdict #30184 put in
1153+
// `resolveFieldRuleState`). A create form passes no `previous`, so it
1154+
// keeps the plain verdict.
1155+
name: field.name,
11491156
},
11501157
previous ?? undefined,
11511158
predicateScope,

‎ui/packages/plugin-form/src/WizardForm.tsx‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,13 @@ import React, { useState, useCallback, useMemo } from 'react';
1717
import type { FormField, FormSchema, DataSource, ObjectFormSchema, ObjectFormSection } from '@object-ui/types';
1818
import { Button, cn, toast } from '@object-ui/components';
1919
import { AlertCircle, Check, ChevronLeft, ChevronRight, Loader2 } from 'lucide-react';
20-
import { resolveFieldRuleState, evalFieldPredicate, isMissingForRequired, isServerOwnedValue } from '@object-ui/core';
20+
import {
21+
resolveFieldRuleState,
22+
evalFieldPredicate,
23+
isMissingForRequired,
24+
isServerOwnedValue,
25+
requiredWhenRestsOnStoredRecord,
26+
} from '@object-ui/core';
2127
import { createSafeTranslation, useLocalization } from '@object-ui/i18n';
2228
import { FormSectionContainer } from './FormSection';
2329
import { SchemaRenderer, useSafeFieldLabel, usePredicateScope } from '@object-ui/react';
@@ -772,6 +778,9 @@ export const WizardForm: React.FC<WizardFormProps> = ({
772778
// `required` suppression use, so this wizard cannot be seeded as a create
773779
// form and gated as an edit one.
774780
const isCreateWizard = isCreateFormMode(schema);
781+
// The edit test the step forms already use to hand `persistedRecord` on as
782+
// `previousValues`; the final gate's stored-row check reads the same one.
783+
const isEditWizard = schema.mode === 'edit';
775784

776785
/**
777786
* The host shell's global predicate scope (`ExpressionProvider` →
@@ -838,6 +847,20 @@ export const WizardForm: React.FC<WizardFormProps> = ({
838847
* half is the same mechanism, accepted as a residual under the same refusal
839848
* of option D on objectui#8069 — ⛔ so `persistedRecord` is deliberately NOT
840849
* bound here.
850+
*
851+
* ## `requiredWhen` is a transition gate on an EDIT wizard (objectstack#30220)
852+
*
853+
* The engine refuses an update only when the merged record violates
854+
* `requiredWhen` AND the stored row complied (ADR-0113 non-regression). So on
855+
* an EDIT wizard a field the live verdict calls required is skipped when the
856+
* stored row already rests inside its gate, as `requiredWhenRestsOnStoredRecord`
857+
* answers over `persistedRecord` — the same verdict the step forms read. As
858+
* ruled on objectstack#30220 (option A), the persisted record reaches ONLY
859+
* that stored-row check: the live `resolveFieldRuleState` call above still
860+
* binds no `previous`, so the option-D refusal and its `visibleWhen` residual
861+
* stand unchanged. The check can only relax a `requiredWhen`, never a static
862+
* `required`, and reports no fault (an unevaluable predicate counts the stored
863+
* row as compliant, as on the server).
841864
*/
842865
const gateFinalSubmit = useCallback(
843866
(
@@ -896,12 +919,26 @@ export const WizardForm: React.FC<WizardFormProps> = ({
896919
// A hidden or read-only field is not the user's to fill in.
897920
if (!viewVisible || !state.visible || state.readonly || !state.required) continue;
898921
if (!isEmptyValue(record[name])) continue;
922+
// The stored row already rests inside this field's `requiredWhen`
923+
// gate: the engine lets the write through (see the note above).
924+
if (
925+
isEditWizard &&
926+
!field.required &&
927+
requiredWhenRestsOnStoredRecord(
928+
(field as any).requiredWhen,
929+
name,
930+
persistedRecord,
931+
predicateScope,
932+
)
933+
) {
934+
continue;
935+
}
899936
out.set(index, [...(out.get(index) ?? []), (field as any).label || name]);
900937
}
901938
});
902939
return { missing: out, faultedVisibleWhen };
903940
},
904-
[schema.sections, buildSectionFields, isCreateWizard, predicateScope],
941+
[schema.sections, buildSectionFields, isCreateWizard, isEditWizard, persistedRecord, predicateScope],
905942
);
906943

907944
// Handle step data collection (merge partial data into formData)

‎ui/packages/plugin-form/src/wizardSkipValidation.test.tsx‎

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,3 +272,81 @@ describe('WizardForm — the form view’s own `columns`', () => {
272272
expect(document.body.querySelector('[class*="@2xl:grid-cols-3"]')).toBeNull();
273273
});
274274
});
275+
276+
/**
277+
* objectstack#30220 (ruled option A) — on an EDIT wizard the cross-step gate
278+
* judges `requiredWhen` as the engine does: a TRANSITION gate. objectql
279+
* refuses an update only when the merged record violates the rule AND the
280+
* stored row complied (ADR-0113 non-regression), so a record already inside
281+
* the gate without the value saves an unrelated edit. The field sits on the
282+
* step the user skips, so only the cross-step gate judges it. The CONTROL is a
283+
* record that complied and is moved into the gate: still refused.
284+
*/
285+
describe('WizardForm edit — the final gate reads requiredWhen as a transition gate (objectstack#30220)', () => {
286+
const CONTRACT_SCHEMA = {
287+
name: 'contract',
288+
fields: {
289+
subject: { type: 'text', label: 'Subject' },
290+
status: { type: 'text', label: 'Status' },
291+
signed_copy: { type: 'text', label: 'Signed Copy', requiredWhen: "record.status == 'activated'" },
292+
notes: { type: 'text', label: 'Notes' },
293+
},
294+
};
295+
296+
const editWizard = (stored: Record<string, unknown>) => {
297+
const dataSource = {
298+
getObjectSchema: vi.fn().mockResolvedValue(CONTRACT_SCHEMA),
299+
findOne: vi.fn().mockResolvedValue({ id: 'c1', ...stored }),
300+
create: vi.fn(),
301+
update: vi.fn(async (_o: string, _id: string, d: any) => ({ id: 'c1', ...stored, ...d })),
302+
};
303+
render(
304+
<WizardForm
305+
schema={{
306+
type: 'object-form',
307+
formType: 'wizard',
308+
objectName: 'contract',
309+
mode: 'edit',
310+
recordId: 'c1',
311+
allowSkip: true,
312+
sections: [
313+
{ name: 's1', label: 'Step 1', fields: ['subject', 'status'] },
314+
{ name: 's2', label: 'Step 2', fields: ['signed_copy'] },
315+
{ name: 's3', label: 'Step 3', fields: ['notes'] },
316+
],
317+
}}
318+
dataSource={dataSource as any}
319+
/>,
320+
);
321+
return dataSource;
322+
};
323+
324+
/** Edit step 1 as asked, jump past step 2 to step 3, and press Update. */
325+
async function editAndSubmit(step1: Record<string, string>) {
326+
await waitFor(() => {
327+
const el = document.body.querySelector<HTMLInputElement>('[data-field="subject"] input');
328+
if (!el || el.value !== 'Lease') throw new Error('record not on screen yet');
329+
});
330+
for (const [name, value] of Object.entries(step1)) fill(name, value);
331+
fireEvent.click(stepIndicator(2));
332+
await waitFor(() => expect(document.body.querySelector('[data-field="notes"]')).toBeTruthy());
333+
fill('notes', 'renewed');
334+
expect(document.body.querySelector('[data-field="signed_copy"]')).toBeNull();
335+
fireEvent.click(screen.getByRole('button', { name: /Update/i }));
336+
}
337+
338+
it('saves an unrelated edit to a record already inside the gate without the value', async () => {
339+
const dataSource = editWizard({ subject: 'Lease', status: 'activated', signed_copy: null, notes: '' });
340+
await editAndSubmit({ subject: 'Lease 2026' });
341+
await waitFor(() => expect(dataSource.update).toHaveBeenCalledTimes(1));
342+
expect(dataSource.update.mock.calls[0][2]).toMatchObject({ subject: 'Lease 2026', notes: 'renewed' });
343+
});
344+
345+
it('CONTROL: moving a compliant record into the gate without the value is still refused', async () => {
346+
const dataSource = editWizard({ subject: 'Lease', status: 'draft', signed_copy: null, notes: '' });
347+
await editAndSubmit({ status: 'activated' });
348+
// Nothing is sent, and the wizard lands on the step holding the field.
349+
await waitFor(() => expect(document.body.querySelector('[data-field="signed_copy"]')).toBeTruthy());
350+
expect(dataSource.update).not.toHaveBeenCalled();
351+
});
352+
});

‎ui/packages/plugin-kanban/src/requiredWhenPrompt.test.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -172,3 +172,46 @@ describe('collectRequiredWhenPromptFields (#4254)', () => {
172172
).toEqual([]);
173173
});
174174
});
175+
176+
/**
177+
* objectstack#30220 — the drop is judged as the engine judges the write: a
178+
* TRANSITION gate. objectql refuses an update only when the merged record
179+
* violates `requiredWhen` AND the stored row complied (ADR-0113
180+
* non-regression), so a record already inside the gate without the value
181+
* moves between columns of an unrelated grouping, and the board must not ask.
182+
* The verdict is `resolveFieldRuleState`'s `requiredWhenAtRest`, read through
183+
* the stored column `name` this module hands it — never a second rule here.
184+
*/
185+
describe('collectRequiredWhenPromptFields judges the transition gate (objectstack#30220)', () => {
186+
const SIGNED_WHEN_ACTIVATED = 'record.status == "activated"';
187+
const contractFields = {
188+
priority: { type: 'picklist', label: 'Priority' },
189+
status: { type: 'picklist', label: 'Status' },
190+
signed_copy: { type: 'text', label: 'Signed Copy', requiredWhen: SIGNED_WHEN_ACTIVATED },
191+
};
192+
193+
it('asks for nothing when the stored record already rests inside the gate without the value', () => {
194+
// An activated contract with no signed copy, regrouped by priority: the
195+
// engine lets this PATCH through, so a prompt would only block the move.
196+
expect(
197+
collectRequiredWhenPromptFields(
198+
contractFields,
199+
{ id: 'c1', priority: 'low', status: 'activated', signed_copy: null },
200+
'priority',
201+
'high',
202+
),
203+
).toEqual([]);
204+
});
205+
206+
it('CONTROL: still asks when the drop moves a compliant record INTO the gate', () => {
207+
// The stored row complied (a draft needs no signed copy) and the drop
208+
// makes it violate: the engine refuses, so the board collects the value.
209+
const out = collectRequiredWhenPromptFields(
210+
contractFields,
211+
{ id: 'c2', priority: 'low', status: 'draft', signed_copy: null },
212+
'status',
213+
'activated',
214+
);
215+
expect(out.map((f) => f.name)).toEqual(['signed_copy']);
216+
});
217+
});

‎ui/packages/plugin-kanban/src/requiredWhenPrompt.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,12 @@ export function collectRequiredWhenPromptFields(
111111
requiredWhen: def.requiredWhen as never,
112112
},
113113
next,
114-
{ required: def.required === true, readonly: def.readonly === true },
114+
// `name` says which column of the pre-drop record is this field's stored
115+
// value, so the drop is judged as the engine judges the write: a
116+
// transition gate (objectstack#30220). A record already inside the gate
117+
// without the value rests there, the engine lets the drop through, and
118+
// the board asks for nothing.
119+
{ required: def.required === true, readonly: def.readonly === true, name },
115120
// The pre-drop record, so a predicate reading `previous.*` sees what the
116121
// form would show it.
117122
record,

0 commit comments

Comments
 (0)