Settings: point a failed save at the field that caused it #272

Merged
robbertbos merged 16 commits from settings-field-errors into main 2026-08-09 12:52:07 +00:00
Owner

Works through the actionable part of #208, after re-verifying its claims
against NLDD 0.8.79. The issue was written against 0.8.70; four of the
mechanisms it cites have since moved, so it is being rewritten separately.

The defect this starts from

POST /api/accounts answered 422 for three unrelated causes: Pydantic
validation (token shape, and the SSRF guard on base_url), an invalid
Mattermost token, and an unreachable server. The panel mapped every 422 to
one sentence, so pasting http://10.0.0.5/chat as Basis-URL reported
"Token is ongeldig" on a token that was fine.

#208 proposed routing 422 to the Token field. Done as written that would have
painted a correct token red for two of the three causes.

What changed

The statuses split. The catch-all in _verify_token is now 502: the
request was fine, the upstream was not reachable. 422 keeps its one meaning.

One pure function decides field versus banner. accountErrorTarget maps
an error onto a field and a sentence, and always returns a target: an error
carrying no status - a network failure, an abort, a thrown TypeError - is
panel-level rather than nothing. That was the objection to the mapping #208
proposed, which returned null for exactly those.

Cause Lands on
422, loc names base_url Basis-URL
422, otherwise Token
409 Basis-URL
502 Basis-URL
400, or no status at all the panel banner

The recovery-code field joins the form system. It was the last hand-written
<label> plus <input> in Instellingen, and its likeliest failure landed in
the panel-wide banner while focus jumped to a field showing nothing.

The last two dialogs move onto Modal.vue. The disconnect and delete
confirms were the only ones still on nldd-modal-dialog; the heading-size
drift #208 spotted (3 against 5) was a symptom of that, so it resolves itself.

The delete button says why it is off. Three conditions gated it and none
was announced. A mismatched address now says so, on blur rather than per
keystroke.

Thema is a radio group. The choice directly below it already was one, so
one panel offered the same kind of choice two ways. Sync-interval and both
retention fields keep dropdowns: five options with long labels.

Settled without code

  • The <output aria-label> in RecoveryKeyModal. Measured in Chromium's
    accessibility tree: the label becomes the accessible name, and the key stays
    exposed as a StaticText child. The feared "the label replaces the key" does
    not happen.
  • nldd-table for the import diff table. It fits mechanically, but the
    family has no <table>, <th scope> or <caption> anywhere - it is a CSS
    grid with role="table"/"columnheader"/"cell". The current markup is a
    native table with real <th scope="col">, so adopting it would trade
    semantics for roles.

Two traps worth knowing

  • An NLDD error text is invisible unless the input carries both invalid
    and an error-message naming the text's id. Half of it renders nothing, with
    no warning. The base-URL and label fields moved into
    MattermostServerFields so that recipe exists once.
  • A false boolean binding on an NLDD element is not nothing: on an element
    that has not upgraded, Vue writes the attribute "false", and NLDD reads it
    with hasAttribute(). A stray invalid makes a form field show its error
    text permanently. Bindings are cond || undefined. Both are now in CLAUDE.md.

Verification

  • 1726 backend tests, 100% coverage; 734 frontend unit tests; vue-tsc;
    npm run build; uvx pre-commit run --all-files; the full e2e suite
    (67 passed, 3 skipped).
  • Driven in a browser, because a design-system change is not detectable by
    gates alone. Confirmed live: a rejected base URL and an unreachable server
    each land on the Basis-URL field with aria-describedby and aria-invalid
    reaching the real inner <input> and the token field untouched; the delete
    dialog opens as a true top-layer modal with focus on the first checkbox and
    the mismatch error appearing only after blur; zero nldd-modal-dialog
    elements left in the app.

One e2e spec caught what the unit tests could not - it still drove Thema as a
combobox. It now addresses the radio by its accessible name.

Works through the actionable part of #208, after re-verifying its claims against NLDD 0.8.79. The issue was written against 0.8.70; four of the mechanisms it cites have since moved, so it is being rewritten separately. ## The defect this starts from `POST /api/accounts` answered 422 for three unrelated causes: Pydantic validation (token shape, and the SSRF guard on `base_url`), an invalid Mattermost token, and an unreachable server. The panel mapped every 422 to one sentence, so pasting `http://10.0.0.5/chat` as Basis-URL reported **"Token is ongeldig"** on a token that was fine. #208 proposed routing 422 to the Token field. Done as written that would have painted a correct token red for two of the three causes. ## What changed **The statuses split.** The catch-all in `_verify_token` is now 502: the request was fine, the upstream was not reachable. 422 keeps its one meaning. **One pure function decides field versus banner.** `accountErrorTarget` maps an error onto a field and a sentence, and always returns a target: an error carrying no status - a network failure, an abort, a thrown `TypeError` - is panel-level rather than nothing. That was the objection to the mapping #208 proposed, which returned `null` for exactly those. | Cause | Lands on | |---|---| | 422, `loc` names `base_url` | Basis-URL | | 422, otherwise | Token | | 409 | Basis-URL | | 502 | Basis-URL | | 400, or no status at all | the panel banner | **The recovery-code field joins the form system.** It was the last hand-written `<label>` plus `<input>` in Instellingen, and its likeliest failure landed in the panel-wide banner while focus jumped to a field showing nothing. **The last two dialogs move onto `Modal.vue`.** The disconnect and delete confirms were the only ones still on `nldd-modal-dialog`; the heading-size drift #208 spotted (3 against 5) was a symptom of that, so it resolves itself. **The delete button says why it is off.** Three conditions gated it and none was announced. A mismatched address now says so, on blur rather than per keystroke. **Thema is a radio group.** The choice directly below it already was one, so one panel offered the same kind of choice two ways. Sync-interval and both retention fields keep dropdowns: five options with long labels. ## Settled without code - **The `<output aria-label>` in RecoveryKeyModal.** Measured in Chromium's accessibility tree: the label becomes the accessible name, and the key stays exposed as a `StaticText` child. The feared "the label replaces the key" does not happen. - **`nldd-table` for the import diff table.** It fits mechanically, but the family has no `<table>`, `<th scope>` or `<caption>` anywhere - it is a CSS grid with `role="table"`/`"columnheader"`/`"cell"`. The current markup is a native table with real `<th scope="col">`, so adopting it would trade semantics for roles. ## Two traps worth knowing - An NLDD error text is invisible unless the input carries **both** `invalid` and an `error-message` naming the text's id. Half of it renders nothing, with no warning. The base-URL and label fields moved into `MattermostServerFields` so that recipe exists once. - A **`false` boolean binding on an NLDD element is not nothing**: on an element that has not upgraded, Vue writes the attribute `"false"`, and NLDD reads it with `hasAttribute()`. A stray `invalid` makes a form field show its error text permanently. Bindings are `cond || undefined`. Both are now in CLAUDE.md. ## Verification - 1726 backend tests, 100% coverage; 734 frontend unit tests; `vue-tsc`; `npm run build`; `uvx pre-commit run --all-files`; the full e2e suite (67 passed, 3 skipped). - Driven in a browser, because a design-system change is not detectable by gates alone. Confirmed live: a rejected base URL and an unreachable server each land on the Basis-URL field with `aria-describedby` and `aria-invalid` reaching the real inner `<input>` and the token field untouched; the delete dialog opens as a true top-layer modal with focus on the first checkbox and the mismatch error appearing only after blur; zero `nldd-modal-dialog` elements left in the app. One e2e spec caught what the unit tests could not - it still drove Thema as a combobox. It now addresses the radio by its accessible name.
robbertbos force-pushed settings-field-errors from ff08dc865b
All checks were successful
CI / release-scripts (pull_request) Successful in 5s
security-scan / SBOM (trivy) (pull_request) Successful in 11s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 14s
security-scan / JS SCA (npm audit) (pull_request) Successful in 16s
security-scan / Python SAST (bandit) (pull_request) Successful in 21s
CI / pre-commit (pull_request) Successful in 25s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 38s
test-build / build (frontend) (pull_request) Successful in 57s
test-build / build (backend) (pull_request) Successful in 1m1s
test-build / build (pull_request) Successful in 0s
CI / frontend-test (pull_request) Successful in 1m10s
CI / backend-test (pull_request) Successful in 1m48s
CI / e2e (pull_request) Successful in 2m15s
to 2a78b1849e
All checks were successful
CI / release-scripts (pull_request) Successful in 5s
security-scan / SBOM (trivy) (pull_request) Successful in 10s
security-scan / JS SCA (npm audit) (pull_request) Successful in 18s
security-scan / Python SAST (bandit) (pull_request) Successful in 21s
security-scan / Filesystem scan (trivy fs) (pull_request) Successful in 16s
security-scan / Python SCA (pip-audit) (pull_request) Successful in 39s
test-build / build (frontend) (pull_request) Successful in 58s
test-build / build (backend) (pull_request) Successful in 1m1s
CI / pre-commit (pull_request) Successful in 1m1s
test-build / build (pull_request) Successful in 0s
CI / frontend-test (pull_request) Successful in 1m10s
CI / backend-test (pull_request) Successful in 2m16s
CI / e2e (pull_request) Successful in 2m26s
2026-08-09 12:49:22 +00:00
Compare
robbertbos deleted branch settings-field-errors 2026-08-09 12:52:07 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
robbertbos/waggle!272
No description provided.