Skip to content

Commit f18e260

Browse files
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: Claude <[email protected]> Signed-off-by: Myasnikov Daniil <[email protected]>
1 parent befe0db commit f18e260

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
@@ -94,6 +94,17 @@ describe("shell subtitle", () => {
9494
expect(screen.queryByText("Tenant", { exact: true })).toBeNull()
9595
})
9696

97+
// The rest of /admin mounts the same tenant-scoped resource pages as
98+
// /console, and they resolve their namespace from the tenant context, so the
99+
// picker has to stay: hiding it there leaves the active tenant invisible and
100+
// unchangeable on the page it actually governs.
101+
it("keeps the tenant picker on tenant-scoped admin pages", async () => {
102+
const client = makeClient(TENANTS)
103+
renderWithK8sProvider(<App />, { client, initialRoute: "/admin/virtualmachines" })
104+
105+
expect(await screen.findByText("Tenant", { exact: true })).toBeTruthy()
106+
})
107+
97108
it("still shows the tenant picker outside the admin portal", async () => {
98109
const client = makeClient(TENANTS)
99110
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
@@ -17,6 +17,14 @@ import { CommandPalette } from "./components/command-palette/command-palette.tsx
1717
import type { AppConfig } from "./lib/config.ts"
1818
import { DEFAULT_LANDING_PATH } from "./lib/portal.ts"
1919

20+
const CLUSTER_SCOPED_ADMIN = [
21+
"/admin/capacity",
22+
"/admin/backups/backupclasses",
23+
"/admin/external-ips",
24+
"/admin/tenants",
25+
"/admin/modules",
26+
]
27+
2028
interface ShellProps {
2129
config: AppConfig
2230
username?: string
@@ -26,6 +34,14 @@ function Shell({ config, username }: ShellProps) {
2634
const { pathname } = useLocation()
2735
const inMarketplace = pathname.startsWith("/marketplace")
2836
const inAdmin = pathname.startsWith("/admin")
37+
// Only the genuinely cluster-scoped admin pages have no tenant to show. The
38+
// rest of /admin mounts the same tenant-scoped resource pages as /console
39+
// (see lib/portal.ts), and those resolve their namespace from the tenant
40+
// context, so hiding the picker there would leave the active tenant both
41+
// invisible and unchangeable.
42+
const inClusterScope = CLUSTER_SCOPED_ADMIN.some((prefix) =>
43+
pathname.startsWith(prefix),
44+
)
2945
const marketplaceSections = useMarketplaceSidebarSections()
3046
const consoleSections = useConsoleSidebarSections()
3147
const adminSections = useAdminSidebarSections()
@@ -47,7 +63,7 @@ function Shell({ config, username }: ShellProps) {
4763
<AppShell
4864
tabs={tabs}
4965
sections={sections}
50-
subtitle={inAdmin ? undefined : <Breadcrumb />}
66+
subtitle={inClusterScope ? undefined : <Breadcrumb />}
5167
onSearchClick={toggle}
5268
version={config.version || import.meta.env.VITE_APP_VERSION}
5369
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-templates.tsx"
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)