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.
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
| Prop | Type | Default | Description |
|---|---|---|---|
config | TConfig | - | Current block config (type-specific only). Available via form context. |
allBlocks | DynamicBlockData[] | - | All blocks in the form - for conditional logic or references |
disabled | boolean | false | Whether the editor is disabled (read-only mode) |
BlockTypeDefinition
| Prop | Type | Default | Description |
|---|---|---|---|
type | string | required | Unique block type identifier (e.g., "text_input", "rating_scale") |
category | "input" | "content" | "layout" | required | Block category for grouping in selector |
icon | IconDefinition | required | Font Awesome icon for the block |
label | string | required | Display name in block selector |
canBePII | boolean | false | If true, shows Privacy section with isPII toggle |
canBeRequired | boolean | false | If 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';