CentercodePorygon
DittoPorygonAPI

Form System

Dynamic block-based form engine architecture

Safe

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

CategoryPurposeExamples
shareDisplay contentcontent, heading
collectGather data inputtext_input, single_choice, rating_scale
assignAllocate resourcesproduct codes, tracking
verifyConfirm dataUDE verification
utilityStructurepage_break, group_field

Render Modes

ModeContextBehavior
canvasDesigner previewDisabled, no form context
previewInteractive previewFormProvider, no submission
formParticipant viewFull validation + submission
resultsResponse displayRead-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

FilePurpose
src/ui/blocks/dynamic-block/types.tsBlockTypeDefinition interface
src/ui/blocks/dynamic-block/blocks/index.tsBlock registry (BLOCK_TYPES, getBlockType)
src/ui/blocks/dynamic-block/capabilities.tsCapability presets
src/ui/blocks/dynamic-block/block-renderer.tsxMode-based rendering dispatcher
src/features/resources/services.tsAnalytics aggregation dispatch

Related

Block Definitions

Creating new block types

Analytics

Response aggregation

Validation

Zod schemas