All of this can be fairly cleanly accomplished with inputs_for/4
(see target.html.eex
).
For demonstration purposes only:
# in FormArrayWeb.Router
#
scope "/", FormArrayWeb do
pipe_through :browser
get "/", PageController, :index
post "/", PageController, :update
end
# file: form_array/lib/form_array_web/controllers/page_controller.ex
#
defmodule FormArrayWeb.PageController do
use FormArrayWeb, :controller
@init_targets %{
"target_00" => %{
"status_0" => "false",
"status_1" => "true",
"status_2" => "false"
},
"target_01" => %{
"status_0" => "true",
"status_1" => "false",
"status_2" => "true"
}
}
def index(conn, _params) do
conn = Map.update!(conn, :params, &Map.put(&1, "targets", @init_targets))
render(conn, "index.html")
end
def update(conn, params) do
IO.inspect(params)
conn = put_flash(conn, :info, "update params: #{inspect(params)}")
render(conn, "index.html")
end
end
# file: form_array/lib/form_array_web/views/page_view.ex
#
defmodule FormArrayWeb.PageView do
use FormArrayWeb, :view
@template_name "target_dd"
@template_states %{
"status_0" => "false",
"status_1" => "false",
"status_2" => "false"
}
# extract list of {key_name, map_of_values} tuples
# sorted by "key_name"
# from the map stored under "name"
def target_list(conn, name) do
targets = conn.params[name]
targets
|> Map.keys()
|> Enum.sort()
|> Enum.map(&{&1, targets[&1]})
end
def target_state_list(states) do
states
|> Map.keys()
|> Enum.sort()
|> Enum.map(&{&1, states[&1]})
end
def render_target(form, name, states) do
render("target.html", form: form, name: name, states: states)
end
def render_target_template(form) do
render_target(form, @template_name, @template_states)
end
end
<!-- form_array/lib/form_array_web/templates/page/index.html.eex -->
<style>
input[type="checkbox"] + label {
display: inline;
padding-left: 0.3rem;
font-weight: normal;
}
fieldset li {
margin-bottom: 0;
}
fieldset input[type="checkbox"] {
margin-bottom: 0.3rem;
}
fieldset ol {
margin-bottom: 1.0rem;
}
form section {
padding: 0.5rem 1.0rem;
border: thin solid;
margin-bottom: 1.0rem;
}
</style>
<% targets_name = "targets" %>
<%= form_for @conn, Routes.page_path(@conn, :update), [as: targets_name, method: :post, id: targets_name], fn f ->%>
<section>
<%= for {target, states} <- target_list(@conn, targets_name) do %>
<%= render_target(f, target, states) %>
<% end %>
<button type="button" data-action="add-target">Add</button>
<template id="template-target">
<%= render_target_template(f) %>
</template>
</section>
<%= submit "Update" %>
<% end %>
<!-- END form_array/lib/form_array_web/templates/page/index.html.eex -->
<%# file: form_array/lib/form_array_web/templates/page/target.html.eex %>
<fieldset>
<legend><%= humanize(@name) %></legend>
<ol>
<%= inputs_for @form, @name, fn fp -> %>
<%= for {status, value} <- target_state_list(@states) do %>
<li>
<%= checkbox(fp, status, checked_value: "true", unchecked_value: "false") %>
<%= label(fp, status) %>
</li>
<% end %>
<% end %>
</ol>
<button type="button" data-action="remove-target">Remove</button>
</fieldset>
/* At the end of
form_array/assets/js/app.js
*/
// access template for adding targets
const templateTarget = document.getElementById('template-target')
// Single event listener for the entire "targets" section
let targetsForm = document.getElementById("targets")
targetsForm.addEventListener('click', targetsClickListener)
/*
--- functions ---
*/
// routes the clicks to the correct function
//
function targetsClickListener({target: element}) {
// only interested in clicked buttons
if(element.nodeName !== "BUTTON") {
return
}
switch(element.dataset.action) {
// no action - nothing to do
case undefined:
break
case 'remove-target':
let root = element.closest('fieldset')
if (root) {
root.remove()
}
break
case 'add-target':
let parent = element.parentNode
let newTarget = makeTarget(templateTarget, parent)
if(newTarget) {
parent.insertBefore(newTarget, element)
}
break
}
}
function makeTarget(template, parent) {
let allTargets = parent.querySelectorAll('fieldset')
let clone = document.importNode(template.content, true)
const fromTarget = allTargets.length > 0 ? allTargets[allTargets.length - 1] : clone
const lastName = extractTargetName(fromTarget)
const newName = nextTargetName(lastName)
prepareTarget(clone, newName)
return clone
}
function extractTargetName(target) {
const input = target.querySelector('input[type=hidden]')
const beginIndex = input.name.indexOf('[') + 1
const endIndex = input.name.indexOf(']')
const targetName = input.name.slice(beginIndex, endIndex).trim()
const legend = target.querySelector('legend')
const targetDesc = legend.innerText.trim()
return [targetName, targetDesc]
}
function nextTargetName([lastName, lastDesc]) {
const beginIndex = lastName.lastIndexOf('_') + 1
const lastSuffix = lastName.slice(beginIndex)
const lastIndex = parseInt(lastSuffix, 10)
const newIndex = isNaN(lastIndex) ? 0 : lastIndex + 1
const newSuffix = newIndex.toFixed(0).padStart(2,'0')
const newName = lastName.slice(0, lastName.lastIndexOf(lastSuffix)) + newSuffix
const newDesc = lastDesc.slice(0, lastDesc.lastIndexOf(lastSuffix)) + newSuffix
return [newName, newDesc]
}
function prepareTarget(root, [name, desc]) {
const [oldName, oldDesc] = extractTargetName(root)
const prepareInput = input => {
prepareTargetInput(input, oldName, name)
}
const prepareLabel = label => {
prepareTargetLabel(label, oldName, name)
}
root.querySelectorAll('input').forEach(prepareInput)
root.querySelectorAll('label').forEach(prepareLabel)
let legend = root.querySelector('legend')
legend.innerText = replaceTargetDesc(legend.innerText, oldDesc, desc)
}
function replaceTargetDesc(oldText, oldDesc, newDesc) {
return oldText.split(oldDesc).join(newDesc)
}
function prepareTargetInput(input, oldName, newName) {
if (input.name) {
input.name = replaceTargetName(input.name, newName)
}
if (input.id) {
input.id = replaceTargetId(input.id, oldName, newName)
}
}
function prepareTargetLabel(label, oldName, newName) {
if (label.htmlFor) {
label.htmlFor = replaceTargetId(label.htmlFor, oldName, newName)
}
}
function replaceTargetName(name, newTargetName) {
const beginIndex = name.indexOf('[') + 1
const endIndex = name.indexOf(']')
return name.slice(0, beginIndex) + newTargetName + name.slice(endIndex)
}
function replaceTargetId(id, oldTargetName, newTargetName) {
const beginIndex = id.indexOf(oldTargetName)
const endIndex = beginIndex + oldTargetName.length
return id.slice(0, beginIndex) + newTargetName + id.slice(endIndex)
}
The generated HTML from index
looks like this:
<!-- form_array/lib/form_array_web/templates/page/index.html.eex -->
<style>
input[type="checkbox"] + label {
display: inline;
padding-left: 0.3rem;
font-weight: normal;
}
fieldset li {
margin-bottom: 0;
}
fieldset input[type="checkbox"] {
margin-bottom: 0.3rem;
}
fieldset ol {
margin-bottom: 1.0rem;
}
form section {
padding: 0.5rem 1.0rem;
border: thin solid;
margin-bottom: 1.0rem;
}
</style>
<form accept-charset="UTF-8" action="/" id="targets" method="post">
<input name="_csrf_token" type="hidden" value="EAgIDBYMbyFnGgYiQFBkBTY4FhYkLhk6uoDcym8WPXvC3e7KoJAUucxM">
<input name="_utf8" type="hidden" value="✓">
<section>
<fieldset>
<legend>Target 00</legend>
<ol>
<li>
<input name="targets[target_00][status_0]" type="hidden" value="false">
<input id="targets_target_00_status_0" name="targets[target_00][status_0]" type="checkbox" value="true">
<label for="targets_target_00_status_0">Status 0</label>
</li>
<li>
<input name="targets[target_00][status_1]" type="hidden" value="false">
<input id="targets_target_00_status_1" name="targets[target_00][status_1]" type="checkbox" value="true" checked>
<label for="targets_target_00_status_1">Status 1</label>
</li>
<li>
<input name="targets[target_00][status_2]" type="hidden" value="false">
<input id="targets_target_00_status_2" name="targets[target_00][status_2]" type="checkbox" value="true">
<label for="targets_target_00_status_2">Status 2</label>
</li>
</ol>
<button type="button" data-action="remove-target">Remove</button>
</fieldset>
<fieldset>
<legend>Target 01</legend>
<ol>
<li>
<input name="targets[target_01][status_0]" type="hidden" value="false">
<input id="targets_target_01_status_0" name="targets[target_01][status_0]" type="checkbox" value="true" checked>
<label for="targets_target_01_status_0">Status 0</label>
</li>
<li>
<input name="targets[target_01][status_1]" type="hidden" value="false">
<input id="targets_target_01_status_1" name="targets[target_01][status_1]" type="checkbox" value="true">
<label for="targets_target_01_status_1">Status 1</label>
</li>
<li>
<input name="targets[target_01][status_2]" type="hidden" value="false">
<input id="targets_target_01_status_2" name="targets[target_01][status_2]" type="checkbox" value="true" checked>
<label for="targets_target_01_status_2">Status 2</label>
</li>
</ol>
<button type="button" data-action="remove-target">Remove</button>
</fieldset>
<button type="button" data-action="add-target">Add</button>
<template id="template-target">
<fieldset>
<legend>Target dd</legend>
<ol>
<li>
<input name="targets[target_dd][status_0]" type="hidden" value="false">
<input id="targets_target_dd_status_0" name="targets[target_dd][status_0]" type="checkbox" value="true">
<label for="targets_target_dd_status_0">Status 0</label>
</li>
<li>
<input name="targets[target_dd][status_1]" type="hidden" value="false">
<input id="targets_target_dd_status_1" name="targets[target_dd][status_1]" type="checkbox" value="true">
<label for="targets_target_dd_status_1">Status 1</label>
</li>
<li>
<input name="targets[target_dd][status_2]" type="hidden" value="false">
<input id="targets_target_dd_status_2" name="targets[target_dd][status_2]" type="checkbox" value="true">
<label for="targets_target_dd_status_2">Status 2</label>
</li>
</ol>
<button type="button" data-action="remove-target">Remove</button>
</fieldset>
</template>
</section>
<button type="submit">Update</button>
</form>
<!-- END form_array/lib/form_array_web/templates/page/index.html.eex -->
After clicking “Add” and then “Update” the console shows this:
[info] POST /
[debug] Processing with FormArrayWeb.PageController.update/2
Parameters: %{"_csrf_token" => "LFB0B1okDRFVDz8pGXkDLzgKY3sYDyMgI78h5EZgbMOHjLPaax48IBBW", "_utf8" => "✓", "targets" => %{"target_00" => %{"status_0" => "false", "status_1" => "true", "status_2" => "false"}, "target_01" => %{"status_0" => "true", "status_1" => "false", "status_2" => "true"}, "target_02" => %{"status_0" => "false", "status_1" => "false", "status_2" => "false"}}}
Pipelines: [:browser]
%{
"_csrf_token" => "LFB0B1okDRFVDz8pGXkDLzgKY3sYDyMgI78h5EZgbMOHjLPaax48IBBW",
"_utf8" => "✓",
"targets" => %{
"target_00" => %{
"status_0" => "false",
"status_1" => "true",
"status_2" => "false"
},
"target_01" => %{
"status_0" => "true",
"status_1" => "false",
"status_2" => "true"
},
"target_02" => %{
"status_0" => "false",
"status_1" => "false",
"status_2" => "false"
}
}
}
[info] Sent 200 in 3ms
and the page looks like this: