CentercodePorygon
DittoPorygonAPI

Block Definitions

Creating and registering form block types

Safe

Overview

Block definitions are self-contained modules that define all aspects of a form block: config schema, response schema, data transformation, UI components, UDE mapping, and analytics aggregation. This page covers the backend/implementation side - for UI patterns, see the ditto-design skill.

What it is

A BlockTypeDefinition interface that encapsulates all block behavior in one file. Type-safe config and response schemas with Zod validation.

Why we use it

Zero switch statements to add new blocks. Co-located behavior makes blocks easy to understand and maintain. AI metadata enables template generation.

When to use

Creating new form field types. Each block type gets its own folder under src/ui/blocks/dynamic-block/blocks/

Capability Presets

PresetUse ForCapabilities
FORM_FIELD_CAPABILITIESData collection blockshasTitle, hasSubtitle, hasHint, collectsData
HEADING_CAPABILITIESSection headershasTitle, hasSubtitle, isDecorative
CONTENT_CAPABILITIESRich text displayhasTitle, hasSubtitle (body), isDecorative
PAGE_BREAK_CAPABILITIESPage boundariesisPageBreak, isDecorative
CONTAINER_CAPABILITIESGroup containershasTitle, hasSubtitle, isContainer

Quick Start

1. Define Schemas

Define schemas for config and response data.

import { z } from 'zod';
import { faIcon } from '@fortawesome/duotone-regular-svg-icons';
import type { BlockTypeDefinition } from '../../types';
import { FORM_FIELD_CAPABILITIES } from '../../capabilities';
import { MyConfigEditor } from './config-editor';
import { MyRenderer } from '../../renderers/my-renderer';
import { aggregateMyBlock } from '@/features/resources/analytics';

// =============================================================================
// Config Schema
// =============================================================================

export const myBlockConfigSchema = z.object({
  variant: z.enum(['option1', 'option2']).default('option1'),
  placeholder: z.string().optional(),
  maxLength: z.number().min(1).optional(),
});

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

// =============================================================================
// Response Schema
// =============================================================================

export const myBlockResponseSchema = z.object({
  value: z.string(),
});

export type MyBlockResponse = z.infer<typeof myBlockResponseSchema>;

2. Create Definition

Create the block definition with all required properties.

// =============================================================================
// Block Definition
// =============================================================================

export const myBlockDefinition: BlockTypeDefinition<MyBlockConfig, MyBlockResponse> = {
  type: 'my_block',
  category: 'collect',
  label: 'My Block',
  description: 'Short description for picker',
  icon: faIcon,

  capabilities: FORM_FIELD_CAPABILITIES,

  configSchema: myBlockConfigSchema,
  defaultConfig: {
    variant: 'option1',
    placeholder: '',
  },

  responseSchema: myBlockResponseSchema,

  transformResponse: (value, block, context) => {
    if (value === undefined || value === null || value === '') {
      return null;
    }
    return { value: String(value) };
  },

  flattenResponse: (response) => ({
    value: response.value,
  }),

  ConfigEditor: MyConfigEditor,
  Renderer: MyRenderer,

  ude: {
    fieldType: 'TEXT',
    columnHelper: 'text',
    getValue: (response) => {
      const r = response as MyBlockResponse | null;
      return r?.value ?? null;
    },
  },

  analytics: {
    type: 'responses',
    aggregate: (responses, config, total) =>
      aggregateMyBlock(responses as MyBlockResponse[], total),
  },

  insights: {
    contributesToScore: false,
  },

  ai: {
    description: 'Detailed description for AI template generation.',
    useCases: ['Scenario 1', 'Scenario 2'],
    configExamples: [
      {
        scenario: 'Example scenario',
        config: { variant: 'option1', placeholder: 'Enter value...' },
      },
    ],
  },
};

3. Register Block

Add to BLOCK_TYPES registry.

// src/ui/blocks/dynamic-block/blocks/index.ts
import { myBlockDefinition } from './my-block/config';

export const BLOCK_TYPES = {
  // ... existing blocks
  my_block: myBlockDefinition,
} as const;

export function getBlockType(type: string) {
  return BLOCK_TYPES[type as keyof typeof BLOCK_TYPES] ?? null;
}

Patterns

Display-Only Block

