Overview
The form system uses self-contained block definitions that handle UI, data transformation, analytics aggregation, and UDE display. Each block type is registered in a central registry - no switch statements needed.
What it is
A plugin architecture where each block type defines its own rendering, validation, data transformation, and analytics in a single definition file.
Why we use it
Adding new block types requires zero edits to switch statements. All behavior is co-located in the block definition file.
When to use
Always use getBlockType() to access block capabilities. Never hardcode block behavior in switch statements.
Block Categories
| Category | Purpose | Examples |
|---|---|---|
| share | Display content | content, heading |
| collect | Gather data input | text_input, single_choice, rating_scale |
| assign | Allocate resources | product codes, tracking |
| verify | Confirm data | UDE verification |
| utility | Structure | page_break, group_field |
Render Modes
| Mode | Context | Behavior |
|---|---|---|
| canvas | Designer preview | Disabled, no form context |
| preview | Interactive preview | FormProvider, no submission |
| form | Participant view | Full validation + submission |
| results | Response display | Read-only, response prop |
Quick Start
Block Lookup
Always use the block registry to access block behavior.
// Block definition lookup - always use registry
import { getBlockType } from '@/ui/blocks/dynamic-block/blocks';
const blockDef = getBlockType(block.blockType);
if (blockDef?.analytics?.aggregate) {
return blockDef.analytics.aggregate(responses, config, total);
}Patterns
Response Transformation
Convert between form values and typed responses.
// transformResponse: Form value → Typed response
transformResponse: (value, block, context) => {
if (value === undefined || value === null || value === '') {
return null;
}
return { value: String(value) };
},
// flattenResponse: Typed response → Form value (for hydration)
flattenResponse: (response) => ({
value: response.value,
}),Choice Block Transform
Handle auxiliary fields like 'Other' text input.
// Choice block with "Other" option support
transformResponse: (value, block, context) => {
if (!value) return null;
const selectedId = value as string;
const result: SingleChoiceResponse = { selectedId };
// Include other value if selected
if (selectedId === '__other__') {
const otherValue = context.auxiliaryFields[`${block.id}_other`] as string;
result.otherValue = otherValue || '';
}
return result;
},
getAuxiliaryFieldNames: (blockId, config) => {
if (config.allowOther) {
return [`${blockId}_other`];
}
return [];
},Analytics Aggregation
Aggregate responses for analytics dashboard.
// Analytics aggregation via block definition
import { getBlockType } from '@/ui/blocks/dynamic-block/blocks';
function aggregateBlockResponses(block: Block, responses: Response[], total: number) {
const blockDef = getBlockType(block.blockType);
if (blockDef?.analytics?.aggregate) {
return blockDef.analytics.aggregate(responses, block.config, total);
}
// Fallback for unknown block types
return { responseCount: responses.length, responseRate: responses.length / total };
}Watch Out
Never use switch statements for block behavior
Don't
// WRONG - Hardcoded switch statement
switch (block.blockType) {
case 'rating_scale':
return aggregateRatingScale(responses);
case 'single_choice':
return aggregateSingleChoice(responses);
case 'multi_choice':
return aggregateMultiChoice(responses);
// ... grows with every new block
}Do
// RIGHT - Block definition lookup
import { getBlockType } from '@/ui/blocks/dynamic-block/blocks';
const blockDef = getBlockType(block.blockType);
if (blockDef?.analytics?.aggregate) {
return blockDef.analytics.aggregate(responses, config, total);
}Always handle null/empty values in transformResponse
Don't
// WRONG - No null/empty handling
transformResponse: (value) => ({ value: String(value) }),Do
// RIGHT - Proper null/empty handling
transformResponse: (value, block, context) => {
if (value === undefined || value === null || value === '') {
return null;
}
return { value: String(value) };
},- Block type names use snake_case (text_input, not textInput)
- Data-collecting blocks MUST define responseSchema and transformResponse
- Display-only blocks must set analytics.type = hidden
Key Files
| File | Purpose |
|---|---|
| src/ui/blocks/dynamic-block/types.ts | BlockTypeDefinition interface |
| src/ui/blocks/dynamic-block/blocks/index.ts | Block registry (BLOCK_TYPES, getBlockType) |
| src/ui/blocks/dynamic-block/capabilities.ts | Capability presets |
| src/ui/blocks/dynamic-block/block-renderer.tsx | Mode-based rendering dispatcher |
| src/features/resources/services.ts | Analytics aggregation dispatch |