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

Aufnahmeprüfung 2026
0/1000
0/1000

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. WidgetTemplate sorgt dafür, dass die Zeile nach dem POST wieder als dasselbe Feld erscheint; IstPflichtfeld wird von der clientseitigen RequiredIf-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 DisplayFromViewModel selbst; ein zusätzliches HiddenFor(x => x.Bezeichnung) erzeugt nur ein doppeltes Feld.
  • RequiredIf löst den Feldnamen nur eine Präfix-Ebene tief auf. Die clientseitige Prüfung schneidet den Namen am ersten Punkt: aus Fragen[0].Text wird der Verweis Fragen[0].IstPflichtfeld richtig. Bei einer tieferen Verschachtelung wie Abschnitt.Fragen[0].Text entstünde dagegen Abschnitt.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 EditorFor und trägt bis in die versteckten Felder durch, von Hand zusammengebaute Namen sind nicht nötig und gehen meist schief.