Skip to content

Dynamic Forms - Developer Guide

This guide explains the Dynamic Forms architecture from a developer's perspective: how to register new hydration targets, how the hybrid frontend works, and how to extend the system.

Architecture Overview

┌─────────────────────────────────────────────────────────────────────────┐
│                           DYNAMIC FORMS SYSTEM                          │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  ┌─────────────┐    ┌──────────────┐    ┌─────────────────────────────┐│
│  │ Form.io     │───▶│ Form         │───▶│ FormSubmission              ││
│  │ Builder     │    │ Definition   │    │ (immutable payload)         ││
│  └─────────────┘    └──────────────┘    └─────────────────────────────┘│
│                            │                         │                  │
│                            │ use_case FK             │                  │
│                            ▼                         ▼                  │
│                     ┌──────────────┐          ┌─────────────┐           │
│                     │ UseCase      │◀─────────│ Hydration   │           │
│                     │ - target     │          │ Service     │           │
│                     │ - config     │          └─────────────┘           │
│                     └──────────────┘                 │                  │
│                            │                         │                  │
│                            │ target_model            │                  │
│                            ▼                         ▼                  │
│  ┌─────────────────────────────────────────────────────────────────────┐│
│  │                    HYDRATION REGISTRY                               ││
│  │  ┌─────────────────┐  ┌─────────────────┐  ┌─────────────────┐     ││
│  │  │ occurrences.    │  │ equipment.      │  │ your_app.       │     ││
│  │  │ Occurrence      │  │ Equipment       │  │ YourModel       │     ││
│  │  │                 │  │                 │  │                 │     ││
│  │  │ • get_fields()  │  │ • get_fields()  │  │ • get_fields()  │     ││
│  │  │ • hydrate()     │  │ • hydrate()     │  │ • hydrate()     │     ││
│  │  │ • config_schema │  │ • config_schema │  │ • config_schema │     ││
│  │  └─────────────────┘  └─────────────────┘  └─────────────────┘     ││
│  └─────────────────────────────────────────────────────────────────────┘│
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

Key Concepts

Concept Description
FormDefinition A form template with a Form.io schema. Links to a UseCase for hydration.
UseCase Defines what a form creates. Contains target_model (e.g., occurrences.Occurrence) and target_config (e.g., {occurrence_type_id: 5}).
HydrationTarget A registered handler that knows how to turn submissions into domain objects. Each app registers its own.
FieldBinding Maps a form field path to a target model field. Supports transforms (TO_INT, LOOKUP, GEO_PARSE).

The Registry Pattern

Dynamic Forms stays 100% generic by using a registry. Target apps register themselves at startup:

# apps/occurrences/apps.py
class OccurrencesConfig(AppConfig):
    def ready(self):
        from apps.occurrences.hydration import register_occurrence_target
        register_occurrence_target()

This means: - dynamic_forms has zero knowledge of occurrences or any other app - New models can become hydration targets without modifying dynamic_forms - The admin UI auto-discovers available targets from the registry


Registering a New Hydration Target

To make your model a hydration target, create a hydration.py in your app:

Step 1: Define Field Discovery

# apps/your_app/hydration.py
from apps.dynamic_forms.registry import HydrationTarget, FieldInfo, registry

def get_your_model_fields(target_config: dict) -> list[FieldInfo]:
    """
    Return all fields available for mapping.

    Categories:
    - 'required': Must be provided
    - 'auto_filled': Set automatically (e.g., created_by, municipality)
    - 'optional': Can be mapped but not required
    - 'dynamic': Type-specific fields (if your model has polymorphism)
    """
    fields = [
        FieldInfo(
            key="title",
            label="Title",
            field_type="CharField",
            category="required",
            required=True,
        ),
        FieldInfo(
            key="status",
            label="Status",
            field_type="CharField",
            category="auto_filled",
            help_text="Defaults to 'draft'",
        ),
        # ... more fields
    ]

    # If your model has type-specific fields:
    type_id = target_config.get("your_type_id")
    if type_id:
        # Add dynamic fields based on type
        pass

    return fields

