Skip to content
scalive
Menu
ConnectingLiveReconnectingOffline
GitHub

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

  1. Type a title, such as Release notes, and click Begin save. Save state shows saving, and the simulation buttons appear.

  2. Click Simulate success. Save state returns to idle, Dirty to false, and Baseline advancements to 1.

  3. Click Replay stale success. The notice reads "A stale completion was ignored and changed no workflow state."

  4. 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.

  5. Clear the title and click Begin save. The notice reads "Save did not start because the typed form is invalid."

Live result

ConnectedReconnectingRead-only

Edit the form, then begin a save.

Dirty: false

Revision: 0

Save state: idle

Baseline advancements: 0

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 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 FormWorkflowExample

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 FormSubmissionToken for 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.

  • failureForCurrentRevision shows 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 stays failed. Dismiss failure calls dismissFailure, which returns the save state to idle without changing the draft, dirty state, baseline, or revision.