Wizard

Fünf Schritte

Der Regelfall. Der letzte Schritt ist über IsDisabled für den Direktsprung gesperrt, «Speichern» heisst «Entwurf speichern», und die Pflichtangabe blockiert nur «Weiter» – nicht Speichern und nicht die Schritt-Navigation.

@model WizardBeispielViewModel

@* Der Wizard ist ein Seiten-Steuerelement, kein Feld: Kopfbereich und Navigation umschliessen den
   Inhalt der Seite. Die Konfiguration kommt wie im Projekt über ViewBag.Wizard, die Eingabefelder
   dazwischen über die EditorTemplates der Komponenten. Nr und Anzahl sind reine Transportfelder für
   den POST und deshalb HiddenFor — sie haben nichts anzuzeigen. «Weiter» führt in den Ablauf. *@
<form method="post" asp-area="Wizard" asp-controller="WizardBeispiel" asp-action="Schritt">
    @Html.Fmh(Fmh.Wizard()
        .Id("wz-fuenf")
        .Add(ViewBag.Wizard as WizardSetup))

    <div class="my-4" style="max-width: 40rem;">
        @Html.HiddenFor(m => m.Nr)
        @Html.HiddenFor(m => m.Anzahl)
        @Html.EditorFor(m => m.Bemerkung)
    </div>

    @Html.Fmh(Fmh.WizardNavigation()
        .Add(ViewBag.Wizard as WizardSetup))
</form>
using System.Collections.Generic;
using System.Linq;
using FMH.Komponente.Infrastruktur.Ui.Controls.Button;
using FMH.Komponente.WebUI.Areas.Wizard.Models;
using FMH.Komponente.Wizard.Ui.Controls.Wizard.Daten;
using FMH.Komponente.Wizard.Ui.Controls.Wizard.Erweiterungen;
using Microsoft.AspNetCore.Mvc;

