Skip to content

Commit 9f88cc4

Browse files
myasnikovdaniillexfrei
authored andcommitted
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 <[email protected]>
1 parent 98e579c commit 9f88cc4

6 files changed

Lines changed: 81 additions & 5 deletions

File tree

‎packages/system/dashboard/images/console/apps/console/src/App.routing.test.tsx‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,17 @@ describe("shell subtitle", () => {
134134
expect(screen.queryByText("Tenant", { exact: true })).toBeNull()
135135
})
136136

137+
// The rest of /admin mounts the same tenant-scoped resource pages as
138+
// /console, and they resolve their namespace from the tenant context, so the
139+
// picker has to stay: hiding it there leaves the active tenant invisible and
140+
// unchangeable on the page it actually governs.
141+
it("keeps the tenant picker on tenant-scoped admin pages", async () => {
142+
const client = makeClient(TENANTS)
143+
renderWithK8sProvider(<App />, { client, initialRoute: "/admin/virtualmachines" })
144+
145+
expect(await screen.findByText("Tenant", { exact: true })).toBeTruthy()
146+
})
147+
137148
it("still shows the tenant picker outside the admin portal", async () => {
138149
const client = makeClient(TENANTS)
139150
renderWithK8sProvider(<App />, { client, initialRoute: "/console" })

‎packages/system/dashboard/images/console/apps/console/src/App.tsx‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,27 @@ import { CommandPalette } from "./components/command-palette/command-palette.tsx
1818
import { AppConfigContext, useAppConfig, type AppConfig } from "./lib/config.ts"
1919
import { DEFAULT_LANDING_PATH } from "./lib/portal.ts"
2020

21+
const CLUSTER_SCOPED_ADMIN = [
22+
"/admin/capacity",
23+
"/admin/backups/backupclasses",
24+
"/admin/external-ips",
25+
"/admin/tenants",
26+
"/admin/modules",
27+
]
28+
2129
function Shell({ username }: { username?: string }) {
2230
const config = useAppConfig()
2331
const { pathname } = useLocation()
2432
const inMarketplace = pathname.startsWith("/marketplace")
2533
const inAdmin = pathname.startsWith("/admin")
34+
// Only the genuinely cluster-scoped admin pages have no tenant to show. The
35+
// rest of /admin mounts the same tenant-scoped resource pages as /console
36+
// (see lib/portal.ts), and those resolve their namespace from the tenant
37+
// context, so hiding the picker there would leave the active tenant both
38+
// invisible and unchangeable.
39+
const inClusterScope = CLUSTER_SCOPED_ADMIN.some((prefix) =>
40+
pathname.startsWith(prefix),
41+
)
2642
const marketplaceSections = useMarketplaceSidebarSections()
2743
const consoleSections = useConsoleSidebarSections()
2844
const adminSections = useAdminSidebarSections()
@@ -44,7 +60,7 @@ function Shell({ username }: { username?: string }) {
4460
<AppShell
4561
tabs={tabs}
4662
sections={sections}
47-
subtitle={inAdmin ? undefined : <Breadcrumb />}
63+
subtitle={inClusterScope ? undefined : <Breadcrumb />}
4864
onSearchClick={toggle}
4965
version={config.version || import.meta.env.VITE_APP_VERSION}
5066
logoSvg={config.logoSvg}

‎packages/system/dashboard/images/console/apps/console/src/components/AdditionalPropertiesField.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
import { useState, useMemo } from "react"
22
import type { FieldProps, RJSFSchema, TemplatesType } from "@rjsf/utils"
3+
import { RJSF_ID_SEPARATOR } from "../lib/rjsf-ids.ts"
34
import Form from "@rjsf/core"
45
import validator from "@rjsf/validator-ajv8"
56
import { customTemplates, customWidgets } from "./rjsf-registry.ts"
67
import { addDynamicOptionWidgets } from "../lib/dynamic-options.ts"
78

89
export function AdditionalPropertiesField(props: FieldProps) {
9-
const { schema, formData, onChange, readonly, disabled, name, required } = props
10+
const { schema, formData, onChange, readonly, disabled, name, required, idSchema } =
11+
props
1012
const [newKey, setNewKey] = useState("")
1113

1214
// Get the schema for items from additionalProperties
@@ -87,6 +89,12 @@ export function AdditionalPropertiesField(props: FieldProps) {
8789
<div className="rounded-md bg-white p-3">
8890
<Form
8991
tagName="div"
92+
// Continue the outer form's id namespace into this entry.
93+
// Without it the nested form restarts at "root", so a map
94+
// entry's field renders as root_<field> while its validation
95+
// error names <outer>.<key>.<field>, and nothing resolves it.
96+
idPrefix={`${idSchema.$id}${RJSF_ID_SEPARATOR}${key}`}
97+
idSeparator={RJSF_ID_SEPARATOR}
9098
schema={itemSchema}
9199
uiSchema={itemUiSchema}
92100
formData={formData[key]}

‎packages/system/dashboard/images/console/apps/console/src/components/SchemaForm.test.tsx‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,40 @@ describe("SchemaForm validate handle", () => {
208208
expect(document.activeElement).toBe(document.getElementById("root_name"))
209209
})
210210

211+
// #3135's own example lives inside an additionalProperties map, and those
212+
// render in a nested form. Until that form continued the outer id namespace
213+
// it restarted at "root", so the error named nodeGroups.md0.instanceType
214+
// while the DOM held root_instanceType and the submit stayed silent.
215+
it("focuses a required field inside an additionalProperties map", () => {
216+
const mapSchema = JSON.stringify({
217+
type: "object",
218+
properties: {
219+
nodeGroups: {
220+
type: "object",
221+
additionalProperties: {
222+
type: "object",
223+
required: ["instanceType"],
224+
properties: { instanceType: { type: "string" } },
225+
},
226+
},
227+
},
228+
})
229+
const ref = createRef<SchemaFormHandle>()
230+
render(
231+
<SchemaForm
232+
ref={ref}
233+
openAPISchema={mapSchema}
234+
formData={{ nodeGroups: { md0: {} } }}
235+
onChange={noop}
236+
/>,
237+
)
238+
239+
expect(validate(ref)).toBe(false)
240+
expect(document.activeElement).toBe(
241+
document.getElementById("root_nodeGroups_md0_instanceType"),
242+
)
243+
})
244+
211245
it("reports valid through the ref once the required field is populated", () => {
212246
const ref = createRef<SchemaFormHandle>()
213247
render(

‎packages/system/dashboard/images/console/apps/console/src/components/SchemaForm.tsx‎

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { getDefaultFormState } from "@rjsf/utils"
55
import type { RJSFSchema, UiSchema, TemplatesType } from "@rjsf/utils"
66
import { keysOrderToUiSchema, sanitizeSchema } from "../lib/keys-order.ts"
77
import { focusFirstError } from "@/lib/focus-first-error.ts"
8+
import { RJSF_ID_PREFIX, RJSF_ID_SEPARATOR } from "../lib/rjsf-ids.ts"
89
import { addSensitiveStringWidgets } from "../lib/sensitive-fields.ts"
910
import {
1011
IMMUTABLE_HELP_TEXT,
@@ -18,9 +19,6 @@ import { ResourceQuotasField } from "./ResourceQuotasField.tsx"
1819
import { SourceField } from "./SourceField.tsx"
1920
import "./schema-form.css"
2021

21-
const RJSF_ID_PREFIX = "root"
22-
const RJSF_ID_SEPARATOR = "_"
23-
2422
/**
2523
* Recursively find all fields with additionalProperties schema and add widget.
2624
* Walks nested objects AND array items, so a map nested inside array elements
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* RJSF's id configuration, shared rather than repeated: `SchemaForm` passes it
3+
* to the form, `AdditionalPropertiesField` continues it into the nested form it
4+
* mounts per map entry, and `focusFirstError` rebuilds an id from an error's
5+
* property path. If those three disagree, a blocked submit resolves no element
6+
* and scrolls nowhere.
7+
*/
8+
export const RJSF_ID_PREFIX = "root"
9+
export const RJSF_ID_SEPARATOR = "_"

0 commit comments

Comments
 (0)