CentercodeDitto
DittoPorygonAPI

Block Type Authoring

Create new block types using the ConfigEditor pattern for the form engine

Overview

The form engine uses a separation of concerns pattern: BlockPropertiesPanel owns common fields shared by all blocks (title, subtitle, hint, required, isPII), while each block type provides a ConfigEditor for its type-specific configuration. This prevents code duplication across 40+ block types.

Key Concepts

  • Panel-owned fields: title, subtitle, hint, required, isPII, colSpan
  • ConfigEditor-owned fields: type-specific config (variant, placeholder, options)
  • ConfigEditor renders inside Panel's FormBlock - use "config.{field}" naming
  • ~62% code reduction per editor vs duplicating common fields

Architecture

BlockPropertiesPanel wraps all content in a single FormBlock with auto-save. ConfigEditor components render directly inside this context without their own FormBlock wrapper.

┌─────────────────────────────────────────────────────────────────┐
│  BlockPropertiesPanel                                           │
│  ├─ FormBlock (auto-save mode)                                  │
│  │  ├─ BLOCK SETTINGS ← Panel owns these                        │
│  │  │  ├─ title                                                 │
│  │  │  ├─ subtitle                                              │
│  │  │  ├─ hint                                                  │
│  │  │  └─ required (if canBeRequired)                           │
│  │  │                                                           │
│  │  ├─ {TYPE} OPTIONS ← ConfigEditor renders here               │
│  │  │  └─ <ConfigEditor />                                      │
│  │  │     ├─ config.style                                       │
│  │  │     ├─ config.placeholder                                 │
│  │  │     └─ config.{...}                                       │
│  │  │                                                           │
│  │  ├─ LAYOUT ← Panel owns                                      │
│  │  │  └─ colSpan                                               │
│  │  │                                                           │
│  │  └─ PRIVACY ← Panel owns (if canBePII)                       │
│  │     └─ isPII                                                 │
│  │                                                              │
│  └─ Footer (Duplicate / Delete)                                 │
└─────────────────────────────────────────────────────────────────┘

Live Examples

Select Block Type

Click a block type to see its ConfigEditor in the panel below. Notice how common fields (title, subtitle, hint, required) are always present, while block-specific config changes.

BlockPropertiesPanel (Live Demo)

This is a working simulation of BlockPropertiesPanel. The Panel owns common fields while the ConfigEditor (highlighted section) handles block-specific configuration.

Block Settings

Common fields managed by the Panel


Required

Participants must complete this block

Text Input Options

Block-specific fields from ConfigEditor


ConfigEditor renders here

Privacy

Contains PII

Mark if this block collects personally identifiable information

Last Saved Values

Make changes in the panel to see auto-saved values here. Notice how config.* fields are namespaced under the config object.

// Make changes to see saved values

Key Architecture Points

  • Panel owns FormBlock - Single auto-save context for all fields
  • ConfigEditor uses "config.*" names - Namespaced to avoid conflicts with common fields
  • ~62% code reduction - Each editor only handles its specific config
  • Consistent UX - All block types share the same Panel layout

Props Reference

ConfigEditorProps

PropTypeDefaultDescription
configTConfig-Current block config (type-specific only). Available via form context.
allBlocksDynamicBlockData[]-All blocks in the form - for conditional logic or references
disabledbooleanfalseWhether the editor is disabled (read-only mode)

BlockTypeDefinition

PropTypeDefaultDescription
typestringrequiredUnique block type identifier (e.g., "text_input", "rating_scale")
category"input" | "content" | "layout"requiredBlock category for grouping in selector
iconIconDefinitionrequiredFont Awesome icon for the block
labelstringrequiredDisplay name in block selector
canBePIIbooleanfalseIf true, shows Privacy section with isPII toggle
canBeRequiredbooleanfalseIf true, shows Required toggle in Block Settings

Usage

Creating a ConfigEditor

Each ConfigEditor renders only block-specific fields. Use the "config.{field}" naming convention to namespace fields properly.

'use client';

import { useTranslations } from 'next-intl';
import { FormTextField, FormRadioField } from '@/ui/blocks/form-block';
import type { ConfigEditorProps } from '../../types';
import type { MyBlockConfig } from './config';

/**
 * My Block Config Editor
 *
 * Renders ONLY block-specific configuration fields.
 * Common fields (title, subtitle, hint, required, isPII) are handled by BlockPropertiesPanel.
 *
 * IMPORTANT: This component renders inside the Panel's FormBlock context.
 * Use "config.{field}" as the name for form fields.
 */
export function MyBlockConfigEditor({ disabled: _disabled }: ConfigEditorProps<MyBlockConfig>) {
  const t = useTranslations();

  return (
    <>
      <FormRadioField
        name="config.style"
        label={t('my-block.style')}
        options={[
          { value: 'default', label: 'Default' },
          { value: 'compact', label: 'Compact' },
        ]}
        orientation="horizontal"
      />
      <FormTextField
        name="config.placeholder"
        label={t('form-engine.editor.placeholder')}
      />
    </>
  );
}

Complete Block Type Setup

Each block type needs a config schema (Zod), a type definition, and a ConfigEditor. Export everything from the block's index.ts and register in the main blocks index.

// blocks/my-block/config.ts
import { z } from 'zod';
import { faInputText } from '@fortawesome/duotone-regular-svg-icons';
import type { BlockTypeDefinition } from '../../types';

export const myBlockConfigSchema = z.object({
  style: z.enum(['default', 'compact']).default('default'),
  placeholder: z.string().optional(),
});

export type MyBlockConfig = z.infer<typeof myBlockConfigSchema>;

export const myBlockDefinition: BlockTypeDefinition = {
  type: 'my_block',
  category: 'input',
  icon: faInputText,
  label: 'My Block',
  canBePII: true,        // Shows Privacy section
  canBeRequired: true,   // Shows Required toggle
};

// blocks/my-block/index.ts
export { myBlockDefinition, myBlockConfigSchema, type MyBlockConfig } from './config';
export { MyBlockConfigEditor } from './config-editor';