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
Click Move down on the first contact. Current order lists
contact-2 — Grace Hopperbeforecontact-1 — Ada Lovelace: the keys moved with their contacts.Click Add contact. A blank
contact-3row appears at the end.Click Save contacts. The blank row shows "Name is required." and "Email is required.", and nothing is saved.
Click Remove on
contact-3, then Save contacts. The form reads "Saved 2 contacts in the displayed order."Click Reset to restore the two original rows.
Live result
ConnectedReconnectingRead-onlyCurrent order
- contact-1 — Ada Lovelace
- contact-2 — Grace Hopper
Disconnected. Controls resume after reconnection.
Interaction inspector
See how actions in the live result above travel through typed messages, server state, protocol frames, and DOM updates.
The live result and its inspector resume when the connection returns.
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 RepeatedContactsFormExampleView source (documentation/site/src/scalive/docs/examples/RepeatedContactsFormExample.scala:8-249)
How it works
Root.rows("contacts")declares the repeated group, andGroup.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, andform.movedBeforechange the rows of the current form without losing the values typed into other rows.
Related guides
Repeated form rows explains row keys and row operations.