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:

ComponentUse Case
TextInputText input with label
NumberInputNumber input
DateInputFieldDate picker (has label built-in)
SelectInputDropdown select
CheckboxInputCheckbox
SwitchInputToggle switch
TextareaInputMulti-line text
ComboboxInputSearchable dropdown
AsyncComboboxInputAsync searchable dropdown
LookupInputSearchable 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 if transformed !== currentValue. You provide a pure function; you do not call store.setValue.
  • Use cases: Trim and lowercase email, round numbers, normalize date strings.
  • Limitation: transformValue only 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 LookupValues datasource based on lookupType
  • 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 displayOrder and label
  • 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

Previous
Application Menu