Skip to main content

Form Component Examples

Email Input with Validation

This example uses the react-hook-form validation state in order to display an icon indicating the validity of the email address, as defined by the custom field "pattern" option:

Email input

src/plugins/my-plugin/dashboard/components/email-input.tsx
import {AffixedInput, DashboardFormComponent} from '@vendure/dashboard';
import {Mail, Check, X} from 'lucide-react';
import {useFormContext} from 'react-hook-form';

export const EmailInputComponent: DashboardFormComponent = ({name, value, onChange, disabled}) => {
const {getFieldState} = useFormContext();
const isValid = getFieldState(name).invalid === false;

return (
<AffixedInput
prefix={<Mail className="h-4 w-4 text-muted-foreground" />}
suffix={
value &&
(isValid ? (
<Check className="h-4 w-4 text-green-500" />
) : (
<X className="h-4 w-4 text-red-500" />
))
}
value={value || ''}
onChange={e => onChange(e.target.value)}
disabled={disabled}
placeholder="Enter email address"
className="pl-10 pr-10"
name={name}
/>
);
};

Multi-Currency Price Input

This example demonstrates a component with its own state (using useState) and more complex internal logic.

Currency input

src/plugins/my-plugin/dashboard/components/price-input.tsx
import {
AffixedInput,
DashboardFormComponent,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
useLocalFormat,
} from '@vendure/dashboard';
import { useState } from 'react';

export const MultiCurrencyInputComponent: DashboardFormComponent = ({ value, onChange, disabled, name }) => {
const [currency, setCurrency] = useState('USD');
const { formatCurrencyName } = useLocalFormat();

const currencies = [
{ code: 'USD', symbol: '$', rate: 1 },
{ code: 'EUR', symbol: '€', rate: 0.85 },
{ code: 'GBP', symbol: '£', rate: 0.73 },
{ code: 'JPY', symbol: '¥', rate: 110 },
];

const selectedCurrency = currencies.find(c => c.code === currency) || currencies[0];

// Convert price based on exchange rate
const displayValue = value ? (value * selectedCurrency.rate).toFixed(2) : '';

const handleChange = (val: string) => {
const numericValue = parseFloat(val) || 0;
// Convert back to base currency (USD) for storage
const baseValue = numericValue / selectedCurrency.rate;
onChange(baseValue);
};

return (
<div className="flex space-x-2">
<Select value={currency} onValueChange={setCurrency} disabled={disabled}>
<SelectTrigger className="w-24">
<SelectValue>
<div className="flex items-center gap-1">{currency}</div>
</SelectValue>
</SelectTrigger>
<SelectContent>
{currencies.map(curr => {
return (
<SelectItem key={curr.code} value={curr.code}>
<div className="flex items-center gap-2">{formatCurrencyName(curr.code)}</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
<AffixedInput
prefix={selectedCurrency.symbol}
value={displayValue}
onChange={e => onChange(e.target.value)}
disabled={disabled}
placeholder="0.00"
name={name}
/>
</div>
);
};

Tags Input Component

This component brings better UX to a simple comma-separated tags custom field.

Tags input

src/plugins/my-plugin/dashboard/components/tags-input.tsx
import { Input, Badge, Button, DashboardFormComponent } from '@vendure/dashboard';
import { useState, KeyboardEvent } from 'react';
import { X } from 'lucide-react';

export const TagsInputComponent: DashboardFormComponent = ({ value, onChange, disabled, name, onBlur }) => {
const [inputValue, setInputValue] = useState('');

// Parse tags from string value (comma-separated)
const tags: string[] = value ? value.split(',').filter(Boolean) : [];

const addTag = (tag: string) => {
const trimmedTag = tag.trim();
if (trimmedTag && !tags.includes(trimmedTag)) {
const newTags = [...tags, trimmedTag];
onChange(newTags.join(','));
}
setInputValue('');
};

const removeTag = (tagToRemove: string) => {
const newTags = tags.filter(tag => tag !== tagToRemove);
onChange(newTags.join(','));
};

const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' || e.key === ',') {
e.preventDefault();
addTag(inputValue);
} else if (e.key === 'Backspace' && inputValue === '' && tags.length > 0) {
removeTag(tags[tags.length - 1]);
}
};

return (
<div className="space-y-2">
{/* Tags Display */}
<div className="flex flex-wrap gap-1">
{tags.map((tag, index) => (
<Badge key={index} variant="secondary" className="gap-1">
{tag}
<Button
type="button"
variant="ghost"
size="icon"
className="h-4 w-4 p-0 hover:bg-transparent"
onClick={() => removeTag(tag)}
disabled={disabled}
>
<X className="h-3 w-3" />
</Button>
</Badge>
))}
</div>

{/* Input */}
<Input
value={inputValue}
onChange={e => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={onBlur}
disabled={disabled}
placeholder="Type a tag and press Enter or comma"
name={name}
/>
</div>
);
};

Auto-generating Slug Input

This example demonstrates a component that automatically generates a slug from the product name. It uses the react-hook-form watch method to get the value of another field in the form and react to changes in that field.

Slug input

src/plugins/my-plugin/dashboard/components/slug-input.tsx
    import { Input, Button, Switch, DashboardFormComponent } from '@vendure/dashboard';
import { useFormContext } from 'react-hook-form';
import { useState, useEffect } from 'react';
import { RefreshCw, Lock, Unlock } from 'lucide-react';

export const SlugInputComponent: DashboardFormComponent = ({ value, onChange, disabled, name }) => {
const [autoGenerate, setAutoGenerate] = useState(!value);
const [isGenerating, setIsGenerating] = useState(false);
const { watch } = useFormContext();
const nameValue = watch('translations.0.name');

const generateSlug = (text: string) => {
return text
.toLowerCase()
.replace(/[^a-z0-9 -]/g, '') // Remove special characters
.replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') // Replace multiple hyphens with single
.trim('-'); // Remove leading/trailing hyphens
};

useEffect(() => {
if (autoGenerate && nameValue) {
const newSlug = generateSlug(nameValue);
if (newSlug !== value) {
onChange(newSlug);
}
}
}, [nameValue, autoGenerate, onChange, value]);

const handleManualGenerate = async () => {
if (!nameValue) return;

setIsGenerating(true);
// Simulate API call for slug validation/generation
await new Promise(resolve => setTimeout(resolve, 500));

const newSlug = generateSlug(nameValue);
onChange(newSlug);
setIsGenerating(false);
};

return (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Input
value={value || ''}
onChange={e => onChange(e.target.value)}
disabled={disabled || autoGenerate}
placeholder="product-slug"
className="flex-1"
name={name}
/>

<Button
type="button"
variant="outline"
size="icon"
disabled={disabled || !nameValue || isGenerating}
onClick={handleManualGenerate}
>
<RefreshCw className={`h-4 w-4 ${isGenerating ? 'animate-spin' : ''}`} />
</Button>
</div>

<div className="flex items-center space-x-2">
<Switch checked={autoGenerate} onCheckedChange={setAutoGenerate} disabled={disabled} />
<div className="flex items-center space-x-1 text-sm text-muted-foreground">
{autoGenerate ? <Lock className="h-3 w-3" /> : <Unlock className="h-3 w-3" />}
<span>Auto-generate from name</span>
</div>
</div>
</div>
);
};
note

Input components completely replace the default input for the targeted field. Make sure your component handles all the data types and scenarios that the original input would have handled.