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 <myasnikovdaniil2001@gmail.com>
This commit is contained in:
Myasnikov Daniil authored and Aleksei Sviridkin committed 2026-10-07 05:03:19 +03:00
1 parent 2da6594466
commit 242786f3de
4 files changed
+89

No files matched your search

@@ -198,6 +198,16 @@ describe("SchemaForm validate handle", () => {
expect(validate(ref)).toBe(false)
})
it("focuses the first offending field so a blocked submit is not silent", () => {
const ref = createRef<SchemaFormHandle>()
render(
<SchemaForm ref={ref} openAPISchema={requiredSchema} formData={{}} onChange={noop} />,
)
validate(ref)
expect(document.activeElement).toBe(document.getElementById("root_name"))
})
it("reports valid through the ref once the required field is populated", () => {
const ref = createRef<SchemaFormHandle>()
render(
@@ -4,6 +4,7 @@ import validator from "@rjsf/validator-ajv8"
import { getDefaultFormState } from "@rjsf/utils"
import type { RJSFSchema, UiSchema, TemplatesType } from "@rjsf/utils"
import { keysOrderToUiSchema, sanitizeSchema } from "../lib/keys-order.ts"
import { focusFirstError } from "../lib/focus-first-error.ts"
import { addSensitiveStringWidgets } from "../lib/sensitive-fields.ts"
import {
IMMUTABLE_HELP_TEXT,
@@ -352,6 +353,7 @@ export const SchemaForm = forwardRef<SchemaFormHandle, SchemaFormProps>(function
onChange={(e) => onChange(e.formData)}
liveValidate={false}
showErrorList={false}
focusOnFirstError={focusFirstError}
>
{children}
</Form>
@@ -0,0 +1,51 @@
import { describe, it, expect, beforeEach } from "vitest"
import { focusFirstError } from "./focus-first-error.ts"
describe("focusFirstError", () => {
beforeEach(() => {
document.body.innerHTML = ""
})
it("focuses the field carrying the generated id", () => {
document.body.innerHTML = `<input id="root_name" />`
const field = document.getElementById("root_name")
focusFirstError({ property: ".name" })
expect(document.activeElement).toBe(field)
})
it("resolves a bracketed property path", () => {
document.body.innerHTML = `<input id="root_spec_first-name" />`
const field = document.getElementById("root_spec_first-name")
focusFirstError({ property: ".spec['first-name']" })
expect(document.activeElement).toBe(field)
})
// SourceField and SourceWidget render no element carrying the generated id,
// only radios named `<id>-source`, so the exact lookup misses and a blocked
// submit used to scroll nowhere.
it("falls back to the name attribute when no element carries the id", () => {
document.body.innerHTML = `
<div class="field">
<input type="radio" name="root_source-source" value="http" />
<input type="radio" name="root_source-source" value="pvc" />
</div>
`
const first = document.querySelector<HTMLInputElement>('input[value="http"]')
focusFirstError({ property: ".source" })
expect(document.getElementById("root_source")).toBeNull()
expect(document.activeElement).toBe(first)
})
it("does nothing when the field is nowhere in the document", () => {
document.body.innerHTML = `<input id="root_other" />`
expect(() => focusFirstError({ property: ".missing" })).not.toThrow()
expect(document.activeElement).toBe(document.body)
})
})
@@ -0,0 +1,26 @@
import type { RJSFValidationError } from "@rjsf/utils"
/**
* Errors render inline with the error list hidden, so a blocked submit is
* invisible unless the offending field is brought into view. RJSF's built-in
* focus resolves the field through `form.elements`, which the `tagName="div"`
* form does not have, so resolve it by generated id instead.
*
* Not every widget puts that id on an element. `SourceField` and
* `SourceWidget` render radios carrying only `name="<id>-source"`, so an exact
* lookup finds nothing and the submit scrolls nowhere. Fall back to the first
* element whose id or name starts with the generated id.
*/
export function focusFirstError(error: Pick<RJSFValidationError, "property">) {
const segments = (error.property ?? "")
.replace(/\['?([^'\]]+)'?\]/g, ".$1")
.split(".")
.filter(Boolean)
const id = ["root", ...segments].join("_")
const escaped = id.replace(/["\\]/g, "\\$&")
const field =
document.getElementById(id) ??
document.querySelector<HTMLElement>(`[id^="${escaped}"], [name^="${escaped}"]`)
field?.scrollIntoView?.({ block: "center" })
field?.focus?.({ preventScroll: true })
}