Skip to content

fix(ui): list each failed validation rule as its own line in the form banner and toast - #28151

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-ail428
Oct 30, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/project-thread-ail428

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

domain:ui#1 · os-tesla · session_01L9qibzC6RxMfB59EsgAoNN

Requested by Tesla · project thread

Fixes #28053

Clause-②: yes (widening: @object-ui/react gains the public export unmarkedRefusalLines; no accept set moves)
Metadata-protocol: none

维护者速读

  • 改了什么:一次保存同时违反两条及以上校验规则时,表单横幅和 toast 改为每条规则一行;规则点名了表单上画出的字段时,消息留在该字段下,横幅只列其余的。
  • 为什么改:以前横幅显示服务端用 ; 拼起来的一句话,用户读成一句长句,往往只改第一处;HotCRM 为此把两条预测规则改成互斥来绕开。
  • 风险与回滚:只改控制台的展示,服务端拒绝信封不变(实测本就逐条规则带 fields[]);单条规则的拒绝在表单渲染器上不变;回滚即 revert 本 PR。
  • 先看:ui/packages/react/src/utils/error-message.ts 的 unmarkedRefusalLines、form.tsx 提交 catch 里的 shownInline、FormPage.tsx 提交 catch。
  • 两分钟验证:pnpm --dir ui exec vitest run packages/components/src/renderers/form/__tests__/form-refusal-lines-28053.test.tsx,预期 3 个用例全绿。
  • 需要你做的:无,席位落地。

What changes

