Skip to content

refactor(forms): lift VM disk lookup into SchemaForm + form-validation fixes#26

Open
Aleksei Sviridkin (lexfrei) wants to merge 3 commits into
mainfrom
refactor/schemaform-vmdisk-lookup
Open

refactor(forms): lift VM disk lookup into SchemaForm + form-validation fixes#26
Aleksei Sviridkin (lexfrei) wants to merge 3 commits into
mainfrom
refactor/schemaform-vmdisk-lookup

Conversation

@lexfrei
Copy link
Copy Markdown
Contributor

What

Follow-up to the VM-disk-selection fix. Adopts the cleaner data-fetching architecture for the disk dropdown and addresses the form-validation review feedback that surfaced on that change.

Changes

  • Lift the VM disk lookup into SchemaForm. VMDiskWidget no longer calls useK8sList/useTenantContext itself. A gated SchemaForm subcomponent fetches vmdisks once (one-shot, no watch) only when the schema declares a disks[] array, and hands the list to the widget through RJSF formContext. The widget is now a pure controlled <select> that needs no providers, and schemas without disks never touch the K8s hook path. Adds a non-throwing useOptionalTenantContext.
  • SchemaForm.validate() fails closed when the inner RJSF form ref is unbound — it gates submit, so a missing ref should block rather than pass.
  • StorageClassWidget: keep an optional storage class empty after the user clears it. The widget reset its auto-default guard on clear, so the cluster default was immediately reapplied and an optional storageClass could not be left empty. The auto-default now fires only once, before the user touches the field.
  • Run validate() before the page-level alerts on the backup create pages, so a schema-required field renders RJSF inline errors instead of being masked by a manual alert.

Testing

  • VMDiskWidget tests now drive options via formContext (no K8s provider needed); a new SchemaForm test exercises the gated disk fetch end-to-end.
  • New StorageClassWidget test pins that a cleared optional field stays empty.
  • pnpm typecheck, pnpm test (250 tests), and pnpm build pass.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 1, 2026

Warning

Review limit reached

@lexfrei, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 42 minutes and 30 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6221b9f9-2b2c-42a9-af37-b4190c28a502

📥 Commits

Reviewing files that changed from the base of the PR and between 9c95976 and 3b38d70.

📒 Files selected for processing (16)
  • apps/console/src/components/SchemaForm.failclosed.test.tsx
  • apps/console/src/components/SchemaForm.test.tsx
  • apps/console/src/components/SchemaForm.tsx
  • apps/console/src/components/StorageClassWidget.test.tsx
  • apps/console/src/components/StorageClassWidget.tsx
  • apps/console/src/components/VMDiskWidget.test.tsx
  • apps/console/src/components/VMDiskWidget.tsx
  • apps/console/src/lib/tenant-context.tsx
  • apps/console/src/routes/BackupCreatePage.test.tsx
  • apps/console/src/routes/BackupCreatePage.tsx
  • apps/console/src/routes/BackupJobCreatePage.test.tsx
  • apps/console/src/routes/BackupJobCreatePage.tsx
  • apps/console/src/routes/BackupPlanCreatePage.test.tsx
  • apps/console/src/routes/BackupPlanCreatePage.tsx
  • apps/console/src/routes/BackupRestoreJobCreatePage.test.tsx
  • apps/console/src/routes/BackupRestoreJobCreatePage.tsx
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch refactor/schemaform-vmdisk-lookup

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions Bot added size/L This PR changes 100-499 lines, ignoring generated files area/forms Issues or PRs related to RJSF schema forms and widgets (backup, external-ips, storage-class, etc.) kind/cleanup Categorizes issue or PR as related to cleanup of code, process, or technical debt labels Jun 1, 2026
Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request refactors SchemaForm to conditionally fetch VMDisks only when a disks array is present in the schema, passing the options down to VMDiskWidget via formContext. It also updates StorageClassWidget to prevent auto-defaulting from re-triggering after a user clears the field, introduces an opportunistic useOptionalTenantContext hook, and runs form validation earlier in backup creation pages. The review feedback suggests enhancing schemaHasDiskField to recursively traverse array items for nested disks fields, and initializing hasAutoDefaulted in StorageClassWidget based on the initial value on mount to better support pre-existing values.

