Technical reference for the survey builder. For the user-facing guide, see Getting Started and Surveys; for the outline grammar the builder imports from, see Import.
Overview
The builder is a server-rendered HTMX app: every mutation (create, edit,
copy, delete, reorder) POSTs to a Django view that re-renders an HTML
fragment and swaps it into the page. There is no client-side state machine โ
builder.js only toggles form sections per selected type and pre-fills the
form for edits from a JSON payload embedded in each card.
Routes and views
All routes live under /<slug>/builder/... (see surveys/urls.py).
| Route | View | Renders |
|---|---|---|
/builder/ |
survey_builder |
full builder page (ungrouped questions) |
/builder/groups/<gid>/ |
group_builder |
per-section builder page |
.../questions/create |
builder_question_create |
questions_list.html fragment |
.../questions/<qid>/edit |
builder_question_edit |
single question_row.html |
.../questions/<qid>/copy |
builder_question_copy |
questions_list.html fragment |
.../questions/<qid>/delete |
builder_question_delete |
questions_list.html fragment |
.../questions/reorder |
builder_questions_reorder |
questions_list.html fragment |
The _group_ variants (builder_group_question_edit, etc.) are the same
flows scoped to a section; both call the same helpers. On a successful save
the single-row views re-render just the affected card via
_render_template_question_row; the list views re-render the whole list.
Data flow
flowchart TD
A[builder_question_pane.html<br/>form] -->|POST| B[_parse_builder_question_form]
B --> C{type?}
C --> D[options dict per type]
D --> E[SurveyQuestion.objects.create/save]
E --> F[_render_template_question_row]
G[question_row.html card] -->|builder_payload_json| H[builder.js<br/>populateFormForPayload]
H -->|prefill form| A
- POST contract. Every type posts the same base fields (
text,type,required,hidden_by_default,group_id) plus per-type extras. The parse is centralised in_parse_builder_question_form(data), which normalises inputs per type (e.g. min/max for Likert number, labels for Yes/No) and returns{"text", "type", "required", "hidden_by_default", "options", "dataset_key", "grid_new_rows"}. Parse-level unit tests (test_builder_question_creation.py,test_yesno_labels.py) exercise it directly without a request. - Options storage. Each type stores its config in the
optionsJSONField: a list of option dicts for choice questions, a single-element list wrapper for Likert ([{"type": "number-scale"|"categories", ...}]) and text formats ([{"type": "text", "format": ...}]), a dict for content blocks and grid stems,[]for long text. - Edit payloads.
_prepare_question_renderingattaches view helpers to every question (num_scale_valuesfor Likert radio rendering) and buildsbuilder_payloadper question via_serialize_question_for_builder. The payload is JSON-encoded with<,>,&escaped to Unicode escapes and embedded in a hidden<div data-question-payload>on each card. - Edit mode. The card's Edit button reads its payload div, calls
populateFormForPayload(mirrors the POST contract in reverse), flips the form'shx-postto the edit URL, and switches the submit button label. Cancel resets viaform.reset()and arefresh().
Per-type form toggles (builder.js)
initCreateFormToggles binds a refresh() that runs on every type,
text_format, and likert_mode change:
- Section visibility:
data-sectionblocks (text-options,options,likert,grid,yesno-followup,image-options) are toggled per type. The Likert section is shared by Likert questions and grid column scales. - The per-type explainer under the type picker is updated from the
TYPE_EXPLAINERSmap (keep in sync with the type radios inbuilder_question_pane.html). - Render-mode radios are hidden for grids โ rows always render as radios.
populateFormForPayload is the edit-mode mirror: it must consume every key
_serialize_question_for_builder emits, or edits silently drop config.
Hidden questions
Two classes of questions never appear as top-level cards:
- Linked consent questions โ yesno questions created by the content
block configure panel, marked with a
_content_block_parentkey inoptions(see_is_linked_consent_question). They render inline in the content block. - Grid rows โ questions with
parent_idset (see_is_grid_row).
_prepare_question_rendering filters both out of the builder list and
attaches grid rows to their parent as grid_rows (plus
grid_rows_required for the required badge). The branching target/source
picker is built from the same pass (all_questions_meta filters
parent__isnull=True).
Grid of sub-questions
A grid is a stem with no answer of its own plus rows sharing one scale.
- Model.
SurveyQuestion.parentis a self-FK (cascade); the stem istype="grid"withoptions = {"type": "grid", "row_type": "likert", "scale": {...}}. Rows are real questions (currently Likert) that copy the shared scale into their ownoptions, so every flat consumer โ submission collection, partial answers, progress, analytics, CSV export โ treats a row as an ordinary question with no special casing. clean()guards. Grid stems are neverrequired(the flag lives on rows), rows cannot be grids (no nesting), and row types are limited toGRID_ROW_TYPES(likert, yesno, mc_single, mc_multi, dropdown, text, long_text).- Create.
builder_question_createcreates stem + rows atomically;*on the stem appliesrequiredto every row. - Edit.
_sync_grid_rowssyncs rows from the POST: existing rows post asgrid_row_id_<id>inputs (a rename keeps the row โ and its answers โ intact; a missing input deletes the row),grid_new_rowsappends, and the stem's scale/required/hidden flags are re-applied to every row. Switching a question away from grid deletes its rows. - Copy.
_duplicate_questionclones the stem, then shifts subsequent orders to make room and clones the rows as a block. - Outline grammar.
(grid likert number|categories)+###row headings +na:โ see Import. The parser and both export paths (_export_survey_to_markdown,_export_question_group_to_markdown) round-trip the scale and rows.
Reordering
The list is a SortableJS container; onEnd POSTs the card order as CSV to
questions/reorder, which updates order in place. Grid rows are not
individually reorderable โ their order follows the stem and is edited
within the grid's edit pane.
Related documentation
- Import โ the outline grammar the builder imports
- Surveys โ question types (user-facing)
- Branching and Repeats โ conditions wired from builder cards
- Survey Layouts (Technical) โ the organise page and layout config cards