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:
- Upload file → receive
file_token - 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.pyin your app - [ ] Implement
get_fields(target_config)→ returnslist[FieldInfo] - [ ] Implement
get_config_schema()→ returns config field definitions - [ ] Implement
hydrate(submission, mappings, target_config)→ creates model instance - [ ] Call
registry.register(HydrationTarget(...))inAppConfig.ready() - [ ] (Optional) Implement
get_suggestions()for auto-mapping hints - [ ] (Optional) Implement
get_standard_mappings()for preset field paths