feat(console): dynamic form dropdowns via x-cozystack-options (DynamicOptionsWidget)#30
feat(console): dynamic form dropdowns via x-cozystack-options (DynamicOptionsWidget)#30Andrei Kvapil (kvaps) wants to merge 2 commits into
Conversation
Add a single generic DynamicOptionsWidget that renders any string field carrying the x-cozystack-options schema keyword as a <select>, populated from the cozystack-api Option resource (one source per dropdown). A shared addDynamicOptionWidgets walker binds the widget across nested properties, array items and additionalProperties (so vm-instance disks[].name and kubernetes nodeGroups.* are covered); the AdditionalPropertiesField passes it through to its nested forms. Replaces the bespoke StorageClassWidget, VMDiskWidget and BackupClassWidget (default-StorageClass preselect and disk-size labels are preserved via the server-provided item.default flag and labels), so every form dropdown now uses one mechanism. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
The standalone backup-resource create pages (Plan, BackupJob, Backup, RestoreJob) maintained a parallel client-side dropdown mechanism: they fetched BackupClasses / Plans / Backups / application kinds and injected them into the CRD schema as static enum arrays via enumMap. The CRD schemas now carry the x-cozystack-options keyword on these fields, so DynamicOptionsWidget renders them automatically from the cozystack-api Option resource. Drop the now-redundant enumMap entries and their backing useK8sList fetches for backupClassName, planRef.name, backupRef.name and applicationRef.kind / targetApplicationRef.kind. Keep applicationRef.name and targetApplicationRef.name on enumMap: they depend on the sibling kind field, a context the Option contract cannot express. Add unit coverage for the addDynamicOptionWidgets walker over the nested backup CRD shape. Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrei Kvapil <andrei.kvapil@aenix.io>
|
Warning Review limit reached
More reviews will be available in 51 minutes and 50 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 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 configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (17)
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Code Review
This pull request consolidates several field-specific widgets (such as StorageClassWidget, VMDiskWidget, and BackupClassWidget) into a single, generic DynamicOptionsWidget driven by the x-cozystack-options schema keyword. It introduces utility functions to recursively apply this widget across forms and simplifies multiple creation pages by offloading option resolution to the cluster. The review feedback identifies a logic inversion bug in the auto-defaulting behavior of DynamicOptionsWidget that prevents users from clearing the field, suggests adding a free-text fallback input when no options are available, and recommends sanitizing the ui parameter in buildUi for improved robustness.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| return ( | ||
| <select | ||
| value={currentValue} | ||
| onChange={(e) => { | ||
| if (!e.target.value) hasAutoDefaulted.current = false | ||
| 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. */} | ||
| <option value="" disabled={required}> | ||
| {placeholder} | ||
| </option> | ||
| {/* Keep the committed value visible even before the list loads, so an | ||
| async re-render never drops the parent's selection. */} | ||
| {currentValue && !hasCurrentInList && ( | ||
| <option value={currentValue}>{currentValue}</option> | ||
| )} | ||
| {items.map((it) => ( | ||
| <option key={it.value} value={it.value} title={it.description}> | ||
| {it.label || it.value} | ||
| </option> | ||
| ))} | ||
| </select> | ||
| ) |
There was a problem hiding this comment.
This change addresses two key issues in the dropdown widget:
- Logic Inversion Bug in Auto-Defaulting: The current implementation resets
hasAutoDefaulted.current = falsewhen the user clears the field (i.e., when!e.target.valueis true). This causes theuseEffectto immediately re-trigger and re-apply the default value, making it impossible for the user to deliberately clear the field or select-- None --. Removing this reset ensures the default is only applied once on initial load. - Missing Free-Text Fallback: The PR description mentions a "free-text fallback when a source is unavailable". However, the widget currently only renders a
<select>element. If theOptionresource is missing or empty, the user is left with an empty dropdown and cannot proceed. Falling back to a standard<input type="text">when loading is complete anditemsis empty solves this issue.
if (!isLoading && items.length === 0) {
return (
<input
type="text"
value={currentValue}
onChange={(e) => onChange(e.target.value || undefined)}
disabled={disabled || readonly}
required={required}
placeholder={placeholder}
className="w-full rounded-lg border border-slate-300 bg-white px-3 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"
/>
)
}
return (
<select
value={currentValue}
onChange={(e) => 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. */}
<option value="" disabled={required}>
{placeholder}
</option>
{/* Keep the committed value visible even before the list loads, so an
async re-render never drops the parent's selection. */}
{currentValue && !hasCurrentInList && (
<option value={currentValue}>{currentValue}</option>
)}
{items.map((it) => (
<option key={it.value} value={it.value} title={it.description}>
{it.label || it.value}
</option>
))}
</select>
)
| function buildUi(node: unknown, ui: Record<string, unknown> = {}): Record<string, unknown> { | ||
| if (!node || typeof node !== "object") return ui | ||
| const n = node as Record<string, unknown> | ||
| const result = { ...ui } |
There was a problem hiding this comment.
To enforce defensive programming and ensure robustness, we should sanitize the ui parameter before spreading it. If ui is passed as a non-object (such as a string or null), { ...ui } can lead to unexpected behavior or runtime errors. Ensuring safeUi is a plain object prevents these issues.
| function buildUi(node: unknown, ui: Record<string, unknown> = {}): Record<string, unknown> { | |
| if (!node || typeof node !== "object") return ui | |
| const n = node as Record<string, unknown> | |
| const result = { ...ui } | |
| function buildUi(node: unknown, ui: unknown = {}): Record<string, unknown> { | |
| const safeUi = ui && typeof ui === "object" && !Array.isArray(ui) ? (ui as Record<string, unknown>) : {} | |
| if (!node || typeof node !== "object") return safeUi | |
| const n = node as Record<string, unknown> | |
| const result = { ...safeUi } |
What
DynamicOptionsWidget+ aSchemaFormwalker (lib/dynamic-options.ts) that turns any schema field carryingx-cozystack-optionsinto a dropdown populated at runtime from theOptionresource (core.cozystack.io) in the current namespace, with free-text fallback when a source is unavailable.StorageClassWidget/VMDiskWidget/BackupClassWidgetwith the single generic mechanism.x-cozystack-options.Depends on
x-cozystack-optionsschemas).Screenshots
To add: dropdowns on VM/Kubernetes/VMDisk/Bucket/Backup create forms.