Skip to content

fix(ui): a refused inline-grid save marks its cells and says every reason; a refused form save is said once - #28396

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-mcx7eo
Oct 31, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/project-thread-mcx7eo

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Part of #27520 (rows 6 and 19; every other row stays on the card)

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:表格行内编辑点「Save All」被服务端拒绝时,出错的单元格加红框(悬停显示原因),每条原因完整地单独一行显示在工具栏下方,不再截断;表格自带保存在发送前先按字段规则(必填、最小/最大值、长度、邮箱/网址格式)检查,违规的行不发送,其他行照常保存;「Save All」不再贴在表格右边缘。表单保存被拒(如 Studio 建的对象的校验规则)时只显示一条横幅并滚动到可见处,不再同时弹一条一模一样的 toast。
  • 为什么改:18.3.0 RC 走查第 6、19 行:用户看不出是哪一格错了、原因被截断、明知违反 min 的值还发到服务端;表单被拒时同一句话说两遍。
  • 风险与回滚:发送前检查只读字段自己声明的规则,读不到就不判,服务端仍是最终裁判;表单不再弹错误 toast,这推翻了 objectui#13340 钉住的「横幅+一条 toast」行为(测试已按席位要求改写,保留「重试不叠加」的保证),需要契约复审。回滚即 revert 本 PR。
  • 先看:ui/packages/components/src/renderers/complex/data-table.tsx(saveRefusalLines、cellErrors);ui/packages/plugin-grid/src/objectGridInlineRules.ts;ui/packages/components/src/renderers/form/form.tsx(raiseRefusal)。
  • 两分钟验证:showcase 打开 Tasks → Grid,清空一行的 Title、把 Estimate 改成 -5,点 Save All,预期两格红框、横幅列出两条原因、网络面板没有 PATCH;用一个带 script 校验规则的对象新建记录并触发规则,预期只有横幅、没有 toast。
  • 需要你做的:无,席位落地。

What changes

