Frontend: document form validation rules and error copy register - #49041
Conversation
Extend Data validation in frontend/docs/patterns.md with 10 subsections (timing, clearing, priority, submit state, server-side errors, conditional validation, optional/disabled fields, input hygiene, in-flight lifecycle, visual affordances, copy register). Add matching terse rules under a new Validation section in .claude/rules/fleet-frontend.md.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #49041 +/- ##
==========================================
+ Coverage 68.11% 68.32% +0.21%
==========================================
Files 3731 3864 +133
Lines 235434 238315 +2881
Branches 12520 13285 +765
==========================================
+ Hits 160371 162837 +2466
- Misses 60670 61082 +412
- Partials 14393 14396 +3
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Shorten the Validation section in .claude/rules/fleet-frontend.md to a strong "read patterns.md" trigger plus a handful of Fleet-specific anti-defaults, so validation guidance lives in one place. Drop the "shared validation hook is planned" mention and the backend/UI field key mapping bullet.
Note that GitOps mode disabling the whole form is a valid reason to disable the submit button. Explain why field errors clear on focus: the error text replaces the label, so clearing on focus restores the label the user needs to see while editing.
Name the mainstream React libraries (Formik, react-hook-form, MUI, Ant Design) Fleet diverges from and the four categories where it diverges, so the anti-defaults list has clearer motivation.
|
|
||
| - Trim leading and trailing whitespace client-side before submitting. Send the trimmed value to the API. | ||
| - Whitespace-only content in a required field counts as empty. | ||
| - Cap free-text `maxLength` to the backend column length via `inputOptions={{ maxLength: N }}` on `InputField`. The native input silently truncates paste. See [Forms](#forms) in the top-level rules. |
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Updates Fleet’s frontend documentation to standardize React form validation behavior and UX conventions (error timing/placement, submit behavior, copy).
Changes:
- Rewrites
frontend/docs/patterns.mdform validation guidance with detailed rules for error display/clearing, submit state, server errors, and lifecycle. - Adds an authoritative pointer and summary checklist to
.claude/rules/fleet-frontend.mdto steer contributors toward the patterns doc.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| frontend/docs/patterns.md | Expands and clarifies form validation/UX rules (submit behavior, touched semantics, server errors, lifecycle, copy). |
| .claude/rules/fleet-frontend.md | Adds a “Validation” rules section that links to the authoritative patterns doc and summarizes key divergences. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| - Never show a field's error before the user has interacted with that field. A field becomes interacted-with when the user types into it or blurs it while it holds a value. | ||
| - On blur of a field the user has interacted with, run validation and show the resulting error (if any) for that field only. Do not touch errors on other fields. | ||
| - On submit, show inline errors on every invalid field simultaneously, then return without submitting. | ||
| - Autofill counts as user interaction — treat autofilled fields as touched. | ||
| - On an Edit form, pre-filled values that are invalid do not show errors until the user interacts with the field. |
There was a problem hiding this comment.
Agree, I think we should define dirty ("interacted-with" in L351 would fall into this category I think), touched, any other?
EDIT: we also mention isDirty below so I'd try to stay with just one term, either dirty or interacted-with to narrow down the cognitive load when reading this 😄
There was a problem hiding this comment.
Thanks for flagging!! I just addressed this by consolidating to a single dirty term with a definition line and called out the field-level dirty vs form-level isDirty distinction so they don't get conflated <3
| - On focus (click-in) of a field that has an error, clear that field's error immediately — do not wait for the user to type a valid value. The error text replaces the field's label (see [Visual affordances](#visual-affordances)), so clearing on focus restores the label and lets the user see what they're editing. | ||
| - Re-validate on blur, not on keystroke. | ||
| - Typing in one field never clears errors on other fields. Clearing is per-field. | ||
| - When a validation becomes irrelevant (e.g. a conditional requirement is removed by toggling a checkbox), clear the newly-irrelevant error immediately. |
|
|
||
| - Trim leading and trailing whitespace client-side before submitting. Send the trimmed value to the API. | ||
| - Whitespace-only content in a required field counts as empty. | ||
| - Cap free-text `maxLength` to the backend column length via `inputOptions={{ maxLength: N }}` on `InputField`. The native input silently truncates paste. See [Forms](#forms) in the top-level rules. |
|
|
||
| #### Conditional / dependent validation | ||
|
|
||
| - Cross-field checks (e.g. password + confirmation match) run on blur of either field. The error attaches to the field that is invalid, not to both. |
There was a problem hiding this comment.
Updating so it shows on the confirmation.
| - The submit handler must guard against a second submission while one is in flight. Do not rely solely on the button being disabled. | ||
| - The Cancel button remains enabled during submission and closes the modal immediately. It does not abort the in-flight request; the request completes in the background. |
There was a problem hiding this comment.
Agree w/ Copilot here. I think "Cancel" buttons in modals should cancel in-flight requests (I believe react-query does this behind the scenes IF we use useMutation for mutative operations... but we still have lots of occurrences where we use plain Promises, so worth double-checking).
There was a problem hiding this comment.
Is cancelling requests possible for all actions? Is considering a confirmation dialog a good resolution to this problem for background instances, to ensure the user knows this will or will not cancel the request?
There was a problem hiding this comment.
Agree w/ Copilot here. I think "Cancel" buttons in modals should cancel in-flight requests (I believe react-query does this behind the scenes IF we use useMutation for mutative operations... but we still have lots of occurrences where we use plain Promises, so worth double-checking).
Just checked — we only use useMutation in 3 places (all in policies: resetPolicy, createPolicy, useUpdatePolicyAutomations). And FWIW useMutation doesn't actually auto-cancel in-flight mutations — react-query cancels queries on unmount, but mutations run to completion unless the mutationFn wires up an AbortController itself. So we'd be building this from scratch either way.
Agree it might be worth a future iteration. @mike-j-thomas has also floated dropping the Cancel button entirely (users find it confusing during submission), which might be the cleaner resolution than wiring abort everywhere. For now the doc calls out the resilience requirement so we don't ship the toast-after-unmount bug in the meantime.
Is cancelling requests possible for all actions? Is considering a confirmation dialog a good resolution to this problem for background instances, to ensure the user knows this will or will not cancel the request?
I added a line to address this:
+- The Cancel button remains enabled during submission and closes the modal immediately. It does not abort the in-flight reques
+t; the request completes in the background. We don't require abort because most call sites use plain Promises (notuseMutatio +n), and we don't want a confirmation dialog on Cancel — it adds friction to the common case for a rare one.
411 +- Because Cancel doesn't abort, the submission must be resilient to the modal being closed before the request resolves. Guard
+post-success side effects (toast, navigation, cache invalidation) so they don't fire against an unmounted component or a scree
+n the user has already left. Failures on a closed modal are dropped silently — no toast, no re-open.
There was a problem hiding this comment.
Yeah, (auto-)canceling queries make sense, not mutations (I was wrong on the concept as well, since as soon as a mutative operation has begun, we'd also have to handle cancelation on the server which doesn't make much sense).
| - Form fields are disabled while a submission is in flight — the user cannot edit during the request. | ||
| - The submit handler must guard against a second submission while one is in flight. Do not rely solely on the button being disabled. | ||
| - The Cancel button remains enabled during submission and closes the modal immediately. It does not abort the in-flight request; the request completes in the background. | ||
| - On success, fire the success toast BEFORE navigation (see [Notifications](../../.claude/rules/fleet-frontend.md#notifications) and #48088) and close the modal. |
There was a problem hiding this comment.
removing thanks ai!
|
@mike-j-thomas , @rachaelshaw , @marko-lisica please review at your earliest convenience slack thread |
| - Form fields are disabled while a submission is in flight — the user cannot edit during the request. | ||
| - The submit handler must guard against a second submission while one is in flight. Do not rely solely on the button being disabled. | ||
| - The Cancel button remains enabled during submission and closes the modal immediately. It does not abort the in-flight request; the request completes in the background. | ||
| - On success, fire the success toast BEFORE navigation (see [Notifications](../../.claude/rules/fleet-frontend.md#notifications) and #48088) and close the modal. |
There was a problem hiding this comment.
@RachelElysia This is confusing. I understood this like we should show toast and then navigate, but I assume you're describing this:
- notify.success/notify.error defer the actual toast creation by one tick (setTimeout). So if a handler calls notify.success(...) and then router.push(...) synchronously, the dismiss-on-navigate listener fires first (clearing whatever was on the old page), and the new toast is created after — landing it on the destination page instead of getting wiped by its own navigation.
- Ordering matters and is load-bearing: call notify.success before router.push/router.replace, not after. Reversing the order can break the toast's auto-dismiss timer (see #48088).
The "Add software" flow (SoftwareCustomPackage.tsx:134-160) is the canonical example:
notify.success(<>Package successfully added.</>); // called first
...
router.push(PATHS.SOFTWARE_TITLE_DETAILS(...)); // navigation after
So it uploads, calls notify.success immediately on API success, then pushes to the software title details page — the toast survives the route change and shows up there.
There was a problem hiding this comment.
Thanks for flagging! The "BEFORE" phrasing was ambiguous. I meant the code-call order, not the visual order.
For context, the notify.success defers the toast creation by a tick via setTimeout, so if you call it before router.push, the deferred toast survives the navigation and shows up on the destination page. If you call it after, the dismiss-on-navigate listener wipes it. SoftwareCustomPackage.tsx:134-160 is the canonical example.
Updating to clarify!
| - **No terminal periods.** The error renders in the label slot, and labels don't end with periods. | ||
| - Use `fleet` not `team` in new copy. The codebase still uses `team_id` etc.; that stays. See [Terminology](../../.claude/rules/fleet-frontend.md#terminology). | ||
|
|
||
| For errors the user can't fix by editing the field — server failures, timeouts, network errors — use a different register: **what happened + what to do**. Example: `Couldn't save your changes. Try again in a few minutes.` This is the one place where periods appear (two sentences). |
There was a problem hiding this comment.
@mike-j-thomas clarified all server side errors will appear in the toast message, but can/should appear inline as well if they are specific to a specific field
nulmete
left a comment
There was a problem hiding this comment.
Thanks for putting this up, I think it's going in a good direction 💯
LMK what you think about my comments below.
|
|
||
| **Read `frontend/docs/patterns.md#data-validation` before adding or editing form validation — that doc is authoritative.** Fleet diverges from what mainstream React libraries (Formik, react-hook-form, MUI, Ant Design) do by default on submit-button behavior, error timing, error position, and copy tone. Pattern-matching from another React app will land you in these specific mistakes: | ||
| - No visible required-field indicator (no `*`, no `(required)` suffix). Users discover requirements via post-interaction errors. | ||
| - Submit button stays enabled with invalid fields. Only disable during in-flight submission, or when the form is disabled by GitOps mode. Handler shows errors and returns early. |
There was a problem hiding this comment.
Submit button stays enabled with invalid fields
We'll need to make sure we do not fire any API calls even though users can click it
There was a problem hiding this comment.
Updated to clarify the submit handler should not call the API if it finds clientside errors
| - No visible required-field indicator (no `*`, no `(required)` suffix). Users discover requirements via post-interaction errors. | ||
| - Submit button stays enabled with invalid fields. Only disable during in-flight submission, or when the form is disabled by GitOps mode. Handler shows errors and returns early. | ||
| - Field errors clear on **focus**, not on typing. | ||
| - Re-validate on blur, never on keystroke. |
There was a problem hiding this comment.
We should not revalidate on blur if the form is not dirty (i.e. we haven't typed anything yet), right? Otherwise we'll get all those red inputs when clicking in and out of an input field with nothing typed.
There was a problem hiding this comment.
Ok, this is what I modified to:
- Empty required field, user tabs through without typing → not dirty → no blur error, submit surfaces it ✓
- User types a, deletes back to empty, blurs → dirty (sticky) → presence error shows ✓
- Edit form pre-filled invalid, user tabs through without typing → not dirty → no error ✓
- Edit form user edits and reverts to initial → dirty (sticky) → validation still fires
| an "optimistic" user experience. The user is only told they have an error once they navigate | ||
| away from a field or hit enter, actions which imply they are finished editing the field, while they are informed they have fixed | ||
| an error as soon as possible, that is, as soon as they make the fixing change. e.g. | ||
| - Never show a field's error before the user has interacted with that field. A field becomes interacted-with when the user types into it or blurs it while it holds a value. |
There was a problem hiding this comment.
👍 I think this answers my comment above related to Re-validate on blur, never on keystroke.
| - Never show a field's error before the user has interacted with that field. A field becomes interacted-with when the user types into it or blurs it while it holds a value. | ||
| - On blur of a field the user has interacted with, run validation and show the resulting error (if any) for that field only. Do not touch errors on other fields. | ||
| - On submit, show inline errors on every invalid field simultaneously, then return without submitting. | ||
| - Autofill counts as user interaction — treat autofilled fields as touched. | ||
| - On an Edit form, pre-filled values that are invalid do not show errors until the user interacts with the field. |
There was a problem hiding this comment.
Agree, I think we should define dirty ("interacted-with" in L351 would fall into this category I think), touched, any other?
EDIT: we also mention isDirty below so I'd try to stay with just one term, either dirty or interacted-with to narrow down the cognitive load when reading this 😄
| - Never show a field's error before the user has interacted with that field. A field becomes interacted-with when the user types into it or blurs it while it holds a value. | ||
| - On blur of a field the user has interacted with, run validation and show the resulting error (if any) for that field only. Do not touch errors on other fields. | ||
| - On submit, show inline errors on every invalid field simultaneously, then return without submitting. | ||
| - Autofill counts as user interaction — treat autofilled fields as touched. |
There was a problem hiding this comment.
Autofill would mean we interacted with the field(s). I think touched is implicitly included in interacted-with (as a user, whenever you want to interact with a field, i.e. type something, select a checkbox/radio, you have to touch it first)
Perhaps ^ is more of a "global" rule not specific to autofilling.
There was a problem hiding this comment.
Thank you! I consolidated the section around a single term. touched and "interacted-with" are both gone. Dirty is now the only field-level state name and it's defined as "once the user types into it or the browser autofills it" (sticky for the session). Autofill is folded into that definition rather than a separate bullet, so the redundancy you flagged goes away. Curious what you think of the updated version! Going to push shortly when I address everything
| setFormErrors(validateFormData(formData)); | ||
| }; | ||
| ``` | ||
| - Presence errors take priority over format errors. If a field is both empty and format-invalid, show the presence error. |
There was a problem hiding this comment.
When we define validation errors, you're saying that we should define something like a priority for each of them (if we have multiple)?
There was a problem hiding this comment.
Yeah, the only ones I have seen a second validation along with "is present", in which "is present" should always take priority
| - Whitespace-only content in a required field counts as empty. | ||
| - Cap free-text `maxLength` to the backend column length via `inputOptions={{ maxLength: N }}` on `InputField`. The native input silently truncates paste. See [Forms](#forms) in the top-level rules. | ||
| - If the max length is unusual (e.g. a 48-character password), show an inline error on the field instead of relying on silent truncation. | ||
| - Autofill counts as user interaction — treat autofilled fields as touched. |
There was a problem hiding this comment.
This is already mentioned above in L354
There was a problem hiding this comment.
Good catch, dropping in the upcoming revision!
| - The submit handler must guard against a second submission while one is in flight. Do not rely solely on the button being disabled. | ||
| - The Cancel button remains enabled during submission and closes the modal immediately. It does not abort the in-flight request; the request completes in the background. |
There was a problem hiding this comment.
Agree w/ Copilot here. I think "Cancel" buttons in modals should cancel in-flight requests (I believe react-query does this behind the scenes IF we use useMutation for mutative operations... but we still have lots of occurrences where we use plain Promises, so worth double-checking).
There was a problem hiding this comment.
🟡 Not ready to approve
The new “dirty” definition is internally inconsistent with the later rule that submit marks all fields dirty, which should be clarified to prevent divergent implementations.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Comments suppressed due to low confidence (1)
frontend/docs/patterns.md:351
- The definition of field-level dirty says it only becomes dirty when the user types or the browser autofills, but a later bullet says submit "marks all fields dirty" (explicit exception). As written, that’s internally inconsistent and could lead to divergent implementations; update the dirty definition to include submit attempts (or rename the submit behavior).
A field is **dirty** once the user has typed into it or the browser has autofilled it. It stays dirty for the session, even if the value returns to its initial state. Field errors gate on `dirty`. Form-level `isDirty` ("form has changes") is a separate concept — see [Submit button state](#submit-button-state).
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Low
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
The prior phrasing ("submit marks all fields dirty") stretched the
definition of dirty to cover a system-triggered event. Cleaner to keep
dirty as pure user-interaction and describe submit as a checkpoint that
bypasses the gate.
There was a problem hiding this comment.
🟡 Not ready to approve
It introduces at least one non-clickable/incorrect doc reference link and a validation rule that is ambiguous enough to cause inconsistent implementations.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Comments suppressed due to low confidence (3)
.claude/rules/fleet-frontend.md:112
- The link to the validation patterns doc is likely broken in GitHub, because
frontend/docs/patterns.md#data-validationis relative to.claude/rules/and will resolve to.claude/rules/frontend/docs/patterns.md. Use a root-relative or correctly relative path so the rule can be followed easily.
**Read `frontend/docs/patterns.md#data-validation` before adding or editing form validation — that doc is authoritative.** Fleet diverges from what mainstream React libraries (Formik, react-hook-form, MUI, Ant Design) do by default on submit-button behavior, error timing, error position, and copy tone. Pattern-matching from another React app will land you in these specific mistakes:
frontend/docs/patterns.md:329
- This sentence reads like existing forms already follow the target behavior (“implement the rules directly”), but the next clause says they’re being migrated. Consider rewording to avoid implying that everything already matches the new rules.
The rules below describe the target behavior. Existing forms implement the rules directly and are migrated one at a time. New forms should follow these rules on day one.
frontend/docs/patterns.md:387
- This cross-field guidance is ambiguous about which blur triggers the mismatch check. If implemented on blur of either field, it would end up setting an error on a different field than the one being blurred (contradicting the earlier “blur validates that field only” rule). Clarify that mismatch validation runs on blur of the dependent/confirmation field.
- Cross-field checks (e.g. password + confirmation match) run on blur only when both fields are non-empty. If either is empty, skip the check — the empty field's own required-error covers it. On mismatch, attach the error to the dependent/confirmation field, not the source.
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Low
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
…rminology bullet - Fix relative link from .claude/rules/fleet-frontend.md to frontend/docs/patterns.md#data-validation (was resolving one level too high). - Reword "Existing forms implement the rules directly" to make clear that not every form complies yet. - Cross-field mismatch check now specifies blur of the dependent/confirmation field only, consistent with "blur validates that field only". - Roll the bold "Verb + object + constraint" divider into the sentence above so GitHub doesn't render it as a fake heading. - Collapse the double blank line before "Error message copy register". - Drop duplicative fleet-vs-team bullet; keep the Terminology pointer.
There was a problem hiding this comment.
🟡 Not ready to approve
The new validate example trims for presence but validates format against the untrimmed value, creating an inconsistency with the stated “trim before submit” hygiene rule.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Comments suppressed due to low confidence (1)
frontend/docs/patterns.md:342
- In the
validateexample, presence checking usestrim()but the format check uses the untrimmed value. This would treat trailing/leading whitespace as a format error even though the doc later recommends trimming before submit; using the trimmed value for both checks keeps the example consistent and avoids accidental whitespace validation bugs.
if (!formData.email.trim()) {
errors.email = "Enter your email";
} else if (!isValidEmail(formData.email)) {
errors.email = "Enter a valid email";
}
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Low
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
Batched fixes from a local Copilot-flavored sweep, plus the trim-both-checks example update from the last review round: - Validate example trims once and uses trimmed value for both presence and format checks (matches the input-hygiene rule). - Submit rule: "return without submitting" now conditioned on invalid fields, not absolute. - Focus-clear rule now names click, tab, and programmatic focus so keyboard users aren't excluded by the "(click-in)" parenthetical. - Multiple-server-errors rule now says each field-specific error still gets its own toast — drops the ambiguous "prefer summary when many" hedge that contradicted the always-toast rule two bullets above. - Cross-field mismatch rule now states what happens when the source field is edited after the confirmation blur (error stays until confirmation re-edits/re-blurs). - Optional-field bullet no longer restates the submit-button rule; points at Submit button state instead. - "No terminal periods" scoped to field errors; the toast register carve-out is named on the same line. - System/transport error paragraph now explicitly excludes field- specific server errors (which stay in the verb+object register). - fleet-frontend.md period rule aligned to the patterns.md scoping.
There was a problem hiding this comment.
🟢 Ready to approve
The changes are documentation-only, internally consistent, and remove a previously ambiguous reference without introducing broken links or contradictory guidance.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
There was a problem hiding this comment.
🟢 Ready to approve
Changes are documentation-only and the updated anchors/links and examples appear consistent and renderable based on the reviewed markdown context.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
- Files reviewed: 2/2 changed files
- Comments generated: 0 new
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
Issue
Docs-only. Kicks off the validation standardization workstream — target behavior for a forthcoming shared validation hook and per-form migrations.
Description
frontend/docs/patterns.md"Data validation" with 10 subsections: error timing, clearing, priority, submit button state, server-side errors, conditional validation, optional/disabled fields, input hygiene, in-flight lifecycle, visual affordances, and copy register..claude/rules/fleet-frontend.md.Screenrecording
Testing