The custom form system¶
Midsummer’s most distinctive subsystem is its JSON-schema form builder. A single engine renders dynamic forms for registration, vendor applications, panel submissions, and shop items — and the same field-type definitions are reused by both the authoring (editor) side and the rendering (end-user) side.
This page explains the theory so you can extend it confidently.
Why a custom form system?¶
Conventions are wildly different from one another. A “registration form” for one event might ask for a fursuit name and shirt size; another asks for legal name, emergency contact, and a waiver signature. Hardcoding fields would mean a fork per event. Instead, an organizer composes a form from field components in a graphical editor, and that definition is stored as JSON and rendered by a shared factory.
The trade-off, accepted deliberately: the submission is also stored as JSON, so reading a specific answer requires drilling into that JSON (see Reading submissions).
The three parts¶
1. REGISTRY a TS map: field-type key → {Angular component, metadata, options}
(register-components.ts, vendor-components.ts, schedule-components.ts,
vendor-task-components.ts, register-checkin-components.ts, shop-components.ts)
2. FACTORY register-form-component-factory dynamically instantiates the
component for a field and injects props/context into it
3. SCHEMA (data) backend JSON: the form DEFINITION (RegistrationForm.fields /
VendorType.form / ShopItem.form) + the SUBMISSION
(Registration.data / Vendor.data / Panel.data)
The field definition (FormField)¶
Every field — whether on the registration form, a vendor application, or a shop
item — conforms to this interface
(ui/src/app/apps/register/register-form-components/form-field.ts):
interface FormField {
id: string; // unique id of this field instance
label: string; // user-facing label
name: string; // storage key / slug
component: string; // field-type key into the registry, e.g.
// 'register-form-component-input'
// 'register-form-component-registration-tiers'
additional_css_classes: string;
required: boolean;
user_editable: boolean;
options: any; // per-type config (see below)
valid: any[]; // choice list for selects, etc.
default: string;
help_text: string;
value: ...; // the ANSWER (on the submission side)
prerequisites?: string[]; // ordered prerequisites (used by vendor tasks)
_ready, _new, _configured: boolean; // editor-side lifecycle flags
}
The component string is the key into the registry. The value field carries
the user’s answer on the submission side (and null/default on the authoring
side). The options field carries per-type configuration authored in the
editor (e.g. an input’s type/maxchars/limit_input, a select’s choices, a
checkbox’s default).
The registry¶
Each registry is a plain object mapping the field-type key to a descriptor.
Example (abridged from register-components.ts):
export const components = {
'register-form-component-input': {
component: RegisterFormComponentInputComponent, // the Angular component
name: 'Input Field',
description: 'A standard input field.',
icon: PrimeIcons.PEN_TO_SQUARE,
canOnlyHaveOne: false,
options: [ // authoring-time config schema
{ param: 'type', widget: 'select', values: [...], label: 'Input Type', default: 'text' },
{ param: 'maxchars', widget: 'number', label: 'Maximum Characters', default: 255 },
{ param: 'limit_input', widget: 'select', values: [...], label: 'Limit Input?', default: 'no' },
{ param: 'is_checkin_column', widget: 'checkbox', label: 'Is Check-in Column', default: false },
],
},
'register-form-component-registration-tiers': {
component: RegisterFormComponentRegistrationTiersComponent,
name: 'Registration Tiers',
canOnlyHaveOne: true, // only one tier picker per form
options: [ { param: 'show_included_merch', ... } ],
},
// …select, multiselect, checkbox, donation, merchandise, coupon,
// signature pad, markdown text, basic details, …
};
Two things to notice:
The descriptor’s
optionsarray is itself a mini form schema (param / widget / values / default). The editor renders that to let the author configure the field. So the form system is recursive: you configure a field using the same param/widget vocabulary.canOnlyHaveOneconstrains singleton fields (e.g. a form has exactly one tier picker and one basic-details block).
There are several registries because different surfaces have different field
sets, but they all share the same factory and the same FormField shape:
Registry file |
Used by |
|---|---|
|
Registration forms |
|
Vendor applications |
|
Vendor required-task authoring |
|
Panel ingest forms |
|
Configurable check-in procedure |
|
Shop item forms |
The factory¶
RegisterFormComponentFactoryComponent
(register-form-component-factory/…component.ts) is the engine that turns a
field definition into a live Angular component:
Receives
component(the registry key) andprops(theFormField).Looks up
componentMap[component](defaults to the registration registry, but accepts a different map — that’s how vendor/schedule/shop reuse it).Uses Angular’s
ViewContainerRef.createComponent()to instantiate the field component.Injects the shared context onto the instance:
props,fieldDetails,editor,form,form_attribute,factoryId,viewer,staffEdit,registrant,errorFlag, plus theFormElementRegistryService.Registers the instance with
FormElementRegistryServiceunder afactoryIdso the parent can read/write values and validate the whole form.
The factory operates in several modes via boolean inputs:
editor— authoring mode (the organizer is configuring the field).viewer— read-only display (e.g. check-in staff viewing a submission).staffEdit— staff editing a submitted answer.
This one component, parameterized, is what renders every field everywhere.
Storage: definition vs. submission¶
The form definition¶
Stored as a JSON array of field definitions on the owning model:
Model field |
Holds |
|---|---|
|
the registration form’s field list |
|
the vendor application’s field list |
|
the ordered required-task list (vendor task flow) |
|
the shop item’s field list |
|
(where applicable) |
The submission¶
Stored as JSON on the submission record’s data field (Registration.data,
Vendor.data, Panel.data). Conceptually it’s the form’s field list with each
field’s value filled in by the user.
Reading submissions (retrieve_from_data)¶
Because answers live in JSON, the backend needs helpers to read a specific
answer. Models expose retrieve_from_data(component_key, path) and
retrieve_from_specific_data(...) which walk the data array: find the entry
whose component matches, then drill a dotted path into its value
(e.g. 'value.email', 'value.first_name').
email = registration.retrieve_from_data(
'register-form-component-basic-details', 'value.email'
)
Important
These drillers are hardened against non-dict entries. If a field’s value
is a string (e.g. an unedited basic-details field persists value = ''), the
driller returns a -1 sentinel instead of raising AttributeError. When you
write new extraction logic, follow the same defensive pattern — a malformed
submission must never crash a view. Copies of this helper exist in
register/models.py, vendors/models.py, and schedule/models.py; keep them
in sync.
Field types you’ll encounter¶
Key (prefix |
Purpose |
|---|---|
|
Markdown/static content block |
|
Text/number/email/url/date/time input (configurable type + input limits) |
|
Dropdown(s) |
|
Boolean |
|
Signature capture |
|
The required name/email block (singleton) |
|
The tier picker (singleton; carries selected merch) |
|
Purchasable merch picker (singleton) |
|
Donation amount |
|
Stripe coupon / promotion code |
Vendor/schedule surfaces add their own (e.g. vendor-form-component-table-sizes,
vendor-task-component, schedule-form-component-basic-details,
schedule-form-component-tracks).
Adding a new field type¶
Create the component under the relevant
primatives/folder (mirrors the existing ones:.ts/.html/.scss).Implement the field interface: accept
props, expose the answer throughprops.value, and register withFormElementRegistryServiceso the parent can read it.Add a descriptor to the appropriate registry (
<x>-components.ts) withcomponent,name,description,icon,canOnlyHaveOne, and anoptionsschema for authoring-time config.The factory picks it up automatically — no other wiring is needed.
If the field’s answer must be read on the backend, ensure
retrieve_from_datacan find it by itscomponentkey.
Tip
Study register-form-component-coupon or register-form-component-multiselect
as clean reference implementations of a non-trivial field.
The vendor task flow: a variant worth knowing¶
The vendor required tasks (VendorType.tasks) reuse the form-component
factory for authoring (the organizer configures tasks through the factory with
form_attribute="'tasks'" and a vendorTaskComponents map). But the vendor-
facing side renders the authored config with its own task-card template rather
than the imperative factory — because tasks are interactive action-launchers
(Stripe redirect, assistant dialog) that don’t fit the factory’s value-binding
model. Built-in tasks derive completion from real data; self-report tasks
(book-hotel, custom-link) read Vendor.task_completions. Tasks support
prerequisites (prerequisites?: string[]).
Where to look¶
Interface:
ui/src/app/apps/register/register-form-components/form-field.tsRegistries:
ui/src/app/apps/register/register-form-components/*-components.tsandui/src/app/apps/shop/shop-form-components/shop-components.tsFactory:
register-form-components/register-form-component-factory/Base behavior:
register-form-components/register-form-component-base/Registry service:
ui/src/app/apps/register/services/form-element-registry.service.tsBackend extraction:
retrieve_from_datainregister/models.py,vendors/models.py,schedule/models.py