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 = "_"