Uh oh!
There was an error while loading. Please reload this page.
fix(form): a server rejection that names fields now marks those fields (objectstack#3896) - #2966
Merged
Merged
Conversation
…s (objectstack#3896) The server has always said which field it rejected. objectql's validators throw VALIDATION_FAILED with fields[] — one entry per offending field, each carrying a human message — and both the REST layer and the runtime dispatcher serve that as a 400 with the entries intact. Every form dropped them. The submit handler caught the rejection, ran the message through extractWriteErrorMessage, and showed one undirected toast: the user was told something was wrong but not WHAT, on a surface that already knows how to mark an input and already does exactly that for client-side validation. On a long form the offending field was often off-screen, so the submit button appeared to do nothing. Now the two failures share one implementation. The toast naming the fields and the scroll-and-focus of the first offender (#2793) were extracted from the client-side invalid handler into `announceFieldErrors`; the server path calls it. To the person filling in the form these are the same event — only the referee differs. Three layers, each of which was dropping the detail: - @object-ui/react: new extractFieldErrors() normalises the three shapes the error can arrive in — a typed ValidationError from the adapter, the raw client error (whose `details` falls back to the whole response body, which is where fields[] lands), and a hand-rolled error carrying `fields` directly, a shape the server duck-types identically. Entries with no usable field are dropped rather than guessed at: marking an innocent input is worse than the generic toast. - @object-ui/data-objectstack: normaliseClientError maps a 400 VALIDATION_FAILED onto the ValidationError class that has sat in errors.ts exported and never once constructed — its validationErrors shape was already exactly right. `create` now normalises at all: only `update` did, so a rejected insert reached callers as the raw client error, and create is the path that most often trips required-field validation. - @object-ui/components: the form renderer applies the entries via form.setError and takes over the failure ONLY when every rejected field has a visible input to carry it. If the server also rejected something this form does not render, it falls through to the banner, whose top-level message names every field — so the part the user cannot see inline is still said out loud. Non-field failures (403 / permission denials / anything without fields[]) take exactly the path they took before. Verification: 14 new unit tests across the two helpers plus 6 form-renderer tests driving a real rejected submit; 107 files / 1073 tests green across components, plugin-form, react and data-objectstack; tsc clean on all three packages; eslint 0 errors. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QuViRSR1j6GJjf9qGbnqFX
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
os-zhuang
marked this pull request as ready for review
July 29, 2026 23:35
Uh oh!
There was an error while loading. Please reload this page.
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.
The server always said which field
@objectstack/objectql's validators throwVALIDATION_FAILEDwithfields[]— one entry per offending field, each carrying a humanmessage— and both the REST layer and the runtime dispatcher serve that as a 400 with the entries intact.Every form dropped them. The submit handler caught the rejection, ran the message through
extractWriteErrorMessage, and showed one undirected toast. The user was told something was wrong but not what — on a surface that already knows how to mark an input, and already does exactly that for client-side validation. On a long form the offending field is usually off-screen, so the submit button appears to do nothing.This is the general fix behind the specific one in #2962. That PR had to add a client-side hint precisely because the server's precise rejection only ever arrived as a toast, after Save.
Now both failures share one implementation
The toast naming the fields and the scroll-and-focus of the first offender (#2793) were extracted from the client-side invalid handler into
announceFieldErrors; the server path calls it. To the person filling in the form these are the same event — only the referee differs.Three layers, each of which was dropping the detail:
@object-ui/react— newextractFieldErrors(err), exported besideextractWriteErrorMessage/isPermissionError. Normalises the three shapes the error can arrive in:validationErrorsValidationErrorfrom the ObjectStack adapterdetails.fields@objectstack/clienterror — itsdetailsfalls back to the whole response body, and the validation envelope has nodetailskey, so this is wherefields[]landsfieldsEntries with no usable
fieldare dropped rather than guessed at — marking an innocent input is worse than the generic toast we already show.@object-ui/data-objectstack—normaliseClientErrormaps a 400VALIDATION_FAILEDonto theValidationErrorclass that has sat inerrors.tssince the package was written: exported, and never once constructed. ItsvalidationErrors: Array<{ field, message }>shape was already exactly right for this.createalso now normalises at all. Onlyupdatedid, so a rejected insert reached callers as the raw client error with no typed shape to branch on — and a create is the path that most often trips required-field validation.@object-ui/components— the form renderer applies the entries viaform.setErrorand takes over the failure, but only when every rejected field has a visible input to carry it. If the server also rejected something this form does not render, it falls through to the banner, whose top-level message concatenates every field's reason — so the part the user cannot see inline is still said out loud instead of silently dropped.Why this matters beyond one form
It removes the reason for the client-side predicate mirroring added in #2962. A form should not have to guess what the server will reject in order to warn about it beforehand — mirrored predicates drift, and the copy that drifts is the one users read.
Verification
components,plugin-form,react,data-objectstack.tsc --noEmitclean on all three changed packages. eslint 0 errors (remaining warnings are the file's pre-existingno-explicit-anyconvention).Non-field failures — 403, permission denials, anything without
fields[]— take exactly the path they took before.Generated by Claude Code