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:
list_entries/1— normalize a raw value into an ordered entry listlist_entry_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 list_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
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.
Normalizes a paneldynamic value to a list of entries.
Builds one child changeset per entry of a paneldynamic question.
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 paneldynamic questions without initial data with panelCount fresh
entries (at least minPanelCount), mirroring SurveyJS. Every entry is
seeded separately so each gets its own id.
Seeds dynamic_form_id on every entry of the given questions' values.
Walks the params tree along an entry path and updates the entry list at
its end with fun. Path segments alternate between map keys (question
names) and list indexes (entries).
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
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.
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.Component) 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).
The initial params for a newly added entry.
Template questions' defaultValues seed the entry, overridden by the
question's defaultPanelValue. The entry's dynamic_form_id is seeded
separately by seed_entry_ids/2, which runs over the changeset's params —
generating one here would leak into initial_data and reset the form on
every parent re-render.
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 paneldynamic questions without initial data with panelCount fresh
entries (at least minPanelCount), mirroring SurveyJS. Every entry is
seeded separately so each gets its own id.
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.
Walks the params tree along an entry path and updates the entry list at
its end with fun. Path segments alternate between map keys (question
names) and list indexes (entries).
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.