Adding Custom Form Fields
This guide walks you through adding a new field type to Terra’s form engine. We’ll use a hypothetical “Rating” field as an example.
Overview
Adding a field requires changes in four places:
- Schema (
apps/terra/src/types/schema.ts) – Define the field’s shape
- Component (
apps/terra/src/components/engine/fields/) – Build the React component
- Registry (
apps/terra/src/components/engine/registry.tsx) – Register the component
- Toolbox (
apps/terra/src/components/form-builder/toolbox.tsx) – Add to the builder
Define the Schema
Add your field type to the Zod discriminated union in apps/terra/src/types/schema.ts:// types/schema.ts
// Add the new field schema
export const RatingFieldSchema = z.object({
type: z.literal("rating"),
id: z.string(),
label: I18nStringSchema,
required: z.boolean().default(false),
// Field-specific properties
maxStars: z.number().min(3).max(10).default(5),
allowHalf: z.boolean().default(false),
// Visibility
logic: LogicConditionSchema.optional(),
logicAction: z.enum(["show", "hide"]).default("show"),
// Layout
layout: FieldLayoutSchema.optional(),
});
export type RatingField = z.infer<typeof RatingFieldSchema>;
// Add to the union
export const FormElementSchema = z.discriminatedUnion("type", [
TextFieldSchema,
ChoiceFieldSchema,
// ... existing fields
RatingFieldSchema, // ← Add here
]);
Remember to add your new schema to the discriminated union array, or the form engine won’t recognize it.
Create the Component
Create a new file at apps/terra/src/components/engine/fields/rating-field.tsx:"use client";
import { useFormContext, Controller } from "react-hook-form";
import { Star } from "lucide-react";
import { RatingField as RatingFieldType, getLocalizedString } from "@/types/schema";
import { useFormReadOnly } from "@/components/engine/renderer";
import { cn } from "@unify/ui";
interface RatingFieldProps {
field: RatingFieldType;
locale?: string;
}
export function RatingField({ field, locale = "en" }: RatingFieldProps) {
const { control } = useFormContext();
const readOnly = useFormReadOnly();
const label = getLocalizedString(field.label, locale);
const maxStars = field.maxStars || 5;
return (
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">
{label}
{field.required && <span className="text-red-500 ml-1">*</span>}
</label>
<Controller
name={field.id}
control={control}
rules={{ required: field.required ? "Rating is required" : false }}
render={({ field: formField, fieldState }) => (
<div className="space-y-1">
<div className="flex gap-1">
{Array.from({ length: maxStars }, (_, i) => (
<button
key={i}
type="button"
disabled={readOnly}
onClick={() => formField.onChange(i + 1)}
className={cn(
"p-1 transition-colors",
readOnly && "cursor-default"
)}
>
<Star
className={cn(
"h-6 w-6",
i < (formField.value || 0)
? "fill-amber-400 text-amber-400"
: "text-gray-300"
)}
/>
</button>
))}
</div>
{fieldState.error && (
<p className="text-xs text-red-500">{fieldState.error.message}</p>
)}
</div>
)}
/>
</div>
);
}
Use Controller from react-hook-form for custom inputs that don’t use native HTML elements.
Register the Component
Add your component to the registry in apps/terra/src/components/engine/registry.tsx:// components/engine/registry.tsx
import { RatingField } from "./fields/rating-field";
export const FORM_COMPONENTS: Record<string, React.ComponentType<any>> = {
text: TextField,
choice: ChoiceField,
// ... existing components
rating: RatingField, // ← Add here
};
Add to the Toolbox
Update the builder toolbox in apps/terra/src/components/form-builder/toolbox.tsx:// In the FIELD_GROUPS array, add to an appropriate section
{
title: "Advanced",
icon: Sparkles,
fields: [
// ... existing fields
{
type: "rating",
label: "Rating",
icon: Star,
defaultProps: {
maxStars: 5,
allowHalf: false,
},
},
],
},
Add Properties Panel Support
Update apps/terra/src/components/form-builder/properties-panel.tsx to show field-specific options:// In the renderFieldProperties function
{selectedField.type === "rating" && (
<>
<div className="space-y-2">
<Label>Max Stars</Label>
<Input
type="number"
min={3}
max={10}
value={selectedField.maxStars || 5}
onChange={(e) => updateField({ maxStars: parseInt(e.target.value) })}
/>
</div>
<div className="flex items-center gap-2">
<Switch
checked={selectedField.allowHalf || false}
onCheckedChange={(checked) => updateField({ allowHalf: checked })}
/>
<Label>Allow Half Stars</Label>
</div>
</>
)}
Testing Your Field
- Unit Test: Create
apps/terra/src/components/engine/fields/__tests__/rating-field.test.tsx
- Visual Test: Open the form builder, add your field, verify it renders
- Submission Test: Submit a form with your field, verify data saves correctly
// Example unit test
import { render, screen, fireEvent } from "@testing-library/react";
import { FormProvider, useForm } from "react-hook-form";
import { RatingField } from "../rating-field";
const Wrapper = ({ children }) => {
const methods = useForm();
return <FormProvider {...methods}>{children}</FormProvider>;
};
test("renders stars and allows selection", () => {
render(
<Wrapper>
<RatingField
field={{
type: "rating",
id: "rating_1",
label: "Rate us",
maxStars: 5
}}
/>
</Wrapper>
);
const stars = screen.getAllByRole("button");
expect(stars).toHaveLength(5);
fireEvent.click(stars[3]);
// Verify 4 stars are now filled
});
Field Type Checklist
Before merging a new field type, verify:
Schema is added to FormElementSchema union
Component handles required validation
Component respects readOnly mode
Component supports locale for labels
Registry maps type → component
Toolbox includes the field with icon
Properties panel shows field-specific options
PDF renderer handles the field (if applicable)
Back to Stack Overview
Review the full technology stack.