Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-detail): present the server's VALIDATION_FAILED as per-field inline-edit hints, and record the ruling that the server is the authority - #7109
Merged
Conversation
… inline-edit hints The inline-edit save bar surfaced the backend's raw string when a write was refused, leaving the user to guess which edited field was wrong. The refusal has always been field-scoped; this reads it through @object-ui/react's extractFieldErrors -- the same single normaliser the form surface uses -- and renders one reason per rejected field, named by that field's label. Attribution never guesses: an envelope entry with no usable `field` is dropped, and callback (drawer) mode attributes from the call shape, where onFieldSave carries exactly one key per call. Non field-scoped failures keep the cleaned single-line message. Also records the maintainer's ruling on objectui#6868 in both modules' headers: the server is the validation authority on this surface. That was an absence and is now a decision. No client-side evaluator was added. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Its pin drives it through the rendered save bar, which is the path that actually reaches the mapping, so the export bought nothing and cost a react-refresh/only-export-components warning this file did not have before (measured: baseline 7 no-explicit-any + 0 react-refresh; now identical). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
…ut it refused Option A, authorised by the PM after the measurement showed the honest completion of the objectui#6868 ruling needs one adjacent file: the save bar and the field rows are siblings under InlineEditProvider in both persistence modes, so there was no channel between the component that receives a refusal and the components that render the fields it is about. - @object-ui/react: InlineEditContextValue gains `fieldErrors` (field machine name to the server's reason, nullable) and `setFieldErrors`, the exact companions of the `error`/`setError` pair it already carried. Cleared by enter() and teardown like `error`, so an attribution cannot outlive its session. Additive; nothing removed, nothing reshaped. - InlineEditSaveBar publishes the attributed refusal onto the session instead of holding it locally, and keeps the record-level summary for a field that is collapsed or scrolled out of view. - DetailSection and HeaderHighlight read it and draw the reason under the input, with role="alert". - InlineFieldInput takes `error` and forwards it to the widgets' published #3222 slot (aria-invalid), and marks the terminal raw input directly. The prop is no longer dead now that a value exists to put in it. Presentation only. Nothing here evaluates a rule: the server remains the sole validation authority on this surface. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
Contributor
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
os-warren
marked this pull request as ready for review
September 1, 2026 01:42
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Sep 1, 2026
os-warren pushed a commit
that referenced
this pull request
Sep 1, 2026
objectui#7009 closed the DOM half of `FieldWidgetComponentProps` at this factory. The rest of the contract — `error`, `onUploadingChange`, and the "Host plumbing" block — still type-checked, read as supported, and never reached the widget. `error` was the live one: `InlineFieldInput` has passed it into this factory since PR #7109 and the factory dropped it, so an inline-edit control that had failed validation never reported `aria-invalid`. The keys travel through a new sibling executor, `toHostProps`, never through `DOM_PASS_THROUGH_KEYS` — none of them is DOM-legal. Three compile-time assertions make the two executors partition the contract. `dataSource` precedence is stated: the explicit prop wins over `SchemaRendererContext`, the order `LookupField` already implements. The factory is a conduit and resolves nothing. `RequiredFieldsDialog` now hands its computed required-validation state to the control, and its stale `takes no id to associate with` justification — false since objectui#7009 — is corrected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
akarma-synetal pushed a commit
to akarma-synetal/objectui
that referenced
this pull request
Sep 1, 2026
…ectstack-ai#7128) objectui#7009 closed the DOM half of `FieldWidgetComponentProps` at this factory. The rest of the contract — `error`, `onUploadingChange`, and the "Host plumbing" block — still type-checked, read as supported, and never reached the widget. `error` was the live one: `InlineFieldInput` has passed it into this factory since PR objectstack-ai#7109 and the factory dropped it, so an inline-edit control that had failed validation never reported `aria-invalid`. The keys travel through a new sibling executor, `toHostProps`, never through `DOM_PASS_THROUGH_KEYS` — none of them is DOM-legal. Three compile-time assertions make the two executors partition the contract. `dataSource` precedence is stated: the explicit prop wins over `SchemaRendererContext`, the order `LookupField` already implements. The factory is a conduit and resolves nothing. `RequiredFieldsDialog` now hands its computed required-validation state to the control, and its stale `takes no id to associate with` justification — false since objectui#7009 — is corrected. Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM Co-authored-by: Claude <noreply@anthropic.com>
This was referenced Sep 2, 2026
This was referenced Sep 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#6868
Both deliverables of the maintainer's ruling (维护者, 2026-08-31, 决裁批 #13) now land, and both are presentation-layer. Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
(a) The decision is written down
The ruling is recorded in the headers of both modules where the question gets asked:
InlineEditSaveBar.tsx(which handles the refusal) andInlineFieldInput.tsx(which runs no rules and, until this PR, took noerrorprop — for two years that read as an oversight).The ruling's own framing is why it is written twice: 此前这是一个「缺席」,现在是一个「决定」. An absence and a decision are indistinguishable in code until someone writes the second one down. Each header states that the server is the validation authority here, quotes the ruling, points at #6868, and says plainly what a future editor must not do: no shared evaluator, no second validation implementation, no
buildValidationRulescall from this surface.(b) The server's reason now renders beside the input it refused
Before:
cleanErrorhanded the user the backend's own string, e.g.VALIDATION_FAILED: Validation failed for crm_opportunity, after they had edited three fields.After: the reason appears under the field the server named, with
role="alert", and that input is markedaria-invalid. The save bar keeps a labelled per-field summary as well — a rejected field can be collapsed or scrolled out of view, which is the same reasonform.tsxkeeps its banner on the form surface.The transport.
InlineEditContextValuegainsfieldErrors(field machine name to the server's reason, nullable) andsetFieldErrors, the exact companions of theerror/setErrorpair it already carried and already documented as "driven by the save bar". This was the missing piece and nothing else was: the save bar and the field rows are siblings underInlineEditProviderin both persistence modes (app-shell/RecordDetailView.tsx:2340/:2509;RecordDetailDrawer.tsx:346/:398), so there was no channel between the component that receives a refusal and the components that render the fields it is about. Cleared byenter()and teardown exactly likeerror, so an attribution cannot outlive its session. No host wiring was needed —HeaderHighlightalready reads the session andDetailSectionnow does too — soapp-shellandRecordDetailDrawerare untouched, as the sizing predicted.Attribution never guesses, two sources in strict order:
@object-ui/react'sextractFieldErrors, the single in-repo normaliser, the same oneform.tsxuses. It reads all three shapes aVALIDATION_FAILEDarrives in (validationErrorsfrom@object-ui/data-objectstack's re-wrap,details.fieldsfrom the raw@objectstack/clienterror, or a barefields) and drops any entry with no usablefield. That drop is load-bearing: a wrong mark on an innocent input is worse than the undirected string it replaces. It is the only source on the DataSource path, whose write is one atomic multi-key update.onFieldSave(field, value)one key per call, so a rejection belongs to that key by construction. The atomic path passesundefined, so a multi-key write can never be attributed this way. The envelope still wins when both are available (pinned).Anything not field-scoped — a network failure, a permission denial, a bare
Error— keeps the cleaned single-line message. Nothing here evaluates a rule.The
errorprop onInlineFieldInputis no longer deadIt was deliberately not shipped in round 1: with no value to put in it, it would have been a declared-but-dead prop. Option A creates the value, so it is threaded now and forwarded to the widgets' published #3222 slot.
⭐ Measured, and it changes what that slot is for: the #3222
errorslot marksaria-invalid— it does not render text, on this surface or on the form one, whereform.tsx(not the widget) draws the visible message. So threading it buys the a11y marking, and the visible hint is this package's markup either way. That is why the delegated tail's drop oferror(the subject of #7008, which reproduces here independently) was never a blocker for this work, and nopackages/fieldsedit was made.Clause-② check, run before the interface was widened
The PM authorised the addition on the reading that
InlineEditContextValueis a runtime React context shape, not an authoring contract, and asked me to stop and report if measurement said otherwise. It does not:InlineEditContextValueanywhere outside its own module and one prose comment — controlFieldSchemahits 4 files underscripts/in the same sweep, so the zero is a reading and not a broken grep.check-spec-symbols,check-element-data-source-declaration,check-designer-field-key-parity).packages/react/README.mdmentionsInlineEditzero times (control:SchemaRenderer, 13 hits), so no documented contract enumerates it.⇒ Not clause ②. It is an additive public API member, which is why
@object-ui/reactis gradedminor.Evidence
InlineEditSaveBar.serverVerdictFieldHint-6868.test.tsx, 11 tests, all named under--reporter=verbose. Four are new this round and drive the real tree (DetailSection/HeaderHighlightand the save bar as siblings under one provider, as both hosts mount them): the reason is drawn under the refused field and only that field; the refused input isaria-invalidwhile the accepted one is not (exactly 1 marked element); the hint clears on cancel + re-enter; and it reaches the highlights strip too, proving one session serves both surfaces. The first asserts its own control — both editors really rendered — so the zero on the unrefused field is a real absence, not an empty harness.attributeInlineRefusalforced tonull). Mutation proven on disk first (marker occurrences 1/1, blob364735c2→7180c2fb), restore proven after (git diff HEADempty, blob back to364735c2). Result 9 of 11 red. The 2 that stay green are, by name,DROPS an entry with no usable fieldandleaves a NON field-scoped failure on the cleaned string— the two negative cases, which assert the fallback the ablation forces everywhere. Green is the correct direction for those, and it is the same pair as round 1.Type 'Record<string, string> | null' is not assignable to type 'number'. The message names the new member's declared type rather than "property does not exist", which is what makes it discriminating:plugin-detailis compiling against the rebuilt@object-ui/reactdeclarations, not a cache. Restored and re-proven byte-identical.2012c0a87:pnpm exec vitest run packages/plugin-detail/src packages/react/src→Test Files 187 passed (187) / Tests 1938 passed (1938). Typecheck: both packages, each echoingtsc --noEmit && tsc -p tsconfig.test.json, exit 0. Root-relative vitest throughout, so thepnpm --filter @object-ui/app-shell test跑的是 @object-ui/console 的 22 个文件,app-shell 自己的 276 个一个没跑,却报绿 #3378 silent re-root could not occur.2012c0a87, all exit 0:check:control-bytes,check:phantom-deps,check:self-import,check:esm-specifiers,check:vi-mock-specifiers,check:vi-mock-inherit,check:i18n-keys,check:i18n-drift,check:element-data-source-declaration, and all four changeset gates —check-changeset-presencereporting "6 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)".check:readme-exports(exit 1) andcheck:sdui-registration-pins(exit 2) both refuse to render a verdict without a full build — "the population COLLAPSED — this run proves nothing … packagesRead: found 12, floor is 25" and "No console build to weigh … a run with nothing to read has measured nothing". Both are repo-wide scans needing all 40 packages plusapps/consolebuilt; CI owns them. Neither can be affected by this diff — nothing is removed from any barrel and no registration array is touched.--no-inline-config): exit 0, 0 errors. Every changed source file's warning profile is identical to itsorigin/mainbaseline, measured file by file —InlineEditContext4+1,InlineEditSaveBar7,InlineFieldInput34+5,DetailSection10+1+1,HeaderHighlight8. Zero new.Changeset:
@object-ui/plugin-detail: patch(nothing enters or leaves its callable surface; an existing surface renders an existing refusal better) and@object-ui/react: minor(one additive public API member, spelled out in the changeset body). Nomajor— refused by the gate, and objectui's major is a cross-repo pin, not a severity dial.⛔ Draft. Not ready, not enqueued, no auto-merge — the PM arms landing.