docs/docs/guides/extending-the-dashboard/custom-form-components/index.mdx
The Dashboard has two different APIs for customizing form inputs. Choose based on what kind of field you are changing:
| I want to... | Use this API | How it is selected |
|---|---|---|
Replace the rendered input for a native entity field, such as Product.slug | detailForms[].inputs | Target a specific pageId, blockId, and field |
| Use my own input for a plugin-defined custom field | customFormComponents.customFields | Register a component ID, then reference it from the custom field's ui.component |
| Use my own input for a configurable operation argument | customFormComponents.customFields | Register a component ID, then reference it from the argument config's ui.component |
Both APIs use the same DashboardFormComponent type, but they are registered differently. Use detailForms[].inputs only when you are replacing a native field on a specific detail page. Use customFormComponents.customFields when the field itself is defined in your plugin configuration.
All form components must implement the DashboardFormComponent type.
This type is based on the props that are made available from react-hook-form, which is the
underlying form library used by the Dashboard.
In addition to the standard React component signature, a dashboard form component can define a static metadata property.
This lets the dashboard know how your component should be rendered in certain contexts.
isListInput: Declare whether your component is intended for list fields. Use 'dynamic' if it can handle both list and non-list fields.isFullWidth: When true, the dashboard will render the field so it spans the full width of the standard 2-column detail form grids (e.g. DetailFormGrid).import { DashboardFormComponentProps } from '@vendure/dashboard';
export function MyCustomInput(props: DashboardFormComponentProps<string>) {
return <textarea value={props.value} onChange={e => props.onChange(e.target.value)} />;
}
MyCustomInput.metadata = {
isFullWidth: true,
};
Here's an example custom form component that has been annotated to explain the typical parts you will be working with:
import {
Button,
Card,
CardContent,
cn,
DashboardFormComponent,
Input,
useFormContext,
} from '@vendure/dashboard';
import { useState } from 'react';
// By typing your component as DashboardFormComponent, the props will be correctly typed
export const ColorPickerComponent: DashboardFormComponent = ({ value, onChange, name }) => {
// You can use any of the built-in React hooks as usual
const [isOpen, setIsOpen] = useState(false);
// To access the react-hook-form context, use this hook.
// This is useful for getting information about the current
// field, or even other fields in the form, which allows you
// to create advanced components that depend on the state of
// other fields in the form.
const { getFieldState } = useFormContext();
// The current field name is always passed in as a prop, allowing
// you to look up the field state
const error = getFieldState(name).error;
const colors = ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FECA57', '#FF9FF3', '#54A0FF', '#5F27CD'];
return (
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Button
type="button"
variant="outline"
size="icon"
className={cn('w-8 h-8 border-2 border-gray-300 p-0', error && 'border-red-500')}
style={{ backgroundColor: error ? 'transparent' : value || '#ffffff' }}
onClick={() => setIsOpen(!isOpen)}
/>
<Input value={value || ''} onChange={e => onChange(e.target.value)} placeholder="#ffffff" />
</div>
{isOpen && (
<Card>
<CardContent className="grid grid-cols-4 gap-2 p-2">
{colors.map(color => (
<Button
key={color}
type="button"
variant="outline"
size="icon"
className="w-8 h-8 border-2 border-gray-300 hover:border-gray-500 p-0"
style={{ backgroundColor: color }}
onClick={() => {
onChange(color);
setIsOpen(false);
}}
/>
))}
</CardContent>
</Card>
)}
</div>
);
};
Here's how this component will look when rendered in your form:
Use this path for fields defined in your Vendure custom field configuration. Custom field components are registered globally with an ID, then selected from the custom field's ui.component property. They are not targeted by pageId, blockId, or field location.
Let's configure a custom field which uses the ColorPickerComponent as its form component.
First we need to register the component with the defineDashboardExtension function:
import { defineDashboardExtension } from '@vendure/dashboard';
import { ColorPickerComponent } from './components/color-picker';
defineDashboardExtension({
customFormComponents: {
// Custom field components for custom fields
customFields: [
{
// The "id" is a global identifier for this custom component. We will // [!code highlight]
// reference it in the next step. // [!code highlight]
id: 'color-picker', // [!code highlight]
component: ColorPickerComponent, // [!code highlight]
},
],
},
// ... other extension properties
});
Now that we've registered it as a custom field component, we can use it in our custom field definition.
@VendurePlugin({
// ...
configuration: config => {
config.customFields.Product.push({
name: 'color',
type: 'string',
pattern: '^#[A-Fa-f0-9]{6}$',
label: [{ languageCode: LanguageCode.en, value: 'Color' }],
description: [{ languageCode: LanguageCode.en, value: 'Main color for this product' }],
ui: {
// This is the ID of the custom field // [!code highlight]
// component we registered above. // [!code highlight]
component: 'color-picker', // [!code highlight]
},
});
return config;
},
})
export class MyPlugin {}
The ColorPickerComponent can also be used as a configurable operation argument component. For example, we can add a color code
to a shipping calculator:
const customShippingCalculator = new ShippingCalculator({
code: 'custom-shipping-calculator',
description: [{ languageCode: LanguageCode.en, value: 'Custom Shipping Calculator' }],
args: {
color: {
type: 'string',
label: [{ languageCode: LanguageCode.en, value: 'Color' }],
description: [
{ languageCode: LanguageCode.en, value: 'Color code for this shipping calculator' },
],
ui: { component: 'color-picker' }, // [!code highlight]
},
},
calculate: (ctx, order, args) => {
// ...
},
});
Use this path when you want to replace the rendered input for a native field on an existing detail page, such as Product.description, Product.slug, or Customer.emailAddress. These overrides are targeted to a specific page, block, and field.
For plugin-defined custom fields, use custom field components instead.
Let's say we want to use a plain text editor for the product description field rather than the default html-based editor.
import { DashboardFormComponent, Textarea } from '@vendure/dashboard';
// This is a simplified example - a real markdown editor should use
// a library that handles markdown rendering and editing.
export const MarkdownEditorComponent: DashboardFormComponent = props => {
return (
<Textarea
className="font-mono"
ref={props.ref}
onBlur={props.onBlur}
value={props.value}
onChange={e => props.onChange(e.target.value)}
disabled={props.disabled}
/>
);
};
You can then use this component in your detail form definition:
import { defineDashboardExtension } from '@vendure/dashboard';
import { MarkdownEditorComponent } from './components/markdown-editor';
defineDashboardExtension({
detailForms: [
{
pageId: 'product-detail', // [!code highlight]
inputs: [
// [!code highlight]
{
// [!code highlight]
blockId: 'main-form', // [!code highlight]
field: 'description', // [!code highlight]
component: MarkdownEditorComponent, // [!code highlight]
}, // [!code highlight]
], // [!code highlight]
},
],
});
Native detail-page input overrides are targeted in two levels:
pageId: The ID of the page, defined on the surrounding detailForms[] entry.blockId: The ID of the form block, defined on each inputs[] item.field: The native field name to replace, defined on each inputs[] item.defineDashboardExtension({
detailForms: [
{
pageId: 'customer-detail',
inputs: [
{
blockId: 'main-form',
field: 'emailAddress',
component: EmailInputComponent,
},
],
},
],
});
You can discover the required IDs by turning on dev mode:
and then hovering over any of the form elements will allow you to view the IDs:
Form validation is handled by the react-hook-form library, which is used by the Dashboard. Internally,
the Dashboard uses the zod library to validate the form data, based on the configuration of the custom field or
operation argument.
You can access validation data for the current field or the whole form by using the useFormContext hook.
:::note[Error Messages] Your component does not need to handle standard error messages - the Dashboard will handle them for you.
For example, if your custom field specifies a pattern property, the Dashboard will automatically display an error message
if the input does not match the pattern.
:::
import { DashboardFormComponent, Input, Alert, AlertDescription, useFormContext } from '@vendure/dashboard';
import { CheckCircle2 } from 'lucide-react';
export const ValidatedInputComponent: DashboardFormComponent = ({
value,
onChange,
onBlur,
disabled,
name,
}) => {
const { getFieldState } = useFormContext();
const fieldState = getFieldState(name);
console.log(fieldState);
// will log something like this:
// {
// "invalid": false,
// "isDirty": false,
// "isValidating": false,
// "isTouched": false
// }
// You can use this data to display validation errors, etc.
return (
<div className="space-y-2">
<Input
value={value || ''}
onChange={e => onChange(e.target.value)}
onBlur={onBlur}
disabled={disabled}
className={fieldState.error ? 'border-destructive' : ''}
/>
{fieldState.error && (
<Alert variant="destructive">
<AlertDescription>{fieldState.error.message}</AlertDescription>
</Alert>
)}
{fieldState.isTouched && !fieldState.error && (
<div className="flex items-center gap-1 text-sm text-green-600">
<CheckCircle2 className="h-4 w-4" />
Valid input
</div>
)}
</div>
);
};
:::tip[Best Practices]
@vendure/dashboard package for consistent stylingonChange and onBlur appropriatelyfieldState.error when they existtext-destructive, text-muted-foreground, etc.disabled proppageId, blockId, and field:::
:::important Design System Consistency
Always import UI components from the @vendure/dashboard package rather than creating custom inputs or buttons. This ensures your components follow the dashboard's design system and remain consistent with future updates.
:::
Together, custom field components and native detail-page field overrides give you complete control over how data is presented and edited in the Dashboard, while maintaining integration with React Hook Form and the Dashboard design system.
When creating custom form components that contain their own forms (e.g., dialogs with forms inside detail pages), you need to prevent form submission events from bubbling up to parent forms. The dashboard provides the handleNestedFormSubmit utility for this purpose.
Detail pages in the dashboard are themselves forms. If you add a custom component with its own form (like a dialog with create/edit functionality), submitting the inner form will also trigger the outer detail page form submission. This can cause:
The handleNestedFormSubmit utility prevents event propagation and properly handles form submission:
import {
Button,
Dialog,
DialogContent,
DialogTrigger,
Form,
DashboardFormComponent,
handleNestedFormSubmit,
useForm,
} from '@vendure/dashboard';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
const formSchema = z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().min(1, 'Description is required'),
});
type FormData = z.infer<typeof formSchema>;
export const NestedFormDialogComponent: DashboardFormComponent = props => {
const form = useForm<FormData>({
resolver: zodResolver(formSchema),
defaultValues: {
title: '',
description: '',
},
});
const onSubmit = (data: FormData) => {
// Handle your form submission logic
console.log('Form submitted:', data);
// You might update the parent form value here
props.onChange(data);
form.reset();
};
return (
<Dialog>
<DialogTrigger render={<Button variant="outline" />}>Open Form</DialogTrigger>
<DialogContent>
<Form {...form}>
<form onSubmit={handleNestedFormSubmit(form, onSubmit)}>
<Button type="submit">Save</Button>
</form>
</Form>
</DialogContent>
</Dialog>
);
};
The utility function:
e.stopPropagation())e.preventDefault())Use handleNestedFormSubmit whenever you have:
The dashboard includes powerful relation selector components for selecting related entities with built-in search and pagination:
import {
SingleRelationInput,
createRelationSelectorConfig,
graphql,
DashboardFormComponentProps,
} from '@vendure/dashboard';
const productConfig = createRelationSelectorConfig({
listQuery: graphql(`
query GetProductsForSelection($options: ProductListOptions) {
products(options: $options) {
items {
id
name
}
totalItems
}
}
`),
idKey: 'id',
labelKey: 'name',
placeholder: 'Search products...',
buildSearchFilter: (term: string) => ({
name: { contains: term },
}),
});
export function ProductSelectorComponent({ value, onChange }: DashboardFormComponentProps) {
return <SingleRelationInput value={value} onChange={onChange} config={productConfig} />;
}
Features include:
For detailed examples of reusable input components, see these dedicated guides: