From 9f88cc4cd70a680a3cd437252e65defeff3370ec Mon Sep 17 00:00:00 2001 From: Myasnikov Daniil Date: Fri, 28 Aug 2026 16:36:48 +0500 Subject: [PATCH] fix(dashboard): reach fields inside additionalProperties maps, and keep the picker where the tenant still matters AdditionalPropertiesField mounts a nested form per map entry, and that form was given no id configuration, so it restarted at "root": an entry field rendered as root_ while its validation error named .., and the focus helper resolved nothing. That is the case #3135 names -- nodeGroups.*.instanceType is inside such a map -- along with nine other required fields across computeplane, mariadb, rabbitmq and seaweedfs. Continue the outer namespace into the nested form, from one shared definition so the generated ids and the ids the helper rebuilds cannot drift apart. Hiding the tenant picker keyed on the whole /admin prefix, but /admin also mounts the same tenant-scoped resource pages as /console and they take their namespace from the tenant context. Reaching one of those from Modules switches the active tenant on the way, so the tenant was left invisible and unchangeable on the pages it governs. Key on the cluster-scoped areas the issue actually names. Assisted-by: LLM Signed-off-by: Myasnikov Daniil --- .../apps/console/src/App.routing.test.tsx | 11 ++++++ .../images/console/apps/console/src/App.tsx | 18 +++++++++- .../components/AdditionalPropertiesField.tsx | 10 +++++- .../src/components/SchemaForm.test.tsx | 34 +++++++++++++++++++ .../console/src/components/SchemaForm.tsx | 4 +-- .../console/apps/console/src/lib/rjsf-ids.ts | 9 +++++ 6 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 packages/system/dashboard/images/console/apps/console/src/lib/rjsf-ids.ts diff --git a/packages/system/dashboard/images/console/apps/console/src/App.routing.test.tsx b/packages/system/dashboard/images/console/apps/console/src/App.routing.test.tsx index bffb82fd5..326b61b9d 100644 --- a/packages/system/dashboard/images/console/apps/console/src/App.routing.test.tsx +++ b/packages/system/dashboard/images/console/apps/console/src/App.routing.test.tsx @@ -134,6 +134,17 @@ describe("shell subtitle", () => { expect(screen.queryByText("Tenant", { exact: true })).toBeNull() }) + // The rest of /admin mounts the same tenant-scoped resource pages as + // /console, and they resolve their namespace from the tenant context, so the + // picker has to stay: hiding it there leaves the active tenant invisible and + // unchangeable on the page it actually governs. + it("keeps the tenant picker on tenant-scoped admin pages", async () => { + const client = makeClient(TENANTS) + renderWithK8sProvider(, { client, initialRoute: "/admin/virtualmachines" }) + + expect(await screen.findByText("Tenant", { exact: true })).toBeTruthy() + }) + it("still shows the tenant picker outside the admin portal", async () => { const client = makeClient(TENANTS) renderWithK8sProvider(, { client, initialRoute: "/console" }) diff --git a/packages/system/dashboard/images/console/apps/console/src/App.tsx b/packages/system/dashboard/images/console/apps/console/src/App.tsx index 92b9121e6..a88a07257 100644 --- a/packages/system/dashboard/images/console/apps/console/src/App.tsx +++ b/packages/system/dashboard/images/console/apps/console/src/App.tsx @@ -18,11 +18,27 @@ import { CommandPalette } from "./components/command-palette/command-palette.tsx import { AppConfigContext, useAppConfig, type AppConfig } from "./lib/config.ts" import { DEFAULT_LANDING_PATH } from "./lib/portal.ts" +const CLUSTER_SCOPED_ADMIN = [ + "/admin/capacity", + "/admin/backups/backupclasses", + "/admin/external-ips", + "/admin/tenants", + "/admin/modules", +] + function Shell({ username }: { username?: string }) { const config = useAppConfig() const { pathname } = useLocation() const inMarketplace = pathname.startsWith("/marketplace") const inAdmin = pathname.startsWith("/admin") + // Only the genuinely cluster-scoped admin pages have no tenant to show. The + // rest of /admin mounts the same tenant-scoped resource pages as /console + // (see lib/portal.ts), and those resolve their namespace from the tenant + // context, so hiding the picker there would leave the active tenant both + // invisible and unchangeable. + const inClusterScope = CLUSTER_SCOPED_ADMIN.some((prefix) => + pathname.startsWith(prefix), + ) const marketplaceSections = useMarketplaceSidebarSections() const consoleSections = useConsoleSidebarSections() const adminSections = useAdminSidebarSections() @@ -44,7 +60,7 @@ function Shell({ username }: { username?: string }) { } + subtitle={inClusterScope ? undefined : } onSearchClick={toggle} version={config.version || import.meta.env.VITE_APP_VERSION} logoSvg={config.logoSvg} diff --git a/packages/system/dashboard/images/console/apps/console/src/components/AdditionalPropertiesField.tsx b/packages/system/dashboard/images/console/apps/console/src/components/AdditionalPropertiesField.tsx index ac04d9b8e..e2c0371eb 100644 --- a/packages/system/dashboard/images/console/apps/console/src/components/AdditionalPropertiesField.tsx +++ b/packages/system/dashboard/images/console/apps/console/src/components/AdditionalPropertiesField.tsx @@ -1,12 +1,14 @@ import { useState, useMemo } from "react" import type { FieldProps, RJSFSchema, TemplatesType } from "@rjsf/utils" +import { RJSF_ID_SEPARATOR } from "../lib/rjsf-ids.ts" import Form from "@rjsf/core" import validator from "@rjsf/validator-ajv8" import { customTemplates, customWidgets } from "./rjsf-registry.ts" import { addDynamicOptionWidgets } from "../lib/dynamic-options.ts" export function AdditionalPropertiesField(props: FieldProps) { - const { schema, formData, onChange, readonly, disabled, name, required } = props + const { schema, formData, onChange, readonly, disabled, name, required, idSchema } = + props const [newKey, setNewKey] = useState("") // Get the schema for items from additionalProperties @@ -87,6 +89,12 @@ export function AdditionalPropertiesField(props: FieldProps) {
while its validation + // error names .., and nothing resolves it. + idPrefix={`${idSchema.$id}${RJSF_ID_SEPARATOR}${key}`} + idSeparator={RJSF_ID_SEPARATOR} schema={itemSchema} uiSchema={itemUiSchema} formData={formData[key]} 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 3ec1702c5..4aabf4d6c 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 @@ -208,6 +208,40 @@ describe("SchemaForm validate handle", () => { expect(document.activeElement).toBe(document.getElementById("root_name")) }) + // #3135's own example lives inside an additionalProperties map, and those + // render in a nested form. Until that form continued the outer id namespace + // it restarted at "root", so the error named nodeGroups.md0.instanceType + // while the DOM held root_instanceType and the submit stayed silent. + it("focuses a required field inside an additionalProperties map", () => { + const mapSchema = JSON.stringify({ + type: "object", + properties: { + nodeGroups: { + type: "object", + additionalProperties: { + type: "object", + required: ["instanceType"], + properties: { instanceType: { type: "string" } }, + }, + }, + }, + }) + const ref = createRef() + render( + , + ) + + expect(validate(ref)).toBe(false) + expect(document.activeElement).toBe( + document.getElementById("root_nodeGroups_md0_instanceType"), + ) + }) + 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 0a4a95bc4..8bacebd88 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 @@ -5,6 +5,7 @@ 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 { RJSF_ID_PREFIX, RJSF_ID_SEPARATOR } from "../lib/rjsf-ids.ts" import { addSensitiveStringWidgets } from "../lib/sensitive-fields.ts" import { IMMUTABLE_HELP_TEXT, @@ -18,9 +19,6 @@ import { ResourceQuotasField } from "./ResourceQuotasField.tsx" import { SourceField } from "./SourceField.tsx" import "./schema-form.css" -const RJSF_ID_PREFIX = "root" -const RJSF_ID_SEPARATOR = "_" - /** * Recursively find all fields with additionalProperties schema and add widget. * Walks nested objects AND array items, so a map nested inside array elements diff --git a/packages/system/dashboard/images/console/apps/console/src/lib/rjsf-ids.ts b/packages/system/dashboard/images/console/apps/console/src/lib/rjsf-ids.ts new file mode 100644 index 000000000..40edfa32b --- /dev/null +++ b/packages/system/dashboard/images/console/apps/console/src/lib/rjsf-ids.ts @@ -0,0 +1,9 @@ +/** + * RJSF's id configuration, shared rather than repeated: `SchemaForm` passes it + * to the form, `AdditionalPropertiesField` continues it into the nested form it + * mounts per map entry, and `focusFirstError` rebuilds an id from an error's + * property path. If those three disagree, a blocked submit resolves no element + * and scrolls nowhere. + */ +export const RJSF_ID_PREFIX = "root" +export const RJSF_ID_SEPARATOR = "_"