The server already says each failed rule separately: ValidationError keeps one fields[] entry per rule, located on the field the rule names, else on _record; only its top-level message is the ; -join. The console threw the entries away whenever one of them had no drawn input and showed the joined text instead. Now:

  • @object-ui/react adds unmarkedRefusalLines(err, isShownInline): one line per fields[] entry the caller could not show under a visible input, in server order. It answers null (the caller keeps the envelope's message) when the refusal has no entry list, or when an entry it would have to say carries no human-readable message; it answers [] when every entry is inline. It reads the same three shapes extractFieldErrors reads (the adapter's validationErrors, the client's details.fields, a bare fields), now through one private fieldEntries reader.
  • Form renderer (ui/packages/components/src/renderers/form/form.tsx): the submit catch records which rejected fields it wrote under a DRAWN input (fieldWrapper), and the banner and toast say only the other entries, one list item each. One remaining entry stays a plain string, so a single-rule refusal reads exactly as before. The permission, delegated-scope and author-marked userMessage paths keep their precedence.
  • FormPage (ui/apps/console/src/components/FormPage.tsx): the same rule over the rows it marks. Its banner and toast used to show Submit failed (400): plus the raw response body for every server refusal; a refusal with entries no row carries now shows those entries' messages instead. A refusal whose every entry is marked on a row, or that has no entry list, still shows what it showed before.

Both surfaces draw several lines as a ul of li, in the banner and in the toast. No server, spec or envelope change.

Tests

  • ui/packages/react/src/utils/error-message.unmarked-refusal-lines.test.ts (new, 8 cases): the measured two-rule body; located entry left out; all inline gives []; two _record rules give two lines; adapter, client and hand-rolled shapes agree; an entry with no field is said; an entry with no message gives null; no entry list gives null.
  • ui/packages/components/src/renderers/form/__tests__/form-refusal-lines-28053.test.tsx (new): two record-level rules give two banner items and two toast items and no ; ; one located plus one record-level rule gives the inline mark and one banner line; a single record-level rule is unchanged (the toast gets the plain string).
  • form-server-field-errors.test.tsx: the case for a rejected field the form does not render now asserts the toast says only that field's reason (it asserted a substring of the joined text).
  • FormPage.refusalMarks-27417.test.tsx: two #28053 cases (two _record rules; one row-marked plus one record-level rule), and the undrawn-field case now asserts the banner says the reason and no row carries it (it asserted the reason appeared nowhere, which was the defect).

Reverse verification. With both submit catches restored to the base (git restore --source=6410029d of form.tsx and FormPage.tsx, helper kept), 6 of the 22 cases in the three consumer files went red (the four new consumer cases and the two updated ones); the single-rule case stayed green, as it should. Restored with git checkout HEAD --, proven by git diff HEAD empty and both blob hashes equal to HEAD's.

Acceptance notes

  • Premise measured on the wire, not only the evaluator. On origin/main 6410029d, POST /api/v1/data/:object through RestServer over a real ObjectQL and an SQLite SqlDriver (a throwaway probe in packages/rest, not committed), with three cross_field rules:
    • one rule naming best plus one naming no field answered 400 with error = the ; -join and fields = [{field: best, code: rule_violation, message: …}, {field: _record, code: rule_violation, message: …}];
    • two rules naming no field answered two _record entries, each with its own message.
      So this is not a Seam; the ui lane fixes it.
  • FormPage single-entry change is deliberate. A refusal with exactly one entry no row carries used to show Submit failed (400): plus the raw JSON body; it now shows that entry's message. Same rule as the multi-entry case.
  • An entry with no human-readable message falls back to the envelope's top-level text for the whole banner rather than putting an untranslated code on a line by itself.
  • Not changed here: FormPage's banner and toast for a server refusal with no fields[] list, or whose every entry is marked on a row, still show Submit failed (STATUS): plus the raw response body. Noted, not filed.
  • Out of bounds and untouched: ui/packages/app-shell/src/console/AppContent.tsx, providers/writeWarningToast.ts, views/RecordFormPage.tsx (held by Console: a save that passes a warning-severity rule shows one toast, the record-named save toast carrying the warning #27999).

Verification

  • Head f4877f89 adds only the changeset's Clause-② line over 767d45bb; the Check Changeset scripts (check-adr-0087-registration, check-changeset-no-major, check-empty-changeset, each --base origin/main) re-ran there, exit 0.
  • Local subset on head 767d45bb: the dispatch-derived gate list, every line run with stdin from /dev/null, exit codes on disk, reconciled with dispatch-gates.mjs --ran.
Derived gates (local)
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 --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:pm-changeset-deadline-census :: exit 0
pnpm check:published-files :: exit 0
pnpm check:query-options-erasure :: exit 0
pnpm check:refd-timer-probe :: 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:undeclared-dep-imports :: exit 0
pnpm check:watch-hint-literal :: exit 0
pnpm check:where-matcher :: exit 0
pnpm check:type-check-debt :: exit 0
pnpm --dir ui exec vitest run scripts/ :: exit 0
pnpm --dir ui run check:new-line-citations :: exit 0

dispatch-gates --ran: 34 derived, 34 run, 0 NOT-MEASURED, 0 UNRUN (exit 0)
pnpm --filter @object-ui/react type-check :: exit 0
pnpm --filter @object-ui/components type-check :: exit 0
pnpm --filter @object-ui/console type-check :: exit 0
Targeted tests
pnpm --dir ui exec vitest run FILES  (head 767d45bb)
  packages/react/src/utils/error-message.unmarked-refusal-lines.test.ts
  packages/react/src/utils/error-message.test.ts
  packages/react/src/utils/error-message.normalisation-boundary.test.ts
  packages/components/src/renderers/form/__tests__/form-refusal-lines-28053.test.tsx
  packages/components/src/renderers/form/__tests__/form-server-field-errors.test.tsx
  apps/console/src/components/FormPage.refusalMarks-27417.test.tsx
 Test Files  6 passed (6)
      Tests  67 passed (67)
os-verify-lock: VERDICT command-exit 0

Reverse verification (both submit catches at base 6410029d): Tests 6 failed | 16 passed (22), VERDICT command-exit 1; restored, git diff HEAD empty.

Generated by Claude Code

… line

A refused write keeps one fields[] entry per failed rule; only its
top-level message joins them with '; '. The form renderer and FormPage
now mark what they can inline and say the rest one line per entry,
falling back to the envelope's message when there is no entry list.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibwcER4bPAfAhbS8zYY
@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 30, 2026 14:45
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#1 · os-tesla · session_01L9qibzC6RxMfB59EsgAoNN

席位核对

ACCEPT · seat domain:ui#1 · head f4877f89 · 2026-10-30T14:46Z

席位意见:一次保存同时违反多条校验规则时,表单横幅和提示现在一条规则一行;指向表单上可见字段的规则仍标在该字段下,横幅只说其余的。服务端返回的拒收信封不变,控制台只是读取它本来就带的每条规则一项。新增一个公开辅助函数 unmarkedRefusalLines,已按扩面申报 Clause-②: yes (widening)。

  1. Shape: Fixes #28053, draft, base main; 9 files, +437/−28: ui/packages/react/src/utils/error-message.ts (fieldEntries extracted from extractFieldErrors, new unmarkedRefusalLines(err, isShownInline); null when there is no entry list or an unshown entry lacks a message, so the caller keeps the envelope text), its export in ui/packages/react/src/index.ts, the submit catches of ui/packages/components/src/renderers/form/form.tsx and ui/apps/console/src/components/FormPage.tsx (one list item per unmarked entry; a single line stays a string), tests beside each, root changeset 28053-one-line-per-failed-rule.md (react, components, console patch).
  2. Premise measured: the dev drove POST /api/v1/data/:object over a real engine on 6410029d: one fields[] entry per failed rule, so this is console-only and not a Seam (triage 6520981886 direction). Seat read of the diff: declaredUserMessage / delegationScopeMessage / the permission line keep their precedence over the new lines; a refusal without fields[] (unique violation, plain 500) is unchanged.
  3. Declaration corrected: the claim's Clause-②: no was the seat's error; amended on console: two failing validation rules on one save show as one banner line joined by '; ' instead of one entry per rule #28053, and the changeset and PR body now read Clause-②: yes (widening: @object-ui/react gains the public export unmarkedRefusalLines; no accept set moves). Owed review = this seat's own record at the default tier (no packages/spec path, no accept set moves).
  4. Gates / check-runs: on head f4877f89: 46 success, 13 skipped, 0 failure (Lint & Repo Gates, TypeScript Type Check, Lint UI, Type Check UI, Build & E2E UI, Test UI, Check Changeset all success); mergeable_state clean. The three reds on 767d45bb were roll-ups of jobs cancelled by the push to f4877f89. Dev's local run: 34/34 derived gates exit 0, react/components/console type-check exit 0, 67 targeted tests green, ablation red then restored; relay writes 2, MCP writes 0.
  5. Out of scope: FormPage still shows Submit failed (STATUS): plus the raw response body for a refusal with no fields[] list (PR Acceptance notes); pre-existing and untouched here, noted, not filed. Console: a save that passes a warning-severity rule shows one toast, the record-named save toast carrying the warning #27999's files untouched.
  6. Landing: check-governed-merges --branch NOT governed, 465 changed lines ≤ 3000. The same seat session dispatched and reviewed this PR. The seat readies it and arms auto-merge on the maintainer's 「按 pm-dispatch 审过的pr你可以自主落地」 (seat thread, 2026-10-30T14:19Z).

@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit 9f13ee8 Oct 30, 2026
60 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-ail428 branch October 30, 2026 15:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

console: two failing validation rules on one save show as one banner line joined by '; ' instead of one entry per rule

1 participant