Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline
GitHub

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:

Source
scala
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))

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:

Source
scala
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")
  )

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:

Source
scala
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
    )

Start a form with existing rows through the group's initial and row:

Source
scala
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
    )

Add, remove, and move rows

Row operations are ordinary messages. Each row's buttons send its own key:

Source
scala
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"
  )
)

The handler applies added and removed to the form in the model:

Source
scala
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))

movedBefore and movedAfter reorder a row relative to another key:

Source
scala
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))

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][]:

Source
scala
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 PhoenixPhoneForm

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.