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 326b61b9d..4081ecf5b 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
@@ -124,31 +124,41 @@ describe("shell subtitle", () => {
metadata: { name, labels: {} },
}))
- it("hides the tenant picker in the admin portal", async () => {
- const client = makeClient(TENANTS)
- renderWithK8sProvider(, { client, initialRoute: "/admin/modules" })
+ // Every cluster-scoped prefix, and a deeper path under each, so dropping one
+ // from the list fails here instead of passing quietly.
+ const CLUSTER_SCOPED = [
+ "/admin/capacity/cluster",
+ "/admin/backups/backupclasses",
+ "/admin/external-ips",
+ "/admin/modules",
+ ]
- // The picker's first render says "Loading tenants…"; wait that out, or the
- // absence below passes only because the list had not arrived yet.
+ it.each(CLUSTER_SCOPED)("hides the tenant picker on %s", async (route) => {
+ const client = makeClient(TENANTS)
+ renderWithK8sProvider(, { client, initialRoute: route })
+
+ // The marker is on every Breadcrumb branch, so absence means the subtitle
+ // was not rendered at all rather than that the list had not arrived.
await waitFor(() => expect(screen.queryByText("Loading tenants…")).toBeNull())
- expect(screen.queryByText("Tenant", { exact: true })).toBeNull()
+ expect(screen.queryByTestId("tenant-picker")).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" })
+ // The tenant list is scoped by the picker's own selection, so it needs the
+ // picker even though it lives under /admin.
+ it.each(["/admin/tenants", "/admin/vminstances", "/admin/vminstances/db1"])(
+ "keeps the tenant picker on %s",
+ async (route) => {
+ const client = makeClient(TENANTS)
+ renderWithK8sProvider(, { client, initialRoute: route })
- expect(await screen.findByText("Tenant", { exact: true })).toBeTruthy()
- })
+ expect(await screen.findByTestId("tenant-picker")).toBeTruthy()
+ },
+ )
it("still shows the tenant picker outside the admin portal", async () => {
const client = makeClient(TENANTS)
renderWithK8sProvider(, { client, initialRoute: "/console" })
- expect(await screen.findByText("Tenant", { exact: true })).toBeTruthy()
+ expect(await screen.findByTestId("tenant-picker")).toBeTruthy()
})
})
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 a88a07257..c800cb287 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,14 @@ 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"
+// Admin pages whose content does not depend on the selected tenant, so the
+// picker has nothing to say on them. /admin/tenants is deliberately absent:
+// its rows come from the picker-filtered context list, unlike Modules and
+// External IPs which list tenant namespaces themselves.
const CLUSTER_SCOPED_ADMIN = [
"/admin/capacity",
"/admin/backups/backupclasses",
"/admin/external-ips",
- "/admin/tenants",
"/admin/modules",
]
@@ -36,8 +39,10 @@ function Shell({ username }: { username?: string }) {
// (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),
+ // Anchored on a segment boundary: an unanchored prefix would also swallow a
+ // sibling route that merely starts with the same characters.
+ const inClusterScope = CLUSTER_SCOPED_ADMIN.some(
+ (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
)
const marketplaceSections = useMarketplaceSidebarSections()
const consoleSections = useConsoleSidebarSections()
diff --git a/packages/system/dashboard/images/console/apps/console/src/components/Breadcrumb.tsx b/packages/system/dashboard/images/console/apps/console/src/components/Breadcrumb.tsx
index 8f6a8641f..15ad1f4fb 100644
--- a/packages/system/dashboard/images/console/apps/console/src/components/Breadcrumb.tsx
+++ b/packages/system/dashboard/images/console/apps/console/src/components/Breadcrumb.tsx
@@ -10,14 +10,22 @@ export function Breadcrumb() {
const { tenants, selectedTenant, selectTenant, isLoading } = useTenantContext()
if (isLoading && !tenants.length) {
- return Loading tenants…
+ return (
+
+ Loading tenants…
+
+ )
}
if (!tenants.length) {
- return No tenants found
+ return (
+
+ No tenants found
+
+ )
}
return (
-
+ Tenant/
{tenants.length > 1 ? (
diff --git a/packages/system/dashboard/images/console/apps/console/src/components/SourceField.test.tsx b/packages/system/dashboard/images/console/apps/console/src/components/SourceField.test.tsx
index 54d30361a..101060357 100644
--- a/packages/system/dashboard/images/console/apps/console/src/components/SourceField.test.tsx
+++ b/packages/system/dashboard/images/console/apps/console/src/components/SourceField.test.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest"
-import { screen } from "@testing-library/react"
+import { screen, fireEvent } from "@testing-library/react"
import type { FieldProps } from "@rjsf/utils"
import { SourceField } from "./SourceField.tsx"
import { createMockK8sClient } from "../test-utils/mock-k8s-client.ts"
@@ -81,3 +81,50 @@ describe("SourceField image picker", () => {
expect(screen.queryByRole("option", { name: "ubuntu" })).not.toBeInTheDocument()
})
})
+
+// The shipped vm-disk shape: each branch marks its own leaf required, and
+// `source` itself is not, so validation reports `.source.http.url`.
+const sourceSchema = {
+ type: "object",
+ description: "The source image location used to create a disk.",
+ properties: {
+ http: {
+ type: "object",
+ required: ["url"],
+ properties: { url: { type: "string", title: "URL" } },
+ },
+ disk: {
+ type: "object",
+ required: ["name"],
+ properties: { name: { type: "string", title: "Name" } },
+ },
+ },
+}
+
+function props(overrides: Partial = {}): FieldProps {
+ return {
+ schema: sourceSchema,
+ formData: {},
+ onChange: vi.fn(),
+ name: "source",
+ required: false,
+ idSchema: { $id: "root_source" },
+ ...overrides,
+ } as unknown as FieldProps
+}
+
+describe("SourceField", () => {
+ // Validation reports the branch leaf, and focus-first-error resolves the
+ // field by RJSF's generated id, so the control has to carry it or a blocked
+ // submit scrolls nowhere. Nothing else in the widget renders that id.
+ it("puts the generated id on the selected branch's control", () => {
+ renderWithK8sProvider(, {
+ client: createMockK8sClient({ lists: [] }),
+ })
+
+ fireEvent.click(screen.getByRole("radio", { name: /http/i }))
+
+ expect(document.getElementById("root_source_http_url")).not.toBeNull()
+ })
+
+})
diff --git a/packages/system/dashboard/images/console/apps/console/src/components/SourceField.tsx b/packages/system/dashboard/images/console/apps/console/src/components/SourceField.tsx
index 3fe49c429..ed5a6dcef 100644
--- a/packages/system/dashboard/images/console/apps/console/src/components/SourceField.tsx
+++ b/packages/system/dashboard/images/console/apps/console/src/components/SourceField.tsx
@@ -1,6 +1,7 @@
import { useState } from "react"
import type { FieldProps } from "@rjsf/utils"
import { useK8sList } from "@cozystack/k8s-client"
+import { RJSF_ID_SEPARATOR } from "../lib/rjsf-ids.ts"
import { APPS_GROUP, APPS_VERSION } from "@cozystack/types"
import { useTenantContext } from "../lib/tenant-context.tsx"
import type { OptionObject } from "./DynamicOptionsWidget.tsx"
@@ -121,10 +122,14 @@ export function SourceField(props: FieldProps) {
const isDiskName = option === "disk" && key === "name"
const isImageName = option === "image" && key === "name"
+ // RJSF's id for this control. Its required sub-fields are what
+ // validation reports (source.http.url, not source), so the focus
+ // helper resolves the field by this id and finds nothing without it.
+ const fieldId = [idSchema.$id, option, key].join(RJSF_ID_SEPARATOR)
return (