Nested/repeating child forms — the SurveyJS paneldynamic question type.
A paneldynamic question holds a repeating template (templateElements);
its value is a list of entries, one map per repetition. This module owns
the entry machinery shared by validation and rendering:
entries/1— normalize a raw value into an ordered entry listentry_changesets/3— build one child changeset per entrynew_entry/1— seed params for a newly added entryvalidate/3— validate a parent changeset's paneldynamic questions
Entry changesets are ordinary schemaless changesets built recursively via
DynamicForm.Changeset.create_changeset/3 — there is no Ecto relation
(no cast_embed/inputs_for) involved. Because entries aren't tracked
inside the parent changeset, they are derived state: a pure function of
the question and the parent's raw params. Validation and rendering both
call entry_changesets/3, so the errors they see are always identical.
SurveyJS vocabulary ("panel") appears only at the boundary — the question
type string, the Instance.Question fields, and {panel.field}
expression scoping. Internally these are entries of a nested form.
Summary
Functions
Normalizes a paneldynamic value to a list of entries.
Builds one child changeset per entry of a paneldynamic question.
Resolves the paneldynamic question at a dot-separated entry path.
The initial params for a newly added entry.
Converts indexed-map values (as submitted by the browser) into ordered
entry lists for every paneldynamic question, so the {:array, :map} cast
succeeds and changeset.params holds a stable shape.
Validates every paneldynamic question on an already-cast parent changeset:
entry changesets (validity propagates to the parent), isRequired,
minPanelCount/maxPanelCount, and replaces the raw cast value with each
entry's applied data.
Functions
Normalizes a paneldynamic value to a list of entries.
Browser submissions arrive as an indexed map (%{"0" => %{...}, "1" => %{...}}, possibly with non-integer bookkeeping keys such as the always-
present __empty__ hidden input); programmatic values are already lists.
Builds one child changeset per entry of a paneldynamic question.
Each entry of the question's value is validated against the question's
templateElements with the same rules as a top-level form (casting,
required fields, validators, conditional expressions), recursively — so
nested paneldynamic questions work too. Conditional expressions inside the
template can reference sibling values as {panel.field} (or plain
{field}), and form-level values by their names.
When the question defines keyName, entries duplicating another entry's
value for that field get an error (message: keyDuplicationError).
Both validation (DynamicForm.Changeset.create_changeset/3) and rendering
(DynamicForm.Renderer) call this with the parent's raw params, so the
changesets — and their errors — are identical in both places.
parent_params is the parent changeset's params map; the entry list is
read from it under the question's name (either a list or a
%{"0" => ...}-indexed map as submitted by the browser).
Resolves the paneldynamic question at a dot-separated entry path.
Path segments alternate question names and entry indexes ("addresses",
or "contacts.0.phones" when nested). Each name resolves within its own
scope — the top-level elements for the first segment, then each matched
question's templateElements — so questions in different scopes may
share a name. Returns nil when the path doesn't resolve.
The initial params for a newly added entry.
Template questions' defaultValues seed the entry, overridden by the
question's defaultPanelValue.
Converts indexed-map values (as submitted by the browser) into ordered
entry lists for every paneldynamic question, so the {:array, :map} cast
succeeds and changeset.params holds a stable shape.
Validates every paneldynamic question on an already-cast parent changeset:
entry changesets (validity propagates to the parent), isRequired,
minPanelCount/maxPanelCount, and replaces the raw cast value with each
entry's applied data.