Components
Forms
Forms in @wayvo-ai/core use custom input components that integrate seamlessly with Stores. They provide type safety, validation, and automatic state management.
Input Components
All input components are imported from @wayvo-ai/core/ui:
| Component | Use Case |
|---|---|
TextInput | Text input with label |
NumberInput | Number input |
DateInputField | Date picker (has label built-in) |
SelectInput | Dropdown select |
CheckboxInput | Checkbox |
SwitchInput | Toggle switch |
TextareaInput | Multi-line text |
ComboboxInput | Searchable dropdown |
AsyncComboboxInput | Async searchable dropdown |
LookupInput | Searchable dropdown for lookup values |
Basic Form Pattern
'use client';
import { TextInput, NumberInput, SelectInput, DateInputField } from '@wayvo-ai/core/ui';
import { useCurrentRowSync, useIsStoreDirty, useIsStorePosting } from '@wayvo-ai/core/ui';
import type { Store } from '@wayvo-ai/core/common';
import type { Entity } from '@/lib/common/ds/types/module/Entity';
export function EntityForm({ store }: { store: Store<Entity> }) {
const row = useCurrentRowSync(store); // ✅ Always use sync mode for forms
const isDirty = useIsStoreDirty(store);
const isPosting = useIsStorePosting(store);
if (!row) return null;
return (
<div className="grid gap-4">
<TextInput
label="Name"
value={row.name || ''}
onChange={(value) => store.setValue('name', value)}
required
/>
<NumberInput
label="Amount"
value={row.amount}
onChange={(value) => store.setValue('amount', value)}
/>
<SelectInput
label="Status"
value={row.status}
onChange={(value) => store.setValue('status', value)}
options={[
{ value: 'draft', label: 'Draft' },
{ value: 'active', label: 'Active' },
]}
/>
<DateInputField
label="Start Date"
value={row.startDate}
onChange={(value) => store.setValue('startDate', value)}
/>
<Button disabled={isPosting || !isDirty}>Save</Button>
</div>
);
}
Store-backed form fields
Prefer store-backed binding when you only need simple field-to-store mapping: pass store and attributeCode instead of value/onChange. The component derives value, dirty state, and field errors from the store. Use controlled (value/onChange) when you need custom logic (e.g. syncing two fields, conditional updates).
'use client';
import { TextInput, NumberInput, SelectInput, DateInputField } from '@wayvo-ai/core/ui';
import { useCurrentRowSync, useIsStoreDirty, useIsStorePosting } from '@wayvo-ai/core/ui';
import type { Store } from '@wayvo-ai/core/common';
import type { Entity } from '@/lib/common/ds/types/module/Entity';
export function EntityForm({ store }: { store: Store<Entity> }) {
const row = useCurrentRowSync(store);
const isDirty = useIsStoreDirty(store);
const isPosting = useIsStorePosting(store);
if (!row) return null;
return (
<div className="grid gap-4">
<TextInput label="Name" required store={store} attributeCode="name" />
<NumberInput label="Amount" store={store} attributeCode="amount" />
<SelectInput
label="Status"
store={store}
attributeCode="status"
options={[
{ value: 'draft', label: 'Draft' },
{ value: 'active', label: 'Active' },
]}
/>
<DateInputField label="Start Date" store={store} attributeCode="startDate" />
<Button disabled={isPosting || !isDirty}>Save</Button>
</div>
);
}
Dirty state and field errors are handled by the component when using store-backed props.
Normalizing on blur (transformValue)
TextInput, NumberInput, and DatePicker support an optional store-backed-only prop transformValue?: (value: T) => T. Use it to normalize or format the value when the user blurs the field (or, for DatePicker, when the popover closes).
- Semantics: On blur, if the value changed since focus, the component calls
transformed = transformValue(currentValue)and writes to the store only iftransformed !== currentValue. You provide a pure function; you do not callstore.setValue. - Use cases: Trim and lowercase email, round numbers, normalize date strings.
- Limitation:
transformValueonly affects the current attribute. It cannot sync another field (e.g. copy email to userName). For that, use controlled props or a form-level effect.
Example: store-backed email with normalize on blur:
<TextInput
label="Email"
store={store}
attributeCode="email"
transformValue={(v) => v?.trim().toLowerCase() ?? undefined}
/>
ComboboxInput with Static Options
Fixed Options
import { ComboboxInput } from '@wayvo-ai/core/ui';
import { CURRENCY_OPTIONS } from '@/lib/common/ui-constants';
<ComboboxInput
label="Currency"
options={CURRENCY_OPTIONS}
getLabel={(opt) => opt.label}
getValue={(opt) => opt.value}
value={row.currency ?? ''}
onSelect={(v) => store.setValue('currency', v ?? '')}
placeholder="Select currency..."
/>
Dynamic Options from Store
// hooks/use-customer-options.ts
export function useCustomerOptions() {
const store = useStore<Customer>({
datasourceId: 'Customers',
page: 'page-name',
alias: 'customer-options',
limit: 1000,
autoQuery: true,
select: ['customerId', 'customerName'],
sort: { customerName: 1 },
});
return { rows: useDBRows(store), isLoading: useIsStoreLoading(store) };
}
// In form component
const { rows: customerOptions } = useCustomerOptions();
<ComboboxInput
label="Customer"
options={customerOptions}
getLabel={(opt) => opt.customerName}
getValue={(opt) => opt.customerId}
value={row.customerId ?? ''}
onSelect={(v) => store.setValue('customerId', v ?? '')}
placeholder="Select customer..."
searchPlaceholder="Search customers..."
/>
LookupInput for System Lookups
LookupInput is a specialized component for displaying lookup values from the system's lookup tables (LookupTypes and LookupValues). It automatically fetches and caches lookup options, making it ideal for standardized reference data like status codes, categories, or other system-defined options.
Basic Usage
import { LookupInput } from '@wayvo-ai/core/ui';
<LookupInput
label="Status"
lookupType="Status"
value={row.status}
onSelect={(value) => store.setValue('status', value)}
placeholder="Select status..."
searchPlaceholder="Search statuses..."
/>
Key Features
- Automatic Data Fetching: Fetches lookup values from
LookupValuesdatasource based onlookupType - Caching: Lookup values are cached for 1 hour to improve performance
- Type Safety: Supports both string and number lookup values
- Sorted Display: Options are automatically sorted by
displayOrderandlabel - Active Filtering: Only shows active lookup values (
isActive: true)
When to Use LookupInput vs ComboboxInput
Use LookupInput when:
- ✅ You're working with system-defined lookup values (stored in
LookupTypes/LookupValues) - ✅ The lookup type is standardized and managed centrally
- ✅ You want automatic caching and data fetching
Use ComboboxInput when:
- ✅ You need custom options from any datasource
- ✅ You want more control over data fetching and filtering
- ✅ You're working with entity relationships (e.g., selecting a Customer)
Example: Multiple Lookup Fields
import { LookupInput } from '@wayvo-ai/core/ui';
<div className="grid gap-4">
<LookupInput
label="Priority"
lookupType="Priority"
value={row.priority}
onSelect={(value) => store.setValue('priority', value)}
required
/>
<LookupInput
label="Category"
lookupType="Category"
value={row.category}
onSelect={(value) => store.setValue('category', value)}
placeholder="Select category..."
/>
</div>
Form Layout
Grid Alignment
Always use items-start for side-by-side fields:
// ✅ Correct - fields align at top
<div className="grid grid-cols-2 items-start gap-4">
<TextInput label="Field 1" helpText="Has help text" />
<ComboboxInput label="Field 2" />
</div>
// ❌ Incorrect - fields may misalign
<div className="grid grid-cols-2 gap-4">
<TextInput label="Field 1" helpText="Has help text" />
<ComboboxInput label="Field 2" />
</div>
Optional Date Fields
For optional dates (like project end date):
// 1. TypeScript type
endDate?: ISODateString | null;
// 2. DataSource
{
...DefaultAttribute,
code: 'endDate',
type: 'Date',
optional: true,
}
// 3. Form field
<DateInputField
value={row.endDate ?? ''}
onChange={(value) => store.setValue('endDate', value || null)}
label="End Date"
helpText="Defaults to project end (ongoing)"
/>
// 4. Display
displayValue={project.endDate ? formatDateDisplay(project.endDate) : 'Ongoing'}
Form Validation
Validation happens at the DataSource level with beforeInsert and beforeUpdate hooks. You can also add client-side validation using either notification-based errors or field-level errors.
Notification-Based Validation
For simple validation, use showError() to display a notification:
import { showError } from '@wayvo-ai/core/ui';
const handleSave = async () => {
if (!row.name?.trim()) {
showError('Name is required');
return;
}
const success = await store.save({ feedback: 'Saved successfully' });
if (success) {
onClose();
}
};
Field-Level Validation
For better UX, use store.setError() to display errors directly on form fields. This provides immediate, contextual feedback to users.
Setting Field Errors
import { useCurrentRowId } from '@wayvo-ai/core/ui';
const handleSave = async () => {
const rowId = useCurrentRowId(store);
// Clear previous errors
store.clearAllErrors();
// Validate and set field errors
if (!row.name?.trim()) {
store.setError({
attribute: 'name',
rowId: rowId!,
errorMessage: 'Name is required',
source: 'Controller',
});
}
if (row.email && !row.email.includes('@')) {
store.setError({
attribute: 'email',
rowId: rowId!,
errorMessage: 'Invalid email format',
source: 'Controller',
});
}
// Check if there are any errors
if (store.hasErrors()) {
return; // Don't save if there are validation errors
}
const success = await store.save({ feedback: 'Saved successfully' });
if (success) {
onClose();
}
};
Displaying Field Errors in Forms
Use the errorText prop on input components to display field errors:
import { useCurrentRowId, useCellErrors } from '@wayvo-ai/core/ui';
export function EntityForm({ store }: { store: Store<Entity> }) {
const row = useCurrentRowSync(store);
const rowId = useCurrentRowId(store);
const nameError = useCellErrors(store, rowId!, 'name');
const emailError = useCellErrors(store, rowId!, 'email');
if (!row) return null;
return (
<div className="grid gap-4">
<TextInput
label="Name"
value={row.name || ''}
onChange={(value) => {
store.setValue('name', value);
// Clear error when user starts typing
if (nameError) {
store.clearError({
attribute: 'name',
rowId: rowId!,
source: 'Controller',
});
}
}}
errorText={nameError}
required
/>
<TextInput
label="Email"
value={row.email || ''}
onChange={(value) => {
store.setValue('email', value);
if (emailError) {
store.clearError({
attribute: 'email',
rowId: rowId!,
source: 'Controller',
});
}
}}
errorText={emailError}
/>
</div>
);
}
Using useStoreFieldErrors Hook
For forms with many fields, you can get all field errors at once:
import { useStoreFieldErrors } from '@wayvo-ai/core/ui';
export function EntityForm({ store }: { store: Store<Entity> }) {
const row = useCurrentRowSync(store);
const fieldErrors = useStoreFieldErrors(store);
if (!row) return null;
return (
<div className="grid gap-4">
<TextInput
label="Name"
value={row.name || ''}
onChange={(value) => store.setValue('name', value)}
errorText={fieldErrors?.name?.Controller}
required
/>
<TextInput
label="Email"
value={row.email || ''}
onChange={(value) => store.setValue('email', value)}
errorText={fieldErrors?.email?.Controller}
/>
</div>
);
}
Error Sources
Field errors can come from different sources (e.g., 'Controller', 'Server'). The error object structure is:
fieldErrors: {
[rowId]: {
[attribute]: {
Controller?: string; // Client-side validation
Server?: string; // Server-side validation
// ... other sources
}
}
}
Use useCellErrors() to get the combined error message (all sources joined), or access specific sources via useStoreFieldErrors().
When to Use Field-Level vs Notification Errors
Use field-level errors (
store.setError()) when:- ✅ You want to show errors directly on the problematic field
- ✅ You have multiple validation errors to display
- ✅ You want users to see all issues at once
- ✅ Errors are tied to specific form fields
Use notification errors (
showError()) when:- ✅ The error is not field-specific (e.g., "Failed to save")
- ✅ You want a simple, single error message
- ✅ The error is about the overall operation, not a specific field
Next Steps
- Dialogs - Use forms in dialogs
- CRUD Operations - Complete form patterns