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.
Whether a paneldynamic question seeds entry ids. On unless generateIds
is explicitly false.
The entry field carrying a stable id, when generateIds is on.
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.
Seeds dynamic_form_id on every entry of the given questions' values.
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.
Whether a paneldynamic question seeds entry ids. On unless generateIds
is explicitly false.
The entry field carrying a stable id, when generateIds is on.
Entries are otherwise positional, so a value referencing one (a checkbox
listing another nested form's entries, say) has nothing durable to point
at: a member field changes when the user edits it, and an index changes
when entries are added or removed. This field is seeded once — copied from
the entry's id when the data came from a stored record, generated
otherwise — and round-trips through a hidden input.
It is only stable across sessions if the application persists it and
passes it back in data.
The initial params for a newly added entry.
Template questions' defaultValues seed the entry, overridden by the
question's defaultPanelValue, plus a generated dynamic_form_id unless
the question sets generateIds to false.
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.
Seeds dynamic_form_id on every entry of the given questions' values.
Runs over string-keyed params when a form is built or reset, recursing
into nested templates. An entry that already carries an id keeps it; one
carrying an id from a stored record adopts that; anything else — an
entry the user created this session — gets a generated one.
Generation happens here rather than at render time on purpose: a fresh id per render would be worse than no id at all.
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.