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 14 15 16 17 | |
Labels, Fields and Help Text
A field is waldur-ui's FormField around a FormLabel, a control and a
FormText. The field links its label to its control by itself: Input,
Textarea, NativeSelect (so also NumberInput and SecretInput), the date
pickers and Select give themselves an id when they sit in a field, and the
label points at the first of them. Write no htmlFor or id, and no
controlId.
One label per control. A <label> names one control. A field that holds
several controls under one question (a start and an end, a select and its custom
value, a list of rows) is a group, not a field with several controls: draw it
as a FormFieldset named by a FormLegend (<FormGroup group label=...> in a
react-final-form form), and give every control inside a name of its own, a
FormField + FormLabel or an aria-label that says which one it is
(aria-label={${translate('Limit')} ${i + 1}} for a row). The group's name is
announced with each control, and FormText written directly inside the
fieldset describes the fieldset, not each control. A select with an "other"
value input gets the label on the select and an aria-label on the input. A
table of controls is a group too, named by the legend, each cell by its
aria-label. A FormLabel outside any field links to nothing, and the
waldur-custom/no-orphan-form-label lint rule rejects it: use the legend for a
group, FormHeading for text that captions something that is not a control (a
read-only value, a preview), and a FormField for a label and its control.
Where a field does hold several controls and no group fits, each keeps its own
unique id and the label names the first.
A label and a control that cannot share a wrapper, such as the two cells of a
table row, share a FieldScope instead (the same linking with no element of its
own): PropertyItem is one, so its label names the control in its value with
no htmlFor. A hand-written <label> reads the target with
useFieldLabelFor().
The label always names the control that is really there: if a control keeps an
id of its own, even inside a field with a controlId, that is the one the
label points at.
Pass controlId only when the id has to be known: it is then the id of every
control inside, so use it for one control. FormGroup from @/form passes one
only when you give it controlId or id; it no longer derives one from the
field's name, because react-select and the pickers take the id too and a group
with several of them would repeat it. Tests look a control up by role and name
or data-testid, not by an id. A control with an id of its own keeps it, and
the label points at that. FormLabel required draws the
asterisk; <FormText variant="danger"> is an error line. FormField draws no
spacing, so give it the margin the layout needs, and as="td" makes it a table
cell. Inside a react-final-form form use FormGroup (below), which builds all
of this from a field's props.
Errors are announced. FieldError renders role="alert", so a screen reader
reads the message when it appears, not only when the control is next focused;
aria-describedby (linked by the field) covers that second case. FieldWarning
is a polite role="status". Mount the message only while there is something to
say (both already return nothing without an error) and do not wrap it in
another alert, or it is read twice. A one-off error line written with
<FormText variant="danger"> needs role="alert" itself.
Never react-bootstrap Form.Group, Form.Label or Form.Text, nor the
form-label, form-text and required classes; the
waldur-custom/no-bootstrap-form-control lint rule rejects them.
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 <FormField> (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 in a flex row or 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 29 30 31 32 33 34 | |
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 PropertyItem 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 |
— |
SwitchField |
BooleanGroup |
BooleanEditField |
BooleanFilter |
RadioGroupField |
RadioGroup |
— | — |
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 set({}, field.name, value) (es-toolkit/compat) 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 viaget, writes viaset, both fromes-toolkit/compat, which treat the path exactly as lodash did.label,description,required,validate,format,parse— passed through to the modal's<Field>. There is nonormalize: React Final Form ignores it, so convert the stored value withformatand the typed value withparse.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 <PropertyList> (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 10 11 12 13 14 15 16 17 | |
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.
Date & Time Pickers
All date controls are built on Calendar, DatePicker and DateRangePicker
from waldur-ui
(shadcn's recipe over react-day-picker, opened in a
Radix popover); waldur-ui also exports the pieces for custom pickers
(DatePickerPopover, TimeInput, the dateBounds helpers,
useCalendarLocale).
| Component | Stores in form state | Notes |
|---|---|---|
DateField |
ISO date yyyy-MM-dd |
DateGroup, DateEditField, DateFilter |
DateTimeField |
ISO datetime with offset | DateTimeGroup; shown as yyyy-MM-dd HH:mm |
DateTimeRangeField |
[Date, Date] |
Floors at now unless minDate={null}; ref exposes open() |
RangeDateField |
{ min, max } ISO dates |
DateRangeFilter; date-only, no floor |
DatePicker |
— (plain value/onChange Date) |
For non-form UI; from waldur-ui |
DateRangePicker |
— (plain [Date, Date]) |
For non-form UI; from waldur-ui |
MonthPicker |
— (plain Date, first of month) |
Billing/usage periods; from waldur-ui |
minDate/maxDateaccept aDate, an ISO date or datetime string, a luxonDateTimeor'today'. They are inclusive and day-granular in the calendar; a datetime bound also clamps the picked time.enabletakes predicates a day must match (e.g. first of the month only);inlinerenders the calendar without a popup.captionLayout="dropdown"(onDatePicker/DateField/DateEditField) replaces the caption with month and year dropdowns — use it for dates far from today, like a birth date. Years spanminDate–maxDate, else the last 100 years.- Closing the popup calls
input.onBlur, so validation shows without a submit. DateFilter,DateTimeFilterandDateRangeFilteropen their calendar as soon as the filter is picked (autoOpen), as select filters show their menu.- Popups use
--z-index-picker-popover, above modals and filter dropdowns. - Never use native
<input type="date|datetime-local|time|month|week">— thewaldur-custom/no-native-date-inputlint rule rejects it. For state kept as strings, read withparseDateValueand write withtoIsoDate/toIsoMonth(all fromwaldur-ui).
Behaviour is specified by stories, each rule in one place, under
Forms/Date & time in Storybook: DatePicker and DateRangePicker
(packages/ui/src/DatePicker/) specify the pickers; Form
fields (src/form/dateFields.stories.tsx) covers only what the adapters
add — stored shape, touched-on-close, the maintenance floor; Calendar
shows the primitive. Screens built on the pickers keep their stories under
their own domain (Maintenance/…, Booking/…, Reporting/…). All of them
drive the pickers through waldur-ui/src/DatePicker/testing (imported by
path, never from the package index) and assert only on what the user or form
sees, so a markup change is fixed in the driver, not the stories. Use the
toolbar's theme switch for dark mode rather than adding dark copies. Unit
tests only cover what a story can't: TimeInput's editing rules, anything
that needs a frozen clock (the maintenance floor), and crash safety on
malformed data — don't repeat a story's assertion in a test. Run
them with yarn test:storybook packages/ui/src/DatePicker src/form.
Form Stories in Storybook
Form stories sit under Forms/, one folder per kind of control, in the order
set by storySort in .storybook/preview.tsx:
| Folder | What lives there |
|---|---|
Layout |
FormGroup, PropertyList: what wraps a control |
Text inputs |
Input, Textarea, Input group, Number input, Secret input, Range input |
Selection |
Native select, Select, and fields built on them |
Check controls |
Checkbox, Switch, Radio, BoxRadioField |
Date & time |
Calendar and the pickers |
Edit fields |
The read-only-with-edit rows |
- One story file per primitive, next to it in
packages/ui, titledForms/<Folder>/<Component>. A text control hasPlayground(with controls),States(the sharedStateGrid: default, filled, invalid, disabled, read only, outlined beside solid) andSizes, then whatever is specific to it. Hover and focus are live; use the pseudo-state toolbar to pin them. Form fieldsper folder covers only what the react-final-form adapters insrc/formadd: the stored value, touched and invalid reaching the control, props such assolidandicon. It does not repeat the look, which the primitive's page specifies.- A story asserts what is on screen, not markup, so it survives a restyle.
- Add a new control's folder and its pages to
storySort; an unlisted page sorts alphabetically after the listed ones.
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 | |
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 19 20 21 22 23 24 25 26 | |
Key Features
- URL Synchronization: Automatically syncs the active tab to the URL query string using
useUrlTab(@/navigation/useUrlTab), 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.
- PropertyList Integration: Automatically wraps the tab's fields in a
PropertyListfor consistent alignment and styling.