Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline
GitHub

Stable repeated contact rows

A form with a list of contacts. Each row has a stable key, such as contact-1, that stays with the contact when rows are added, removed, or reordered.

Try it

  1. Click Move down on the first contact. Current order lists contact-2 — Grace Hopper before contact-1 — Ada Lovelace: the keys moved with their contacts.

  2. Click Add contact. A blank contact-3 row appears at the end.

  3. Click Save contacts. The blank row shows "Name is required." and "Email is required.", and nothing is saved.

  4. Click Remove on contact-3, then Save contacts. The form reads "Saved 2 contacts in the displayed order."

  5. Click Reset to restore the two original rows.

Live result

ConnectedReconnectingRead-only
Contact contact-1
Contact contact-2

Current order

  1. contact-1 — Ada Lovelace
  2. contact-2 — Grace Hopper

Disconnected. Controls resume after reconnection.

LiveView observability

Interaction inspector

See how actions in the live result above travel through typed messages, server state, protocol frames, and DOM updates.

Reconnect to inspect live interactions

The live result and its inspector resume when the connection returns.

LiveView
scala
final class RepeatedContactsFormExample
    extends LiveView[RepeatedContactsFormExample.Msg, RepeatedContactsFormExample.Model]:
  import RepeatedContactsFormExample.*

  def mount(ctx: MountContext): Task[Model] =
    ZIO.succeed(Model.initial)

  def handleMessage(model: Model, ctx: MessageContext) =
    case Msg.Validate(event) =>
      ZIO.succeed(model.copy(form = event.form, saved = None))
    case Msg.Save(event) =>
      ZIO.succeed(model.copy(form = event.form, saved = event.valueOption))
    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))
    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))
    case Msg.MoveDown(key) =>
      val rows  = model.form.rows(Contacts.Rows)
      val index = rows.indexWhere(_.key == key)
      val moved =
        if index >= 0 && index < rows.size - 1 then
          model.form.movedAfter(Contacts.Rows, key, rows(index + 1).key)
        else model.form
      ZIO.succeed(model.copy(form = moved, saved = None))
    case Msg.Reset =>
      ZIO.succeed(Model.initial)
  end handleMessage

  def view(model: Signal[Model]): HtmlElement[Msg] =
    val contactForm = model.map(_.form)
    val rows        = contactForm.rows(Contacts.Rows)

    div(
      cls := "docs-repeated-contacts",
      model.map(_.saved).option { saved =>
        p(
          dataAttr("contacts-saved") := "",
          cls                        := "docs-profile-saved",
          role                       := "status",
          saved.map(value => s"Saved ${value.contacts.size} contacts in the displayed order.")
        )
      },
      form(
        dataAttr("contacts-form") := "",
        idAttr                    := "repeated-contacts-form",
        Contacts.Definition.onChange(Msg.Validate(_)),
        Contacts.Definition.onSubmit(Msg.Save(_)),
        rows.splitBy(_.key) { (key, row) =>
          val keyValue   = key.value
          val nameField  = contactForm.field(Contacts.Rows, key, Contacts.Name)
          val emailField = contactForm.field(Contacts.Rows, key, Contacts.Email)

          fieldSet(
            dataAttr("contact-key") := keyValue,
            cls                     := "docs-contact-row",
            contactForm.presence(Contacts.Rows, key),
            legend(row.map(value => s"Contact ${value.key.value}")),
            div(
              cls := "docs-profile-field",
              label(forId := nameField.id, "Name"),
              nameField.text(nameField.validationAttributes),
              nameField.errorFeedback(
                error => error.map(value => messages.getOrElse(value.message, value.message)),
                dataAttr("contact-error") := "name"
              )
            ),
            div(
              cls := "docs-profile-field",
              label(forId := emailField.id, "Email"),
              emailField.email(emailField.validationAttributes),
              emailField.errorFeedback(
                error => error.map(value => messages.getOrElse(value.message, value.message)),
                dataAttr("contact-error") := "email"
              )
            ),
            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(Msg.MoveUp(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(Msg.MoveDown(key)),
                "Move down"
              ),
              button(
                typ                    := "button",
                dataAttr("remove-row") := "",
                aria.label             := s"Remove $keyValue",
                on.click(Msg.Remove(key)),
                "Remove"
              )
            )
          )
        },
        div(
          cls := "docs-profile-actions",
          button(
            typ                     := "button",
            dataAttr("add-contact") := "",
            disabled                := rows.map(_.size >= MaximumContacts),
            on.click(Msg.Add),
            "Add contact"
          ),
          button(typ := "submit", "Save contacts"),
          button(typ := "button", on.click(Msg.Reset), "Reset")
        )
      ),
      sectionTag(
        cls        := "docs-contact-order",
        aria.label := "Current contact identity and order",
        h4("Current order"),
        ol(
          dataAttr("contact-summary") := "",
          rows.splitBy(_.key.value) { (keyValue, row) =>
            li(
              dataAttr("summary-key") := keyValue,
              row.map { value =>
                val name = value.fieldView(Contacts.Name).fieldValue
                s"$keyValue — ${if name.isBlank then "(blank)" else name}"
              }
            )
          }
        )
      )
    )
  end view

end RepeatedContactsFormExample

object RepeatedContactsFormExample:
  final case class Contact(name: String, email: String)
  final case class ContactBook(contacts: Vector[Contact])

  object Contacts:
    val Root  = FormRoot("contact_book")
    val Group = Root.rows("contacts")
    val Name  = Group.text("name").map(_.trim).required(FieldIssue("contact.name.required"))
    val Email = Group
      .text("email")
      .map(_.trim)
      .required(FieldIssue("contact.email.required"))
      .validate(FieldIssue("contact.email.invalid"))(_.contains('@'))
    val Rows       = Group.product[Contact]((name = Name, email = Email))
    val Definition = Root.product[ContactBook]((contacts = Rows))

    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
        )

  private val messages = Map(
    "contact.name.required"  -> "Name is required.",
    "contact.email.required" -> "Email is required.",
    "contact.email.invalid"  -> "Enter a valid email address."
  )

  private val MaximumContacts = 20

  private def nextAvailableKey(form: Contacts.Definition.Form, first: Long): Long =
    val used = form.rows(Contacts.Rows).iterator.map(_.key.value).toSet
    Iterator.iterate(first)(_ + 1).find(number => !used(s"contact-$number")).get

  final case class Model(
    form: Contacts.Definition.Form,
    nextKey: Long,
    saved: Option[ContactBook] = None)

  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
      )

  enum Msg:
    case Validate(event: Contacts.Definition.Event)
    case Save(event: Contacts.Definition.Event)
    case Add
    case Remove(key: Contacts.Group.Key)
    case MoveUp(key: Contacts.Group.Key)
    case MoveDown(key: Contacts.Group.Key)
    case Reset
end RepeatedContactsFormExample

How it works

  • Root.rows("contacts") declares the repeated group, and Group.product[Contact]((name = Name, email = Email)) declares one row.

  • Contacts.key(number) builds each row key. The application chooses keys, so a row keeps its identity, and its errors, wherever it moves.

  • form.added, form.removed, and form.movedBefore change the rows of the current form without losing the values typed into other rows.