Row 6, a refused inline save says every reason and marks its cells (data-table.tsx, the renderer that owns the grid's save bar).

  • The refusal was one truncate line in the save cluster, with the rest only in a title tooltip. It is now its own role="alert" row (data-testid="table-save-refusal") under the toolbar, taking the full width (sm:basis-full). One reason reads Save failed: <reason>; several read Save failed: with one list item each. The lines come from the shared @object-ui/react readers the form already uses (unmarkedRefusalLines, falling back to the error's message), deduplicated across rows.
  • The cells a refusal names are marked: aria-invalid, data-cell-error and title carry the reason, and an inset destructive ring draws it. The marks come from extractFieldErrors (fields[] / details.fields / validationErrors) and are kept per row key, so they follow the record across re-reads. Editing a marked cell clears that cell's mark; a save that lands, Cancel and Cancel All clear the row's or all marks. A whole-batch rejection names no row, so it says its reasons and marks no cell.
  • The toolbar gets data-slot="table-toolbar" and wraps on sm. ObjectGrid insets it (px-3 pt-2), so Save All no longer sits on the grid's right edge.

Row 6, the grid checks a field's own rules before sending (plugin-grid).

  • New objectGridInlineRules.ts (not exported): inlineEditRuleRefusals reads each changed field's declaration through buildValidationRules from @object-ui/fields, the same rules the record form checks: required (blank value), min / max (a staged number or number string), minLength / maxLength and the email / URL pattern. An authored rule message wins; otherwise the validation.* key is localized with the header's label. inlineRuleRefusalError builds the server's refusal shape (code: 'VALIDATION_FAILED', fields[]), so the table marks and says it like a server refusal.
  • defaultRowSave throws that refusal before claiming or writing. saveRowsClaimed refuses such a row unsent and still sends the others. A host's own onRowSave / onBatchSave is unchanged. A field with no declaration, or a rule set that cannot be read, is not judged; validation rules, uniqueness and state machines still reach the server.
  • objectGridTranslations.ts gains the validation.* defaults, byte-identical to the en pack.

Row 19, a refused form save is said once (form.tsx).

  • The three refusal points (a visibleWhen fault, onAction's result.error, a thrown write) raised the banner and toast.error(…, { id: outcomeToastId }) together. They now call raiseRefusal: dismiss any outcome toast, set the banner, and scroll the banner into view (block: 'nearest'). No error toast is raised, so a refusal the banner draws is said once. Every refusal path of this renderer draws the banner, so the seat's "keep the toast where no banner is drawn" case does not arise here.
  • The field mark for a rule that names a field already worked on main (measured: a cross_field rule with fields: [qty, max_qty] marks Quantity). A script rule declares no fields, so its refusal names only _record and there is nothing to mark. Recorded on the card, not filed.

Tests

  • New ui/packages/components/src/__tests__/data-table-refusal-cells-27520.test.tsx (4): Save All says two reasons as two whole list items and marks the refused row's two cells only; editing a marked cell removes its mark and keeps the other; a single-row save marks, and a later save that lands clears; a whole-batch rejection marks no cell.
  • New ui/packages/plugin-grid/src/__tests__/inlineEditRuleCheck-27520.test.tsx (7): over the real ObjectGrid + data-table, Save All refuses the row below min unsent, marks its cell and saves the other row; the per-row save refuses the same edit unsent; a control edit inside the rules is sent. Unit: one entry per broken rule with the localized message; a staged number string is read as a number and length is judged on text; no declaration judges nothing; an authored message wins.
  • Adapted, not deleted (the seat's condition): form-one-toast-per-refusal-13340.test.tsx now pins one banner, scrolled into view, zero error toasts, and a retry that still leaves one banner. The other refusal pins that asserted the toast now read the banner and assert no error toast: in components form-invalid-toast-joiner, form-refusal-lines-28053, form-server-field-errors, visibleWhen-fault-refuses-submit-8069, fieldtab-visiblewhen-6237; in plugin-form MasterDetailForm.outcomeToastSupersede, MasterDetailForm.singleRefusalRaiser, MasterDetailForm, WizardForm.outcomeToastSupersede, submitHandlerSeam, submitTargetRefusal.
  • pnpm --dir ui exec vitest run --maxWorkers=3 packages/components/ packages/plugin-grid/ packages/plugin-form/ at f77d796: 944 files passed, 1 skipped; 9131 tests passed, 25 skipped (lock VERDICT command-exit 0). The first run at 9edf9af had 25 reds in seven plugin-grid inline-edit files, all Cannot read properties of undefined (reading 'min') from the pre-send check. f77d796 makes an unreadable rule set judge nothing, and the rerun above is green.
  • type-check of @object-ui/components and @object-ui/plugin-grid: exit 0.
  • The whole app-shell and console suites were not run locally; they are left to CI. A search of both for the old refusal toast and the save-bar text found no pin.

Acceptance notes

Verification

Live before/after (showcase on a --fresh stack, admin, 1440x900)

Base origin/main 3ba5206 (before) and this branch (after). Backend examples/app-showcase objectstack dev --seed-admin --fresh, Console from ui/apps/console source through vite, driven with Playwright.

  • Row 6, Tasks → Grid, Title cleared and Estimate set to -5, Save All. Before: one truncated banner line Save failed: Title is required and cannot be cleared; Es… (384x20 px), no cell marked, PATCH /api/v1/data/showcase_task/<id> sent with -5 and answered 400 VALIDATION_FAILED naming title and estimate_hours, Save All 0 px from the content edge. After: Title and Estimate cells marked, no write sent, banner "Save failed:" with "Title is required" and "Estimate (h) must be at least 0" as two lines, Save All 12 px from the edge.
  • Row 19, an object authored with PUT /api/v1/meta/object/t27520_widget (a script rule and a cross_field rule), create page /new and the list's New dialog. Before: a script-rule refusal (fields: [{ field: '_record', code: 'rule_violation' }]) drew the banner and an identical toast, on both. After: the banner only, no toast, on both. The cross_field refusal marks Quantity before and after.
Derived gates

At f77d796: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 35 families against the diff. All 35 ran with </dev/null, exit codes recorded first. dispatch-gates --ran: "35 derived famil(ies) accounted for — 35 run, 0 NOT-MEASURED".

  • pnpm check:type-check-debt first exited 3 (not measured): it queued behind this branch's own test run on the verify lock and timed out. Rerun under the lock: exit 0, "0 raw tsc error(s) total, none above its recorded number".
  • pnpm --dir ui exec vitest run scripts/ under the verify lock: 169 files passed, 2 skipped, exit 0.
  • Also run: pnpm --dir ui check:new-line-citations exit 0; node scripts/check-sdui-manifest.mjs exit 0.

Generated by Claude Code

The grid's save bar says a refusal whole, one line per reason, marks the
cells the refusal names, and sits on the grid's inset; ObjectGrid checks
the record form's field rules before sending an inline edit. A refused
form submit is said once, by the banner brought into view.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibxQtKRyLyL6eK14pgY
…grid's pre-send rule check

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L9qibxQtKRyLyL6eK14pgY
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tooling labels Oct 31, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract-review-claim: session_01L9qibw58ehQ2gRHP2h2Puz · head 2e90f2c9294845ee2720b293629deebc4292ba11 · 2026-10-31T11:08Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 2e90f2c9294845ee2720b293629deebc4292ba11
Local-runs: none
Scope: ui/packages/components/src/renderers/form/form.tsx (raiseRefusal, the banner ref and scroll, the three refusal points), ui/packages/components/src/renderers/complex/data-table.tsx (saveRefusalLines, refusedCellsOf, saveError as lines, cellErrors per row key and its clears, the refusal row, the cell marks, the toolbar slot), ui/packages/plugin-grid/src/objectGridInlineRules.ts (new, not exported from the package), ui/packages/plugin-grid/src/ObjectGrid.tsx (inlineRuleRefusal in defaultRowSave and saveRowsClaimed, the toolbar inset), ui/packages/plugin-grid/src/objectGridTranslations.ts (validation.* defaults), the two new test files and the twelve adapted pins, .changeset/27520-refused-grid-and-form-saves.md

Inputs: card #27520 (rows 6 and 19 of the 18.3.0 RC walkthrough; the seat's claim 6541435370 and its amendment 6541672013 after the live measure, which adds data-table.tsx and form.tsx to the surface and rules the behaviour: a refused submit shows one message, the banner, scrolled into view, no toast; the #13340 pin adapted with its no-stacking guarantee kept; the toast stays where no banner is drawn), PR #28396 (body with the live before and after on showcase and a t27520_widget object, the acceptance notes, the file list of 20 files, the diff of 955 lines against main read whole for every hand-written hunk, 5 commits from the merge-base), the 55 check-runs on this head (read 2026-10-31T11:10Z: all 55 runs completed, none failed, none cancelled, none in progress; success: Auto Label, Build & E2E UI, Build UI Deps, Check Changeset, Check PR Size, Dogfood Regression Gate, Governed Surface Queue Guard, Lint & Repo Gates, Lint UI, Lint · CI, release & engine gates, Lint · ESLint, Lint · PM dispatch-gates, Lint · docs & code guards, Lint · repo & tooling gates, Lint · scoped · query-options erasure, Lint · scoped · slot lookup & corpus, Lint · scoped · tooling self-tests, Live E2E UI, PR-body guards (claim · duplicate · Part-of · single-writer path), Test Core, Test UI, Test UI (all 16), Type Check UI, Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, Type Check · workspace, TypeScript Type Check, filter; skipped: 18.x upgrade harness (opt-in), Build Core, Build Docs, Dogfood Regression Gate (matrix shard/3), Dogfood Verify CLI, Packed-tarball smoke (opt-in), Plan Test Core shards, Schedule gate · skip when a green push run covers the commit, Temporal Conformance (live PG + MySQL), Test Core (matrix shard/8), UI Published Artifacts), and the tree at this head for @object-ui/react error-message.ts (extractFieldErrors :219, unmarkedRefusalLines :279, the readers form.tsx already uses), @object-ui/fields buildValidationRules (the rule emitter the record form checks through), the validation.* keys of the @object-ui/i18n en pack beside the new objectGridTranslations.ts defaults (same eight keys, same strings), and the #13340 pin before this PR (banner plus one toast under the outcome id, the toast "for when the banner has scrolled out of view"). The head is mergeable and clean against today's main; the app-shell files that moved on main since the merge-base (#28392) are outside this PR's files.

① Derived judgments

  • Row 19 reverses driver-memory: bulkCreate is Promise.all(map(create)), so a refused row leaves every earlier row of the batch landed — updateMany on the same driver refuses before mutating anything #13340 by the seat's ruling, and keeps what driver-memory: bulkCreate is Promise.all(map(create)), so a refused row leaves every earlier row of the batch landed — updateMany on the same driver refuses before mutating anything #13340 was for - right. The toast existed for a banner a long modal or drawer form had scrolled out of view; raiseRefusal now dismisses any outcome toast, sets the banner, and scrolls it into view (block: 'nearest') on every raise, so the one message is the one the user sees. All three refusal points (a visibleWhen fault, onAction's result.error, a thrown write) go through it, and every refusal path of this renderer draws the banner, so the ruling's "toast stays where no banner is drawn" case has no instance here. The adapted pin holds one banner, scrolled, zero error toasts, and a refused retry that keeps one banner and scrolls again; the eleven sibling pins that asserted the toast now assert the banner and no toast. Nothing is deleted, as the seat conditioned.
  • Row 6 says every reason whole and marks the cells the refusal names - right. The refusal is its own role="alert" row under the toolbar, one line per reason from the same unmarkedRefusalLines reader the form uses (falling back to the error's message), deduplicated across rows; the cells come from extractFieldErrors keyed by row key, drawn as aria-invalid, data-cell-error, title and an inset ring. A mark leaves when its cell is edited again, when its row saves, on Cancel and on Cancel All; a whole-batch rejection, whose fields belong to no known row, marks nothing and says its reasons. A field with no column marks nothing and its reason is still said.
  • The pre-send check judges the field's own rules and nothing more - right. inlineEditRuleRefusals reads each changed field's declaration through buildValidationRules, the record form's own emitter: required on a blank value, min / max on a staged number or number string, minLength / maxLength and the email / URL pattern on text; an authored message wins, else the validation.* key with the header's label. A missing declaration or an unreadable rule set judges nothing, so the server stays the judge of everything else (validation rules, uniqueness, state machines). A refused row takes the server's own shape (code: 'VALIDATION_FAILED', fields[]), so the table marks and says it the same way; in saveRowsClaimed the refused row is never sent and the other rows still go, each its own outcome. A host's onRowSave / onBatchSave is untouched.
  • The declarations are right - Clause-②: no: objectGridInlineRules.ts is not exported from @object-ui/plugin-grid, no renderer or hook gains a member, no accept set moves; Metadata-protocol: none: no spec path. A reversed visible behaviour is this record's reason to exist, not a surface change.
  • Pins both ways - right. Eleven new cases (four on the table's refusal row and marks, seven on the grid's pre-send check over the real ObjectGrid and data-table, with a control edit inside the rules that is sent), the driver-memory: bulkCreate is Promise.all(map(create)), so a refused row leaves every earlier row of the batch landed — updateMany on the same driver refuses before mutating anything #13340 pin adapted, the twelve sibling pins moved to the banner; the first run's 25 reds (an unreadable rule set) are what f77d796 fixed, and the rerun and CI are green.
  • Check-runs as gate verdicts - read as above; every run on this head completed and none is red, cancelled or in progress at the read.

② Semver level

  • @object-ui/components and @object-ui/plugin-grid patch - accepted. Behaviour fixes with no exported surface moved; the changeset names the reversal of the one-error-toast behaviour for a host that pinned it.

③ Boundary flags

  • A host pinned on the refusal toast - the changeset says the toast is gone; a host that watched toast.error for a form refusal reads the banner now. That is the reversal the seat ruled and the card asked for; no further line owed.
  • Row 19's field mark for a script rule - cannot be drawn: the spec gives a script rule no fields, so the server names _record. Recorded on the card, not filed; a spec card if an author ever needs a script rule to name a field.
  • The pre-send check covers the built-in saves only - a host supplying its own save owns its checking, as the body says.
  • Partial landing - Part of #27520; rows 7, 9, 12, 14, 17 and 21 stay on the card.
  • The seat lands - no Tier H path (no docs/adr, no skills/**, 955 lines), no human gate; no name approval owed.

④ Design judgment

  • Tried: a number field edited to a non-numeric string (numericValue answers undefined, so min / max do not judge and the server does); a required field cleared to an empty array (blank, refused as required); a refusal naming a field the row did not edit (marked where drawn, which is right: the server's answer is the truth); a batch where two rows fail the same rule (one deduplicated line in the bar, each row's cell marked). Judging through the same emitter the form uses is what keeps the grid and the form from disagreeing on a rule. Nothing to add.

Verdict reasoning. The form says a refusal once and brings that one message into view, which is the case the retired toast existed for; the grid says every reason whole, marks the cells the refusal names and clears them on edit or save; the pre-send check judges the field's own rules through the form's own emitter and leaves everything else to the server with the refused row unsent and the others saved; the declarations and the patch level are right; the pins are adapted not deleted and hold both ways; and every check on this head is green or skipped.

Implemented-by: claude/project-thread-mcx7eo
Reviewed-by: session_01L9qibw58ehQ2gRHP2h2Puz

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#1 · os-tesla · session_01L9qibzC6RxMfB59EsgAoNN

席位核对

ACCEPT · seat domain:ui#1 · head 2e90f2c9 · 2026-10-31T11:11Z

席位意见:在表格里批量编辑后点「全部保存」,如果服务器拒绝,保存栏现在完整列出每一条原因,一条一行,不再截断;被拒绝的单元格会标出来,改动该单元格后标记消失。表格自带的保存在发送前会先检查必填、最小值/最大值、长度和格式:不通过的行不发送,其余行照常保存。「全部保存」按钮离右边缘留出 12 像素。表单保存被拒绝时只显示一条消息,就是表单顶部的横幅,并自动滚动到可见处,不再同时弹出内容相同的 toast。

  1. Shape: Part of #27520 (rows 6 and 19), draft, base main, 20 files, +814/−141. Changes:
    • data-table.tsx: the refusal shown in full as lines, cell marks per row key, the inset toolbar slot.
    • ObjectGrid.tsx plus the new module-private objectGridInlineRules.ts: the pre-send check, through buildValidationRules.
    • objectGridTranslations.ts: validation.* defaults.
    • form.tsx: raiseRefusal, the banner ref and scroll.
    • Two new test files, twelve adapted pins and a changeset.
  2. Seat read: the surface matches amendment 6541672013. The driver-memory: bulkCreate is Promise.all(map(create)), so a refused row leaves every earlier row of the batch landed — updateMany on the same driver refuses before mutating anything #13340, MasterDetailForm, WizardForm and single-raiser pins are adapted, not deleted. They pin one banner, zero error toasts and no stacking on a retry. Every refusal path of the form renderer draws the banner, so the "keep the toast without a banner" case has no instance. A script rule names only _record, so it has nothing to mark. That is recorded, not filed. Lint UI was red at f77d7968 on an unused waitFor import, and 2e90f2c9 fixes it.
  3. Contract review: PASS 6542621536 on this head. ② is patch for @object-ui/components and @object-ui/plugin-grid, and the changeset names the toast reversal. ③ found no name approval owed. The pre-send check covers only the built-in saves; a host's own save does its own checking.
  4. Gates / check-runs: on 2e90f2c9: 44 success, 11 skipped, 0 failure, including Test UI (all 16) and Lint UI. mergeable_state clean.
  5. Landing: check-governed-merges --pr 28396 reports NOT governed, 955 changed lines ≤ 3000. The seat readies the PR and arms auto-merge, on the maintainer's 「按 pm-dispatch 审过的pr你可以自主落地」 (seat thread, 2026-10-30T14:19Z).

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/l tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant