Skip to content

Commit 87f93e5

Browse files
fix(dashboard): focus the offending field when a submit is blocked
The app form validates on submit with the error list hidden, so a required field left empty made Save look like a no-op: the error rendered somewhere off screen. Pass focusOnFirstError. RJSF's built-in handler resolves the field through form.elements, which the tagName="div" form does not have, so resolve it by generated id instead. Fixes #3135 Signed-off-by: Myasnikov Daniil <[email protected]>
1 parent 0ebc6ba commit 87f93e5

4 files changed

Lines changed: 89 additions & 0 deletions

File tree

‎packages/system/dashboard/images/console/apps/console/src/components/SchemaForm.test.tsx‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,16 @@ describe("SchemaForm validate handle", () => {
198198
expect(validate(ref)).toBe(false)
199199
})
200200

201+
it("focuses the first offending field so a blocked submit is not silent", () => {
202+
const ref = createRef<SchemaFormHandle>()
203+
render(
204+
<SchemaForm ref={ref} openAPISchema={requiredSchema} formData={{}} onChange={noop} />,
205+
)
206+
207+
validate(ref)
208+
expect(document.activeElement).toBe(document.getElementById("root_name"))
209+
})
210+
201211
it("reports valid through the ref once the required field is populated", () => {
202212
const ref = createRef<SchemaFormHandle>()
203213
render(

‎packages/system/dashboard/images/console/apps/console/src/components/SchemaForm.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import validator from "@rjsf/validator-ajv8"
44
import { getDefaultFormState } from "@rjsf/utils"
55
import type { RJSFSchema, UiSchema, TemplatesType } from "@rjsf/utils"
66
import { keysOrderToUiSchema, sanitizeSchema } from "../lib/keys-order.ts"
7+
import { focusFirstError } from "../lib/focus-first-error.ts"
78
import { addSensitiveStringWidgets } from "../lib/sensitive-fields.ts"
89
import {
910
IMMUTABLE_HELP_TEXT,
@@ -345,6 +346,7 @@ export const SchemaForm = forwardRef<SchemaFormHandle, SchemaFormProps>(function
345346
onChange={(e) => onChange(e.formData)}
346347
liveValidate={false}
347348
showErrorList={false}
349+
focusOnFirstError={focusFirstError}
348350
>
349351
{children}
350352
</Form>
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, it, expect, beforeEach } from "vitest"
2+
import { focusFirstError } from "./focus-first-error.ts"
3+
4+
describe("focusFirstError", () => {
5+
beforeEach(() => {
6+
document.body.innerHTML = ""
7+
})
8+
9+
it("focuses the field carrying the generated id", () => {
10+
document.body.innerHTML = `<input id="root_name" />`
11+
const field = document.getElementById("root_name")
12+
13+
focusFirstError({ property: ".name" })
14+
15+
expect(document.activeElement).toBe(field)
16+
})
17+
18+
it("resolves a bracketed property path", () => {
19+
document.body.innerHTML = `<input id="root_spec_first-name" />`
20+
const field = document.getElementById("root_spec_first-name")
21+
22+
focusFirstError({ property: ".spec['first-name']" })
23+
24+
expect(document.activeElement).toBe(field)
25+
})
26+
27+
// SourceField and SourceWidget render no element carrying the generated id,
28+
// only radios named `<id>-source`, so the exact lookup misses and a blocked
29+
// submit used to scroll nowhere.
30+
it("falls back to the name attribute when no element carries the id", () => {
31+
document.body.innerHTML = `
32+
<div class="field">
33+
<input type="radio" name="root_source-source" value="http" />
34+
<input type="radio" name="root_source-source" value="pvc" />
35+
</div>
36+
`
37+
const first = document.querySelector<HTMLInputElement>('input[value="http"]')
38+
39+
focusFirstError({ property: ".source" })
40+
41+
expect(document.getElementById("root_source")).toBeNull()
42+
expect(document.activeElement).toBe(first)
43+
})
44+
45+
it("does nothing when the field is nowhere in the document", () => {
46+
document.body.innerHTML = `<input id="root_other" />`
47+
48+
expect(() => focusFirstError({ property: ".missing" })).not.toThrow()
49+
expect(document.activeElement).toBe(document.body)
50+
})
51+
})
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { RJSFValidationError } from "@rjsf/utils"
2+
3+
/**
4+
* Errors render inline with the error list hidden, so a blocked submit is
5+
* invisible unless the offending field is brought into view. RJSF's built-in
6+
* focus resolves the field through `form.elements`, which the `tagName="div"`
7+
* form does not have, so resolve it by generated id instead.
8+
*
9+
* Not every widget puts that id on an element. `SourceField` and
10+
* `SourceWidget` render radios carrying only `name="<id>-source"`, so an exact
11+
* lookup finds nothing and the submit scrolls nowhere. Fall back to the first
12+
* element whose id or name starts with the generated id.
13+
*/
14+
export function focusFirstError(error: Pick<RJSFValidationError, "property">) {
15+
const segments = (error.property ?? "")
16+
.replace(/\['?([^'\]]+)'?\]/g, ".$1")
17+
.split(".")
18+
.filter(Boolean)
19+
const id = ["root", ...segments].join("_")
20+
const escaped = id.replace(/["\\]/g, "\\$&")
21+
const field =
22+
document.getElementById(id) ??
23+
document.querySelector<HTMLElement>(`[id^="${escaped}"], [name^="${escaped}"]`)
24+
field?.scrollIntoView?.({ block: "center" })
25+
field?.focus?.({ preventScroll: true })
26+
}

0 commit comments

Comments
 (0)