Your first form
Define the form
A form starts as a definition of its fields and the value they decode to. This
lesson builds a sign-up form with a name and an email address. Create
app/src/quickstart/Signup.scala:
package quickstart
import scalive.*
final case class Signup(name: String, email: String)
object Signup:
val Root = FormRoot("signup")
val Name = Root
.text("name")
.map(_.trim)
.required(FieldIssue("Enter your name."))
val Email = Root
.text("email")
.map(_.trim)
.required(FieldIssue("Enter your email address."))
.validate(FieldIssue("Enter an email address such as ada@example.com."))(_.contains("@"))
val Definition = Root.product[Signup]((Name, Email))View source (documentation/fixtures/learn/src/quickstart/Signup.scala:2-22)
FormRoot("signup")class FormRootDeclares a root-level form schema namespace. names the form, and
each field's HTML name is nested under it, such as signup[name]. Each field
reads text, trims it, and checks it. required rejects an empty value and
validate rejects a value that fails its predicate. The FieldIssue text is
the message shown to the visitor. product combines the fields into
Signup.Definition, which decodes a complete Signup only when every field is
valid.
The definition is plain data. It does not know about any LiveView, so tests and other pages can use it too.
Keep the form in the model
The LiveView keeps the form's current state in its model, next to what happened last:
final case class Model(form: Signup.Definition.Form, signedUp: Option[Signup])
enum Msg:
case Changed(event: Signup.Definition.Event)
case Submitted(event: Signup.Definition.Event)View source (documentation/fixtures/learn/src/quickstart/SignupLiveView.scala:59-63)
Signup.Definition.Form holds the raw text the visitor typed, the current
errors, and which fields the visitor has used. Its Event is what the browser
sends when the form changes or is submitted. mount starts from an empty form:
def mount(ctx: MountContext): Task[Model] =
ZIO.succeed(Model(form = Signup.Definition.initial(), signedUp = None))View source (documentation/fixtures/learn/src/quickstart/SignupLiveView.scala:12-13)
Validate as the visitor types
Signup.Definition.onChange(Msg.Changed(_)) in the view sends Msg.Changed
each time an input changes. The event already contains the updated, validated
form, so the handler only stores it:
case Msg.Changed(event) =>
ZIO.succeed(model.copy(form = event.form))View source (documentation/fixtures/learn/src/quickstart/SignupLiveView.scala:18-19)
Storing event.form is what keeps the inputs and their errors in step with the
browser. Without it, the next render would show the previous form.
Handle the submission
Signup.Definition.onSubmit(Msg.Submitted(_)) sends Msg.Submitted when the
visitor submits. event.form.result is Right(signup) when every field is
valid, and Left(issues) otherwise:
case Msg.Submitted(event) =>
event.form.result match
case Right(signup) =>
ZIO.succeed(Model(form = Signup.Definition.initial(), signedUp = Some(signup)))
case Left(_) =>
ZIO.succeed(model.copy(form = event.form))View source (documentation/fixtures/learn/src/quickstart/SignupLiveView.scala:22-27)
On success, the handler records the Signup and resets the form by building a
fresh Signup.Definition.initial(). On failure, it stores the submitted form.
A submitted form marks every field as used, so all of its errors appear at once.
A real application would save the sign-up through a service before resetting the form. Loading data and live updates shows how a LiveView gets a service.
Render fields and errors
The view reads each field from a signal of the form:
def view(model: Signal[Model]): HtmlElement[Msg] =
val signupForm = model.map(_.form)
val name = signupForm.field(Signup.Name)
val email = signupForm.field(Signup.Email)
mainTag(
h1("Sign up for updates"),
form(
idAttr := "signup-form",
Signup.Definition.onChange(Msg.Changed(_)),
Signup.Definition.onSubmit(Msg.Submitted(_)),
label(forId := name.id, "Name"),
name.text(name.validationAttributes),
name.errorFeedback(error => error.map(_.message)),
label(forId := email.id, "Email"),
email.email(email.validationAttributes),
email.errorFeedback(error => error.map(_.message)),
button(typ := "submit", "Sign up")
),
model.map(_.signedUp).option { signup =>
p(role := "status", signup.map(signup => s"Thanks, ${signup.name}. Check ${signup.email}."))
}
)View source (documentation/fixtures/learn/src/quickstart/SignupLiveView.scala:31-53)
signupForm.field(Signup.Name) gives the field's id, name, current value,
and errors. name.text(...) renders a text input with all of them, and
email.email(...) an email input. validationAttributes adds aria-invalid
and links the input to its error list for screen readers. errorFeedback
renders the field's visible errors, one per issue.
An error becomes visible once the visitor has used its field or submitted the form. This keeps a fresh form from opening covered in "Enter your name."
Try it
The complete app/src/quickstart/SignupLiveView.scala:
package quickstart
import zio.{Task, ZIO}
import scalive.*
final class SignupLiveView extends LiveView[SignupLiveView.Msg, SignupLiveView.Model]:
import SignupLiveView.*
def mount(ctx: MountContext): Task[Model] =
ZIO.succeed(Model(form = Signup.Definition.initial(), signedUp = None))
def handleMessage(model: Model, ctx: MessageContext) =
case Msg.Changed(event) =>
ZIO.succeed(model.copy(form = event.form))
case Msg.Submitted(event) =>
event.form.result match
case Right(signup) =>
ZIO.succeed(Model(form = Signup.Definition.initial(), signedUp = Some(signup)))
case Left(_) =>
ZIO.succeed(model.copy(form = event.form))
def view(model: Signal[Model]): HtmlElement[Msg] =
val signupForm = model.map(_.form)
val name = signupForm.field(Signup.Name)
val email = signupForm.field(Signup.Email)
mainTag(
h1("Sign up for updates"),
form(
idAttr := "signup-form",
Signup.Definition.onChange(Msg.Changed(_)),
Signup.Definition.onSubmit(Msg.Submitted(_)),
label(forId := name.id, "Name"),
name.text(name.validationAttributes),
name.errorFeedback(error => error.map(_.message)),
label(forId := email.id, "Email"),
email.email(email.validationAttributes),
email.errorFeedback(error => error.map(_.message)),
button(typ := "submit", "Sign up")
),
model.map(_.signedUp).option { signup =>
p(role := "status", signup.map(signup => s"Thanks, ${signup.name}. Check ${signup.email}."))
}
)
end SignupLiveView
object SignupLiveView:
final case class Model(form: Signup.Definition.Form, signedUp: Option[Signup])
enum Msg:
case Changed(event: Signup.Definition.Event)
case Submitted(event: Signup.Definition.Event)View source (documentation/fixtures/learn/src/quickstart/SignupLiveView.scala:2-64)
Add its route to Routes.scala:
val signup = live / "signup"View source (documentation/fixtures/learn/src/quickstart/Routes.scala:11-11)
Register it in Main.scala:
Routes.signup -> SignupLiveView(),View source (documentation/fixtures/learn/src/quickstart/Main.scala:39-39)
Run mill app.run and open http://localhost:8080/signup:
Type
adain Email. The email error appears; the name field shows none because you have not used it.Select Sign up. Both errors appear.
Fill in a name and a valid email, then select Sign up again. The page thanks you and the form is empty again.
The typed forms guide covers initial values, other input types, repeated rows, and saving in the background. Next, Loading data and live updates gives a page a service.