Likertskala
Ereignis FrageAntwortStatus
Nach jeder gespeicherten Antwort meldet die Komponente ein CustomEvent am window. Sein detail ist das AntwortResultat des Endpunkts – inklusive der frei belegbaren Metadaten.
@model BeurteilungViewModel
@* Nach jeder gespeicherten Antwort meldet die Komponente ein CustomEvent «FrageAntwortStatus» am
window. Sein detail ist das AntwortResultat des Endpunkts, samt der frei belegbaren Metadaten.
Damit lässt sich alles nachziehen, was die Komponente selbst nicht kennt: ein Fortschrittsbalken,
ein Aktivieren der Weiter-Schaltfläche, ein Protokolleintrag.
Die Komponente blendet die Rückmeldung selbst als Snackbar ein; der Listener ist nur für das
Zusätzliche zuständig. *@
@for (var idx = 0; idx < Model.FrageAntwortListe.Count; idx++)
{
@Html.EditorFor(x => x.FrageAntwortListe[idx], UiEditor.Kompetenzen.FrageAnwort)
<hr />
}
<p class="fw-bold mb-1">Was das Ereignis geliefert hat</p>
<pre class="border rounded p-3 mb-0" data-frageantwort-ausgabe>Noch keine Antwort gespeichert.</pre>
<div class="mt-3">
@Html.Fmh(Fmh.Button()
.Text("Ausgangslage wiederherstellen")
.Size(ButtonSize.Sm)
.Outline()
.Action("FrageAntwortZuruecksetzen", "Demo", new { area = "Kompetenzen", ziel = "FrageAntwortEreignis" }))
</div>
@* Der Listener steht im Tab «JavaScript» (wwwroot/js/demos/frageantwort-status.js). *@
@Html.PostScript(@<script src="~/js/demos/frageantwort-status.js" asp-append-version="true"></script>)
using System.Collections.Generic;
using FMH.Komponente.Kompetenzen.Ui.Controls.FrageAnwort.Daten;
namespace FMH.Komponente.WebUI.Areas.Kompetenzen.Models
{
/// <summary>
/// Die Ausgangsdaten der FrageAntwort-Beispiele: eine Beurteilung mit drei Fragen.
/// </summary>
/// <remarks>
/// Bewusst gemischt: eine Frage ist vom fachfremden Delegierten bereits beantwortet (sie
/// erscheint dem Fachdelegierten als Vorbefund), eine ist offen, eine trägt eine Antwort «Nein»
/// mit Begründung. Nur so zeigen die Beispiele alle drei Zustände einer Frage nebeneinander.
/// </remarks>
public static class FrageAntwortBeispielDaten
{
/// <summary>
/// Die Beurteilung mit ihren Fragen in der Ausgangslage.
/// </summary>
/// <returns>Die Beurteilung; jeder Aufruf liefert eine neue Instanz.</returns>
public static BeurteilungViewModel Beurteilung()
{
return new BeurteilungViewModel
{
FachDelegierterFmhId = 4711,
FachDelegierterName = "Dr. med. Anna Kern",
FachfremdDelegierterFmhId = 4812,
FachfremdDelegierterName = "Dr. med. Beat Roth",
FrageAntwortListe = Fragen(),
};
}
/// <summary>
/// Die Fragen der Beurteilung in der Ausgangslage.
/// </summary>
/// <returns>Die Fragen; jeder Aufruf liefert eine neue Liste.</returns>
public static IList<FrageAntwortViewModel> Fragen()
{
return new List<FrageAntwortViewModel>
{
new FrageAntwortViewModel
{
Id = 1,
Reihenfolge = 1,
Frage = "Wurde die geforderte Anzahl Weiterbildungsjahre am Zentrum absolviert?",
AntwortFachFremdDelegierter = AntwortTyp.Ja,
AntwortTextFachFremdDelegierter = "Belege liegen vollständig vor.",
},
new FrageAntwortViewModel
{
Id = 2,
Reihenfolge = 2,
Frage = "Sind die Lernziele des Weiterbildungsprogramms nachweislich erreicht?",
},
new FrageAntwortViewModel
{
Id = 3,
Reihenfolge = 3,
Frage = "Bestehen Vorbehalte gegen die Erteilung des Facharzttitels?",
AntwortFachFremdDelegierter = AntwortTyp.Nein,
AntwortTextFachFremdDelegierter = "Die Beurteilung der Klinik weicht in Punkt 4 ab.",
},
};
}
}
}
using System.Collections.Generic;
using System.Linq;
using FMH.Komponente.Kompetenzen.Ui.Controls.FrageAnwort.Daten;
namespace FMH.Komponente.WebUI.Areas.Kompetenzen.Models
{
/// <summary>
/// Der Stand der FrageAntwort-Beispiele: die Antworten, die über die Endpunkte eingegangen sind.
/// </summary>
/// <remarks>
/// Alle drei Beispiele teilen sich denselben Stand. Das ist Absicht: Wer im Grundfall als
/// Fachdelegierter antwortet und danach auf «Rollen» wechselt, sieht dort genau diese Antwort
/// als Vorbefund, das Zusammenspiel der beiden Rollen ist der Kern der Komponente.
/// <para>
/// Statt einer Datenbank eine prozessweite Ablage; nach einem Neustart der Anwendung steht
/// wieder die Ausgangslage. In einem Projekt steht an dieser Stelle das Repository.
/// </para>
/// </remarks>
public static class FrageAntwortZustand
{
private static readonly object Sperre = new object();
private static IList<FrageAntwortViewModel> fragen = FrageAntwortBeispielDaten.Fragen();
/// <summary>
/// Der aktuelle Stand der Fragen.
/// </summary>
/// <returns>Die Fragen mit den bisher erfassten Antworten.</returns>
public static IList<FrageAntwortViewModel> Fragen()
{
lock (Sperre)
{
return fragen;
}
}
/// <summary>
/// Übernimmt eine Antwort in den Stand.
/// </summary>
/// <param name="gepostet">Die Frage aus dem Formular des EditorTemplates.</param>
/// <returns>Die aktualisierte Frage oder <c>null</c>, wenn es die Id nicht gibt.</returns>
public static FrageAntwortViewModel Uebernehmen(FrageAntwortViewModel gepostet)
{
lock (Sperre)
{
var ziel = fragen.FirstOrDefault(frage => frage.Id == gepostet.Id);
if (ziel == null)
{
return null;
}
// Geschrieben wird nur, was die postende Rolle bearbeiten durfte. Die Antwort der
// Gegenseite steht im Formular gar nicht drin und darf hier nicht überschrieben
// werden.
if (gepostet.AktuelleRolle == Rolle.FachFremdDelegierter)
{
ziel.AntwortFachFremdDelegierter = gepostet.AntwortFachFremdDelegierter;
ziel.AntwortTextFachFremdDelegierter = gepostet.AntwortTextFachFremdDelegierter;
}
else
{
ziel.AntwortFachDelegierter = gepostet.AntwortFachDelegierter;
ziel.AntwortTextFachDelegierter = gepostet.AntwortTextFachDelegierter;
}
return ziel;
}
}
/// <summary>
/// Der Gesamtstand der Beurteilung aus Sicht einer Rolle.
/// </summary>
/// <param name="rolle">Die Rolle, deren Antworten gezählt werden.</param>
/// <returns>Unbeantwortet, teilweise oder komplett beantwortet.</returns>
public static AntwortStatus Status(Rolle rolle)
{
lock (Sperre)
{
var beantwortet = fragen.Count(frage => IstBeantwortet(frage, rolle));
if (beantwortet == 0)
{
return AntwortStatus.Unbeantwortet;
}
return beantwortet == fragen.Count ? AntwortStatus.KomplettBeantwortet : AntwortStatus.TeilweiseBeantwortet;
}
}
/// <summary>
/// Verwirft die erfassten Antworten; der nächste Aufruf zeigt wieder die Ausgangslage.
/// </summary>
public static void Zuruecksetzen()
{
lock (Sperre)
{
fragen = FrageAntwortBeispielDaten.Fragen();
}
}
private static bool IstBeantwortet(FrageAntwortViewModel frage, Rolle rolle)
{
var antwort = rolle == Rolle.FachFremdDelegierter
? frage.AntwortFachFremdDelegierter
: frage.AntwortFachDelegierter;
return antwort.HasValue && antwort != AntwortTyp.Unbeantwortet;
}
}
}
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Text.Json;
using FMH.Komponente.Infrastruktur.Schnittstellen.Ui;
using FMH.Komponente.Kompetenzen.Ui.Controls.FrageAnwort.Daten;
using FMH.Komponente.Kompetenzen.Ui.Controls.LikertSkala.Daten;
using FMH.Komponente.WebUI.Areas.Kompetenzen.Models;
using FMH.Komponente.WebUI.Erweiterungen;
using FMH.Komponente.WebUI.Models.Demos;
using Microsoft.AspNetCore.Mvc;
namespace FMH.Komponente.WebUI.Areas.Kompetenzen.Controllers
{
/// <summary>
/// Demos des Pakets Kompetenzen: die Likertskala und das EditorTemplate FrageAntwort.
/// </summary>
/// <remarks>
/// Eine Seite je Beispiel: Eine Skala beansprucht die volle Breite, und zwei davon untereinander
/// sind als getrennte Fälle nicht mehr auseinanderzuhalten – sie sehen sich zu ähnlich.
/// <para>
/// Die Zeilen kommen aus <see cref="LernzielZustand" /> und nicht direkt aus
/// <see cref="LernzielDaten" />: Was ein Beispiel bewertet hat, bleibt erhalten. Die
/// **Konfiguration** – Spalten, Rolle, Texte – wird dagegen bei jedem Aufruf neu gesetzt, weil
/// sie in keinem Formularfeld steht und den POST nicht überlebt.
/// </para>
/// <para>
/// Die Endpunkte <see cref="AntwortFD" /> und <see cref="AntwortFFD" /> stehen bewusst hier und
/// nicht in einem eigenen Beispiel-Controller: Das EditorTemplate FrageAntwort baut die Adresse
/// seines Formulars ohne Controllernamen. Es postet damit immer an **den Controller, der die
/// Seite gerendert hat**, hier also an den DemoController.
/// </para>
/// </remarks>
[Area("Kompetenzen")]
public class DemoController : Controller
{
/// <summary>
/// Pflicht für die Antworten der FrageAntwort-Endpunkte: Der Json-Serializer arbeitet
/// standardmässig mit Camel Case, das Skript der Komponente liest die Antwort aber in
/// Pascal Case (<c>Nachricht</c>, <c>IconTyp</c>).
/// </summary>
/// <remarks>
/// Ohne diese Einstellung zeigt die Snackbar «undefined» und das Ereignis
/// <c>FrageAntwortStatus</c> liefert ein Objekt, dessen Eigenschaften nicht die der
/// Klasse <c>AntwortResultat</c> sind.
/// </remarks>
private static readonly JsonSerializerOptions JsonPascalCase = new JsonSerializerOptions { PropertyNamingPolicy = null };
/// <summary>
/// Die Vorbedingungen als eigene Seite, erster Navigationspunkt des Pakets: was die
/// Hostanwendung einmal einrichten muss. Fehlt davon etwas, meldet meist niemand einen Fehler.
/// Inhalt: der Abschnitt «vorbedingungen» aus Anleitungen/Vorbedingungen.md.
/// </summary>
/// <returns>Die Seite mit den Vorbedingungen.</returns>
public IActionResult Vorbedingungen()
{
return this.View(
"Vorbedingungen",
AnleitungLeser.LiesAbschnitte("Areas/Kompetenzen/Anleitungen/Vorbedingungen.md")
.GetValueOrDefault("vorbedingungen"));
}
/// <summary>
/// Endpunkt für die Antwort des Fachdelegierten. Das EditorTemplate postet hierher, sobald
/// die Rolle <see cref="Rolle.FachDelegierter" /> ist.
/// </summary>
/// <remarks>
/// Erwartet wird ein **flaches** <see cref="FrageAntwortViewModel" />: Das Skript der
/// Komponente entfernt den Sammlungs-Präfix, aus <c>FrageAntwortListe[0].Id</c> wird beim
/// Absenden <c>Id</c>. Eine Bindung an die Liste wäre deshalb leer.
/// </remarks>
/// <param name="modell">Die beantwortete Frage aus dem Formular des EditorTemplates.</param>
/// <returns>Das <see cref="AntwortResultat" /> als JSON, im Fehlerfall Status 400.</returns>
[HttpPost]
public IActionResult AntwortFD(FrageAntwortViewModel modell)
{
return this.Speichere(modell, Rolle.FachDelegierter);
}
/// <summary>
/// Endpunkt für die Antwort des fachfremden Delegierten. Das EditorTemplate postet hierher,
/// sobald die Rolle <see cref="Rolle.FachFremdDelegierter" /> ist.
/// </summary>
/// <param name="modell">Die beantwortete Frage aus dem Formular des EditorTemplates.</param>
/// <returns>Das <see cref="AntwortResultat" /> als JSON, im Fehlerfall Status 400.</returns>
[HttpPost]
public IActionResult AntwortFFD(FrageAntwortViewModel modell)
{
return this.Speichere(modell, Rolle.FachFremdDelegierter);
}
/// <summary>
/// Der Grundfall des EditorTemplates FrageAntwort.
/// </summary>
/// <returns>Die Beispiel-Seite.</returns>
public IActionResult FrageAntwortGrundfall()
{
return this.ZeigeFrageAntwort(
Rolle.FachDelegierter,
ErstelleFrageAntwortBeispiel(
"frageantwortgrundfall",
"Grundfall",
"Eine Liste von Fragen, je Frage ein eigenes Formular: Die Antwort geht einzeln und asynchron an den Server. Der Endpunkt antwortet mit einem AntwortResultat, die Komponente blendet dessen Nachricht als Snackbar ein.",
"_BeispielFrageAntwortGrundfall.cshtml",
"9-13"));
}
/// <summary>
/// Das Ereignis <c>FrageAntwortStatus</c> im Browser.
/// </summary>
/// <returns>Die Beispiel-Seite.</returns>
public IActionResult FrageAntwortEreignis()
{
return this.ZeigeFrageAntwort(
Rolle.FachDelegierter,
ErstelleFrageAntwortBeispiel(
"frageantwortereignis",
"Ereignis FrageAntwortStatus",
"Nach jeder gespeicherten Antwort meldet die Komponente ein CustomEvent am window. Sein detail ist das AntwortResultat des Endpunkts – inklusive der frei belegbaren Metadaten.",
"_BeispielFrageAntwortEreignis.cshtml",
"17-18,25",
"frageantwort-status.js"));
}
/// <summary>
/// Der Referenzteil zum EditorTemplate FrageAntwort.
/// </summary>
/// <returns>Die Referenz-Seite.</returns>
public IActionResult FrageAntwortReferenz()
{
this.ViewBag.Baustein = "FrageAntwort";
return this.View(
"Referenz",
AnleitungLeser.LiesAbschnitte("Areas/Kompetenzen/Anleitungen/FrageAntwort.md")
.GetValueOrDefault("frageantwortreferenz"));
}
/// <summary>
/// Dieselben Fragen in den drei Rollen des Templates.
/// </summary>
/// <param name="rolle">Die Rolle, in der die Fragen gezeigt werden.</param>
/// <returns>Die Beispiel-Seite.</returns>
public IActionResult FrageAntwortRollen(Rolle rolle = Rolle.FachDelegierter)
{
return this.ZeigeFrageAntwort(
rolle,
ErstelleFrageAntwortBeispiel(
"frageantwortrollen",
"Rollen",
"AktuelleRolle entscheidet allein, was das Template rendert: welche Antwort bearbeitet wird, welche als Vorbefund darübersteht und ob es überhaupt eine Speichern-Schaltfläche gibt.",
"_BeispielFrageAntwortRollen.cshtml",
"5,10-18,20-24"));
}
/// <summary>
/// Verwirft die erfassten Antworten der FrageAntwort-Beispiele.
/// </summary>
/// <param name="ziel">Die Demo-Action, auf die zurückgekehrt wird.</param>
/// <param name="rolle">Die Rolle, in die zurückgekehrt wird (nur beim Beispiel «Rollen»).</param>
/// <returns>Die Umleitung auf die Demo-Seite.</returns>
public IActionResult FrageAntwortZuruecksetzen(string ziel, Rolle rolle = Rolle.FachDelegierter)
{
FrageAntwortZustand.Zuruecksetzen();
return this.RedirectToAction(ziel, new { area = "Kompetenzen", rolle });
}
/// <summary>
/// Selbst- und Fremdeinschätzung. Die Rolle steht in der Adresse, damit sich die drei
/// Editable-Werte an denselben Daten vergleichen lassen.
/// </summary>
/// <param name="rolle">Die Rolle, in der die Skala gezeigt wird.</param>
/// <returns>Die Beispiel-Seite.</returns>
public IActionResult Einschaetzung(Editable rolle = Editable.SelfAssessment)
{
return this.ZeigeBeispiel(
ErstelleBeispiel(
"likertskalaeinschaetzung",
"Selbst- und Fremdeinschätzung",
"AssessmentType bestimmt, welcher der beiden Werte bearbeitet wird und welcher nur als Markierung danebensteht. Der Umschalter zeigt dieselben Daten in allen drei Rollen; das Bewertete bleibt beim Wechsel erhalten.",
new LernzielViewModel
{
Id = 10,
Rolle = rolle,
Likertskala = LernzielZustand.Skala("einschaetzung", LernzielDaten.Kompetenzen),
Spalten = LernzielDaten.Stufen(),
},
"_BeispielLikertSkalaEinschaetzung.cshtml",
"14-22,24-27,43,48"));
}
public IActionResult Erfuellungsgrad()
{
return this.ZeigeBeispiel(
ErstelleBeispiel(
"likertskalaerfuellungsgrad",
"Erfüllungsgrad",
"ShowDegreeOfAchievement stellt der Skala eine zweite Kopfzeile mit Prozentwerten voran und hebt je Zeile die geforderte Stufe hervor. Die Werte werden bei jedem Rendern aus dem aktuellen Stand gerechnet – eine Bewertung ändert sie sofort.",
new LernzielViewModel
{
Id = 10,
Rolle = Editable.ExternalAssessment,
Likertskala = LernzielZustand.Skala("erfuellungsgrad", LernzielDaten.Kompetenzen),
Spalten = LernzielDaten.Stufen(),
},
"_BeispielLikertSkalaErfuellungsgrad.cshtml",
"3-6,25-26"));
}
public IActionResult Grundfall()
{
return this.ZeigeBeispiel(
ErstelleBeispiel(
"likertskalagrundfall",
"Grundfall",
"Beschriftung der ersten Spalte, die Skalenwerte und Bind – mehr braucht eine Likertskala nicht. Der zweite Parameter von Bind ist der Eigenschaftsname; ohne ihn kommen die Antworten beim POST nicht an.",
new LernzielViewModel
{
Id = 10,
Rolle = Editable.SelfAssessment,
Likertskala = LernzielZustand.Skala("grundfall", LernzielDaten.Lernziele),
},
"_BeispielLikertSkalaGrundfall.cshtml",
"5-8,22,24,26-34"));
}
public IActionResult Gruppe()
{
return this.ZeigeBeispiel(
ErstelleBeispiel(
"likertskalagruppe",
"Mehrere Skalen gruppieren",
"LikertSkalaGroup rendert mehrere Skalen als einen Block. Konfiguriert wird nicht mehr im Markup, sondern am LikertSkalaGroupModel – dafür ist der Aufruf in der View immer derselbe.",
new KompetenzGruppeViewModel { Id = 10, Tabellen = LernzielZustand.Gruppe("gruppe", LernzielDaten.Gruppe) },
"_BeispielLikertSkalaGruppe.cshtml",
"3-7,17-19",
"KompetenzGruppeViewModel.cs"));
}
public IActionResult Index()
{
return this.View(DemoRegistry.Pakete.Single(paket => paket.Area == "Kompetenzen"));
}
/// <summary>
/// Der Referenzteil als eigene Seite, er gilt für alle Beispiele und steckt deshalb nicht
/// im Anleitungs-Tab eines einzelnen. Inhalt: der Abschnitt «likertskalareferenz» derselben
/// Markdown-Datei, aus der die Beispiele ihre Anleitung beziehen.
/// </summary>
/// <returns>Die Referenz-Seite.</returns>
public IActionResult Referenz()
{
this.ViewBag.Baustein = "Likertskala";
return this.View(
"Referenz",
AnleitungLeser.LiesAbschnitte("Areas/Kompetenzen/Anleitungen/LikertSkala.md")
.GetValueOrDefault("likertskalareferenz"));
}
public IActionResult Texte()
{
return this.ZeigeBeispiel(
ErstelleBeispiel(
"likertskalatexte",
"Texte um die Skala",
"Header, Introduction und Footer rahmen die Skala, DescriptionColumn beschriftet die erste Spalte und LikertSkalaRow.Header setzt eine Überschrift in die Zeile. Alle vier nehmen HTML entgegen.",
new LernzielViewModel
{
Id = 10,
Rolle = Editable.SelfAssessment,
Likertskala = LernzielZustand.Skala("texte", LernzielDaten.LernzieleMitUeberschrift),
},
"_BeispielLikertSkalaTexte.cshtml",
"20-23,25-35"));
}
/// <summary>
/// Fünf Tabs: die cshtml mit der Konfiguration der Skala, das ViewModel, die Ausgangsdaten,
/// die Ablage des erfassten Stands und der Controller, der den POST übernimmt.
/// </summary>
/// <param name="id">Die eindeutige Id des Beispiels; zugleich der Schluessel seines Anleitungs-Abschnitts.</param>
/// <param name="titel">Die Ueberschrift des Beispiels.</param>
/// <param name="beschreibung">Der erklaerende Text unter der Ueberschrift.</param>
/// <param name="model">Das Modell, das an das Beispiel-Partial uebergeben wird.</param>
/// <param name="partialDatei">Der Dateiname des Beispiel-Partials.</param>
/// <param name="hervorhebung">Die hervorzuhebenden Zeilen des ersten Code-Tabs.</param>
/// <param name="modellDatei">Der Dateiname des ViewModels.</param>
/// <returns>Das fertige Beispiel fuer die Demo-Seite.</returns>
/// <remarks>
/// Die Hervorhebung sitzt auf dem cshtml-Tab: Die Skala wird nicht über Attribute am Modell
/// konfiguriert, sondern im Markup. Hervorgehoben ist, worum es im jeweiligen Beispiel geht –
/// der Rest ist immer derselbe Rahmen aus Formular und Absenden-Schaltfläche.
/// </remarks>
private static KonfigurationsBeispielViewModel ErstelleBeispiel(
string id,
string titel,
string beschreibung,
object model,
string partialDatei,
string hervorhebung,
string modellDatei = "LernzielViewModel.cs")
{
var assembly = typeof(DemoController).Assembly;
var tabs = new List<CodeTab>
{
new CodeTab
{
Name = "cshtml",
Sprache = "markup",
Quelltext = DemoQuelltextLeser.LiesEingebetteteDatei(
assembly,
$"Areas/Kompetenzen/Views/Demo/{partialDatei}"),
Hervorhebung = hervorhebung,
},
new CodeTab
{
Name = modellDatei.Replace(".cs", string.Empty),
Sprache = "csharp",
Quelltext = DemoQuelltextLeser.LiesEingebetteteDatei(
assembly,
$"Areas/Kompetenzen/Models/{modellDatei}"),
},
new CodeTab
{
Name = "Daten",
Sprache = "csharp",
Quelltext = DemoQuelltextLeser.LiesEingebetteteDatei(
assembly,
"Areas/Kompetenzen/Models/LernzielDaten.cs"),
},
new CodeTab
{
Name = "Zustand",
Sprache = "csharp",
Quelltext = DemoQuelltextLeser.LiesEingebetteteDatei(
assembly,
"Areas/Kompetenzen/Models/LernzielZustand.cs"),
},
new CodeTab
{
Name = "Controller (POST)",
Sprache = "csharp",
Quelltext = DemoQuelltextLeser.LiesEingebetteteDatei(
assembly,
"Areas/Kompetenzen/Controllers/Beispiele/LikertSkalaBeispielController.cs"),
},
};
return new KonfigurationsBeispielViewModel
{
Id = id,
Titel = titel,
Beschreibung = beschreibung,
Model = model,
PartialName = $"~/Areas/Kompetenzen/Views/Demo/{partialDatei}",
Tabs = tabs,
};
}
/// <summary>
/// Vier Tabs: die cshtml mit der Einbindung des Templates, die Ausgangsdaten, die Ablage der
/// eingegangenen Antworten und der Controller mit den beiden Ajax-Endpunkten.
/// </summary>
/// <param name="id">Die eindeutige Id des Beispiels; zugleich der Schluessel seines Anleitungs-Abschnitts.</param>
/// <param name="titel">Die Ueberschrift des Beispiels.</param>
/// <param name="beschreibung">Der erklaerende Text unter der Ueberschrift.</param>
/// <param name="partialDatei">Der Dateiname des Beispiel-Partials.</param>
/// <param name="hervorhebung">Die hervorzuhebenden Zeilen des cshtml-Tabs.</param>
/// <param name="skriptDatei">Der Dateiname des Demo-Skripts; ohne Angabe entfallen die Skript-Tabs.</param>
/// <returns>Das fertige Beispiel fuer die Demo-Seite.</returns>
/// <remarks>
/// Ein ViewModel-Tab entfällt: Das Beispiel bindet die Modellklassen der Komponente
/// unverändert ein, was sie mitbringen, steht in der Referenz.
/// </remarks>
private static KonfigurationsBeispielViewModel ErstelleFrageAntwortBeispiel(
string id,
string titel,
string beschreibung,
string partialDatei,
string hervorhebung,
string skriptDatei = null)
{
var assembly = typeof(DemoController).Assembly;
var tabs = new List<CodeTab>
{
new CodeTab
{
Name = "cshtml",
Sprache = "markup",
Quelltext = DemoQuelltextLeser.LiesEingebetteteDatei(
assembly,
$"Areas/Kompetenzen/Views/Demo/{partialDatei}"),
Hervorhebung = hervorhebung,
},
new CodeTab
{
Name = "Daten",
Sprache = "csharp",
Quelltext = DemoQuelltextLeser.LiesEingebetteteDatei(
assembly,
"Areas/Kompetenzen/Models/FrageAntwortBeispielDaten.cs"),
},
new CodeTab
{
Name = "Zustand",
Sprache = "csharp",
Quelltext = DemoQuelltextLeser.LiesEingebetteteDatei(
assembly,
"Areas/Kompetenzen/Models/FrageAntwortZustand.cs"),
},
new CodeTab
{
Name = "Controller (Endpunkte)",
Sprache = "csharp",
Quelltext = DemoQuelltextLeser.LiesEingebetteteDatei(
assembly,
"Areas/Kompetenzen/Controllers/DemoController.cs"),
// Der Serializer, die beiden Endpunkte und der gemeinsame Rumpf –
// der Rest der Datei ist die Demo-Mechanik.
Hervorhebung = "37-48,61-77,500-543",
},
};
if (string.IsNullOrEmpty(skriptDatei) == false)
{
tabs.Add(ErstelleSkriptTab(skriptDatei, "JavaScript"));
tabs.Add(ErstelleSkriptTab(skriptDatei.Replace(".js", "-jquery.js"), "jQuery"));
}
return new KonfigurationsBeispielViewModel
{
Id = id,
Titel = titel,
Beschreibung = beschreibung,
PartialName = $"~/Areas/Kompetenzen/Views/Demo/{partialDatei}",
Tabs = tabs,
};
}
/// <summary>
/// Code-Tab mit einem Skript des Beispiels aus wwwroot/js/demos.
/// </summary>
/// <param name="skriptDatei">Der Dateiname des Skripts.</param>
/// <param name="name">Die Beschriftung des Code-Tabs.</param>
/// <returns>Der Code-Tab mit dem Quelltext des Skripts.</returns>
private static CodeTab ErstelleSkriptTab(string skriptDatei, string name)
{
return new CodeTab
{
Name = name,
Sprache = "javascript",
Quelltext = DemoQuelltextLeser.LiesEingebetteteDatei(
typeof(DemoController).Assembly,
$"wwwroot/js/demos/{skriptDatei}"),
};
}
private IActionResult ZeigeBeispiel(KonfigurationsBeispielViewModel beispiel)
{
beispiel.AnleitungHtml = AnleitungLeser.LiesAbschnitte("Areas/Kompetenzen/Anleitungen/LikertSkala.md")
.GetValueOrDefault(beispiel.Id);
return this.View("Beispiel", beispiel);
}
/// <summary>
/// Setzt die Rolle an jeder Frage, lädt die Anleitung und rendert die Beispiel-Seite.
/// </summary>
/// <param name="rolle">Die Rolle, in der die Fragen gezeigt werden.</param>
/// <param name="beispiel">Das Beispiel, das gerendert wird.</param>
/// <returns>Die gerenderte Beispiel-Seite.</returns>
/// <remarks>
/// Die Rolle gehört an die einzelne Frage – das Template liest sie dort. Sie kommt aus dem
/// Ablauf und wird bei jedem Aufruf neu gesetzt; im Formular steht sie nur als verstecktes
/// Feld, damit der Endpunkt weiss, wer geantwortet hat.
/// </remarks>
private IActionResult ZeigeFrageAntwort(Rolle rolle, KonfigurationsBeispielViewModel beispiel)
{
var beurteilung = FrageAntwortBeispielDaten.Beurteilung();
beurteilung.FrageAntwortListe = FrageAntwortZustand.Fragen();
foreach (var frage in beurteilung.FrageAntwortListe)
{
frage.AktuelleRolle = rolle;
}
beispiel.Model = beurteilung;
this.ViewBag.Rolle = rolle;
this.ViewBag.Baustein = "FrageAntwort";
beispiel.AnleitungHtml = AnleitungLeser.LiesAbschnitte("Areas/Kompetenzen/Anleitungen/FrageAntwort.md")
.GetValueOrDefault(beispiel.Id);
return this.View("Beispiel", beispiel);
}
/// <summary>
/// Nimmt eine Antwort entgegen und beantwortet den Ajax-Aufruf des EditorTemplates.
/// </summary>
/// <param name="modell">Die beantwortete Frage aus dem Formular.</param>
/// <param name="rolle">Die Rolle, deren Antwort übernommen wird.</param>
/// <returns>Das <see cref="AntwortResultat" /> als JSON, im Fehlerfall Status 400.</returns>
/// <remarks>
/// Der Ablauf ist die Vorlage für ein Projekt: prüfen, speichern, ein
/// <see cref="AntwortResultat" /> zurückgeben. Bei Status 400 wertet das Skript der
/// Komponente die Eigenschaft <c>Nachricht</c> aus und zeigt sie als Fehler-Snackbar.
/// </remarks>
private IActionResult Speichere(FrageAntwortViewModel modell, Rolle rolle)
{
modell.AktuelleRolle = rolle;
if (this.ModelState.IsValid == false)
{
var fehler = this.ModelState.Values.SelectMany(wert => wert.Errors)
.Select(eintrag => eintrag.ErrorMessage)
.FirstOrDefault();
return this.Fehler(fehler ?? "Die Antwort ist unvollständig.");
}
var gespeichert = FrageAntwortZustand.Uebernehmen(modell);
if (gespeichert == null)
{
return this.Fehler($"Zur Frage mit der Id {modell.Id} gibt es keinen Eintrag.");
}
var resultat = new AntwortResultat
{
Nachricht = $"Ihre Antwort zu Frage {gespeichert.Reihenfolge} wurde gespeichert.",
Status = FrageAntwortZustand.Status(rolle),
// Material-Design-Code des Symbols der Snackbar. Ohne Angabe check_circle.
IconTyp = IconTypeDictionary.Get(IconType.FactCheck),
};
// Frei belegbar: Alles, was der Browser nach dem Speichern braucht, aber nicht aus dem
// Formular kennt. Es landet unverändert im detail des Ereignisses FrageAntwortStatus.
resultat.Metadaten.Add("FrageId", gespeichert.Id);
resultat.Metadaten.Add("Rolle", rolle.ToString());
resultat.Metadaten.Add("Uneinig", gespeichert.IstUneinig);
return this.Json(resultat, JsonPascalCase);
}
private IActionResult Fehler(string nachricht)
{
this.Response.StatusCode = (int)HttpStatusCode.BadRequest;
return this.Json(new AntwortResultat { Nachricht = nachricht, IconTyp = "error" }, JsonPascalCase);
}
}
}
// Beispiel «Ereignis FrageAntwortStatus» (Kompetenzen).
//
// Die Komponente meldet nach jeder gespeicherten Antwort ein CustomEvent am window. Das Ereignis
// steigt nicht auf und wird nicht an einem Element ausgelöst, es wird direkt auf window
// abonniert. Der Listener darf deshalb schon vor dem Rendern der Fragen registriert werden.
//
// event.detail ist genau das AntwortResultat des Endpunkts: Nachricht, Status, IconTyp, Metadaten.
window.addEventListener('FrageAntwortStatus', function (ereignis) {
var ausgabe = document.querySelector('[data-frageantwort-ausgabe]');
if (ausgabe === null) {
return;
}
var resultat = ereignis.detail;
// Status ist die Zahl aus dem Enum AntwortStatus (0/1/2), JSON überträgt keinen Namen.
var status = ['Unbeantwortet', 'Teilweise beantwortet', 'Komplett beantwortet'][resultat.Status];
var zeilen = [
'Nachricht : ' + resultat.Nachricht,
'Status : ' + status + ' (' + resultat.Status + ')',
'IconTyp : ' + resultat.IconTyp,
];
Object.keys(resultat.Metadaten || {}).forEach(function (schluessel) {
zeilen.push('Metadaten.' + schluessel + ' : ' + resultat.Metadaten[schluessel]);
});
ausgabe.textContent = zeilen.join('\n');
});
// Dieselbe Umsetzung mit jQuery. Diese Datei wird NICHT geladen, sie steht nur im Code-Tab
// «jQuery». Würden beide Fassungen laufen, schriebe jede von ihnen in dieselbe Ausgabe.
//
// Unterschied zur nativen Fassung: jQuery reicht unbekannte Eigenschaften des Ereignisses an das
// zugrunde liegende CustomEvent durch, event.detail funktioniert also. Sicherer ist der Rückgriff
// auf originalEvent, wenn der Listener auch mit älteren jQuery-Versionen laufen soll.
$(window).on('FrageAntwortStatus', function (ereignis) {
var $ausgabe = $('[data-frageantwort-ausgabe]');
if ($ausgabe.length === 0) {
return;
}
var resultat = ereignis.detail || ereignis.originalEvent.detail;
// Status ist die Zahl aus dem Enum AntwortStatus (0/1/2), JSON überträgt keinen Namen.
var status = ['Unbeantwortet', 'Teilweise beantwortet', 'Komplett beantwortet'][resultat.Status];
var zeilen = [
'Nachricht : ' + resultat.Nachricht,
'Status : ' + status + ' (' + resultat.Status + ')',
'IconTyp : ' + resultat.IconTyp,
];
$.each(resultat.Metadaten || {}, function (schluessel, wert) {
zeilen.push('Metadaten.' + schluessel + ' : ' + wert);
});
$ausgabe.text(zeilen.join('\n'));
});
Darstellung
1. Wurde die geforderte Anzahl Weiterbildungsjahre am Zentrum absolviert?
2. Sind die Lernziele des Weiterbildungsprogramms nachweislich erreicht?
3. Bestehen Vorbehalte gegen die Erteilung des Facharzttitels?
Was das Ereignis geliefert hat
Noch keine Antwort gespeichert.
Die Komponente blendet die Rückmeldung des Endpunkts selbst als Snackbar ein. Alles Weitere, ein
Fortschrittsbalken, das Freischalten einer Weiter-Schaltfläche, ein Protokolleintrag, hängt am
Ereignis FrageAntwortStatus am window:
window.addEventListener('FrageAntwortStatus', function (ereignis) {
var resultat = ereignis.detail; // das AntwortResultat des Endpunkts
});
detail trägt die Eigenschaften in Pascal Case, so wie die Klasse sie deklariert:
Nachricht, Status, IconTyp, Metadaten.
Metadaten sind der vorgesehene Weg für eigene Werte
Die Komponente kennt nur Frage und Antwort. Alles, was der Browser nach dem Speichern sonst noch
braucht, gehört in Metadaten, ein Wörterbuch, das unverändert im detail ankommt:
resultat.Metadaten.Add("FrageId", gespeichert.Id);
resultat.Metadaten.Add("Uneinig", gespeichert.IstUneinig);
Was zu beachten ist
Statuskommt als Zahl an. JSON überträgt den Enum-Wert, nicht seinen Namen: 0, 1 oder 2.- Kein Objekt, kein Ereignis. Antwortet der Endpunkt mit einer blossen Zeichenkette, erscheint
zwar die Snackbar, aber
FrageAntwortStatuswird nicht ausgelöst. - Das Ereignis steigt nicht auf, es wird direkt am
windowausgelöst. Ein Listener an einem Element darunter bekommt nichts mit. - Der Listener darf vor dem Rendern der Fragen registriert werden; das Ereignis entsteht erst beim Speichern.