Skip to main content

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:
  1. Schema (apps/terra/src/types/schema.ts) – Define the field’s shape
  2. Component (apps/terra/src/components/engine/fields/) – Build the React component
  3. Registry (apps/terra/src/components/engine/registry.tsx) – Register the component
  4. Toolbox (apps/terra/src/components/form-builder/toolbox.tsx) – Add to the builder
1

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.
2

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.
3

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
};
4

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,
      },
    },
  ],
},
5

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

  1. Unit Test: Create apps/terra/src/components/engine/fields/__tests__/rating-field.test.tsx
  2. Visual Test: Open the form builder, add your field, verify it renders
  3. 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.