Repeated form rows
Before you start
This guide assumes the form from
Typed forms and validation.
Use repeated rows when each entry has several fields, such as a label and a
number, and the visitor can add, remove, or reorder entries. For a single field
submitted several times, use Root.texts and
appended / removedAt
instead.
Model repeated rows
Declare a row group under the root, define its fields relative to the group, and combine them into a row type. The group's product then becomes one field of the form:
final case class Phone(label: String, number: String)
final case class Contact(name: String, phones: Vector[Phone])
object Contact:
val Root = FormRoot("contact")
val Name = Root.text("name").map(_.trim)
val Phones = Root.rows("phones")
val PhoneLabel = Phones.text("label").map(_.trim)
val PhoneNumber = Phones
.text("number")
.map(_.trim)
.required(FieldIssue("validation.phone.required", Some("required")))
val PhoneRows = Phones.product[Phone]((PhoneLabel, PhoneNumber))
val Definition = Root.product[Contact]((Name, PhoneRows))View source (documentation/fixtures/guides/src/forms/ContactForm.scala:8-21)
Contact.Definition decodes a Vector[Phone] in row order, and each row
accumulates its own field errors at that row's address.
Render the rows
rows(Contact.PhoneRows) on the form signal returns the rows with their stable
keys. Render them with splitBy on the row key. Each row must render its
presence control, then its fields:
def view(model: Signal[Model]): HtmlElement[Msg] =
val contactForm = model.map(_.form)
form(
idAttr := "contact-form",
Contact.Definition.onChange(Msg.Validate(_)),
Contact.Definition.onSubmit(Msg.Save(_)),
contactForm.rows(Contact.PhoneRows).splitBy(_.key.value) { (_, row) =>
val number = row.map(_.field(Contact.PhoneNumber))
div(
row.presence(),
label(forId := number.id, "Phone number"),
number.tel(number.validationAttributes)
)
},
button(typ := "submit", "Save")
)View source (documentation/fixtures/guides/src/forms/ContactForm.scala:35-51)
Each row is a Signal of a row view, so row.map(_.field(...)) selects a field
view and row.presence() renders the hidden control that keeps the row in the
next submission, even when all its visible fields are empty or unchecked.
Give rows stable keys
Every row has a FormRowKey that you choose. Keys, rather than display
indexes, preserve a row's identity, used fields, and error addresses when rows
move. The contacts example builds keys from a counter:
def key(number: Long): Group.Key =
keyFromValue(s"contact-$number")
def keyFromValue(value: String): Group.Key =
FormRowKey
.from[Group.type](value).fold(
error => throw new IllegalArgumentException(error.code),
identity
)View source (documentation/site/src/scalive/docs/examples/RepeatedContactsFormExample.scala:192-200)
Start a form with existing rows through the group's initial and row:
object Model:
def initial: Model =
Model(
Contacts.Definition.initial(
Contacts.Rows.initial(
Contacts.Rows.row(Contacts.key(1))(
Contacts.Name.initial("Ada Lovelace"),
Contacts.Email.initial("ada@example.com")
),
Contacts.Rows.row(Contacts.key(2))(
Contacts.Name.initial("Grace Hopper"),
Contacts.Email.initial("grace@example.com")
)
)
),
nextKey = 3
)View source (documentation/site/src/scalive/docs/examples/RepeatedContactsFormExample.scala:221-237)
Add, remove, and move rows
Row operations are ordinary messages. Each row's buttons send its own key:
div(
cls := "docs-contact-row-actions",
button(
typ := "button",
dataAttr("move-up") := "",
aria.label := s"Move $keyValue up",
disabled := rows.map(_.headOption.exists(_.key.value == keyValue)),
on.click(row.map(value => Msg.MoveUp(value.key))),
"Move up"
),
button(
typ := "button",
dataAttr("move-down") := "",
aria.label := s"Move $keyValue down",
disabled := rows.map(_.lastOption.exists(_.key.value == keyValue)),
on.click(row.map(value => Msg.MoveDown(value.key))),
"Move down"
),
button(
typ := "button",
dataAttr("remove-row") := "",
aria.label := s"Remove $keyValue",
on.click(row.map(value => Msg.Remove(value.key))),
"Remove"
)
)View source (documentation/site/src/scalive/docs/examples/RepeatedContactsFormExample.scala:111-136)
The handler applies added and removed to the form in the model:
case Msg.Add =>
if model.form.rows(Contacts.Rows).size >= MaximumContacts then ZIO.succeed(model)
else
val keyNumber = nextAvailableKey(model.form, model.nextKey)
val key = Contacts.key(keyNumber)
ZIO.succeed(
model.copy(
form = model.form.added(Contacts.Rows, key)(
Contacts.Name.initial(""),
Contacts.Email.initial("")
),
nextKey = keyNumber + 1,
saved = None
)
)
case Msg.Remove(key) =>
val rows = model.form.rows(Contacts.Rows)
val updated =
if rows.exists(_.key == key) then model.form.removed(Contacts.Rows, key)
else model.form
ZIO.succeed(model.copy(form = updated, saved = None))View source (documentation/site/src/scalive/docs/examples/RepeatedContactsFormExample.scala:21-41)
movedBefore and movedAfter reorder a row relative to another key:
case Msg.MoveUp(key) =>
val rows = model.form.rows(Contacts.Rows)
val index = rows.indexWhere(_.key == key)
val moved =
if index > 0 then model.form.movedBefore(Contacts.Rows, key, rows(index - 1).key)
else model.form
ZIO.succeed(model.copy(form = moved, saved = None))View source (documentation/site/src/scalive/docs/examples/RepeatedContactsFormExample.scala:44-50)
Every operation decodes the form again and keeps each row's interaction state.
Bound the number of rows a visitor can add, as the example does with
MaximumContacts. Use row.bind(field) with typed updated, or explicit
updatedRaw, to change one field in one exact row.
The stable repeated contact rows example shows initialization, rendering, typed events, add, remove, both move directions, row-local validation, submission, and reset. Its order summary makes the persistent keys visible while rows move.
Accept Phoenix nested parameters
The core wire shape is keyed and includes the presence control. To accept
Phoenix's indexed nested-parameter convention, adapt that boundary explicitly.
For this recipe the per-row mapping is contact[phones][0][_persistent_id],
while the sibling array controls are contact[phones_sort][] and
contact[phones_drop][]:
object PhoenixPhoneForm:
final case class Phone(label: String, number: String)
final case class Contact(phones: Vector[Phone])
val Root = FormRoot("contact")
val Phones = Root.rows("phones")
val Label = Phones.text("label")
val Number = Phones.text("number")
val PhoneRows = Phones.product[Phone]((Label, Number))
val Definition = Root.product[Contact](PhoneRows)
val Adapter = PhoenixNestedParamsAdapter(Definition, PhoneRows)
enum Msg:
case Validate(event: Adapter.Event)
case Save(event: Adapter.Event)
def render(form: Definition.Form): HtmlElement[Msg] =
scalive.form(
Adapter.onChange(Msg.Validate(_)),
Adapter.onSubmit(Msg.Save(_)),
form.rows(PhoneRows).zipWithIndex.map { case (row, index) =>
val phoneLabel = row.field(Label)
val phoneNumber = row.field(Number)
fieldSet(
Adapter.persistentId(row, index),
Adapter.sortControl(row, index),
legend(s"Phone ${index + 1}"),
label(forId := Adapter.fieldId("contact-form", index, Label), "Phone label"),
input(
typ := "text",
idAttr := Adapter.fieldId("contact-form", index, Label),
nameAttr := Adapter.fieldName(index, Label),
value := phoneLabel.fieldValue
),
label(forId := Adapter.fieldId("contact-form", index, Number), "Phone number"),
input(
typ := "tel",
idAttr := Adapter.fieldId("contact-form", index, Number),
nameAttr := Adapter.fieldName(index, Number),
value := phoneNumber.fieldValue
),
button(
typ := "button",
nameAttr := Adapter.dropName,
value := index.toString,
aria.label := s"Remove phone ${index + 1}",
on.click(JS.dispatch("change")),
"Remove phone"
)
)
},
input(typ := "hidden", nameAttr := Adapter.dropName),
button(
typ := "button",
nameAttr := Adapter.sortName,
value := "new",
on.click(JS.dispatch("change")),
"Add phone"
),
button(typ := "submit", "Save contact")
)
end PhoenixPhoneFormView source (documentation/site/src/scalive/docs/examples/FormRecipes.scala:140-201)
The bounded PhoenixNestedParamsAdapter translates the compatibility payload
into the same keyed form projection. persistentIdName(index) reconnects each
positional index to a stable key, sortName supplies order and requests "new"
rows, and dropName removes indexes. Keep an empty dropName control so
removing the last row still submits the group. configured accepts
deterministic key allocation and blank-row policy when its defaults do not
match the application. Prefer the core keyed controls for new Scalive
interfaces; use the adapter when interoperating with Phoenix-style clients or
markup.
Related tasks
Bind blur feedback to a row's control with Form feedback and recovery.
Test stable rows with Testing.
Render keyed lists outside forms with Rendering, bindings, and diffs.