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
index 553e39ef2..cf075b398 100644
--- 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
@@ -44,17 +44,28 @@ describe("focusFirstError", () => {
expect(document.activeElement).toBe(field)
})
- // Pinned gap: RJSF gives the same dotted path whether the dot separates two
- // properties or sits inside one name, so a map keyed `ghcr.io` cannot be told
- // apart from a nested `ghcr` -> `io` and its field is never focused. The
- // submit still blocks and the inline error still renders; only the scroll is
- // lost. Fixing it needs the schema, not the error.
- it("does not resolve a property whose own name contains a dot", () => {
- document.body.innerHTML = ``
+ // A map key can itself contain a dot -- a mariadb user `john.doe`, a registry
+ // mirror `ghcr.io` -- and the flattened path cannot say whether a dot
+ // separates two properties or sits inside one, so both readings are tried.
+ it("resolves a property whose own name contains a dot", () => {
+ document.body.innerHTML = ``
+ const field = document.getElementById("root_users_john.doe_maxUserConnections")
- focusFirstError({ property: ".talos.registryMirrors.ghcr.io" })
+ focusFirstError({ property: ".users.john.doe.maxUserConnections" })
- expect(document.activeElement).toBe(document.body)
+ expect(document.activeElement).toBe(field)
+ })
+
+ it("prefers the plain split when both readings are present", () => {
+ document.body.innerHTML = `
+
+
+ `
+ const plain = document.getElementById("root_a_b")
+
+ focusFirstError({ property: ".a.b" })
+
+ expect(document.activeElement).toBe(plain)
})
it("uses the same custom prefix and separator as the form", () => {
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
index e46c365cc..cda8842b2 100644
--- 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
@@ -4,16 +4,41 @@ import type { RJSFValidationError } from "@rjsf/utils"
* RJSF builds `property` as the JSON-pointer instance path with the slashes
* swapped for dots (`processRawValidationErrors.js`), so the path is always
* dotted and never bracketed -- an array index arrives as `.items.0.name`.
- *
- * The flattening is lossy for a property whose own name contains a dot, which
- * an additionalProperties map keyed `ghcr.io` produces: the split yields one
- * segment too many and the generated id does not match the rendered one, so
- * that field is not brought into view. Pinned in the tests.
*/
function propertySegments(property: string): string[] {
return property.split(".").filter((segment) => segment !== "")
}
+/**
+ * The flattening is lossy: a dot inside one property name is indistinguishable
+ * from the separator between two, and a map keyed `john.doe` or `ghcr.io`
+ * produces exactly that. So try the plain split first, then every way of
+ * gluing adjacent segments back together with the dot they may have come from.
+ * Bounded, because the count doubles with each segment and only the first
+ * matching id is used.
+ */
+function candidateIds(
+ segments: string[],
+ idPrefix: string,
+ idSeparator: string,
+): string[] {
+ const ids: string[] = []
+ const build = (index: number, parts: string[]) => {
+ if (ids.length >= 64) return
+ if (index === segments.length) {
+ ids.push([idPrefix, ...parts].join(idSeparator))
+ return
+ }
+ build(index + 1, [...parts, segments[index]])
+ if (parts.length > 0) {
+ const glued = `${parts[parts.length - 1]}.${segments[index]}`
+ build(index + 1, [...parts.slice(0, -1), glued])
+ }
+ }
+ build(0, [])
+ return ids
+}
+
/**
* 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
@@ -33,7 +58,12 @@ export function focusFirstError(
idPrefix = "root",
idSeparator = "_",
) {
- const id = [idPrefix, ...propertySegments(error.property ?? "")].join(idSeparator)
+ const candidates = candidateIds(
+ propertySegments(error.property ?? ""),
+ idPrefix,
+ idSeparator,
+ )
+ const id = candidates[0]
const quote = (v: string) => v.replace(/["\\]/g, "\\$&")
const escaped = quote(id)
const escapedSeparator = quote(idSeparator)
@@ -44,8 +74,12 @@ export function focusFirstError(
...controls.map((tag) => `${tag}[id^="${escaped}${escapedSeparator}"]`),
...controls.map((tag) => `${tag}[name^="${escaped}-"]`),
].join(", ")
- const field =
- document.getElementById(id) ?? document.querySelector(fallback)
+ // An exact id is unambiguous, so try every candidate before falling back to
+ // the heuristic descendant match, which only makes sense for the plain split.
+ const exact = candidates
+ .map((candidate) => document.getElementById(candidate))
+ .find((element): element is HTMLElement => element !== null)
+ const field = exact ?? document.querySelector(fallback)
field?.scrollIntoView?.({ block: "center" })
field?.focus?.({ preventScroll: true })
}