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 }) }