Comment on lines +289 to +300
function schemaHasDiskField(schema: unknown): boolean {
if (!schema || typeof schema !== "object") return false
const properties = (schema as { properties?: Record<string, unknown> }).properties
if (!properties || typeof properties !== "object") return false
for (const [key, value] of Object.entries(properties)) {
if (key === "disks" && isDiskArraySchema(value)) return true
if (value && typeof value === "object" && (value as { properties?: unknown }).properties) {
if (schemaHasDiskField(value)) return true
}
}
return false
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

The current implementation of schemaHasDiskField only traverses nested objects if they have a properties field. It does not traverse into array items (which contain object schemas with their own properties). If a schema defines a disks field nested inside an array of objects, this function will fail to detect it.

We can make the traversal more robust by checking both properties and items recursively.

function schemaHasDiskField(schema: unknown): boolean {
  if (!schema || typeof schema !== "object") return false

  const items = (schema as { items?: unknown }).items
  if (items && typeof items === "object") {
    if (schemaHasDiskField(items)) return true
  }

  const properties = (schema as { properties?: Record<string, unknown> }).properties
  if (!properties || typeof properties !== "object") return false

  for (const [key, value] of Object.entries(properties)) {
    if (key === "disks" && isDiskArraySchema(value)) return true
    if (value && typeof value === "object") {
      if (schemaHasDiskField(value)) return true
    }
  }
  return false
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks — this traversal is intentionally identical to the widget binder addVMDiskWidgets, which also recurses only through properties. schemaHasDiskField is the fetch gate and has to match the binder exactly; recursing the gate into array items without doing the same in the binder would reintroduce the gate/binder divergence this PR closes (disks detected for fetch but never bound to VMDiskWidget, or vice-versa). There's a test pinning that parity ("gate matches the widget binder"). No shipped VM openAPISchema nests disks inside an array of objects — it lives at a fixed top-level disks[] path — so the deeper recursion isn't needed today. If a chart ever requires it, I'd add items recursion to the gate and the binder together so they stay in lockstep.

Comment on lines 56 to 62
onChange={(e) => {
if (!e.target.value) hasAutoDefaulted.current = false
// Any user interaction (including clearing) permanently disables the
// auto-default, so an optional field the user clears is not immediately
// refilled with the cluster default on the next render.
hasAutoDefaulted.current = true
onChange(e.target.value || undefined)
}}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

While setting hasAutoDefaulted.current = true on user interaction prevents the auto-default from re-triggering when the user clears the field, it does not handle cases where the field is cleared programmatically or if the component is remounted with a pre-existing value.

To make this more robust, consider initializing hasAutoDefaulted based on whether a value is already present on mount (e.g., const hasAutoDefaulted = useRef(value !== undefined && value !== "") on line 37). This ensures that pre-existing values (including explicitly saved empty values in edit flows) are respected and not overwritten by the cluster default.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Both cases are already handled. A remount with a pre-existing value doesn't auto-default: the effect is guarded by !value, so it never fires when a value is present (covered by the test "does not snap back to the default after clearing a value loaded from formData"). In the controlled RJSF form the value only ever changes through this widget's own onChange, which sets hasAutoDefaulted.current = true, so there's no programmatic-clear path that bypasses the guard. Also, useRef(value !== undefined && value !== "") evaluates to false for an explicitly-empty "", so it wouldn't preserve a saved-empty value the way the suggestion intends. Keeping the current logic.

@lexfrei Aleksei Sviridkin (lexfrei) force-pushed the refactor/schemaform-vmdisk-lookup branch from 69c48cf to 622e603 Compare June 1, 2026 17:29
@github-actions github-actions Bot added size/XL This PR changes 500-999 lines, ignoring generated files and removed size/L This PR changes 100-499 lines, ignoring generated files labels Jun 1, 2026
@lexfrei Aleksei Sviridkin (lexfrei) force-pushed the refactor/schemaform-vmdisk-lookup branch 2 times, most recently from 4e8da2a to 0ab84bc Compare June 1, 2026 17:59
VMDiskWidget called useK8sList and useTenantContext itself, so the disk
lookup ran per field and the widget could not render without K8s/tenant
providers. Move the lookup into a gated SchemaForm subcomponent that
fetches vmdisks once (one-shot, no watch) only when the schema declares a
disks[] array, and hand the list to the widget through RJSF formContext.

The widget is now a pure controlled <select> that needs no providers, and
schemas without disks never touch the K8s hook path. The validate() handle
also fails closed now when the inner form ref is unbound.

Add useOptionalTenantContext (non-throwing) for the gated lookup.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
…s it

The widget reset the auto-default guard whenever the field was cleared, so
on the next render the effect re-applied the cluster-default storage class
— an optional storageClass could not be left empty and a value the user
removed could be silently resubmitted.

Disable the auto-default on any user interaction instead, so the default
is only ever applied once, before the user touches the field.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
The backup create pages ran SchemaForm.validate() only after their manual
required-field alerts, so a schema-required field left empty exited via an
alert without RJSF ever rendering its inline errors. Run validate() right
after the name check, before the page-level alerts, so inline errors show
consistently and the alerts only cover page-only requirements.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <f@lex.la>
@lexfrei Aleksei Sviridkin (lexfrei) force-pushed the refactor/schemaform-vmdisk-lookup branch from 0ab84bc to 3b38d70 Compare June 1, 2026 18:05
@lexfrei Aleksei Sviridkin (lexfrei) marked this pull request as ready for review June 1, 2026 18:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/forms Issues or PRs related to RJSF schema forms and widgets (backup, external-ips, storage-class, etc.) kind/cleanup Categorizes issue or PR as related to cleanup of code, process, or technical debt size/XL This PR changes 500-999 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant