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 options array 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.

  • canOnlyHaveOne constrains 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

register-components.ts

Registration forms

vendor-components.ts

Vendor applications

vendor-task-components.ts

Vendor required-task authoring

schedule-components.ts

Panel ingest forms

register-checkin-components.ts

Configurable check-in procedure

shop-components.ts

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:

  1. Receives component (the registry key) and props (the FormField).

  2. Looks up componentMap[component] (defaults to the registration registry, but accepts a different map — that’s how vendor/schedule/shop reuse it).

  3. Uses Angular’s ViewContainerRef.createComponent() to instantiate the field component.

  4. Injects the shared context onto the instance: props, fieldDetails, editor, form, form_attribute, factoryId, viewer, staffEdit, registrant, errorFlag, plus the FormElementRegistryService.

  5. Registers the instance with FormElementRegistryService under a factoryId so 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

RegistrationForm.fields

the registration form’s field list

VendorType.form

the vendor application’s field list

VendorType.tasks

the ordered required-task list (vendor task flow)

ShopItem.form

the shop item’s field list

Category.form

(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 register-form-component-)

Purpose

text

Markdown/static content block

input

Text/number/email/url/date/time input (configurable type + input limits)

select / multiselect

Dropdown(s)

checkbox

Boolean

signature-pad

Signature capture

basic-details

The required name/email block (singleton)

registration-tiers

The tier picker (singleton; carries selected merch)

merchandise

Purchasable merch picker (singleton)

donation

Donation amount

coupon

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

  1. Create the component under the relevant primatives/ folder (mirrors the existing ones: .ts / .html / .scss).

  2. Implement the field interface: accept props, expose the answer through props.value, and register with FormElementRegistryService so the parent can read it.

  3. Add a descriptor to the appropriate registry (<x>-components.ts) with component, name, description, icon, canOnlyHaveOne, and an options schema for authoring-time config.

  4. The factory picks it up automatically — no other wiring is needed.

  5. If the field’s answer must be read on the backend, ensure retrieve_from_data can find it by its component key.

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.ts

  • Registries: ui/src/app/apps/register/register-form-components/*-components.ts and ui/src/app/apps/shop/shop-form-components/shop-components.ts

  • Factory: 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.ts

  • Backend extraction: retrieve_from_data in register/models.py, vendors/models.py, schedule/models.py