Forms Guide
Waldur HomePort exclusively uses React Final Form for standard forms and dialogs, and a custom VStepperForm component for complex multi-step wizards.
Form Patterns Comparison
| Aspect | React Final Form (Standard) | VStepperForm (Multi-step) |
|---|---|---|
| State Storage | Local component state | Shared across steps |
| Performance | Optimized subscription model | Step-based validation |
| Complexity | Minimal boilerplate | Step progression |
| Persistence | Local to component lifecycle | Visual progress indicators |
| Integration | Isolated, no external dependencies | Complex deployments |
Implementation Examples
React Final Form
The standard form implementation uses a <Form> component that provides the handleSubmit method to its render prop.
1 2 3 4 5 6 7 8 9 10 11 12 13 | |
Autonomous Field Group Architecture (*Group Pattern)
To reduce boilerplate, improve type safety, and enforce UI consistency, Waldur uses the Autonomous Field Group pattern. A single base input (e.g. StringField) is wrapped by three sibling HOCs depending on context — see the Edit Field Architecture section below for the full cross-reference table.
We provide a set of components suffixed with *Group (e.g., StringGroup, SelectGroup, SecretGroup) created using the withFormGroup Higher-Order Component (HOC). These components autonomously bundle:
- The underlying input component (e.g.,
StringField) - React Final Form's
<Field>wrapper - The layout, label, and validation structure via
<FormGroup>
❌ Bad Example: Manual Wrapping (Legacy)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
✅ Good Example: Autonomous Group (Modern)
1 2 3 4 5 6 7 8 9 10 11 12 | |
Rationale & Benefits
- Reduced Boilerplate: You don't need to import
Field, the base component (StringField), andFormGroupseparately. - Unified Props: Form layout properties (
label,description,help,required) and field logic properties (name,validate,maxLength) are cleanly passed to a single component. - Strict Type Checking: The
withFormGroupHOC intelligently strips out internal React Final Form props (component,render) and provides strong IntelliSense for the specific properties required by the underlying input component. - Consistency: It enforces a single, standardized way to render labeled fields and their associated error messages or tooltips.
Available Autonomous Groups
Most standard fields have a corresponding *Group component exported from @/form. A selection includes:
- Text & Input:
StringGroup,TextGroup,SecretGroup,NumberGroup,EmailGroup - Selection:
SelectGroup,AsyncSelectGroup,CreatableSelectGroup,BooleanGroup,RadioGroup - Advanced:
DateGroup,DateTimeGroup,TimeGroup,MarkdownGroup,MonacoGroup(Code Editor) - Specialized:
ImageGroup,FileUploadGroup,CountrySelectGroup,CommaSeparatedListGroup
When to Use Manual FormGroup + Field
While autonomous groups are the standard, there are specific architectural exceptions where manually separating <Form.Group> (or FormGroup) and <Field> (or <FieldArray>) remains the correct approach:
- Complex Arrays (
FieldArray): When mapping over an array of data (e.g., configuring multiple network allocation pools or IP rules), you typically want a single label for the entire collection rather than repeating the label for every row. - Multi-field Inline Layouts: When several logical fields compose a single input concept (e.g., Start IP and End IP, Min/Max range, X/Y coordinates) that need to be grouped horizontally using
InputGroupor a table layout.
Example: Array of Inline Inputs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 | |
Edit Field Architecture (*EditField Pattern)
For details / settings panels that show a value with an inline edit affordance, Waldur uses the Edit Field Architecture. It mirrors the *Group pattern but renders a read-only FormTable.Item plus a button that opens a generic edit modal.
Each base field control has three siblings:
| Base Control | Form Group (withFormGroup) |
Edit Field (withEditField) |
Table Filter (withTableFilter) |
|---|---|---|---|
StringField |
StringGroup |
StringEditField |
StringFilter |
SelectField |
SelectGroup |
SelectEditField |
SelectFilter |
NumberField |
NumberGroup |
NumberEditField |
NumberFilter |
SecretField |
SecretGroup |
SecretEditField |
— |
EmailField |
EmailGroup |
EmailEditField |
— |
AwesomeCheckboxField |
BooleanGroup |
BooleanEditField |
BooleanFilter |
DateField |
DateGroup |
DateEditField |
— |
MarkdownEditor |
MarkdownGroup |
MarkdownEditField |
— |
Exports live in @/form/editFields. To make a custom field editable, run the base component through the HOC:
1 2 3 | |
Provider + field usage
EditFieldProvider supplies the scope (the object being edited) and the callback (the persist function) so individual edit fields don't need to be threaded with props.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 | |
Callback contract
When the user submits the modal, the dialog builds a body via lodash.set({}, field.name, value) and passes it to the provider's callback. The callback receives a partial PATCH-shaped object:
1 2 3 4 5 | |
The backend endpoint behind callback must therefore accept partial updates (PATCH semantics). The marketplace marketplaceProviderOfferingsUpdateIntegration endpoint and projectsPartialUpdate / customersPartialUpdate already do. Wrap the persist call in useManagedMutation so success/error notifications and closeDialog are handled uniformly.
Common props
name— dotted path intoscope(e.g.service_attributes.backend_url). Reads vialodash.get, writes vialodash.set.label,description,required,validate,format,parse,normalize— passed through to the modal's<Field>.isStaffOnly— non-staff users see a<StaffOnlyIndicator/>instead of the edit button; the read-only value is still visible.renderValue(value)— override the read-only display. May returnnull/undefined; the HOC substitutesDASH_ESCAPE_CODE, so no manual dash fallback is needed.tooltip,iconNode— propagate to the compact edit button (e.g. to flag IDP-managed fields with a lock icon).warnTooltip— displays a warning marker next to the row's value, e.g. for fields with non-obvious side effects.- Any other prop is forwarded to the inner field when the modal opens — e.g.
placeholderonCommaSeparatedListEditFieldreachesCommaSeparatedListField.
For panel-wide access control, pass hideActions={!canUpdate} to the enclosing <FormTable> (or TabbedSection) — it hides the entire action column without conditional JSX around every field.
Marketplace plugin credentials
For plugin credentials, compose BaseCredentialsSection (@/marketplace/offerings/update/integration/BaseCredentialsSection) instead of redoing the layout — it provides the scope state badge, the sync button, and the surrounding EditFieldProvider wired to the offering's update mutation:
1 2 3 4 5 6 7 8 9 | |
Tabbed panels
For multi-section panels with URL-synced tabs, use TabbedSection from @/form/TabbedSection:
1 2 3 4 5 6 7 8 9 10 11 12 | |
Search caveat: enableSearch filters by walking direct children only. If a tab's content is a composite subcomponent (e.g. <BasicInfoTab/>), search treats it as a single opaque child. Place *EditFields directly under <TabbedSection.Tab> when search is needed.
Don't do this
- ❌ Don't add fields to a monolithic switch-based
EditFieldDialog. The legacysrc/customer/details/EditFieldDialog.tsx,src/user/support/EditFieldDialog.tsx, etc. have all been removed. - ❌ Don't write your own edit button +
openDialog(SomeEditDialog, …)lazy loader when an*EditFieldwould do — the standardized dialog handles validation, dirty-state, and submit wiring for you. - ❌ Don't render
*EditFieldoutside anEditFieldProvider. The HOC throws — you'll see<withEditField(X)>: "scope" and "callback" must be provided…. - ❌ Don't pass a component that is already wrapped with
withFormGrouptowithEditField. The edit dialog injects its own label, so a*Groupcomponent produces double labels — wrap the base field (e.g.StringField) instead. - ❌ Don't manually check for empty values with ternary operators —
withEditFieldrenders a dash (—) for missing values automatically.
Canonical examples:
src/customer/details/CustomerDetailsPanel.tsx—TabbedSection+ multiple tabs of*EditFields.src/openstack/OpenStackCredentialsSection.tsx—BaseCredentialsSectionwithSelect/Boolean/Secretedit fields.src/marketplace/offerings/update/integration/LifecyclePolicySection.tsx— search-enabled tabs with flat field lists.
Autonomous Table Filters (*Filter Pattern)
Similar to the *Group pattern, Waldur provides autonomous components for table filters. These components combine TableFilterItem, React Final Form's Field, and an input component.
They are created using the withTableFilter HOC and handle the toggle button, menu/sidebar layout, and form state binding autonomously.
Example: Table Filter
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
Available Autonomous Filters
SelectFilter: Standard selectionAsyncSelectFilter: Dynamic selection from APIBooleanFilter: Checkbox toggleStringFilter: Custom text searchDateFilter/DateTimeFilter: Date/Time selectionNumberFilter/NumberRangeFilter: Numeric selectionOfferingFilter: Specialized offering selectorProjectFilter: Specialized project selectorProviderFilter: Specialized service provider selector
Tooltips & Help Text
Avoid creating manual tooltips or extra label wrappers. Use the built-in help or tooltip properties provided by the autonomous groups.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
Key Patterns & Best Practices
Validation
Validation functions are pure functions that return an error string if invalid, or undefined if valid.
1 2 | |
Async Data Integration
Use React Query inside forms to load asynchronous data:
1 2 3 4 | |
Modern Form Submission
When writing the submission handler for modal dialogs, use useManagedMutation or handle success/error states natively.
1 2 3 4 5 6 7 8 9 | |
Error Handling
Issue: Avoid unhandled promise rejections when reporting API errors.
Solution: Don't re-throw errors after showErrorResponse():
1 2 3 4 5 6 7 8 9 10 11 | |
Modal Form Architecture
Key Issue: React Final Form context boundaries - submit buttons must be inside the <Form> component.
Solution: Move submit buttons inside the form context and provide a custom footer structure.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
Advanced Tooling
Dirty Form Protection
If you want to prevent users from accidentally closing a modal dialog when a form has unsaved changes, use the DirtyStateReporter drop-in component anywhere inside your <Form> element:
1 2 3 4 5 6 7 8 9 10 11 | |
Tabbed Sections (TabbedSection)
When building complex forms with many fields, use the TabbedSection component to organize fields into logical, URL-synced tabs. It includes built-in support for searching across tabs and automatically jumping to tabs with matching results.
Example Usage
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
Key Features
- URL Synchronization: Automatically syncs the active tab to the URL query string using
useSettingsUrlSync, allowing users to share links to specific tabs. - Built-in Search (
enableSearch): When enabled, provides a search input that filters fields across all tabs by matching against theirlabelanddescriptionprops. - Auto-jump: When searching, if the current tab yields no results, it automatically jumps to the first tab containing matching fields.
- Field Counters: Displays a badge on each tab showing the number of matching fields during a search.
- FormTable Integration: Automatically wraps the tab's fields in a
FormTablefor consistent alignment and styling.