mirror of
https://github.com/cozystack/cozystack.git
synced 2026-11-12 23:54:01 +09:00
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:
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}
|
||||
|
||||
+9
-1
@@ -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]}
|
||||
|
||||
+34
@@ -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 = "_"
|
||||
Reference in new issue
Block a user