Skip to content

feat(console): a save's toast shows the notice a hook set on the write, one toast per write - #28307

Merged
objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-1iw5ln
Oct 31, 2026
Merged

objectstack-fleet[bot] merged 2 commits into
mainfrom
claude/project-thread-1iw5ln

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

Fixes #28252
Refs #28000

Clause-②: yes (widening: @object-ui/data-objectstack's WriteWarningEvent gains an optional notice; @object-ui/react gains the type RecordSaveFeedback, AnswerWarningsEvent gains an optional notice, and AnswerWarningClaim.take / toastRecordSave move to it. Those two were added by #28267 and have not been released, so no released signature changes)
Metadata-protocol: none

维护者速读

  • 改了什么:保存记录时,如果服务端钩子给这次保存留了一句话(notice,比如「已提交给经理审批」),控制台现在会显示它。记录弹窗(含子表单任务)、整页表单、object-form 抽屉/弹窗、表格行内编辑(单行保存和「全部保存」)都在原来的保存提示里把这句话放在标题下面;仍然只弹一个提示。
  • 为什么改:Save answer: a hook or record-triggered flow can attach one user-facing notice to a record save, shown with the save toast #28000 的契约半边(feat(spec): a hook can tell the person who saved a record what the save set in motion #28247)已经让创建/更新的应答带上 notice,但控制台没有任何地方读它,HotCRM「保存即送审」的提示被丢掉了(Save toast shows the notice a hook or flow attached to the save #28252)。适配器原本只把应答里的记录交出去,notice 在那一层就被丢了。
  • 风险与回滚:适配器 create/update 返回值不变,只是写入提醒事件多带一个可选字段;只有提醒的保存仍是成功样式,同时有警告时才是警告样式。没有页面认领的写入(例如记录详情页行内编辑的保存条)由写入提醒通道弹一个 info 提示。回滚即 revert 本 PR。
  • 先看:ui/packages/react/src/record-save-feedback.ts(take 交回 RecordSaveFeedback,toastRecordSave 的样式选择);ui/packages/plugin-grid/src/ObjectGrid.tsx(行内保存新增认领,「全部保存」按行认领再合并成一个提示);ui/packages/app-shell/src/providers/writeWarningToast.ts(未被认领时通知的落点)。
  • 两分钟验证:pnpm --dir ui exec vitest run packages/react/src/record-save-feedback.test.ts packages/plugin-grid/src/__tests__/inlineEditSaveNotice-28252.test.tsx,预期 2 个文件全绿。
  • 需要你做的:无,席位落地。

What changes

Which surfaces show the notice (the card's should-fix (b)), one toast per write in every row:

Surface Toast
Record dialog (AppContent crud_success) and its child-form task its record-named message, the notice under it
Full-page form (RecordFormPage) its record-named message, the notice under it
object-form drawer and modal (ObjectFormContainer) its record-named message or declared successMessage, the notice under it
Grid inline edit, per-row save and "Save All" (ObjectGrid) "1 row saved" / "N rows saved", the notices under it (distinct, one per line)
Any write no surface claims (the record page's inline-edit Save bar, which raises no save toast of its own; kanban, actions, …) the write-warning channel's own toast: an info toast with the notice as written, or the notice leading the "Saved — with warnings" lines

A notice alone keeps the save toast on the success variant (it reports an outcome, never a problem); with warnings too, the toast is the warning variant and the notice leads its lines. The stripped-fields toast stays its own toast, as before.

The adapter (ui/packages/data-objectstack/src/index.ts). Measured: create / update resolved result.record and read only droppedFields and warnings off the answer, so notice was dropped there. notifyDroppedFields now also reads notice (a non-empty string, else nothing) onto the write-warning event, and emits the event when the notice is the answer's only news. What the two methods resolve is unchanged. The batch path is not touched: the spec declares notice on the single-record answers only.

The claim (ui/packages/react/src/record-save-feedback.ts). AnswerWarningsEvent gains notice; take(saved) returns RecordSaveFeedback (warnings, optional notice, the last one parked winning as on the server); toastRecordSave(sink, message, feedback) titles the toast with the surface's message and puts the notice, then the warnings, under it.

The channel (ui/packages/app-shell/src/providers/writeWarningToast.ts). emitWriteWarning parks when the event carries warnings or a notice, still synchronously before its first await. Unclaimed, the notice is one toast as the table says. WriteWarningSink gains info; sonner's toast, which AdapterProvider passes, has it. The type is not exported from the package.

The surfaces. AppContent, RecordFormPage and ObjectFormContainer hand take's result to toastRecordSave (the crud_success params carry notice beside warnings). ObjectGrid's default row save opens a claim on the row's update for the length of the save, and "Save All" one per row, read once into the single toast, released in a finally.

Tests

New:

  • ui/packages/plugin-grid/src/__tests__/inlineEditSaveNotice-28252.test.tsx (3): per-row save shows one success toast with the notice, the channel nothing; "Save All" over two rows shows one warning toast, the shared notice once then the warning line; CONTROL: a write the grid did not make keeps the channel toast after the save.
  • Added cells to onWriteWarning.test.ts (3: create and update carry the notice and still resolve the record; empty or non-string notice emits nothing), record-save-feedback.test.ts (3), writeWarningToast.test.ts (5: claimed update and create take it; unclaimed notice alone is one info toast; beside warnings it leads the one warnings toast; a released claim shows it), RecordFormPage.saveToastCarriesWarnings-27999.test.tsx (3), AppContent.recordSaveOneToast-27999.test.tsx (4) and containerSaveToastCarriesWarnings-28216.test.tsx (4, drawer and modal).
  • Updated: the existing take and toastRecordSave assertions read the new object shape; their meaning is unchanged.

Ablation (through scripts/ablation-replace.mjs, each mutation proved on disk and restored to the HEAD blob):

  • channel parks on warnings only → 5 notice cells red across writeWarningToast, RecordFormPage and AppContent (the notice-with-warning cells stay green, as predicted: the warnings still open the park);
  • adapter drops notice from the event → 7 red across onWriteWarning and writeWarningToast;
  • grid per-row save toasts without take → 2 red in the new grid file (the per-row cell, and its CONTROL, whose count includes the notice the unread claim hands back).

Acceptance notes

  • The record page's inline-edit Save bar (InlineEditSaveBar) raises no save toast at all today, so it is not a claiming surface; its notice reaches the user through the channel's info toast, which is one toast per write. Giving that bar a save toast of its own is outside this card.
  • "Save All" joins distinct notices from several rows one per line under one toast; rows with the same notice show it once.
  • Not measured live in a browser against a hook that sets onNotice; the server half that lets sandboxed and flow hooks set it is Save notice from a sandboxed hook or a flow: hook context and flow node set the write answer's notice #28251. The chain is pinned through the real adapter (writeWarningToast.test.ts), the real RecordFormPage / AppContent / ObjectFormContainer / ObjectGrid save paths in jsdom.

Verification

Local runs

Head 4056fd97, on main 6285203d. main has moved 7 commits since; none touches a file in this diff and git merge-tree with origin/main (53749673) is clean.

  • pnpm turbo run build --filter='@object-ui/app-shell^...' --filter='@object-ui/plugin-grid^...' --concurrency=2 (the dependency closure, under the verify lock) → Tasks: 64 successful, 64 total, VERDICT command-exit 0; then the four touched lower packages rebuilt → VERDICT command-exit 0.
  • type-check of @object-ui/data-objectstack, @object-ui/react, @object-ui/plugin-form, @object-ui/plugin-grid, @object-ui/app-shell (each package's tsc --noEmit, plus tsc -p tsconfig.test.json where it declares one) → exit 0 each.
  • The eight toast files (new grid file, inlineEditSaveFeedback-12303, onWriteWarning, record-save-feedback, writeWarningToast, RecordFormPage.saveToastCarriesWarnings-27999, AppContent.recordSaveOneToast-27999, containerSaveToastCarriesWarnings-28216) → Test Files 8 passed (8), Tests 100 passed (100).
  • Under the verify lock: pnpm --dir ui exec vitest run packages/react/ packages/data-objectstack/ packages/plugin-form/ packages/plugin-grid/ plus the 130 app-shell and console test files that name writeWarningToast, RecordFormPage, AppContent, toastRecordSave, claimAnswerWarnings, ObjectFormContainer or onWriteWarning → Test Files 845 passed (845), Tests 8464 passed | 1 skipped (8465), VERDICT command-exit 0. The whole packages/app-shell/ suite was not run locally (CI's Test UI runs it).
  • eslint on the 14 changed ts / tsx files → 0 errors. pnpm --dir ui run check:new-line-citations → 0 new citations, exit 0.
Derived gates

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 4056fd97 → 35 commands, each run with stdin from /dev/null and its exit code captured before any pipe; --ran reconcile: 35 derived, 35 run, 0 NOT-MEASURED, 0 UNRUN, exit 0. check:type-check-debt's first run was a lock queue-timeout (exit 3, NOT MEASURED, behind this branch's own test run); the line below is its re-run under the lock.

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:error-status-conformance :: 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: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
pnpm --dir ui exec vitest run scripts/ :: exit 0

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract-review-claim: session_01L9qibw58ehQ2gRHP2h2Puz · head 4056fd97b37bee1ff3517c09b285b4529581b051 · 2026-10-31T04:14Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 4056fd97b37bee1ff3517c09b285b4529581b051
Local-runs: none
Scope: ui/packages/data-objectstack/src/index.ts (WriteWarningEvent, readWriteNotice, notifyDroppedFields), ui/packages/react/src/record-save-feedback.ts (AnswerWarningsEvent, RecordSaveFeedback, AnswerWarningClaim.take, RecordSaveSink, toastRecordSave), ui/packages/app-shell/src/providers/writeWarningToast.ts (WriteWarningSink, emitWriteWarning, emitAnswerFeedback), ui/packages/app-shell/src/console/AppContent.tsx (crud_success, handleCrudSuccess, the child-form branch), ui/packages/app-shell/src/views/RecordFormPage.tsx (handleSuccess), ui/packages/plugin-form/src/ObjectFormContainer.tsx (useContainerSaveFeedback), ui/packages/plugin-grid/src/ObjectGrid.tsx (defaultRowSave, defaultBatchSave, saveRowsClaimed), the eight test files, .changeset/28252-save-toast-hook-notice.md + packages/spec/src/api/protocol.zod.ts (the single-record write answers' notice, z.string().min(1).optional(), :2692 and :2875), ui/packages/app-shell/src/providers/AdapterProvider.tsx (the one emitWriteWarning caller, sonner's toast as the sink), ui/packages/react/src/index.ts (the record-save-feedback barrel line), .changeset/28216-form-container-one-save-toast.md on main (the #28267 entry, still pending)

Inputs: card #28252 (body and all five comments: triage 6532337619 with the one-toast rule, the unblock 6533319341, the seat's claim 6538004798, the dev report 6538812279, the claim amendment 6538826704 which flips Clause-② to widening, accepts ObjectGrid.tsx on the surface and asks the lane to check the two reshaped @object-ui/react signatures), PR #28307 (body with its surface table, the file list of 15 files, the diff of 1147 lines against main read whole, the commits from merge-base 6285203), the 60 check-runs on this head (read 2026-10-31T04:24Z: all 60 runs completed, none failed, none cancelled, none in progress; success: Build & E2E UI, Build UI Deps, Check Changeset, 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), Auto Label, Build Core, Build Docs, Check PR Size, 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 and on main for the items the Scope line names. The head is mergeable and clean against today's main (merge-tree clean); none of the files main moved since the merge-base is in this diff.

① Derived judgments

  • The adapter now carries the answer's notice - right. readWriteNotice answers the key only as a non-empty string, which is exactly the spec's z.string().min(1).optional(), so an answer from a runtime that predates the key adds nothing. notifyDroppedFields emits when any of dropped fields, warnings or notice is present and spreads notice onto the event only when set; what create and update resolve is unchanged. The batch path is untouched, matching the spec, which declares notice on the single-record answers only.
  • One toast per write, on every surface the body's table names - right. The channel parks when the event carries warnings or a notice, still before its first await, so the claim holds the feedback by the time the surface's success handler runs. take returns { warnings, notice? } with the last parked notice winning (the server's onNotice rule); toastRecordSave keeps the surface's message as the title, puts the notice then the warnings under it, and picks the warning variant only when a warning is present. AppContent (the crud_success params carry notice beside warnings, and the child-form branch passes the feedback), RecordFormPage and ObjectFormContainer hand take's result through. ObjectGrid opens an update claim for the row before writeRowGuarded and reads it into its one "saved" toast after the refresh; "Save All" opens one claim per id, reads every claim once into a single toast (distinct warnings, distinct notices one per line), and releases in finally. A row that reloaded or failed wrote nothing, so its claim parks nothing.
  • An unclaimed write still gets one toast - right. emitAnswerFeedback is the old emitAnswerWarnings with the notice leading the warnings lines, or an info toast of its own when there are no warnings; nothing when there is neither. WriteWarningSink.info is satisfied by sonner's toast, the only sink AdapterProvider passes, and the sink type is not on the app-shell barrel. The stripped-fields toast is unchanged.
  • The claim's take(undefined) from the grid - safe. For an update scope every parked event is the claim's own (the scope names the id), so the saved argument is read only for creates, which the grid never makes.
  • Accept-set: nothing an author writes moves - right. No spec changes; Metadata-protocol: none is right.
  • 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/data-objectstack, @object-ui/react, @object-ui/app-shell, @object-ui/plugin-form, @object-ui/plugin-grid all patch; Clause-②: yes (widening); Metadata-protocol: none - accepted. WriteWarningEvent.notice is an optional field; RecordSaveFeedback is an additive type export; the event firing for a notice-only answer is new behaviour a subscriber could not have observed before.
  • The two reshaped signatures the seat asked the lane to check - not a narrowing of anything released. AnswerWarningClaim.take (string[] to RecordSaveFeedback) and toastRecordSave's third argument (readonly string[] to a Partial of RecordSaveFeedback) both first landed in fix(plugin-form): a drawer or modal object-form save that answers warnings shows one toast, the record-named message carrying them #28267 (ba5c754d, 2026-10-30), whose changeset .changeset/28216-form-container-one-save-toast.md is still pending on main at this read, so no published @object-ui/react carries the old shape and the two ship together as additive exports. RecordSaveSink.success gaining an optional options argument is a widening of a structural type sonner already satisfies. If a Version Packages release were cut between fix(plugin-form): a drawer or modal object-form save that answers warnings shows one toast, the record-named message carrying them #28267 and this PR the reading would change; the seat lands this before that happens or re-states the changeset.

③ Boundary flags

④ Design judgment

  • Nothing blocking, and no should-fix on the diff. Tried: a notice with an empty string (dropped at the adapter); a notice-only answer on a claimed write (success variant with a description, pinned); notice beside warnings, claimed and unclaimed (warning variant, notice first, pinned both ways); two rows in "Save All" sharing a notice (shown once); a grid write the grid did not make while a row claim is open (the claim's scope names a different id, so the channel toasts, pinned as CONTROL); a thrown row save (the finally releases the claim and whatever it parked goes to the channel). Putting the notice on the existing claim rather than a second channel is the right shape: it is the mechanism Drawer and modal form saves: answer warnings show as a second toast instead of riding the success toast #28216 built for exactly this one-toast rule.

Verdict reasoning. The adapter reads the key exactly as the spec declares it; the notice rides the claim #28216 built, so every save surface the card names stays at one toast and an unclaimed write gets one from the channel; the grid's new claims are opened before the write and released in finally. The widening is declared and patch is right; the two reshaped signatures are unreleased and ship together. What remains is a live measure that belongs with the server half #28251.

Implemented-by: claude/project-thread-1iw5ln
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 4056fd97 · 2026-10-31T04:25Z

席位意见:服务器在单条记录写入的回答里带 notice 时,控制台现在会显示出来,而且每次保存仍只弹一个提示:notice 单独一行,放在警告上方;只有 notice 时保持成功样式。表单、抽屉、弹窗、记录页和表格的单行保存与「全部保存」都覆盖到了。没被任何保存流程认领的写入,由通道弹一个信息提示。旧版服务器的回答不带这个键,就什么也不多显示。已按扩面申报 Clause-②: yes (widening)。

  1. Shape: Fixes #28252, draft, base main, 15 files, +701/−62. The adapter's WriteWarningEvent gains notice through readWriteNotice. record-save-feedback.ts gains RecordSaveFeedback, take() returns it, and toastRecordSave takes a Partial of it. The PR also touches writeWarningToast.ts, AppContent.tsx, RecordFormPage.tsx and ObjectFormContainer.tsx, and ObjectGrid.tsx is the surface addition accepted in claim amendment 6538826704.
  2. Seat read: the two reshaped @object-ui/react signatures are still unreleased at this landing. .changeset/28216-form-container-one-save-toast.md is still on origin/main at 2026-10-31T04:25Z, and the published @object-ui/react is 18.2.0. So the lane's condition holds, and this lands before any Version Packages release. The grid's claims open before the write and are released in finally.
  3. Contract review: PASS at this head, record 6538972595 (CONTRACT_REVIEW_TIER), by the contract-review lane, not the drafter. No should-fix. Its flags: the inline-edit Save bar has no save toast of its own, so its notice comes through the channel, which is still one toast. The live measure belongs with the server half Save notice from a sandboxed hook or a flow: hook context and flow node set the write answer's notice #28251.
  4. Gates / check-runs: on 4056fd97: 45 success, 15 skipped, 0 failure. mergeable_state clean.
  5. Landing: check-governed-merges --pr 28307 reports NOT governed, 763 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 tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Save toast shows the notice a hook or flow attached to the save

1 participant