namespace FMH.Komponente.WebUI.Areas.Wizard.Controllers.Beispiele
{
    // Ein Endpunkt für alle Schritte: Welcher Schritt gerade läuft, sagt die Route (nr). In einem
    // Projekt sind es üblicherweise benannte Actions je Schritt (Schritt01, Schritt02, …) — die
    // Mechanik ist dieselbe, hier bleibt der Ablauf so aber in einer Datei nachlesbar.
    [Area("Wizard")]
    public class WizardBeispielController : Controller
    {
        [HttpGet]
        public IActionResult Schritt(int nr = 1, int anzahl = 5)
        {
            this.ViewBag.Wizard = BuildWizard(nr, anzahl);

            return this.View(new WizardBeispielViewModel { Nr = nr, Anzahl = anzahl });
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public IActionResult Schritt(WizardBeispielViewModel modell, string submitButton)
        {
            // Die Wizardkonfiguration wird für den POST neu aufgebaut — sie wird nicht mitgeführt.
            var wz = BuildWizard(modell.Nr, modell.Anzahl);
            this.ViewBag.Wizard = wz;

            // Vorgelagerte Prüfung: IsWizardValid berücksichtigt die über DisableInputValidationFor
            // abgeschalteten Bereiche, bevor es auf ModelState.IsValid zurückfällt. Ohne diesen
            // Aufruf würde die Pflichtangabe auch «Speichern» und die Schritt-Navigation blockieren.
            if (this.ModelState.IsWizardValid(wz, submitButton))
            {
                // PRG: Das Ziel ergibt sich aus der Wizardkonfiguration und der gedrückten
                // Schaltfläche — der Controller muss die Schrittlogik nicht selbst kennen.
                return this.RedirectWithWizardSetupAndNavigationValue(wz, submitButton);
            }

            return this.View(modell);
        }

        // Die Konfiguration liegt in einer eigenen Methode, damit GET und POST dieselbe erzeugen.
        private static WizardSetup BuildWizard(int nr, int anzahl)
        {
            var ws = new WizardSetup
            {
                Title = "Antrag einreichen",

                // Präfix der Button-Ids. Darüber sprechen die jQuery-Funktionen
                // wizardNavbarDisabled/-Enabled die Schaltflächen an.
                IdPrefix = "wz",
            };

            ws.Steps = Enumerable.Range(1, anzahl)
                .Select(
                    i => new WizardStep
                    {
                        Title = SchrittTitel(i, anzahl),
                        Action = new MvcAction("Schritt", "WizardBeispiel", new { nr = i, anzahl }),

                        // Der letzte Schritt lässt sich nicht direkt anspringen — ein häufiger
                        // fachlicher Fall: Übermitteln erst nach dem Durchlaufen.
                        IsDisabled = i == anzahl,
                        State = ZuStatus(i, nr),
                    })
                .ToList();

            ws.SetCancelAction("Wizard", "Demo", new { area = "Wizard" });
            ws.SetHelpAction("/Wizard/Demo/Wizard");
            ws.SetSaveButtonText("Entwurf speichern");

            // Speichern und die Schritt-Navigation sollen auch mit unvollständigen Angaben
            // funktionieren; nur «Weiter» erzwingt die Pflichtangabe.
            ws.DisableInputValidationFor(ValidationArea.NavigationWizard | ValidationArea.Save);

            return ws;
        }

        private static string SchrittTitel(int nummer, int anzahl)
        {
            if (nummer == 1)
            {
                return "Bemerkungen";
            }

            if (nummer == anzahl)
            {
                return "Antrag übermitteln";
            }

            return $"Angaben Teil {nummer - 1}";
        }

        // Schritte vor dem aktuellen gelten als erledigt, der aktuelle ist Current, der Rest offen.
        private static WizardStepState ZuStatus(int nummer, int aktuell)
        {
            if (nummer < aktuell)
            {
                return WizardStepState.Filled;
            }

            return nummer == aktuell ? WizardStepState.Current : WizardStepState.ToDo;
        }
    }
}

Darstellung

Antrag einreichen

Step 1 of 5: Bemerkungen

Freitext zum Schritt. Für «Weiter» erforderlich, für «Entwurf speichern» nicht.0/1000
Loading... done highlight_off Cancel

Der Wizard besteht aus zwei Buildern und einem Konfigurationsmodell:

Baustein Aufgabe
Fmh.Wizard() Kopfbereich: Titel, «Schritt x von y», Hilfe-Schaltfläche und die Schritt-Navigation
Fmh.WizardNavigation() Leiste mit Zurück, Abbrechen, Speichern und Weiter
WizardSetup die gemeinsame Konfiguration beider Builder

Konfiguration im Controller

Die Konfiguration gehört in eine eigene Methode — GET und POST müssen dieselbe erzeugen, weil sie nicht mitgeführt wird:

private WizardSetup BuildWizard(int id, int? stepIndex)
{
    var ws = new WizardSetup();
    ws.Title = "Antrag einreichen";
    ws.Steps = new List<WizardStep>
    {
        new WizardStep { Title = "Bemerkungen", Action = new MvcAction("Schritt01", "Wizard", new { id }) },
        new WizardStep { Title = "Antrag ausfüllen", Action = new MvcAction("Schritt02", "Wizard", new { id }) },
        new WizardStep { Title = "Antrag übermitteln", Action = new MvcAction("Schritt05", "Wizard", new { id }), IsDisabled = true },
    };
    ws.SetCancelAction("Index", "Home", new { id });
    ws.SetSaveButtonText("Entwurf speichern");

    if (stepIndex.HasValue)
    {
        ws.Steps[stepIndex.Value].State = WizardStepState.Current;
    }

    ws.DisableInputValidationFor(ValidationArea.NavigationWizard | ValidationArea.Save);
    return ws;
}

Im GET wird sie an die View gegeben, im POST wieder aufgebaut und ausgewertet:

[HttpPost]
public IActionResult Schritt01(Schritt01ViewModel model, string submitButton)
{
    var wz = this.BuildWizard(model.Id, 0);
    this.ViewBag.Wizard = wz;

    if (this.ModelState.IsWizardValid(wz, submitButton))
    {
        return this.RedirectWithWizardSetupAndNavigationValue(wz, submitButton);
    }

    return this.View(model);
}

RedirectWithWizardSetupAndNavigationValue leitet nach dem PRG-Muster weiter und liest das Ziel aus der Konfiguration — der Controller muss die Schrittfolge nicht selbst kennen.

In der View

Beide Builder gehören in dasselbe <form>: Die Schaltflächen sind Submit-Buttons mit name="submitButton", deren Wert im Controller über das Ziel entscheidet.

<form id="fmh-form" method="post">
    @Html.Fmh(Fmh.Wizard().Add(ViewBag.Wizard as WizardSetup))

