Form save workflow
A form that saves in the background. The workflow tracks whether the draft differs from the last saved baseline, ties each save to the revision it submitted, and ignores completions that arrive too late. Instead of a real service, buttons simulate the save's outcome.
Try it
Type a title, such as
Release notes, and click Begin save. Save state showssaving, and the simulation buttons appear.Click Simulate success. Save state returns to
idle, Dirty tofalse, and Baseline advancements to 1.Click Replay stale success. The notice reads "A stale completion was ignored and changed no workflow state."
Edit the title, click Begin save, then Simulate failure. An alert reads "This draft could not be saved. Your changes are still available." Click Dismiss failure to hide it without changing the draft.
Clear the title and click Begin save. The notice reads "Save did not start because the typed form is invalid."
Live result
ConnectedReconnectingRead-onlyEdit the form, then begin a save.
Dirty: false
Revision: 0
Save state: idle
Baseline advancements: 0
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 FormWorkflowExample
extends LiveView[FormWorkflowExample.Msg, FormWorkflowExample.Model]:
import FormWorkflowExample.*
def mount(ctx: MountContext): Task[Model] =
ZIO.succeed(Model.initial)
def handleMessage(model: Model, ctx: MessageContext) =
message => ZIO.succeed(update(model, message))
def view(model: Signal[Model]): HtmlElement[Msg] =
val workflow = model.map(_.workflow)
val draft = workflow.map(_.current)
val title = draft.field(Draft.Title)
sectionTag(
cls := "docs-form-workflow",
dataAttr("form-workflow") := "",
dataAttr("save-state") := model.map(value => saveState(value.workflow)),
dataAttr("dirty") := workflow.map(_.isDirty.toString),
dataAttr("revision") := workflow.map(_.revision.value.toString),
dataAttr("baseline-advancements") := model.map(_.baselineAdvancements.toString),
aria.label := "Form save workflow",
form(
dataAttr("workflow-form") := "",
idAttr := "form-save-workflow",
Draft.Definition.onChange(Msg.Validate(_)),
Draft.Definition.onSubmit(Msg.BeginSave(_)),
label(forId := title.id, "Draft title"),
title.text(title.validationAttributes, placeholder := "Release notes"),
title.errorFeedback(
_ => "A title is required.",
dataAttr("field-error") := "title"
),
button(typ := "submit", "Begin save")
),
div(
dataAttr("workflow-controls") := "",
model.map(activeSubmission).option { submission =>
div(
button(
typ := "button",
on.click(submission.map(value => Msg.PersistenceSucceeded(value.token))),
"Simulate success"
),
button(
typ := "button",
on.click(submission.map(value => Msg.PersistenceFailed(value.token))),
"Simulate failure"
),
button(
typ := "button",
on.click(submission.map(value => Msg.PersistenceCancelled(value.token))),
"Simulate cancellation"
),
button(typ := "button", on.click(Msg.BeginSaveAgain), "Begin another save")
)
},
model.map(_.staleToken).option { token =>
button(
typ := "button",
on.click(token.map(Msg.PersistenceSucceeded(_))),
"Replay stale success"
)
},
button(typ := "button", on.click(Msg.Reset), "Reset to baseline")
),
div(
dataAttr("workflow-status") := "",
p(
dataAttr("workflow-notice") := "",
role := "status",
aria.live := "polite",
aria.atomic := true,
model.map(_.notice.label)
),
workflow.map(_.failureForCurrentRevision).option { _ =>
div(
dataAttr("workflow-failure-feedback") := "",
role := "alert",
aria.atomic := true,
p("This draft could not be saved. Your changes are still available."),
button(
typ := "button",
on.click(Msg.DismissFailure),
"Dismiss failure"
)
)
},
p("Dirty: ", strong(dataAttr("workflow-dirty") := "", workflow.map(_.isDirty.toString))),
p(
"Revision: ",
strong(dataAttr("workflow-revision") := "", workflow.map(_.revision.value.toString))
),
p(
"Save state: ",
strong(
dataAttr("workflow-save-state") := "",
model.map(value => saveState(value.workflow))
)
),
p(
"Baseline advancements: ",
strong(
dataAttr("workflow-baseline-advancements") := "",
model.map(_.baselineAdvancements.toString)
)
)
)
)
end view
end FormWorkflowExample
object FormWorkflowExample:
final case class Draft(title: String)
object Draft:
val Root = FormRoot("workflow-draft")
val Title = Root.text("title").map(_.trim).required(FieldIssue("title.required"))
val Definition = Root.product[Draft]((title = Title))
enum Notice(val label: String):
case Ready extends Notice("Edit the form, then begin a save.")
case EditRecorded extends Notice("The edit changed the current revision.")
case InteractionRecorded
extends Notice("Interaction changed without advancing the values revision.")
case EditRecordedWhileSaving
extends Notice(
"The edit changed the revision while the submitted snapshot remains in flight."
)
case InvalidStart extends Notice("Save did not start because the typed form is invalid.")
case SavingStarted extends Notice("Save started with a revision-bound submission token.")
case AlreadySaving
extends Notice("A second save was rejected because one save is already active.")
case SaveSucceeded
extends Notice(
"Success advanced the baseline; newer edits, if any, remain current and dirty."
)
case SaveFailed extends Notice("The last persistence transition recorded a correlated failure.")
case FailureDismissed
extends Notice("Current failure feedback was dismissed without changing the draft.")
case SaveCancelled
extends Notice("The correlated save was cancelled without advancing the baseline.")
case StaleCompletion
extends Notice("A stale completion was ignored and changed no workflow state.")
case ResetBlocked extends Notice("Reset was blocked while a save is active.")
case ResetComplete extends Notice("Reset restored a pristine form from the current baseline.")
final case class Model(
workflow: Draft.Definition.Workflow[String],
notice: Notice = Notice.Ready,
staleToken: Option[FormSubmissionToken] = None,
baselineAdvancements: Int = 0)
object Model:
val initial: Model = Model(Draft.Definition.workflow(Draft.Definition.initial()))
enum Msg:
case Validate(event: Draft.Definition.Event)
case BeginSave(event: Draft.Definition.Event)
case BeginSaveAgain
case PersistenceSucceeded(token: FormSubmissionToken)
case PersistenceFailed(token: FormSubmissionToken)
case PersistenceCancelled(token: FormSubmissionToken)
case DismissFailure
case Reset
private[examples] def update(model: Model, message: Msg): Model = message match
case Msg.Validate(event) =>
val wasSaving = model.workflow.isSaving
val next = model.workflow.updated(event.form)
model.copy(
workflow = next,
notice =
if next.revision == model.workflow.revision then Notice.InteractionRecorded
else if wasSaving then Notice.EditRecordedWhileSaving
else Notice.EditRecorded
)
case Msg.BeginSave(event) =>
beginSave(model.copy(workflow = model.workflow.updated(event.form)))
case Msg.BeginSaveAgain => beginSave(model)
case Msg.PersistenceSucceeded(token) =>
model.workflow.saveSucceeded(token) match
case FormWorkflowTransition.Applied(next) =>
model.copy(
workflow = next,
notice = Notice.SaveSucceeded,
staleToken = Some(token),
baselineAdvancements = model.baselineAdvancements + 1
)
case FormWorkflowTransition.Stale(_) => model.copy(notice = Notice.StaleCompletion)
case Msg.PersistenceFailed(token) =>
model.workflow.saveFailed(token, "simulated persistence failure") match
case FormWorkflowTransition.Applied(next) =>
model.copy(workflow = next, notice = Notice.SaveFailed, staleToken = Some(token))
case FormWorkflowTransition.Stale(_) => model.copy(notice = Notice.StaleCompletion)
case Msg.PersistenceCancelled(token) =>
model.workflow.saveCancelled(token) match
case FormWorkflowTransition.Applied(next) =>
model.copy(workflow = next, notice = Notice.SaveCancelled, staleToken = Some(token))
case FormWorkflowTransition.Stale(_) => model.copy(notice = Notice.StaleCompletion)
case Msg.DismissFailure =>
val next = model.workflow.dismissFailure
if next eq model.workflow then model
else model.copy(workflow = next, notice = Notice.FailureDismissed)
case Msg.Reset =>
model.workflow.reset match
case FormWorkflowReset.Reset(next) =>
model.copy(workflow = next, notice = Notice.ResetComplete)
case FormWorkflowReset.AlreadySaving(_, _) => model.copy(notice = Notice.ResetBlocked)
private def beginSave(model: Model): Model = model.workflow.beginSave match
case FormSaveStart.Invalid(next) => model.copy(workflow = next, notice = Notice.InvalidStart)
case FormSaveStart.AlreadySaving(current, _) =>
model.copy(workflow = current, notice = Notice.AlreadySaving)
case FormSaveStart.Started(next, _) =>
model.copy(workflow = next, notice = Notice.SavingStarted)
private def activeSubmission(model: Model): Option[model.workflow.Submission] =
model.workflow.save match
case FormSaveState.Saving(submission) => Some(submission)
case _ => None
private def saveState(workflow: Draft.Definition.Workflow[String]): String = workflow.save match
case FormSaveState.Idle => "idle"
case FormSaveState.Saving(_) => "saving"
case FormSaveState.Failed(_, _) => "failed"
end FormWorkflowExampleView source (documentation/site/src/scalive/docs/examples/FormWorkflowExample.scala:8-236)
How it works
The status panel shows the workflow's dirty state, revision, save state, and the number of successful baseline advancements. It never repeats the raw form input.
Starting a save returns a
FormSubmissionTokenfor the submitted revision. The simulation buttons carry that exact token, as a persistence callback would. A completion with an obsolete token, such as Replay stale success, changes nothing.Begin another save while a save is active reads "A second save was rejected because one save is already active." One workflow runs one save at a time.
Success advances the baseline but keeps edits made while the save was in flight. Reset to baseline then returns to that accepted baseline; while a save is active, reset is blocked.
failureForCurrentRevisionshows the failure alert only while the draft is the one that failed. Identical-value updates keep it; editing the title hides it while the save state staysfailed. Dismiss failure callsdismissFailure, which returns the save state toidlewithout changing the draft, dirty state, baseline, or revision.
Related guides
Saving forms explains the workflow and its transitions.