Display blocks with no data collection.

// Display-only block - no responseSchema
export const headingBlockDefinition: BlockTypeDefinition<HeadingConfig> = {
  type: 'heading',
  category: 'share',
  label: 'Heading',
  description: 'Section header',
  icon: faHeading,

  capabilities: HEADING_CAPABILITIES,

  configSchema: headingConfigSchema,
  defaultConfig: { level: 'h2' },

  // No responseSchema - display only
  ConfigEditor: HeadingConfigEditor,
  Renderer: HeadingRenderer,

  // REQUIRED: explicit hidden analytics
  analytics: {
    type: 'hidden',
    aggregate: () => ({ responseCount: 0, responseRate: 0, data: {} }),
  },
};

UDE Configuration

Map block responses to UDE columns.

// UDE field type mapping
ude: {
  fieldType: 'TEXT',      // UDE column type
  columnHelper: 'text',   // Column renderer helper
  getValue: (response) => {
    const r = response as MyBlockResponse | null;
    return r?.value ?? null;
  },
},

// Available fieldTypes:
// - TEXT: Free-form text
// - SINGLE_SELECT: Dropdown/radio
// - MULTI_SELECT: Checkboxes
// - NUMBER: Numeric value
// - DATE: Date/time
// - BOOLEAN: Yes/No

Analytics Configuration

Define how responses are aggregated for analytics.

// Analytics configuration
analytics: {
  type: 'metrics',      // 'metrics' | 'distribution' | 'responses' | 'hidden'
  aggregate: (responses, config, total) => {
    const values = responses.map(r => r.value);
    const avg = values.reduce((a, b) => a + b, 0) / values.length;

    return {
      responseCount: responses.length,
      responseRate: responses.length / total,
      data: {
        average: avg,
        min: Math.min(...values),
        max: Math.max(...values),
      },
    };
  },
},

// Analytics types:
// - metrics: Numeric aggregation (average, median, distribution)
// - distribution: Option counts (bar charts)
// - responses: Raw response list (text blocks)
// - hidden: No dashboard display (decorative blocks)

Watch Out

Display blocks MUST have explicit hidden analytics

Don't

// WRONG - Missing analytics config
export const headingBlockDefinition: BlockTypeDefinition<HeadingConfig> = {
  type: 'heading',
  category: 'share',
  // ... no analytics property
};

Do

// RIGHT - Explicit hidden analytics
export const headingBlockDefinition: BlockTypeDefinition<HeadingConfig> = {
  type: 'heading',
  category: 'share',
  analytics: {
    type: 'hidden',
    aggregate: () => ({ responseCount: 0, responseRate: 0, data: {} }),
  },
};

Collect blocks MUST have responseSchema and transformResponse

Don't

// WRONG - Collect block without responseSchema
export const textInputDefinition: BlockTypeDefinition<TextInputConfig> = {
  type: 'text_input',
  category: 'collect',
  // Missing responseSchema!
  // Missing transformResponse!
};

Do

// RIGHT - Collect block with required schemas
export const textInputDefinition: BlockTypeDefinition<TextInputConfig, TextInputResponse> = {
  type: 'text_input',
  category: 'collect',
  responseSchema: textInputResponseSchema,
  transformResponse: (value, block, ctx) => {
    if (!value) return null;
    return { value: String(value) };
  },
  flattenResponse: (response) => ({ value: response.value }),
};
  • Type names use snake_case (text_input, not textInput or TextInput)
  • Use capability presets from capabilities.ts, not manual objects
  • Response schemas must be JSON-serializable (stored in ResourceSubmission)
  • Include AI metadata for template generation (ai.description, useCases, configExamples)

Definition Interface

PropertyRequiredDescription
typeYesUnique identifier (snake_case)
categoryYesshare | collect | assign | verify | utility
configSchemaYesZod schema for builder config
responseSchemaCollect onlyZod schema for participant input
transformResponseCollect onlyForm value to response
flattenResponseCollect onlyResponse to form value
RendererYesParticipant UI component
ConfigEditorOptionalBuilder settings UI
analyticsYesDashboard aggregation config
udeOptionalUDE table display config
aiOptionalTemplate generation metadata

Related

Form System

Architecture overview

Validation

Zod schemas

Analytics

Response aggregation