Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 110 additions & 0 deletions apps/console/src/components/BackupClassWidget.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
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 { BackupClassWidget } from "./BackupClassWidget.tsx"
import { createMockK8sClient } from "../test-utils/mock-k8s-client.ts"
import { renderWithK8sProvider } from "../test-utils/render.tsx"

interface BackupClass {
apiVersion: string
kind: string
metadata: { name: string }
}

function bc(name: string): BackupClass {
return { apiVersion: "backups.cozystack.io/v1alpha1", kind: "BackupClass", metadata: { name } }
}

function list(...items: BackupClass[]): K8sList<BackupClass> {
return {
apiVersion: "backups.cozystack.io/v1alpha1",
kind: "BackupClassList",
metadata: { resourceVersion: "1" },
items,
}
}

type ListResult =
| K8sList<BackupClass>
| (() => K8sList<BackupClass> | Promise<K8sList<BackupClass>>)

function clientWith(result: ListResult) {
return createMockK8sClient({
lists: [
{ apiGroup: "backups.cozystack.io", apiVersion: "v1alpha1", plural: "backupclasses", result },
],
})
}

const NEVER_RESOLVES = () => new Promise<K8sList<BackupClass>>(() => {})

function makeProps(overrides: Partial<WidgetProps> = {}): 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(
<BackupClassWidget {...makeProps({ required: true })} />,
{ 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(
<BackupClassWidget {...makeProps({ required: true, value: "custom-bc" })} />,
{ 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(
<BackupClassWidget {...makeProps({ required: false, value: "s3", onChange })} />,
{ client: clientWith(list(bc("s3"))) },
)

await screen.findByRole("option", { name: /^s3$/i })
await user.selectOptions(screen.getByRole("combobox"), "")

await waitFor(() => expect(onChange).toHaveBeenLastCalledWith(undefined))
})
})
20 changes: 14 additions & 6 deletions apps/console/src/components/BackupClassWidget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,14 @@ export function BackupClassWidget(props: WidgetProps) {
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 (
<select
value={currentValue}
Expand All @@ -30,7 +38,12 @@ export function BackupClassWidget(props: WidgetProps) {
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"
>
{!required && <option value="">-- None --</option>}
{/* Always render an explicit placeholder so a value-less required select
shows it instead of silently displaying the first class. Disabled when
required so the empty state can be displayed but never picked. */}
<option value="" disabled={required}>
{placeholder}
</option>
{/* Render the parent's value as a stable option even when the list is
still loading or the value isn't present in the loaded results. This
keeps the controlled <select> from losing the parent's selection on
Expand All @@ -43,11 +56,6 @@ export function BackupClassWidget(props: WidgetProps) {
{bc.metadata.name}
</option>
))}
{!isLoading && backupClasses.length === 0 && !currentValue && (
<option value="" disabled>
No backup classes available
</option>
)}
</select>
)
}
44 changes: 42 additions & 2 deletions apps/console/src/components/SchemaForm.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createRef } from "react"
import { describe, it, expect } from "vitest"
import { render, screen } from "@testing-library/react"
import { SchemaForm } from "./SchemaForm.tsx"
import { render, screen, act } from "@testing-library/react"
import { SchemaForm, type SchemaFormHandle } from "./SchemaForm.tsx"
import { IMMUTABLE_HELP_TEXT } from "../lib/immutable-paths.ts"

const schema = JSON.stringify({
Expand Down Expand Up @@ -138,3 +139,42 @@ describe("SchemaForm immutableMode", () => {
expect(screen.getByText(IMMUTABLE_HELP_TEXT)).toBeInTheDocument()
})
})

describe("SchemaForm validate handle", () => {
const requiredSchema = JSON.stringify({
type: "object",
required: ["name"],
properties: { name: { type: "string" }, note: { type: "string" } },
})

function validate(ref: React.RefObject<SchemaFormHandle | null>): boolean | undefined {
let result: boolean | undefined
act(() => {
result = ref.current?.validate()
})
return result
}

it("reports invalid through the ref when a required field is missing", () => {
const ref = createRef<SchemaFormHandle>()
render(
<SchemaForm ref={ref} openAPISchema={requiredSchema} formData={{}} onChange={noop} />,
)

expect(validate(ref)).toBe(false)
})

it("reports valid through the ref once the required field is populated", () => {
const ref = createRef<SchemaFormHandle>()
render(
<SchemaForm
ref={ref}
openAPISchema={requiredSchema}
formData={{ name: "demo-disk" }}
onChange={noop}
/>,
)

expect(validate(ref)).toBe(true)
})
})
26 changes: 22 additions & 4 deletions apps/console/src/components/SchemaForm.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useMemo, useEffect, useRef } from "react"
import { useMemo, useEffect, useRef, forwardRef, useImperativeHandle } from "react"
import Form from "@rjsf/core"
import validator from "@rjsf/validator-ajv8"
import { getDefaultFormState } from "@rjsf/utils"
Expand Down Expand Up @@ -267,14 +267,31 @@ interface SchemaFormProps {
immutableMode?: "enforce" | "off"
}

export function SchemaForm({
export interface SchemaFormHandle {
/**
* Run RJSF validation against the current form data and render any errors
* inline. Returns whether the form is valid. The Deploy button lives
* outside this component and bypasses RJSF's own submit, so callers gate
* their submit on this before sending the resource to the API.
*/
validate: () => boolean
}

export const SchemaForm = forwardRef<SchemaFormHandle, SchemaFormProps>(function SchemaForm({
openAPISchema,
keysOrder,
formData,
onChange,
children,
immutableMode,
}: SchemaFormProps) {
}: SchemaFormProps, ref) {
const formRef = useRef<Form>(null)
useImperativeHandle(
ref,
() => ({ validate: () => formRef.current?.validateForm() ?? true }),
[],
Comment on lines +289 to +292

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Make validate() fail closed when the RJSF ref is missing.

This currently returns true on formRef.current === null, which means every caller will treat the form as valid and still submit if the inner ref ever fails to bind. Since this handle is now the submit gate, the safer fallback is false.

Suggested fix
   useImperativeHandle(
     ref,
-    () => ({ validate: () => formRef.current?.validateForm() ?? true }),
+    () => ({ validate: () => formRef.current?.validateForm() ?? false }),
     [],
   )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/console/src/components/SchemaForm.tsx` around lines 289 - 292, The
useImperativeHandle implementation for validate currently returns true when
formRef.current is null; update the handler in useImperativeHandle so that
validate calls formRef.current?.validateForm() and, if the inner ref is missing,
returns false (fail closed) instead of true; locate the useImperativeHandle
invocation that references formRef and the validate method and change the
fallback from true to false so callers treat a missing RJSF ref as invalid.

)

// Parse the raw schema once, then derive the sanitised RJSFSchema and the
// immutable-path set from it. We keep the raw object around so we don't
// re-parse the same string twice on every render. The contract is "same
Expand Down Expand Up @@ -380,6 +397,7 @@ export function SchemaForm({
return (
<div className="rjsf-container">
<Form
ref={formRef}
tagName="div"
schema={schema}
uiSchema={uiSchema}
Expand All @@ -396,4 +414,4 @@ export function SchemaForm({
</Form>
</div>
)
}
})
119 changes: 119 additions & 0 deletions apps/console/src/components/StorageClassWidget.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
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<string, string> }
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<StorageClass> {
return {
apiVersion: "storage.k8s.io/v1",
kind: "StorageClassList",
metadata: { resourceVersion: "1" },
items,
}
}

type ListResult =
| K8sList<StorageClass>
| (() => K8sList<StorageClass> | Promise<K8sList<StorageClass>>)

function clientWith(result: ListResult) {
return createMockK8sClient({
lists: [{ apiGroup: "storage.k8s.io", apiVersion: "v1", plural: "storageclasses", result }],
})
}

const NEVER_RESOLVES = () => new Promise<K8sList<StorageClass>>(() => {})

function makeProps(overrides: Partial<WidgetProps> = {}): 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(
<StorageClassWidget {...makeProps({ required: true, onChange })} />,
// 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(
<StorageClassWidget {...makeProps({ required: true, value: "custom-sc" })} />,
{ 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(
<StorageClassWidget {...makeProps({ onChange })} />,
{ client: clientWith(list(sc("fast"), sc("standard", true))) },
)

await screen.findByRole("option", { name: /standard/i })

expect(onChange).toHaveBeenCalledWith("standard")
})
})
Loading
Loading