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_<field> while its validation error named
<outer>.<key>.<field>, 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 <myasnikovdaniil2001@gmail.com>
This commit is contained in:
Myasnikov Daniil authored and Aleksei Sviridkin committed 2026-10-07 05:04:39 +03:00
1 parent 98e579c845
commit 9f88cc4cd7
6 files changed
+81 -5

No files matched your search

@@ -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(<App />, { 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(<App />, { client, initialRoute: "/console" })
@@ -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 }) {
<AppShell
tabs={tabs}
sections={sections}
subtitle={inAdmin ? undefined : <Breadcrumb />}
subtitle={inClusterScope ? undefined : <Breadcrumb />}
onSearchClick={toggle}
version={config.version || import.meta.env.VITE_APP_VERSION}
logoSvg={config.logoSvg}
@@ -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) {
<div className="rounded-md bg-white p-3">
<Form
tagName="div"
// Continue the outer form's id namespace into this entry.
// Without it the nested form restarts at "root", so a map
// entry's field renders as root_<field> while its validation
// error names <outer>.<key>.<field>, and nothing resolves it.
idPrefix={`${idSchema.$id}${RJSF_ID_SEPARATOR}${key}`}
idSeparator={RJSF_ID_SEPARATOR}
schema={itemSchema}
uiSchema={itemUiSchema}
formData={formData[key]}
@@ -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<SchemaFormHandle>()
render(
<SchemaForm
ref={ref}
openAPISchema={mapSchema}
formData={{ nodeGroups: { md0: {} } }}
onChange={noop}
/>,
)
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<SchemaFormHandle>()
render(
@@ -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
@@ -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 = "_"