Step 2: Define Config Schema

def get_your_model_config_schema() -> list[dict]:
    """
    Define what options UseCase.target_config can have.

    These appear in the admin when configuring a UseCase for your model.
    """
    return [
        {
            "key": "your_type_id",
            "label": "Your Type",
            "type": "fk",  # or "choice", "boolean", "text"
            "required": False,
            "help_text": "Select the type this form creates",
            "options": [  # For FK/choice types
                {"id": 1, "label": "Type A"},
                {"id": 2, "label": "Type B"},
            ],
        },
        {
            "key": "auto_approve",
            "label": "Auto-approve",
            "type": "boolean",
            "default": False,
            "help_text": "Skip approval workflow",
        },
    ]

Step 3: Implement Hydration

def hydrate_your_model(submission, mappings: list, target_config: dict):
    """
    Create your model instance from a form submission.

    Args:
        submission: FormSubmission instance with payload_json
        mappings: List of FieldBinding instances
        target_config: UseCase target_config dict

    Returns:
        Created model instance
    """
    from apps.your_app.models import YourModel

    payload = submission.payload_json

    # Start with auto-filled values
    data = {
        "municipality": submission.municipality,
        "status": "draft",
    }

    # Apply config defaults
    if target_config.get("your_type_id"):
        data["your_type_id"] = target_config["your_type_id"]

    # Apply field mappings
    for mapping in mappings:
        value = get_nested_value(payload, mapping.source_field_path)
        if value is not None:
            value = apply_transform(value, mapping.transform, mapping.transform_params)
            data[mapping.target_field_path] = value

    return YourModel.objects.create(**data)

Step 4: Register at Startup

def register_your_model_target():
    registry.register(HydrationTarget(
        model_path="your_app.YourModel",
        label="Your Model",
        description="Description shown in admin",
        get_fields=get_your_model_fields,
        hydrate=hydrate_your_model,
        get_config_schema=get_your_model_config_schema,
        # Optional:
        get_suggestions=get_your_model_suggestions,
        get_standard_mappings=get_your_model_standard_mappings,
    ))
# apps/your_app/apps.py
class YourAppConfig(AppConfig):
    def ready(self):
        from apps.your_app.hydration import register_your_model_target
        register_your_model_target()

Hybrid Frontend Integration

For the best UX, we use a hybrid approach: Svelte handles "preset" sections (map, contact info) while Form.io handles configurable type-specific fields.

Architecture

OccurrenceSubmissionForm.svelte
├── PRESET (Svelte)
│   ├── Title input
│   ├── Type selector  ← triggers form schema load
│   ├── Description textarea
│   └── Image upload
│
├── DYNAMIC (Form.io, conditional)
│   └── DynamicFormRenderer
│       └── Type-specific fields from FormDefinition
│
├── PRESET (Svelte)
│   └── Leaflet Map component
│
└── PRESET (Svelte)
    └── Contact info (name, email, phone)

How Form Discovery Works

No FK needed! The relationship exists via UseCase:

FormDefinition.use_case → UseCase.target_config.occurrence_type_id

API lookup:

# Find FormDefinition for occurrence type 5
use_case = UseCase.objects.filter(
    target_model="occurrences.Occurrence",
    target_config__occurrence_type_id=5
).first()

form_def = FormDefinition.objects.filter(use_case=use_case).first()

API Response

GET /api/occurrence-types/{id}/ returns:

{
  "id": 5,
  "name": "Pothole",
  "code": "pothole",
  "form_schema": {
    "version": "v1",
    "format": "FORMIO",
    "schema": { /* Form.io schema */ }
  }
}

Or for legacy (no FormDefinition):

{
  "form_schema": {
    "version": "v0",
    "dynamic_fields": [ /* legacy DynamicField list */ ]
  }
}

Frontend Logic

