Zeile als eigenes Template
Ein Formular, dessen Felder erst zur Laufzeit feststehen: Beschriftung, Eingabeart und Pflichtangabe stehen je Zeile in den Daten. Bei einer solchen Auflistung lohnt sich ein eigenes EditorTemplate auf Zeilenebene – sein Modelltyp ist die Zeilenklasse, damit hat es Zugriff auf alles, was das Rendern steuert.
Die View bleibt dadurch frei von Fallunterscheidungen: Sie reicht jede Zeile an das Template weiter, den Rest entscheidet die Zeile selbst.
Zeile als eigenes EditorTemplate
Beschriftung, Eingabeart und Pflichtangabe stehen je Zeile in den Daten. Die View reicht jede Zeile an ein eigenes Template weiter – dort wählt Model.WidgetTemplate das Eingabefeld.
@model FragebogenBeispielViewModel
@* Die View kennt weder die Fragen noch deren Eingabearten – sie reicht jede Zeile an ein eigenes
EditorTemplate weiter. Alles Weitere entscheidet die Zeile selbst.
Der zweite Parameter von EditorFor benennt dieses Template; das Modell des Templates ist die
Zeilenklasse. Der Feldpräfix (Fragen[0]. …) entsteht dabei automatisch und trägt bis in die
versteckten Felder und die Validierungsregeln durch. *@
<form asp-area="Basis" asp-controller="DisplayFromViewModel" asp-action="Formular" method="post">
<h5 class="h6">@Model.FormularName</h5>
@Html.HiddenFor(m => m.FormularName)
@for (var idx = 0; idx < Model.Fragen.Count; idx++)
{
@Html.EditorFor(m => m.Fragen[idx], "FrageEditorTemplate")
}
@Html.Fmh(Fmh.Button()
.Text("Speichern")
.Size(ButtonSize.Sm)
.Attributes(new { @class = "mt-2" }))
</form>
using System.Collections.Generic;
using FMH.Komponente.Basis.Ui.Validation.DataAnnotation;
using FMH.Komponente.Infrastruktur;
using Microsoft.AspNetCore.Mvc.Rendering;
namespace FMH.Komponente.WebUI.Areas.Basis.Models
{
/// <summary>
/// Eine Zeile des Formulars. Sie trägt neben dem erfassten Wert alles, was das Rendern steuert.
/// </summary>
public class FrageBeispielViewModel
{
/// <summary>
/// Holt oder setzt den Schlüssel der Frage.
/// </summary>
public int Id { get; set; }
/// <summary>
/// Holt oder setzt die Beschriftung des Eingabefelds.
/// </summary>
public string Bezeichnung { get; set; }
/// <summary>
/// Holt oder setzt die Auswahlliste, falls die Zeile als Dropdown gerendert wird.
/// </summary>
public IEnumerable<SelectListItem> Auswahl { get; set; } = new List<SelectListItem>();
/// <summary>
/// Holt oder setzt die Angabe, ob die Zeile beantwortet werden muss. Das Feld geht als
/// verstecktes Feld mit – die clientseitige Prüfung von <c>RequiredIf</c> liest es dort.
/// </summary>
public bool IstPflichtfeld { get; set; }
/// <summary>
/// Holt oder setzt den Namen des EditorTemplates, mit dem die Zeile gerendert wird.
/// </summary>
public string WidgetTemplate { get; set; }
/// <summary>
/// Holt oder setzt den erfassten Wert.
/// </summary>
/// <remarks>
/// Drei Angaben an einer Eigenschaft: Die Beschriftung kommt aus <c>Bezeichnung</c>, die
/// Pflichtangabe aus <c>IstPflichtfeld</c> derselben Zeile, und die Auswahlliste eines
/// Dropdowns aus <c>Auswahl</c>. Alle drei sind Daten und keine Literale – genau deshalb
/// lässt sich das Formular aus einer Konfiguration bauen.
/// </remarks>
[DisplayFromViewModel(nameof(Bezeichnung))]
[RequiredIf(nameof(IstPflichtfeld), "True", ErrorMessage = "Bitte beantworten Sie diese Frage.")]
[AdditionalMetadata(UiMetadata.Basis.DropdownListSelection, nameof(Auswahl))]
public string Text { get; set; }
}
}
using FMH.Komponente.Basis.Erweiterungen;
using FMH.Komponente.Basis.Ui.Feedback.Alert;
using FMH.Komponente.WebUI.Areas.Basis.Models;
using Microsoft.AspNetCore.Mvc;
namespace FMH.Komponente.WebUI.Areas.Basis.Controllers.Beispiele
{
/// <summary>
/// Die Gegenstelle der beiden DisplayFromViewModel-Beispiele.
/// </summary>
/// <remarks>
/// Zwei Varianten, deshalb je ein GET+POST-Paar: <c>Index</c> für das einzelne Feld,
/// <c>Formular</c> für die Auflistung mit eigenem Zeilen-Template.
/// </remarks>
[Area("Basis")]
public class DisplayFromViewModelController : Controller
{
/// <summary>
/// Zeigt das einzelne Feld, dessen Beschriftung aus dem ViewModel kommt.
/// </summary>
/// <returns>Die Beispiel-Seite.</returns>
[HttpGet]
public IActionResult Index()
{
return this.View(new DisplayFromViewModelBeispielViewModel());
}
/// <summary>
/// Nimmt das einzelne Feld entgegen.
/// </summary>
/// <param name="modell">Das ausgefüllte Formular.</param>
/// <returns>Die Umleitung nach erfolgreichem POST, sonst dieselbe Seite mit den Fehlern.</returns>
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult Index(DisplayFromViewModelBeispielViewModel modell)
{
if (this.ModelState.IsValid)
{
// Der Kern des Attributs: Die Beschriftung kam über ein verstecktes Feld zurück und
// steht hier zur Verfügung, ohne sie erneut aus der Datenbank zu holen.
this.AddAlert(
$"Gespeichert. Die Beschriftung «{modell.Bezeichnung}» kam ohne Datenbankzugriff mit zurück.",
AlertType.Success,
autoHide: true);
return this.RedirectToAction(nameof(this.Index));
}
return this.View(modell);
}
/// <summary>
/// Zeigt das Formular, dessen Felder aus der Konfiguration kommen.
/// </summary>
/// <returns>Die Beispiel-Seite.</returns>
[HttpGet]
public IActionResult Formular()
{
return this.View(FragebogenBeispielViewModel.Ausgangslage());
}
/// <summary>
/// Nimmt das Formular entgegen.
/// </summary>
/// <param name="modell">Das ausgefüllte Formular.</param>
/// <returns>Die Umleitung nach erfolgreichem POST, sonst dieselbe Seite mit den Fehlern.</returns>
[HttpPost]
[ValidateAntiForgeryToken]
public IActionResult Formular(FragebogenBeispielViewModel modell)
{
if (this.ModelState.IsValid)
{
this.AddAlert(
$"{modell.Fragen.Count} Zeilen gespeichert – Beschriftungen und Eingabearten kamen mit zurück.",
AlertType.Success,
autoHide: true);
return this.RedirectToAction(nameof(this.Formular));
}
// Vor dem erneuten Rendern die Konfiguration ergänzen, die in keinem Feld steht:
// Ohne die Auswahllisten wäre das Dropdown beim zweiten Aufbau leer.
modell.ErgaenzeKonfiguration();
return this.View(modell);
}
}
}
Darstellung
Der eigentliche Anwendungsfall: ein Formular, dessen Felder erst zur Laufzeit feststehen, ein Fragenkatalog, eine Kriterienliste, ein konfigurierbares Erfassungsformular. Beschriftung, Eingabeart und Pflichtangabe stehen je Zeile in den Daten.
Bei einer Auflistung lohnt sich ein eigenes EditorTemplate auf Zeilenebene. Sein Modelltyp ist die Zeilenklasse; damit hat es Zugriff auf alles, was das Rendern steuert:
@* Areas/<Paket>/Views/Shared/EditorTemplates/FrageEditorTemplate.cshtml *@
@model FrageBeispielViewModel
@Html.EditorFor(x => x.Text, Model.WidgetTemplate)
@Html.HiddenFor(x => x.Id)
@Html.HiddenFor(x => x.WidgetTemplate)
@Html.HiddenFor(x => x.IstPflichtfeld)
Die View bleibt dadurch frei von Fallunterscheidungen:
@for (var idx = 0; idx < Model.Fragen.Count; idx++)
{
@Html.EditorFor(m => m.Fragen[idx], "FrageEditorTemplate")
}
Drei Angaben, alle aus den Daten
An der Zeileneigenschaft stehen drei Attribute, die sich alle auf Geschwister-Eigenschaften derselben Zeile beziehen:
| Attribut | Nimmt aus der Zeile |
|---|---|
[DisplayFromViewModel(nameof(Bezeichnung))] |
die Beschriftung |
[RequiredIf(nameof(IstPflichtfeld), "True")] |
ob die Zeile beantwortet werden muss |
[AdditionalMetadata(UiMetadata.Basis.DropdownListSelection, nameof(Auswahl))] |
die Auswahlliste, wenn die Zeile ein Dropdown ist |
Dazu Model.WidgetTemplate als Templatename. Damit ist das ganze Formular datengesteuert, für eine
neue Frage ist kein Markup nötig.
Wo das eigene Template liegt
Areas/<Paket>/Views/Shared/EditorTemplates/<Name>.cshtml, gefunden wird es, sobald die Seite in
derselben Area gerendert wird. Anwendungsweit gilt Views/Shared/EditorTemplates/. Die Templates
der Komponenten liegen im jeweiligen Paket und werden über dieselbe Suchreihenfolge gefunden, ein
gleichnamiges Template in der Anwendung geht vor.
Was zu beachten ist
- Die zwei Hidden-Felder sind Pflicht.
WidgetTemplatesorgt dafür, dass die Zeile nach dem POST wieder als dasselbe Feld erscheint;IstPflichtfeldwird von der clientseitigenRequiredIf-Prüfung im Formular gesucht. Fehlt eines davon, bricht der Round-Trip, ohne Fehlermeldung. - Für die Beschriftung braucht es kein Hidden-Feld. Das rendert
DisplayFromViewModelselbst; ein zusätzlichesHiddenFor(x => x.Bezeichnung)erzeugt nur ein doppeltes Feld. RequiredIflöst den Feldnamen nur eine Präfix-Ebene tief auf. Die clientseitige Prüfung schneidet den Namen am ersten Punkt: ausFragen[0].Textwird der VerweisFragen[0].IstPflichtfeldrichtig. Bei einer tieferen Verschachtelung wieAbschnitt.Fragen[0].Textentstünde dagegenAbschnitt.IstPflichtfeld. Die Liste gehört deshalb direkt an das ViewModel der Seite.- Die Auswahllisten überstehen den POST nicht. Sie stehen in keinem Formularfeld und müssen vor dem erneuten Rendern wieder gesetzt werden, sonst ist das Dropdown beim zweiten Aufbau leer.
- Der Feldpräfix entsteht automatisch aus dem Ausdruck in
EditorForund trägt bis in die versteckten Felder durch, von Hand zusammengebaute Namen sind nicht nötig und gehen meist schief.