From 242786f3dedf241fbab87a71d78271b569086359 Mon Sep 17 00:00:00 2001 From: Myasnikov Daniil Date: Sat, 15 Aug 2026 15:31:00 +0500 Subject: [PATCH] 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 --- .../src/components/SchemaForm.test.tsx | 10 ++++ .../console/src/components/SchemaForm.tsx | 2 + .../console/src/lib/focus-first-error.test.ts | 51 +++++++++++++++++++ .../apps/console/src/lib/focus-first-error.ts | 26 ++++++++++ 4 files changed, 89 insertions(+) create mode 100644 packages/system/dashboard/images/console/apps/console/src/lib/focus-first-error.test.ts create mode 100644 packages/system/dashboard/images/console/apps/console/src/lib/focus-first-error.ts diff --git a/packages/system/dashboard/images/console/apps/console/src/components/SchemaForm.test.tsx b/packages/system/dashboard/images/console/apps/console/src/components/SchemaForm.test.tsx index 774e7e690..3ec1702c5 100644 --- a/packages/system/dashboard/images/console/apps/console/src/components/SchemaForm.test.tsx +++ b/packages/system/dashboard/images/console/apps/console/src/components/SchemaForm.test.tsx @@ -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() + render( + , + ) + + 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() render( diff --git a/packages/system/dashboard/images/console/apps/console/src/components/SchemaForm.tsx b/packages/system/dashboard/images/console/apps/console/src/components/SchemaForm.tsx index 4c6a3adaa..ecf929466 100644 --- a/packages/system/dashboard/images/console/apps/console/src/components/SchemaForm.tsx +++ b/packages/system/dashboard/images/console/apps/console/src/components/SchemaForm.tsx @@ -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(function onChange={(e) => onChange(e.formData)} liveValidate={false} showErrorList={false} + focusOnFirstError={focusFirstError} > {children} diff --git a/packages/system/dashboard/images/console/apps/console/src/lib/focus-first-error.test.ts b/packages/system/dashboard/images/console/apps/console/src/lib/focus-first-error.test.ts new file mode 100644 index 000000000..5e85cb09a --- /dev/null +++ b/packages/system/dashboard/images/console/apps/console/src/lib/focus-first-error.test.ts @@ -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 = `` + const field = document.getElementById("root_name") + + focusFirstError({ property: ".name" }) + + expect(document.activeElement).toBe(field) + }) + + it("resolves a bracketed property path", () => { + document.body.innerHTML = `` + 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 `-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 = ` +
+ + +
+ ` + const first = document.querySelector('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 = `` + + expect(() => focusFirstError({ property: ".missing" })).not.toThrow() + expect(document.activeElement).toBe(document.body) + }) +}) diff --git a/packages/system/dashboard/images/console/apps/console/src/lib/focus-first-error.ts b/packages/system/dashboard/images/console/apps/console/src/lib/focus-first-error.ts new file mode 100644 index 000000000..041f81c64 --- /dev/null +++ b/packages/system/dashboard/images/console/apps/console/src/lib/focus-first-error.ts @@ -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="-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) { + 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(`[id^="${escaped}"], [name^="${escaped}"]`) + field?.scrollIntoView?.({ block: "center" }) + field?.focus?.({ preventScroll: true }) +}