    @Html.HiddenFor(x => x.Id)
    ...

    @Html.Fmh(Fmh.WizardNavigation().Add(ViewBag.Wizard as WizardSetup))
</form>

WizardSetup

Eigenschaft / Methode Beschreibung
Title Die Überschrift des Wizards.
AdditionalTitleText Zusatzinformation auf Ebene des Titels. Alternativ ViewBag.WzZusatzText — praktisch, wenn ein ActionFilter den Text setzt.
Steps Liste der Schritte vom Typ WizardStep.
StepNavigationDisabled Sperrt die Navigation über die Schritte; es bleiben Zurück, Abbrechen, Speichern und Weiter.
SaveButtonDisabled Blendet die Speichern-Schaltfläche aus. Standard: sie wird angezeigt.
RenderBackButtonAsLink Zurück als Link statt als Submit — dann wird nichts gepostet. Standard: Submit.
IdPrefix Präfix der Button-Ids für die clientseitige Steuerung. Standard wz.
WizardNavigationDefaultState Anfangszustand der Navigations-Schaltflächen. Standard Enabled.
CurrentStepIndex, CurrentStep, NextStep, PreviousStep Abgeleitet aus dem Schritt mit State = Current.
CancelStep, HelpStep Ergebnis von SetCancelAction(...) bzw. SetHelpAction(...).
ValidationAreaDisabled Ergebnis von DisableInputValidationFor(...).
SetCancelAction(action, controller, routeValues) Ziel der Abbrechen-Schaltfläche.
SetHelpAction(action, controller, routeValues) / SetHelpAction(link) Verweis auf die Hilfe. Erst damit erscheint die Hilfe-Schaltfläche im Kopf.
SetSaveButtonText(text), SetNextButtonText(text), SetBackButtonText(text) Beschriftungen anpassen, etwa «Entwurf speichern».
DisableInputValidationFor(area) Schaltet die Validierung je Bereich ab (siehe unten).

WizardStep

Eigenschaft Beschreibung
Title Titel des Schritts.
Action Route des Ziels als MvcAction, wie beim Button.
State ToDo (Standard), Filled, Current, Error. Ergibt die CSS-Klassen item-todo, item-done, item-current, item-error.
IsDisabled Schritt lässt sich in der Navigation nicht direkt anspringen — etwa der Übermittlungsschritt.

Validierung: ValidationArea

ValidationArea ist ein Flags-Enum, die Werte lassen sich mit | kombinieren: None, All, NavigationWizard, Save, Next, Back.

Wer DisableInputValidationFor(...) nutzt, muss im Controller ModelState.IsWizardValid(wz, submitButton) statt ModelState.IsValid verwenden — sonst greift die Abschaltung nicht.

In diesem Beispiel ist NavigationWizard | Save abgeschaltet: Die Bemerkung ist Pflicht, blockiert aber nur «Weiter». Speichern und der Sprung auf einen anderen Schritt funktionieren auch mit leerem Feld — genau das braucht eine Entwurfserfassung.

Clientseitig: die Navigation steuern

Über IdPrefix lassen sich die Schaltflächen aus der Geschäftslogik heraus sperren:

Funktion Wirkung
$(document).wizardNavbarDisabled(idPrefix) deaktiviert die Schaltflächen der Navigationsleiste
$(document).wizardNavbarEnabled(idPrefix) aktiviert sie wieder
$(document).wizardNavbarButtonDisabled(idPrefix, NavbarButton.Next) deaktiviert eine einzelne Schaltfläche
$(document).wizardNavbarButtonEnabled(idPrefix, NavbarButton.Next) aktiviert eine einzelne Schaltfläche

NavbarButton kennt Next, Back und Save. Die Zurück-Schaltfläche lässt sich nur deaktivieren, wenn sie vom Typ Button ist — also nicht bei RenderBackButtonAsLink = true.

Soll der Wizard mit gesperrter Navigation starten, WizardNavigationDefaultState = Disabled setzen und im richtigen Moment wizardNavbarEnabled aufrufen.