diff --git a/CLAUDE.md b/CLAUDE.md index e8f96e4..6e091d7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,10 +96,8 @@ schema). The pipeline in `apps/console/src/components/SchemaForm.tsx`: 2. `keysOrderToUiSchema` reads `spec.dashboard.keysOrder` and emits per-level `ui:order` arrays. 3. A chain of `addXxxWidgets(schema, uiSchema)` walks the schema and binds - widgets by **field name** convention: - - `storageClass` → `StorageClassWidget` - - `backupClassName` → `BackupClassWidget` - - `disks[].name` → `VMDiskWidget` + widgets: + - any field carrying the `x-cozystack-options` schema keyword → `DynamicOptionsWidget`, a runtime dropdown populated from the cluster's `Option` resource (`core.cozystack.io`) keyed by the keyword's `source`; `addDynamicOptionWidgets` (`lib/dynamic-options.ts`) recurses into `properties`, array `items` and `additionalProperties`, and replaces the former field-name-bound `StorageClassWidget` / `BackupClassWidget` / `VMDiskWidget`. - object with `additionalProperties: ` → `AdditionalPropertiesField` - credential-shaped fields (`password`, `*token`, `*accessKey`, …) → `SensitiveStringWidget` — see `lib/sensitive-fields.ts` and its tests for diff --git a/apps/console/src/components/AdditionalPropertiesField.tsx b/apps/console/src/components/AdditionalPropertiesField.tsx index 5146065..587adfa 100644 --- a/apps/console/src/components/AdditionalPropertiesField.tsx +++ b/apps/console/src/components/AdditionalPropertiesField.tsx @@ -3,15 +3,24 @@ import type { FieldProps, RJSFSchema, TemplatesType } from "@rjsf/utils" import Form from "@rjsf/core" import validator from "@rjsf/validator-ajv8" import { customTemplates, customWidgets } from "./rjsf-templates.tsx" +import { addDynamicOptionWidgets } from "../lib/dynamic-options.ts" export function AdditionalPropertiesField(props: FieldProps) { const { schema, formData, onChange, readonly, disabled, name, required } = props const [newKey, setNewKey] = useState("") // Get the schema for items from additionalProperties - const itemSchema = (schema.additionalProperties as RJSFSchema) || {} + const itemSchema = useMemo( + () => (schema.additionalProperties as RJSFSchema) || {}, + [schema.additionalProperties], + ) const keys = Object.keys(formData || {}) + // The nested
renders its own subtree, so it needs its own uiSchema to + // bind x-cozystack-options fields (e.g. nodeGroups.*.instanceType) to the + // DynamicOptionsWidget — the parent SchemaForm's uiSchema does not reach here. + const itemUiSchema = useMemo(() => addDynamicOptionWidgets(itemSchema), [itemSchema]) + // Create templates without submit button for nested forms const templatesWithoutSubmit = useMemo>(() => { return { @@ -79,6 +88,7 @@ export function AdditionalPropertiesField(props: FieldProps) { { - return { - apiVersion: "backups.cozystack.io/v1alpha1", - kind: "BackupClassList", - metadata: { resourceVersion: "1" }, - items, - } -} - -type ListResult = - | K8sList - | (() => K8sList | Promise>) - -function clientWith(result: ListResult) { - return createMockK8sClient({ - lists: [ - { apiGroup: "backups.cozystack.io", apiVersion: "v1alpha1", plural: "backupclasses", result }, - ], - }) -} - -const NEVER_RESOLVES = () => new Promise>(() => {}) - -function makeProps(overrides: Partial = {}): WidgetProps { - const base = { - id: "backupClassName", - name: "backupClassName", - label: "backupClassName", - value: undefined as unknown, - onChange: vi.fn(), - onBlur: vi.fn(), - onFocus: vi.fn(), - required: false, - disabled: false, - readonly: false, - autofocus: false, - placeholder: "", - options: {}, - schema: { type: "string" }, - uiSchema: {}, - formContext: {}, - rawErrors: [], - hideError: false, - multiple: false, - registry: {}, - } - return { ...base, ...overrides } as unknown as WidgetProps -} - -describe("BackupClassWidget", () => { - it("shows an explicit placeholder instead of the first class when required and nothing is chosen", async () => { - renderWithK8sProvider( - , - { client: clientWith(list(bc("s3"), bc("gcs"))) }, - ) - - await screen.findByRole("option", { name: /^s3$/i }) - - const select = screen.getByRole("combobox") as HTMLSelectElement - expect(select.value).toBe("") - expect(screen.getByRole("option", { name: /select a backup class/i })).toBeInTheDocument() - expect((screen.getByRole("option", { name: /^s3$/i }) as HTMLOptionElement).selected).toBe( - false, - ) - }) - - it("keeps a committed value visible while the list is still loading", () => { - renderWithK8sProvider( - , - { client: clientWith(NEVER_RESOLVES) }, - ) - - const select = screen.getByRole("combobox") as HTMLSelectElement - expect(select.value).toBe("custom-bc") - expect(screen.getByRole("option", { name: /custom-bc/i })).toBeInTheDocument() - }) - - it("emits undefined when an optional selection is cleared", async () => { - const user = userEvent.setup() - const onChange = vi.fn() - renderWithK8sProvider( - , - { client: clientWith(list(bc("s3"))) }, - ) - - await screen.findByRole("option", { name: /^s3$/i }) - await user.selectOptions(screen.getByRole("combobox"), "") - - await waitFor(() => expect(onChange).toHaveBeenLastCalledWith(undefined)) - }) -}) diff --git a/apps/console/src/components/BackupClassWidget.tsx b/apps/console/src/components/BackupClassWidget.tsx deleted file mode 100644 index 4b286bc..0000000 --- a/apps/console/src/components/BackupClassWidget.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import type { WidgetProps } from "@rjsf/utils" -import { useK8sList } from "@cozystack/k8s-client" - -interface BackupClass { - apiVersion: string - kind: string - metadata: { - name: string - } -} - -export function BackupClassWidget(props: WidgetProps) { - const { value, onChange, required, disabled, readonly } = props - - const { data: classList, isLoading } = useK8sList({ - apiGroup: "backups.cozystack.io", - apiVersion: "v1alpha1", - plural: "backupclasses", - }) - - const backupClasses = classList?.items || [] - const currentValue = typeof value === "string" ? value : "" - const hasCurrentInList = backupClasses.some((bc) => bc.metadata.name === currentValue) - - const placeholder = isLoading - ? "Loading..." - : backupClasses.length === 0 - ? "No backup classes available" - : required - ? "Select a backup class..." - : "-- None --" - - return ( - from losing the parent's selection on - async re-renders of useK8sList (loading → loaded → refetch). */} - {currentValue && !hasCurrentInList && ( - - )} - {backupClasses.map((bc) => ( - - ))} - - ) -} diff --git a/apps/console/src/components/DynamicOptionsWidget.test.tsx b/apps/console/src/components/DynamicOptionsWidget.test.tsx new file mode 100644 index 0000000..648928d --- /dev/null +++ b/apps/console/src/components/DynamicOptionsWidget.test.tsx @@ -0,0 +1,216 @@ +import { useState } from "react" +import { describe, it, expect, vi } from "vitest" +import { screen, fireEvent, waitFor } from "@testing-library/react" +import type { WidgetProps } from "@rjsf/utils" +import type { K8sList } from "@cozystack/k8s-client" +import { DynamicOptionsWidget } from "./DynamicOptionsWidget.tsx" +import { createMockK8sClient } from "../test-utils/mock-k8s-client.ts" +import { renderWithK8sProvider } from "../test-utils/render.tsx" + +vi.mock("../lib/tenant-context.tsx", () => ({ + useTenantContext: () => ({ + tenants: [], + selectedTenant: "root", + selectTenant: () => {}, + tenantNamespace: "tenant-root", + isLoading: false, + error: null, + }), +})) + +interface OptionItem { + value: string + label?: string + description?: string + default?: boolean +} + +function option(name: string, items: OptionItem[]) { + return { + apiVersion: "core.cozystack.io/v1alpha1", + kind: "Option", + metadata: { name }, + spec: { items }, + } +} + +function list(...options: ReturnType[]): K8sList { + return { + apiVersion: "core.cozystack.io/v1alpha1", + kind: "OptionList", + metadata: { resourceVersion: "1" }, + items: options, + } +} + +type ListResult = K8sList | (() => K8sList | Promise>) + +function clientWith(result: ListResult) { + return createMockK8sClient({ + lists: [ + { + apiGroup: "core.cozystack.io", + apiVersion: "v1alpha1", + plural: "options", + namespace: "tenant-root", + result, + }, + ], + }) +} + +const NEVER_RESOLVES = () => new Promise>(() => {}) + +function makeProps(overrides: Partial = {}, source = "storageclass"): WidgetProps { + const base = { + id: "field", + name: "field", + label: "field", + value: undefined as unknown, + onChange: vi.fn(), + onBlur: vi.fn(), + onFocus: vi.fn(), + required: false, + disabled: false, + readonly: false, + autofocus: false, + placeholder: "", + options: {}, + schema: { type: "string", "x-cozystack-options": { source } }, + uiSchema: {}, + formContext: {}, + rawErrors: [], + hideError: false, + multiple: false, + registry: {}, + } + return { ...base, ...overrides } as unknown as WidgetProps +} + +describe("DynamicOptionsWidget", () => { + it("auto-selects the server-marked default item exactly once when no value is set", async () => { + const onChange = vi.fn() + renderWithK8sProvider( + , + { client: clientWith(list(option("storageclass", [{ value: "fast" }, { value: "standard", default: true }]))) }, + ) + + await waitFor(() => expect(onChange).toHaveBeenCalledWith("standard")) + expect(onChange).toHaveBeenCalledTimes(1) + }) + + it("does not auto-commit when no item is marked default (preserves the VMDisk invariant)", async () => { + // The old VMDiskWidget deliberately never auto-selected the first disk — + // auto-committing dropped the user's choice on a fast submit. The generic + // widget only auto-selects when the server marks item.default, so a + // default-less source (e.g. vmdisk) must stay untouched on load. + const onChange = vi.fn() + renderWithK8sProvider( + , + { client: clientWith(list(option("vmdisk", [{ value: "disk-a" }, { value: "disk-b" }]))) }, + ) + + await screen.findByRole("option", { name: /disk-a/ }) + expect(onChange).not.toHaveBeenCalled() + }) + + it("lists the Option resource namespaced to the active tenant", async () => { + // The Option CRD is namespaced (tenant-scoped); querying it cluster-wide + // would return nothing. Pin the namespaced LIST. + const client = clientWith(list(option("storageclass", [{ value: "fast" }]))) + renderWithK8sProvider(, { client }) + + await screen.findByRole("option", { name: /fast/ }) + expect(client.list).toHaveBeenCalledWith( + "core.cozystack.io", + "v1alpha1", + "options", + "tenant-root", + expect.anything(), + ) + }) + + it("shows an explicit placeholder instead of the first option when required and empty", async () => { + renderWithK8sProvider( + // No default item, so the auto-default effect stays idle and the + // value-less required state is observable. + , + { client: clientWith(list(option("storageclass", [{ value: "fast" }, { value: "slow" }]))) }, + ) + + await screen.findByRole("option", { name: /^fast$/ }) + const select = screen.getByRole("combobox") as HTMLSelectElement + expect(select.value).toBe("") + expect(screen.getByRole("option", { name: /select an option/i })).toBeInTheDocument() + expect((screen.getByRole("option", { name: /^fast$/ }) as HTMLOptionElement).selected).toBe(false) + }) + + it("keeps a committed value visible while the list is still loading", () => { + renderWithK8sProvider( + , + { client: clientWith(NEVER_RESOLVES) }, + ) + + const select = screen.getByRole("combobox") as HTMLSelectElement + expect(select.value).toBe("custom-x") + expect(screen.getByRole("option", { name: /custom-x/ })).toBeInTheDocument() + }) + + it("lets the user clear an optional field that has a server default (no snap-back)", async () => { + // The auto-default must fire only once; clearing must stick rather than the + // effect immediately re-applying the default. Needs a stateful host so the + // cleared value actually flows back and could (wrongly) re-trigger the effect. + function Host() { + const [value, setValue] = useState(undefined) + return ( + void })} + /> + ) + } + renderWithK8sProvider(, { + client: clientWith( + list(option("storageclass", [{ value: "fast" }, { value: "standard", default: true }])), + ), + }) + + const select = (await screen.findByRole("combobox")) as HTMLSelectElement + await waitFor(() => expect(select.value).toBe("standard")) + + fireEvent.change(select, { target: { value: "" } }) + await waitFor(() => expect(select.value).toBe("")) + // The default must not re-apply after a deliberate clear. + expect(select.value).toBe("") + }) + + it("emits undefined (not an empty string) when an optional field is cleared", async () => { + const onChange = vi.fn() + renderWithK8sProvider( + // No default item, so clearing is not immediately re-applied. + , + { client: clientWith(list(option("storageclass", [{ value: "fast" }, { value: "slow" }]))) }, + ) + + await screen.findByRole("option", { name: /^fast$/ }) + fireEvent.change(screen.getByRole("combobox"), { target: { value: "" } }) + expect(onChange).toHaveBeenCalledWith(undefined) + }) + + it("renders labels and resolves items only from the Option whose name matches the source", async () => { + renderWithK8sProvider( + , + { + client: clientWith( + list( + option("storageclass", [{ value: "fast", label: "Fast SSD" }]), + option("backupclass", [{ value: "s3", label: "S3 bucket" }]), + ), + ), + }, + ) + + expect(await screen.findByRole("option", { name: "S3 bucket" })).toBeInTheDocument() + // The unrelated source's items must not leak in. + expect(screen.queryByRole("option", { name: "Fast SSD" })).not.toBeInTheDocument() + }) +}) diff --git a/apps/console/src/components/DynamicOptionsWidget.tsx b/apps/console/src/components/DynamicOptionsWidget.tsx new file mode 100644 index 0000000..ee5e1c5 --- /dev/null +++ b/apps/console/src/components/DynamicOptionsWidget.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef } from "react" +import type { WidgetProps } from "@rjsf/utils" +import { useK8sList } from "@cozystack/k8s-client" +import { useTenantContext } from "../lib/tenant-context.tsx" + +/** + * Generic dropdown widget driven by the `x-cozystack-options` schema keyword. + * + * A string field annotated with `{ "x-cozystack-options": { "source": "gpu" } }` + * is rendered as a onChange(e.target.value || undefined)} + disabled={disabled || readonly} + required={required} + className="w-full rounded-lg border border-slate-300 bg-white pl-3 pr-8 py-2 text-sm text-slate-900 outline-none focus:border-blue-400 focus:ring-1 focus:ring-blue-400 disabled:opacity-50 disabled:cursor-not-allowed" + > + {/* Explicit placeholder so a value-less required select shows it instead + of silently displaying the first option. */} + + {/* Keep the committed value visible even before the list loads, so an + async re-render never drops the parent's selection. */} + {currentValue && !hasCurrentInList && ( + + )} + {items.map((it) => ( + + ))} + + ) +} diff --git a/apps/console/src/components/SchemaForm.tsx b/apps/console/src/components/SchemaForm.tsx index 39f40a6..4ed465a 100644 --- a/apps/console/src/components/SchemaForm.tsx +++ b/apps/console/src/components/SchemaForm.tsx @@ -11,73 +11,12 @@ import { type ImmutablePath, } from "../lib/immutable-paths.ts" import { customTemplates, customWidgets } from "./rjsf-templates.tsx" +import { addDynamicOptionWidgets } from "../lib/dynamic-options.ts" import { AdditionalPropertiesField } from "./AdditionalPropertiesField.tsx" import { ResourceQuotasField } from "./ResourceQuotasField.tsx" import { SourceField } from "./SourceField.tsx" import "./schema-form.css" -/** - * Recursively find all storageClass fields in schema and add widget to uiSchema - */ -function addStorageClassWidgets(schema: RJSFSchema, uiSchema: UiSchema = {}): UiSchema { - if (!schema || typeof schema !== "object") return uiSchema - - const properties = (schema as any).properties - if (!properties || typeof properties !== "object") return uiSchema - - const result = { ...uiSchema } - - for (const [key, value] of Object.entries(properties)) { - if (key === "storageClass" && typeof value === "object" && (value as any).type === "string") { - // Found a storageClass field - add widget - result[key] = { - ...result[key], - "ui:widget": "StorageClassWidget", - } - } else if (typeof value === "object" && (value as any).properties) { - // Recursively process nested objects - result[key] = addStorageClassWidgets(value as RJSFSchema, result[key] as UiSchema) - } - } - - return result -} - -/** - * Recursively find all backupClassName fields in schema and add widget to uiSchema - */ -function addBackupClassWidgets(schema: RJSFSchema, uiSchema: UiSchema = {}): UiSchema { - if (!schema || typeof schema !== "object") return uiSchema - - const properties = (schema as any).properties - if (!properties || typeof properties !== "object") return uiSchema - - const result = { ...uiSchema } - - for (const [key, value] of Object.entries(properties)) { - if (key === "backupClassName" && typeof value === "object" && (value as any).type === "string") { - // Skip attaching the custom widget when an explicit enum is already - // present — the parent supplies the option list, RJSF's native - // SelectWidget handles binding correctly. Auto-attaching here would - // override the select with our BackupClassWidget whose internal - // useK8sList state can drop the user's selection on async re-renders. - if (Array.isArray((value as any).enum)) { - continue - } - // Found a backupClassName field - add widget - result[key] = { - ...result[key], - "ui:widget": "BackupClassWidget", - } - } else if (typeof value === "object" && (value as any).properties) { - // Recursively process nested objects - result[key] = addBackupClassWidgets(value as RJSFSchema, result[key] as UiSchema) - } - } - - return result -} - /** * Recursively find all fields with additionalProperties schema and add widget. * Walks nested objects AND array items, so a map nested inside array elements @@ -144,47 +83,6 @@ function bindAdditionalProperties( return uiNode } -/** - * Add VMDiskWidget to the "name" field inside "disks" array items - */ -function addVMDiskWidgets(schema: RJSFSchema, uiSchema: UiSchema = {}): UiSchema { - if (!schema || typeof schema !== "object") return uiSchema - - const properties = (schema as any).properties - if (!properties || typeof properties !== "object") return uiSchema - - const result = { ...uiSchema } - - for (const [key, value] of Object.entries(properties)) { - if (key === "disks" && typeof value === "object" && value !== null) { - const fieldSchema = value as any - // Check if this is an array of objects with a "name" property - if ( - fieldSchema.type === "array" && - fieldSchema.items?.type === "object" && - fieldSchema.items?.properties?.name - ) { - // Add VMDiskWidget to the "name" field inside array items - result[key] = { - ...result[key], - items: { - ...(result[key] as any)?.items, - name: { - ...((result[key] as any)?.items?.name || {}), - "ui:widget": "VMDiskWidget", - }, - }, - } - } - } else if (typeof value === "object" && (value as any).properties) { - // Recursively process nested objects - result[key] = addVMDiskWidgets(value as RJSFSchema, result[key] as UiSchema) - } - } - - return result -} - /** * Apply ui:disabled + ui:help to every path the schema declares immutable. * The disabled flag (not readonly) gives the grey-out treatment specified @@ -347,9 +245,9 @@ export const SchemaForm = forwardRef(function // Emit defaults to parent once per schema so spec is never empty on first submit. // Uses formDataRef (current parent state, not the initial mount snapshot) so // user input is preserved when the parent recomputes openAPISchema due to - // async sibling data (e.g. plansData/backupClassesData loading) — without - // this, getDefaultFormState would re-emit defaults computed from the stale - // initial formData and wipe whatever the user already typed. + // async sibling data (e.g. instancesData loading) — without this, + // getDefaultFormState would re-emit defaults computed from the stale initial + // formData and wipe whatever the user already typed. useEffect(() => { if (!schema || Object.keys(schema).length === 0) return if (emittedSchemaRef.current === schema) return @@ -373,17 +271,14 @@ export const SchemaForm = forwardRef(function }, } - // Automatically add StorageClassWidget for all storageClass fields - const withStorageClass = addStorageClassWidgets(schema, baseUiSchema) - - // Automatically add BackupClassWidget for all backupClassName fields - const withBackupClass = addBackupClassWidgets(schema, withStorageClass) - - // Automatically add VMDiskWidget for disks[].name field - const withVMDisk = addVMDiskWidgets(schema, withBackupClass) + // Bind every field carrying x-cozystack-options to the single generic + // DynamicOptionsWidget (GPU, instanceType, instanceProfile, network, + // image, storagePool, storageClass, vmdisk — populated from the cluster + // via the cozystack-api Option resource). + const withDynamicOptions = addDynamicOptionWidgets(schema, baseUiSchema) // Automatically add AdditionalPropertiesField for fields with additionalProperties schema - const withAdditionalProps = addAdditionalPropertiesWidgets(schema, withVMDisk) + const withAdditionalProps = addAdditionalPropertiesWidgets(schema, withDynamicOptions) // Mask credential-shaped string fields (access/secret keys, passwords, tokens). const withSensitive = addSensitiveStringWidgets(schema, withAdditionalProps) diff --git a/apps/console/src/components/StorageClassWidget.test.tsx b/apps/console/src/components/StorageClassWidget.test.tsx deleted file mode 100644 index 44c7e87..0000000 --- a/apps/console/src/components/StorageClassWidget.test.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { describe, it, expect, vi } from "vitest" -import { screen } from "@testing-library/react" -import type { WidgetProps } from "@rjsf/utils" -import type { K8sList } from "@cozystack/k8s-client" -import { StorageClassWidget } from "./StorageClassWidget.tsx" -import { createMockK8sClient } from "../test-utils/mock-k8s-client.ts" -import { renderWithK8sProvider } from "../test-utils/render.tsx" - -const DEFAULT_ANNOTATION = "storageclass.kubernetes.io/is-default-class" - -interface StorageClass { - apiVersion: string - kind: string - metadata: { name: string; annotations?: Record } - provisioner: string -} - -function sc(name: string, isDefault = false): StorageClass { - return { - apiVersion: "storage.k8s.io/v1", - kind: "StorageClass", - metadata: { - name, - annotations: isDefault ? { [DEFAULT_ANNOTATION]: "true" } : undefined, - }, - provisioner: "example.com/provisioner", - } -} - -function list(...items: StorageClass[]): K8sList { - return { - apiVersion: "storage.k8s.io/v1", - kind: "StorageClassList", - metadata: { resourceVersion: "1" }, - items, - } -} - -type ListResult = - | K8sList - | (() => K8sList | Promise>) - -function clientWith(result: ListResult) { - return createMockK8sClient({ - lists: [{ apiGroup: "storage.k8s.io", apiVersion: "v1", plural: "storageclasses", result }], - }) -} - -const NEVER_RESOLVES = () => new Promise>(() => {}) - -function makeProps(overrides: Partial = {}): WidgetProps { - const base = { - id: "storageClass", - name: "storageClass", - label: "storageClass", - value: undefined as unknown, - onChange: vi.fn(), - onBlur: vi.fn(), - onFocus: vi.fn(), - required: false, - disabled: false, - readonly: false, - autofocus: false, - placeholder: "", - options: {}, - schema: { type: "string" }, - uiSchema: {}, - formContext: {}, - rawErrors: [], - hideError: false, - multiple: false, - registry: {}, - } - return { ...base, ...overrides } as unknown as WidgetProps -} - -describe("StorageClassWidget", () => { - it("shows an explicit placeholder instead of the first class when required and nothing is chosen", async () => { - const onChange = vi.fn() - renderWithK8sProvider( - , - // No default-class annotation, so the auto-default effect stays idle and - // the value-less required state is observable. - { client: clientWith(list(sc("fast"), sc("slow"))) }, - ) - - await screen.findByRole("option", { name: /^fast$/i }) - - const select = screen.getByRole("combobox") as HTMLSelectElement - expect(select.value).toBe("") - expect(screen.getByRole("option", { name: /select a storage class/i })).toBeInTheDocument() - expect((screen.getByRole("option", { name: /^fast$/i }) as HTMLOptionElement).selected).toBe( - false, - ) - }) - - it("keeps a committed value visible while the list is still loading", () => { - renderWithK8sProvider( - , - { client: clientWith(NEVER_RESOLVES) }, - ) - - const select = screen.getByRole("combobox") as HTMLSelectElement - expect(select.value).toBe("custom-sc") - expect(screen.getByRole("option", { name: /custom-sc/i })).toBeInTheDocument() - }) - - it("still auto-selects the cluster-default class on load when no value is set", async () => { - const onChange = vi.fn() - renderWithK8sProvider( - , - { client: clientWith(list(sc("fast"), sc("standard", true))) }, - ) - - await screen.findByRole("option", { name: /standard/i }) - - expect(onChange).toHaveBeenCalledWith("standard") - }) -}) diff --git a/apps/console/src/components/StorageClassWidget.tsx b/apps/console/src/components/StorageClassWidget.tsx deleted file mode 100644 index 8a0a06e..0000000 --- a/apps/console/src/components/StorageClassWidget.tsx +++ /dev/null @@ -1,86 +0,0 @@ -import { useEffect, useRef } from "react" -import type { WidgetProps } from "@rjsf/utils" -import { useK8sList } from "@cozystack/k8s-client" - -interface StorageClass { - apiVersion: string - kind: string - metadata: { - name: string - annotations?: Record - } - provisioner: string -} - -const DEFAULT_CLASS_ANNOTATION = "storageclass.kubernetes.io/is-default-class" - -export function StorageClassWidget(props: WidgetProps) { - const { value, onChange, required, disabled, readonly } = props - - const { data: scList, isLoading } = useK8sList({ - apiGroup: "storage.k8s.io", - apiVersion: "v1", - plural: "storageclasses", - }) - - const storageClasses = scList?.items || [] - const currentValue = typeof value === "string" ? value : "" - const hasCurrentInList = storageClasses.some((sc) => sc.metadata.name === currentValue) - const defaultSC = storageClasses.find( - (sc) => sc.metadata.annotations?.[DEFAULT_CLASS_ANNOTATION] === "true" - ) - - // Auto-select the cluster default only on initial load, not after the user - // clears the field. Unlike "first disk", a default storage class is a real, - // meaningful default worth pre-filling; the explicit placeholder below still - // removes the visual lie when no default exists. - const hasAutoDefaulted = useRef(false) - useEffect(() => { - if (!hasAutoDefaulted.current && !value && defaultSC && !isLoading) { - hasAutoDefaulted.current = true - onChange(defaultSC.metadata.name) - } - }, [value, defaultSC, isLoading, onChange]) - - const placeholder = isLoading - ? "Loading..." - : storageClasses.length === 0 - ? "No storage classes available" - : required - ? "Select a storage class..." - : "-- None --" - - return ( - - ) -} diff --git a/apps/console/src/components/VMDiskWidget.test.tsx b/apps/console/src/components/VMDiskWidget.test.tsx deleted file mode 100644 index e5e89be..0000000 --- a/apps/console/src/components/VMDiskWidget.test.tsx +++ /dev/null @@ -1,158 +0,0 @@ -import { describe, it, expect, vi } from "vitest" -import { screen, waitFor } from "@testing-library/react" -import userEvent from "@testing-library/user-event" -import type { WidgetProps } from "@rjsf/utils" -import type { K8sList } from "@cozystack/k8s-client" -import { APPS_GROUP, APPS_VERSION } from "@cozystack/types" -import { VMDiskWidget } from "./VMDiskWidget.tsx" -import { createMockK8sClient } from "../test-utils/mock-k8s-client.ts" -import { renderWithK8sProvider } from "../test-utils/render.tsx" - -// The widget reads only `tenantNamespace`; stub the context so the test does -// not have to stand up a TenantProvider (which itself lists tenantnamespaces). -vi.mock("../lib/tenant-context.tsx", () => ({ - useTenantContext: () => ({ tenantNamespace: "tenant-test" }), -})) - -interface VMDisk { - apiVersion: string - kind: string - metadata: { name: string; namespace: string } - spec: { storage: string } -} - -function vmdisk(name: string, storage: string): VMDisk { - return { - apiVersion: `${APPS_GROUP}/${APPS_VERSION}`, - kind: "VMDisk", - metadata: { name, namespace: "tenant-test" }, - spec: { storage }, - } -} - -const TWO_DISKS: K8sList = { - apiVersion: `${APPS_GROUP}/${APPS_VERSION}`, - kind: "VMDiskList", - metadata: { resourceVersion: "1" }, - items: [vmdisk("demo-disk", "5Gi"), vmdisk("other-disk", "10Gi")], -} - -function makeProps(overrides: Partial = {}): WidgetProps { - const base = { - id: "disk-name", - name: "name", - label: "name", - value: undefined as unknown, - onChange: vi.fn(), - onBlur: vi.fn(), - onFocus: vi.fn(), - required: false, - disabled: false, - readonly: false, - autofocus: false, - placeholder: "", - options: {}, - schema: { type: "string" }, - uiSchema: {}, - formContext: {}, - rawErrors: [], - hideError: false, - multiple: false, - registry: {}, - } - return { ...base, ...overrides } as unknown as WidgetProps -} - -function clientWith(result: ListOverrideResult) { - return createMockK8sClient({ - lists: [ - { - apiGroup: APPS_GROUP, - apiVersion: APPS_VERSION, - plural: "vmdisks", - namespace: "tenant-test", - result, - }, - ], - }) -} - -type ListOverrideResult = - | K8sList - | (() => K8sList | Promise>) - -const NEVER_RESOLVES = () => new Promise>(() => {}) - -describe("VMDiskWidget", () => { - it("does not auto-commit a value once the disk list loads", async () => { - const onChange = vi.fn() - renderWithK8sProvider( - , - { client: clientWith(TWO_DISKS) }, - ) - - // Wait until the list has loaded (disk options rendered). - await screen.findByRole("option", { name: /demo-disk/i }) - - // The widget must never push a value on its own — that asynchronous - // commit is exactly the race that drops disks on a fast submit. - expect(onChange).not.toHaveBeenCalled() - }) - - it("shows an explicit placeholder instead of silently displaying the first disk when required", async () => { - renderWithK8sProvider( - , - { client: clientWith(TWO_DISKS) }, - ) - - await screen.findByRole("option", { name: /demo-disk/i }) - - const select = screen.getByRole("combobox") as HTMLSelectElement - expect(select.value).toBe("") - // The placeholder must exist and be selected; the first disk must not be - // presented as the current selection. - expect(screen.getByRole("option", { name: /select a disk/i })).toBeInTheDocument() - expect((screen.getByRole("option", { name: /demo-disk/i }) as HTMLOptionElement).selected).toBe( - false, - ) - }) - - it("keeps a committed value visible while the list is still loading", () => { - renderWithK8sProvider( - , - { client: clientWith(NEVER_RESOLVES) }, - ) - - const select = screen.getByRole("combobox") as HTMLSelectElement - expect(select.value).toBe("demo-disk") - expect(screen.getByRole("option", { name: /demo-disk/i })).toBeInTheDocument() - }) - - it("commits the disk name when the user picks an option", async () => { - const user = userEvent.setup() - const onChange = vi.fn() - renderWithK8sProvider( - , - { client: clientWith(TWO_DISKS) }, - ) - - await screen.findByRole("option", { name: /other-disk/i }) - await user.selectOptions(screen.getByRole("combobox"), "other-disk") - - expect(onChange).toHaveBeenCalledWith("other-disk") - }) - - it("emits undefined when the user clears an optional selection", async () => { - const user = userEvent.setup() - const onChange = vi.fn() - renderWithK8sProvider( - , - { client: clientWith(TWO_DISKS) }, - ) - - await screen.findByRole("option", { name: /demo-disk/i }) - await user.selectOptions(screen.getByRole("combobox"), "") - - await waitFor(() => expect(onChange).toHaveBeenLastCalledWith(undefined)) - }) -}) diff --git a/apps/console/src/components/VMDiskWidget.tsx b/apps/console/src/components/VMDiskWidget.tsx deleted file mode 100644 index b76ba03..0000000 --- a/apps/console/src/components/VMDiskWidget.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import type { WidgetProps } from "@rjsf/utils" -import { useK8sList } from "@cozystack/k8s-client" -import { APPS_GROUP, APPS_VERSION } from "@cozystack/types" -import { useTenantContext } from "../lib/tenant-context.tsx" - -interface VMDisk { - apiVersion: string - kind: string - metadata: { - name: string - namespace: string - } - spec: { - storage: string - storageClass?: string - } -} - -export function VMDiskWidget(props: WidgetProps) { - const { value, onChange, required, disabled, readonly } = props - const { tenantNamespace } = useTenantContext() - - const { data: diskList, isLoading } = useK8sList({ - apiGroup: APPS_GROUP, - apiVersion: APPS_VERSION, - plural: "vmdisks", - namespace: tenantNamespace ?? undefined, - }) - - const disks = diskList?.items || [] - const currentValue = typeof value === "string" ? value : "" - const hasCurrentInList = disks.some((d) => d.metadata.name === currentValue) - - const placeholder = isLoading - ? "Loading..." - : disks.length === 0 - ? "No disks available" - : required - ? "Select a disk..." - : "-- None --" - - return ( - - ) -} diff --git a/apps/console/src/components/rjsf-templates.tsx b/apps/console/src/components/rjsf-templates.tsx index aa852ba..61f919d 100644 --- a/apps/console/src/components/rjsf-templates.tsx +++ b/apps/console/src/components/rjsf-templates.tsx @@ -9,10 +9,8 @@ import type { } from "@rjsf/utils" import { CustomObjectFieldTemplate } from "./CustomObjectFieldTemplate.tsx" import { SourceWidget } from "./SourceWidget.tsx" -import { StorageClassWidget } from "./StorageClassWidget.tsx" +import { DynamicOptionsWidget } from "./DynamicOptionsWidget.tsx" import { AdditionalPropertiesWidget } from "./AdditionalPropertiesWidget.tsx" -import { VMDiskWidget } from "./VMDiskWidget.tsx" -import { BackupClassWidget } from "./BackupClassWidget.tsx" import { SensitiveStringWidget } from "./SensitiveStringWidget.tsx" function IconButton< @@ -91,9 +89,7 @@ export const customTemplates = { export const customWidgets = { SourceWidget: SourceWidget, - StorageClassWidget: StorageClassWidget, + DynamicOptionsWidget: DynamicOptionsWidget, AdditionalPropertiesWidget: AdditionalPropertiesWidget, - VMDiskWidget: VMDiskWidget, - BackupClassWidget: BackupClassWidget, SensitiveStringWidget: SensitiveStringWidget, } diff --git a/apps/console/src/hooks/useBackupClassAdminAccess.ts b/apps/console/src/hooks/useBackupClassAdminAccess.ts index 890fb5b..5d8ca7c 100644 --- a/apps/console/src/hooks/useBackupClassAdminAccess.ts +++ b/apps/console/src/hooks/useBackupClassAdminAccess.ts @@ -1,10 +1,10 @@ import { useSelfSubjectAccessReview } from "@cozystack/k8s-client" -// BackupClass is cluster-scoped. Tenants already hold get/list/watch on it -// (that's what powers the BackupClassWidget dropdown), so a read gate would -// not exclude them — only write does. Gating on `update` is what makes the -// Backup Classes area admin-only. Fail closed: loading and error states -// resolve as "not allowed" so the sidebar entry never flickers in then out. +// BackupClass is cluster-scoped, and tenants already hold get/list/watch on it, +// so a read gate would not exclude them — only write does. Gating on `update` +// is what makes the Backup Classes area admin-only. Fail closed: loading and +// error states resolve as "not allowed" so the sidebar entry never flickers in +// then out. export function useBackupClassAdminAccess(): { allowed: boolean; isLoading: boolean } { const review = useSelfSubjectAccessReview({ resourceAttributes: { diff --git a/apps/console/src/lib/dynamic-options.test.ts b/apps/console/src/lib/dynamic-options.test.ts new file mode 100644 index 0000000..a6014fc --- /dev/null +++ b/apps/console/src/lib/dynamic-options.test.ts @@ -0,0 +1,200 @@ +import { describe, it, expect } from "vitest" +import type { RJSFSchema, UiSchema } from "@rjsf/utils" +import { addDynamicOptionWidgets } from "./dynamic-options.ts" + +describe("addDynamicOptionWidgets", () => { + it("binds a top-level field carrying x-cozystack-options to the widget", () => { + const schema: RJSFSchema = { + type: "object", + properties: { + backupClassName: { + type: "string", + "x-cozystack-options": { source: "backupclass" }, + } as RJSFSchema, + unrelated: { type: "string" }, + }, + } + + const ui = addDynamicOptionWidgets(schema) + + expect(ui.backupClassName?.["ui:widget"]).toBe("DynamicOptionsWidget") + expect(ui.unrelated).toBeUndefined() + }) + + it("recurses into nested object properties (applicationRef.kind)", () => { + // Mirrors the backup CRD shape: applicationRef.kind -> appkind source, + // applicationRef.name left as plain string (served by client enumMap). + const schema: RJSFSchema = { + type: "object", + properties: { + applicationRef: { + type: "object", + properties: { + kind: { + type: "string", + "x-cozystack-options": { source: "appkind" }, + } as RJSFSchema, + name: { type: "string" }, + }, + }, + backupClassName: { + type: "string", + "x-cozystack-options": { source: "backupclass" }, + } as RJSFSchema, + }, + } + + const ui = addDynamicOptionWidgets(schema) + + expect(ui.applicationRef?.kind?.["ui:widget"]).toBe("DynamicOptionsWidget") + expect(ui.applicationRef?.name?.["ui:widget"]).toBeUndefined() + expect(ui.backupClassName?.["ui:widget"]).toBe("DynamicOptionsWidget") + }) + + it("recurses into a nested reference object (planRef.name)", () => { + const schema: RJSFSchema = { + type: "object", + properties: { + planRef: { + type: "object", + properties: { + name: { + type: "string", + "x-cozystack-options": { source: "plan" }, + } as RJSFSchema, + }, + }, + }, + } + + const ui = addDynamicOptionWidgets(schema) + + expect(ui.planRef?.name?.["ui:widget"]).toBe("DynamicOptionsWidget") + }) + + it("recurses into array items (vm-instance disks[].name)", () => { + const schema: RJSFSchema = { + type: "object", + properties: { + disks: { + type: "array", + items: { + type: "object", + properties: { + name: { + type: "string", + "x-cozystack-options": { source: "vmdisk" }, + } as RJSFSchema, + size: { type: "string" }, + }, + }, + } as RJSFSchema, + }, + } + + const ui = addDynamicOptionWidgets(schema) + + expect(ui.disks?.items?.name?.["ui:widget"]).toBe("DynamicOptionsWidget") + expect(ui.disks?.items?.size?.["ui:widget"]).toBeUndefined() + }) + + it("recurses into additionalProperties maps (kubernetes nodeGroups.*.instanceType)", () => { + const schema: RJSFSchema = { + type: "object", + properties: { + nodeGroups: { + type: "object", + additionalProperties: { + type: "object", + properties: { + instanceType: { + type: "string", + "x-cozystack-options": { source: "instancetype" }, + } as RJSFSchema, + }, + }, + } as RJSFSchema, + }, + } + + const ui = addDynamicOptionWidgets(schema) + + expect(ui.nodeGroups?.additionalProperties?.instanceType?.["ui:widget"]).toBe( + "DynamicOptionsWidget", + ) + }) + + it("pins the current 'oneOf/anyOf/allOf branches are not walked' limitation", () => { + // FIXME: extend the walker to recurse into oneOf/anyOf/allOf. Once that + // lands, flip this to expect the inner field to bind DynamicOptionsWidget. + // Matches the same intentional gap pinned in sensitive-fields.test.ts. + const schema: RJSFSchema = { + type: "object", + properties: { + source: { + oneOf: [ + { + type: "object", + properties: { + storageClass: { + type: "string", + "x-cozystack-options": { source: "storageclass" }, + } as RJSFSchema, + }, + }, + ], + }, + }, + } + + const ui = addDynamicOptionWidgets(schema) + + expect(ui.source).toBeUndefined() + }) + + it("does not mutate the input uiSchema (returns fresh objects at every level)", () => { + const schema: RJSFSchema = { + type: "object", + properties: { + applicationRef: { + type: "object", + properties: { + kind: { + type: "string", + "x-cozystack-options": { source: "appkind" }, + } as RJSFSchema, + }, + }, + }, + } + const input: UiSchema = { applicationRef: { kind: { "ui:title": "Kind" } } } + const snapshot = JSON.parse(JSON.stringify(input)) + + const out = addDynamicOptionWidgets(schema, input) + + // The input object graph is left byte-identical. + expect(input).toEqual(snapshot) + // The result and every touched sub-object are new references. + expect(out).not.toBe(input) + expect(out.applicationRef).not.toBe(input.applicationRef) + }) + + it("preserves an existing uiSchema while adding widgets", () => { + const schema: RJSFSchema = { + type: "object", + properties: { + backupClassName: { + type: "string", + "x-cozystack-options": { source: "backupclass" }, + } as RJSFSchema, + }, + } + + const ui = addDynamicOptionWidgets(schema, { + backupClassName: { "ui:title": "Backup Class" }, + }) + + expect(ui.backupClassName?.["ui:widget"]).toBe("DynamicOptionsWidget") + expect(ui.backupClassName?.["ui:title"]).toBe("Backup Class") + }) +}) diff --git a/apps/console/src/lib/dynamic-options.ts b/apps/console/src/lib/dynamic-options.ts new file mode 100644 index 0000000..f551ab9 --- /dev/null +++ b/apps/console/src/lib/dynamic-options.ts @@ -0,0 +1,49 @@ +import type { RJSFSchema, UiSchema } from "@rjsf/utils" + +/** + * Walk a (sanitised) JSON schema and build a uiSchema that binds every field + * carrying the `x-cozystack-options` vendor keyword to the generic + * DynamicOptionsWidget. Recurses into `properties`, array `items` and + * `additionalProperties` so nested fields (e.g. vm-instance `disks[].name`, + * kubernetes `nodeGroups.*.instanceType`) are covered too. + * + * Shared by SchemaForm (top-level form) and AdditionalPropertiesField (the + * custom map editor renders its own nested , so it must build the item + * uiSchema with this same helper or the nested dropdowns are lost). + */ +function buildUi(node: unknown, ui: Record = {}): Record { + if (!node || typeof node !== "object") return ui + const n = node as Record + const result = { ...ui } + + if (n["x-cozystack-options"]) { + result["ui:widget"] = "DynamicOptionsWidget" + } + + const props = n.properties + if (props && typeof props === "object") { + for (const [key, child] of Object.entries(props as Record)) { + const childUi = buildUi(child, result[key] as Record) + if (Object.keys(childUi).length > 0) result[key] = childUi + } + } + + if (n.items && typeof n.items === "object") { + const itemsUi = buildUi(n.items, result.items as Record) + if (Object.keys(itemsUi).length > 0) result.items = itemsUi + } + + if (n.additionalProperties && typeof n.additionalProperties === "object") { + const apUi = buildUi( + n.additionalProperties, + result.additionalProperties as Record, + ) + if (Object.keys(apUi).length > 0) result.additionalProperties = apUi + } + + return result +} + +export function addDynamicOptionWidgets(schema: RJSFSchema, uiSchema: UiSchema = {}): UiSchema { + return buildUi(schema, uiSchema as Record) as UiSchema +} diff --git a/apps/console/src/routes/BackupCreatePage.tsx b/apps/console/src/routes/BackupCreatePage.tsx index 5bb007b..0be40f0 100644 --- a/apps/console/src/routes/BackupCreatePage.tsx +++ b/apps/console/src/routes/BackupCreatePage.tsx @@ -47,15 +47,15 @@ export function BackupCreatePage() { if (!baseSchema) return null const base = JSON.parse(baseSchema) - const kinds: string[] = appDefs?.items.map(d => d.spec?.application.kind).filter((k): k is string => Boolean(k)) ?? [] const instances = instancesData?.items.map((inst: any) => inst.metadata.name) ?? [] const enumMap: Record = {} - // Add enum values for dropdowns - if (kinds.length > 0) { - enumMap["applicationRef.kind"] = kinds - } + // applicationRef.kind is a dynamic dropdown driven by the CRD's + // x-cozystack-options keyword (appkind source), rendered by + // DynamicOptionsWidget. applicationRef.name stays on the client-side enumMap + // because it depends on the chosen kind — a context the Option contract + // cannot express. if (selectedKind && instances.length > 0) { enumMap["applicationRef.name"] = instances } @@ -72,7 +72,7 @@ export function BackupCreatePage() { } return JSON.stringify(enriched) - }, [baseSchema, appDefs, instancesData, selectedKind]) + }, [baseSchema, instancesData, selectedKind]) const handleSubmit = async () => { if (!name.trim()) { diff --git a/apps/console/src/routes/BackupJobCreatePage.tsx b/apps/console/src/routes/BackupJobCreatePage.tsx index 1a18864..3d3b814 100644 --- a/apps/console/src/routes/BackupJobCreatePage.tsx +++ b/apps/console/src/routes/BackupJobCreatePage.tsx @@ -22,24 +22,16 @@ export function BackupJobCreatePage() { "backupjobs.backups.cozystack.io" ) - // Get BackupClasses (cluster-scoped) - const { data: backupClassesData } = useK8sList({ - apiGroup: "backups.cozystack.io", - apiVersion: "v1alpha1", - plural: "backupclasses", - }) - - // Get Plans in the tenant namespace (optional reference) - const { data: plansData } = useK8sList({ - apiGroup: "backups.cozystack.io", - apiVersion: "v1alpha1", - plural: "plans", - namespace: tenantNamespace ?? "", - }, { enabled: !!tenantNamespace }) - - // Resolve instances for the selected application kind. - // Mirrors BackupRestoreJobCreatePage: kind dropdown is gated to - // apps.cozystack.io (the only apiGroup ApplicationDefinitions cover). + // backupClassName, planRef.name and applicationRef.kind are dynamic dropdowns + // driven by the CRD's x-cozystack-options keyword (backupclass / plan / + // appkind sources), rendered by DynamicOptionsWidget. Only applicationRef.name + // stays on the client-side enumMap below, because it depends on the chosen + // kind — a context the Option contract cannot express. + + // Resolve instances for the selected application kind (used for the + // applicationRef.name enum below). The kind is an appkind dropdown regardless + // of apiGroup; only the name enum is gated to apps.cozystack.io — the one + // apiGroup ApplicationDefinitions cover. // Strict undefined check so an explicit empty string from the user means // "no group" — clearing the field opts out of the cozystack defaults. const selectedKind = formData?.applicationRef?.kind @@ -74,29 +66,14 @@ export function BackupJobCreatePage() { console.error("Failed to parse BackupJob schema:", e) return null } - const kinds: string[] = selectedApiGroup === "apps.cozystack.io" - ? appDefs?.items.map(d => d.spec?.application.kind).filter((k): k is string => Boolean(k)) ?? [] - : [] const instances = instancesData?.items.map((inst: any) => inst.metadata.name) ?? [] - const backupClasses = backupClassesData?.items.map((bc: any) => bc.metadata.name) ?? [] - const plans = plansData?.items.map((p: any) => p.metadata.name) ?? [] const enumMap: Record = {} - if (kinds.length > 0) { - enumMap["applicationRef.kind"] = kinds - } + // applicationRef.name depends on the selected kind, so it cannot be served + // by the Option contract — keep it on the client-side enumMap. if (selectedApiGroup === "apps.cozystack.io" && selectedKind && instances.length > 0) { enumMap["applicationRef.name"] = instances } - if (backupClasses.length > 0) { - enumMap["backupClassName"] = backupClasses - } - if (plans.length > 0) { - // planRef is optional in the CRD (default ""). Prepend an empty value - // so the dropdown opens with no plan selected — matches the CRD default - // and avoids accidentally pinning the BackupJob to the first listed Plan. - enumMap["planRef.name"] = ["", ...plans] - } const enriched = enrichSchemaWithEnums(base, [], enumMap) @@ -107,7 +84,7 @@ export function BackupJobCreatePage() { } return JSON.stringify(enriched) - }, [baseSchema, appDefs, backupClassesData, plansData, instancesData, selectedKind, selectedApiGroup]) + }, [baseSchema, instancesData, selectedKind, selectedApiGroup]) const handleSubmit = async () => { if (!tenantNamespace) { diff --git a/apps/console/src/routes/BackupPlanCreatePage.tsx b/apps/console/src/routes/BackupPlanCreatePage.tsx index 66ef234..ff6ac3d 100644 --- a/apps/console/src/routes/BackupPlanCreatePage.tsx +++ b/apps/console/src/routes/BackupPlanCreatePage.tsx @@ -22,12 +22,11 @@ export function BackupPlanCreatePage() { "plans.backups.cozystack.io" ) - // Get BackupClasses - const { data: backupClassesData } = useK8sList({ - apiGroup: "backups.cozystack.io", - apiVersion: "v1alpha1", - plural: "backupclasses", - }) + // backupClassName and applicationRef.kind are dynamic dropdowns driven by the + // CRD's x-cozystack-options keyword (backupclass / appkind sources), rendered + // by DynamicOptionsWidget. Only applicationRef.name stays on the client-side + // enumMap below, because it depends on the chosen kind — a context the Option + // contract cannot express. // Get instances for selected kind. // Strict undefined check so an explicit empty string from the user means @@ -64,27 +63,16 @@ export function BackupPlanCreatePage() { console.error("Failed to parse Plan schema:", e) return null } - // ApplicationDefinitions are exclusive to apps.cozystack.io — show the - // Kind dropdown only when the selected apiGroup matches; otherwise leave - // it as a free-text input (no enum hint). - const kinds: string[] = selectedApiGroup === "apps.cozystack.io" - ? appDefs?.items.map(d => d.spec?.application.kind).filter((k): k is string => Boolean(k)) ?? [] - : [] - const backupClasses = backupClassesData?.items.map((bc: any) => bc.metadata.name) ?? [] const instances = instancesData?.items.map((inst: any) => inst.metadata.name) ?? [] const enumMap: Record = {} - // Add enum values for dropdowns - if (kinds.length > 0) { - enumMap["applicationRef.kind"] = kinds - } + // applicationRef.name depends on the selected kind, so it cannot be served + // by the Option contract — keep it on the client-side enumMap. Only fill it + // when the cozystack apiGroup matches (ApplicationDefinitions cover it). if (selectedApiGroup === "apps.cozystack.io" && selectedKind && instances.length > 0) { enumMap["applicationRef.name"] = instances } - if (backupClasses.length > 0) { - enumMap["backupClassName"] = backupClasses - } // Enrich schema with enum values const enriched = enrichSchemaWithEnums(base, [], enumMap) @@ -101,7 +89,7 @@ export function BackupPlanCreatePage() { } return JSON.stringify(enriched) - }, [baseSchema, appDefs, backupClassesData, instancesData, selectedKind, selectedApiGroup]) + }, [baseSchema, instancesData, selectedKind, selectedApiGroup]) const handleSubmit = async () => { if (!tenantNamespace) { diff --git a/apps/console/src/routes/BackupRestoreJobCreatePage.tsx b/apps/console/src/routes/BackupRestoreJobCreatePage.tsx index eacc8ca..8bd2709 100644 --- a/apps/console/src/routes/BackupRestoreJobCreatePage.tsx +++ b/apps/console/src/routes/BackupRestoreJobCreatePage.tsx @@ -22,13 +22,11 @@ export function BackupRestoreJobCreatePage() { "restorejobs.backups.cozystack.io" ) - // Get Backups - const { data: backupsData } = useK8sList({ - apiGroup: "backups.cozystack.io", - apiVersion: "v1alpha1", - plural: "backups", - namespace: tenantNamespace ?? "", - }, { enabled: !!tenantNamespace }) + // backupRef.name and targetApplicationRef.kind are dynamic dropdowns driven by + // the CRD's x-cozystack-options keyword (backup / appkind sources), rendered + // by DynamicOptionsWidget. Only targetApplicationRef.name stays on the + // client-side enumMap below, because it depends on the chosen kind — a context + // the Option contract cannot express. // Get instances for selected target kind. // Strict undefined check so an explicit empty string from the user means @@ -65,28 +63,15 @@ export function BackupRestoreJobCreatePage() { console.error("Failed to parse RestoreJob schema:", e) return null } - const backups = backupsData?.items.map((b: any) => b.metadata.name) ?? [] - // ApplicationDefinitions live under apps.cozystack.io exclusively, so the - // Kind dropdown is populated only when the selected apiGroup matches. - // For any other apiGroup the user is on their own (no enum hint), which - // matches the free-text fallback behavior of plain CRD fields. - const kinds: string[] = selectedApiGroup === "apps.cozystack.io" - ? appDefs?.items.map(d => d.spec?.application.kind).filter((k): k is string => Boolean(k)) ?? [] - : [] const instances = instancesData?.items.map((inst: any) => inst.metadata.name) ?? [] const enumMap: Record = {} - // Add enum values for dropdowns - if (backups.length > 0) { - enumMap["backupRef.name"] = backups - } - if (kinds.length > 0) { - enumMap["targetApplicationRef.kind"] = kinds - } - // Add instances enum only after kind is selected and apiGroup matches - // (ApplicationDefinitions cover apps.cozystack.io only — for any other - // apiGroup the user is on free-text fallback). + // targetApplicationRef.name depends on the selected kind, so it cannot be + // served by the Option contract — keep it on the client-side enumMap. Only + // fill it when the cozystack apiGroup matches (ApplicationDefinitions cover + // apps.cozystack.io only — for any other apiGroup the name field has no enum + // and stays free-text). The kind itself is always an appkind dropdown. if (selectedApiGroup === "apps.cozystack.io" && selectedKind && instances.length > 0) { enumMap["targetApplicationRef.name"] = instances } @@ -100,9 +85,9 @@ export function BackupRestoreJobCreatePage() { } // The CRD ships backupRef.name with `default: ""` (k8s LocalObjectReference - // convention). Combined with an enum injected here, RJSF's SelectWidget - // can lose the user's selection on re-render — strip the default so the - // widget starts empty and the chosen value is the single source of truth. + // convention). Strip it so the DynamicOptionsWidget starts from a clean + // empty value and the chosen value is the single source of truth, rather + // than RJSF re-applying the empty default on re-render. if (enriched.properties?.backupRef?.properties?.name?.default !== undefined) { delete enriched.properties.backupRef.properties.name.default } @@ -120,7 +105,7 @@ export function BackupRestoreJobCreatePage() { } return JSON.stringify(enriched) - }, [baseSchema, backupsData, appDefs, instancesData, selectedKind, selectedApiGroup]) + }, [baseSchema, instancesData, selectedKind, selectedApiGroup]) const handleSubmit = async () => { if (!tenantNamespace) {