// OccurrenceSubmissionForm.svelte
$effect(async () => {
  if (formData.occurrence_type) {
    const typeData = await fetchOccurrenceType(formData.occurrence_type);

    if (typeData.form_schema?.version === 'v1') {
      // Use DynamicFormRenderer for type-specific fields
      formioSchema = typeData.form_schema.schema;
    } else {
      // Fall back to legacy DynamicField rendering
      legacyFields = typeData.form_schema?.dynamic_fields || [];
    }
  }
});

Combined Submission

async function handleSubmit() {
  // Collect Svelte state
  const svelteData = {
    title: formData.title,
    description: formData.description,
    occurrence_type: parseInt(formData.occurrence_type),
    latitude: formData.location.latitude,
    longitude: formData.location.longitude,
    address: formData.location.address,
    reporter_name: formData.contact_name,
    reporter_email: formData.contact_email,
    reporter_phone: formData.contact_phone,
  };

  // Collect Form.io values
  const formioValues = await dynamicFormRenderer.getValues();

  // Combine into dynamic_fields
  const submissionData = {
    ...svelteData,
    dynamic_fields: formioValues,  // Form.io fields go here
  };

  await occurrenceApi.createPublic(submissionData);
}

Transform Types

Field mappings support transforms:

Transform Description Params
NONE Pass through unchanged -
TO_INT Convert to integer -
LOOKUP Map value via lookup table {table: {"Low": "low", "High": "high"}}
GEO_PARSE Parse lat/lng into Point {component: "latitude"} or {component: "longitude"}

GEO_PARSE Example

For location fields, map two form fields to one Point:

# Mapping 1:
source_field_path = "location.latitude"
target_field_path = "location"
transform = "GEO_PARSE"
transform_params = {"component": "latitude"}

# Mapping 2:
source_field_path = "location.longitude"
target_field_path = "location"
transform = "GEO_PARSE"
transform_params = {"component": "longitude"}

The hydration service combines these into a Point(lng, lat).


Standard Mappings (Preset Fields)

For hybrid forms, "preset" fields have standard paths. Register them so the mapping UI knows they're auto-handled:

def get_occurrence_standard_mappings() -> list[dict]:
    return [
        {"source_path": "location.latitude", "target_field": "location", "component": "latitude"},
        {"source_path": "location.longitude", "target_field": "location", "component": "longitude"},
        {"source_path": "title", "target_field": "title"},
        {"source_path": "description", "target_field": "description"},
        {"source_path": "contact_email", "target_field": "reporter_email"},
        # ... etc
    ]

These don't need explicit FieldBindings - the hydration handler knows to look for them.


File References

Files use a two-step upload pattern:

  1. Upload file → receive file_token
  2. Include token in submission payload:
{
  "photo": {
    "file_token": "abc123",
    "name": "photo.jpg",
    "content_type": "image/jpeg",
    "size": 12345
  }
}

The backend consumes tokens into SubmissionFile records during submission creation.


Code Reference

Component Path
Registry apps/dynamic_forms/registry.py
Hydration Service apps/dynamic_forms/services.py
Occurrence Target apps/occurrences/hydration.py
Field Introspection apps/dynamic_forms/introspection.py
Admin Mapping UI apps/dynamic_forms/templates/admin/dynamic_forms/formdefinition/mappings.html
Admin API apps/dynamic_forms/admin_api.py
Form.io Renderer frontend/src/lib/components/forms/DynamicFormRenderer.svelte
Occurrence Form frontend/src/lib/components/forms/OccurrenceSubmissionForm.svelte

Adding a New Target: Checklist

  • [ ] Create hydration.py in your app
  • [ ] Implement get_fields(target_config) → returns list[FieldInfo]
  • [ ] Implement get_config_schema() → returns config field definitions
  • [ ] Implement hydrate(submission, mappings, target_config) → creates model instance
  • [ ] Call registry.register(HydrationTarget(...)) in AppConfig.ready()
  • [ ] (Optional) Implement get_suggestions() for auto-mapping hints
  • [ ] (Optional) Implement get_standard_mappings() for preset field paths