Tabelle
Umschalt-Aktion
Eine Schaltfläche mit zwei Zuständen: ToogleButton liefert Symbol und Tooltip für beide, ToggleStatus an der Spalte sagt, welcher gerade gilt.
@model IReadOnlyList<KursZeile>
@* Eine Schaltfläche mit zwei Zuständen. Welchen sie zeigt, entscheidet ToggleStatus an der Spalte –
nicht die Schaltfläche selbst. Die Aktion kippt den Wert und kehrt auf die Seite zurück. *@
@Html.Fmh(Fmh<KursZeile>.Grid
.Id("kurse-umschalten")
.BindTo(Model)
.Columns(spalten =>
{
spalten.For(x => x.Bezeichnung)
.Named("Kurs")
.IsSortable();
spalten.For(x => x.Datum)
.Named("Datum")
.Format("{0:dd.MM.yyyy}")
.IsSortable();
spalten.For(x => x.Bestanden)
.Named("Bestanden")
.Format(GridValueFormat.YesNo)
.IsSortable();
@* Symbol und Tooltip gibt es zweimal: für den ein- und den ausgeschalteten Zustand.
Beide Angaben sind Pflicht – ohne IconOnMode hat die Schaltfläche kein Symbol.
ToggleStatus steht an der Spalte, nicht an der Schaltfläche, und liefert den Zustand
der Zeile; er wird zusätzlich als «is»+Eigenschaft an die Adresse angehängt. *@
spalten.Button(
x => FmhGrid.ToogleButton("Bestanden", "TabelleBeispiel", new { area = "Tabelle", id = x.Id }, "Prüfungsergebnis")
.IconOnMode(IconType.Done)
.IconOffMode(IconType.HighlightOff)
.ToggleOnTooltip("Kurs bestanden – klicken, um zurückzusetzen")
.ToggleOffTooltip("Kurs offen – klicken, um auf bestanden zu setzen"))
.ToggleStatus(x => x.Bestanden);
spalten.Button(x => FmhGrid.ViewButton("Ansehen", "TabelleBeispiel", new { area = "Tabelle", id = x.Id }));
}))
using System;
using System.Collections.Generic;
namespace FMH.Komponente.WebUI.Areas.Tabelle.Models
{
/// <summary>
/// Eine Zeile der Demo-Tabelle. Die Eigenschaften decken die Fälle ab, die im Grid
/// unterschiedlich behandelt werden: Text, Zahl, Datum, Wahrheitswert und ein leerer Wert.
/// </summary>
public class KursZeile
{
public int Id { get; set; }
public string Bezeichnung { get; set; }
public string Anbieter { get; set; }
public DateTime Datum { get; set; }
public decimal Punkte { get; set; }
public bool IstAnerkannt { get; set; }
/// <summary>
/// Bewusst teilweise leer: zeigt, was EmptyColumnChar aus einer leeren Zelle macht.
/// </summary>
public string Bemerkung { get; set; }
/// <summary>
/// Fachliche Sperre der Zeile: Ein abgeschlossener Kurs darf nicht mehr entfernt werden.
/// Grundlage für <c>Disabled(...)</c> und <c>Visible(...)</c> an den Zeilen-Aktionen.
/// </summary>
public bool IstAbgeschlossen { get; set; }
/// <summary>
/// Zustand des Umschalters. Wird über die Demo-Aktion «Bestanden» tatsächlich gekippt —
/// deshalb veränderlich und nicht nur lesend.
/// </summary>
public bool Bestanden { get; set; }
/// <summary>
/// Unterzeilen der Tabelle. Bewusst bei einigen Kursen leer: Ohne Einträge rendert das Grid
/// statt der Aufklapp-Schaltfläche das Zeichen aus EmptyColumnChar.
/// </summary>
public IReadOnlyList<KursDokument> Dokumente { get; set; } = new List<KursDokument>();
/// <summary>
/// Nur für den Tooltip der Aufklapp-Spalte — der Standardtext nennt sonst dieselbe Zahl
/// ohne Bezug zum Kurs.
/// </summary>
public int AnzahlDokumente => this.Dokumente.Count;
/// <summary>
/// Fertig formatierte Punktzahl für die Gruppe «Datasource (Ajax)». Auf dem Ajax-Weg wirkt
/// <c>Format(...)</c> nicht — der Wert geht so an den Browser, wie ihn der Endpunkt liefert.
/// </summary>
public string PunkteText => $"{this.Punkte:0.0} Punkte";
/// <summary>
/// Ersatz für <c>Format(GridValueFormat.YesNo)</c> auf dem Ajax-Weg. Sortiert wird über
/// <c>SortByKey(x => x.Bestanden)</c> weiterhin nach dem Wahrheitswert.
/// </summary>
public string BestandenText => this.Bestanden ? "Ja" : "Nein";
/// <summary>
/// Kontaktadresse des Anbieters — Grundlage der Mail-Schaltfläche im Beispiel «Aktionen ohne
/// Seitenwechsel». Sie setzt diesen Wert über <c>ClientDataKey("row.KontaktMail")</c> hinter
/// das <c>mailto:</c> ihrer Adresse, statt wie sonst die Id der Zeile.
/// </summary>
public string KontaktMail => $"kurse@{Kuerzel(this.Anbieter)}.example.ch";
/// <summary>
/// Ersatz für eine <c>Custom</c>-Spalte auf dem Ajax-Weg: Das Markup entsteht im Endpunkt
/// und nicht im Razor-Ausdruck, weil die Zeilen dort gar nicht vorliegen.
/// </summary>
public string StatusHtml => this.IstAbgeschlossen
? "<span class=\"badge text-bg-secondary\">abgeschlossen</span>"
: "<span class=\"badge text-bg-success\">offen</span>";
// Aus «Universität Zürich» wird «universitaet-zuerich»: Umlaute und Leerzeichen haben in
// einer Mailadresse nichts verloren.
private static string Kuerzel(string anbieter)
{
return (anbieter ?? string.Empty).ToLowerInvariant()
.Replace("ä", "ae")
.Replace("ö", "oe")
.Replace("ü", "ue")
.Replace(" ", "-");
}
}
}
using System;
using System.Collections.Generic;
using System.Linq;
namespace FMH.Komponente.WebUI.Areas.Tabelle.Models
{
/// <summary>
/// Die Testdaten der Demo. Statisch und ohne Datenbank — jede Seite arbeitet auf derselben
/// Liste, damit sich die Beispiele untereinander vergleichen lassen.
/// </summary>
/// <remarks>
/// Die Liste ist prozessweit dieselbe Instanz. Die Beispiele «Umschalt-Aktion» und
/// «Unterzeilen» verändern sie bewusst (Umschalten, Umbenennen), damit die Wirkung sichtbar
/// wird; nach einem Neustart der Anwendung steht wieder der Ausgangszustand.
/// </remarks>
public static class KursDaten
{
private static readonly IReadOnlyList<KursZeile> Alle = new List<KursZeile>
{
Zeile(
1,
"Grundkurs Notfallmedizin",
"FMH Bildung",
"2026-01-14",
12.5m,
true,
"Präsenzkurs",
istAbgeschlossen: true,
bestanden: true,
dokumente: new[] { Dokument(101, "Anmeldebestaetigung.pdf", 184320), Dokument(102, "Teilnahmebeleg.pdf", 96256) }),
Zeile(2, "Refresher Innere Medizin", "SIWF Akademie", "2026-02-03", 8m, true, null, istAbgeschlossen: true, bestanden: false),
Zeile(
3,
"Workshop Gesprächsführung",
"Kantonsspital Bern",
"2026-02-19",
4m,
false,
"Wartet auf Beleg",
istAbgeschlossen: false,
bestanden: false,
dokumente: new[] { Dokument(103, "Programm.pdf", 512000) }),
Zeile(4, "Fortbildung Radiologie", "Universität Zürich", "2026-03-07", 16m, true, null, istAbgeschlossen: true, bestanden: true),
Zeile(5, "Seminar Qualitätssicherung", "FMH Bildung", "2026-03-22", 6.5m, false, null, istAbgeschlossen: false, bestanden: false),
Zeile(
6,
"Kongress Kardiologie",
"Swiss Heart",
"2026-04-11",
20m,
true,
"Zweitägig",
istAbgeschlossen: false,
bestanden: true,
dokumente: new[]
{
Dokument(104, "Kongressprogramm.pdf", 1258291),
Dokument(105, "Hotelbestaetigung.pdf", 71680),
Dokument(106, "Quittung.pdf", 40960),
}),
Zeile(7, "Kurs Strahlenschutz", "Bundesamt für Gesundheit", "2026-04-28", 9m, true, null, istAbgeschlossen: true, bestanden: true),
Zeile(
8,
"Update Pädiatrie",
"Kinderspital Zürich",
"2026-05-15",
7.5m,
false,
"Anmeldung offen",
istAbgeschlossen: false,
bestanden: false,
dokumente: new[] { Dokument(107, "Anmeldung.pdf", 65536) }),
Zeile(
9,
"Fallseminar Onkologie",
"SIWF Akademie",
"2026-06-02",
11m,
true,
null,
istAbgeschlossen: true,
bestanden: false,
dokumente: new[] { Dokument(110, "Fallsammlung.pdf", 327680), Dokument(111, "Auswertungsbogen.pdf", 88064) }),
Zeile(10, "Praxiskurs Sonografie", "Universität Basel", "2026-06-24", 14m, true, null, istAbgeschlossen: false, bestanden: true),
Zeile(11, "Kurs Palliative Care", "FMH Bildung", "2026-07-09", 10m, false, null, istAbgeschlossen: false, bestanden: false),
Zeile(
12,
"Symposium Neurologie",
"Inselspital",
"2026-08-18",
18m,
true,
"Mit Poster-Session",
istAbgeschlossen: true,
bestanden: true,
dokumente: new[] { Dokument(108, "Posterabstract.pdf", 245760), Dokument(109, "Zertifikat.pdf", 133120) }),
};
public static IReadOnlyList<KursZeile> Laden()
{
return Alle;
}
public static KursZeile Laden(int id)
{
return Alle.FirstOrDefault(zeile => zeile.Id == id);
}
/// <summary>
/// Leere Liste für das Beispiel «Ohne Daten».
/// </summary>
public static IReadOnlyList<KursZeile> Leer()
{
return new List<KursZeile>();
}
/// <summary>
/// Kippt den Zustand für das Beispiel «Umschalt-Aktion» und liefert den neuen Wert.
/// </summary>
public static bool Umschalten(int id)
{
var zeile = Laden(id);
if (zeile == null)
{
return false;
}
zeile.Bestanden = !zeile.Bestanden;
return zeile.Bestanden;
}
public static KursDokument LadeDokument(int id)
{
return Alle.SelectMany(zeile => zeile.Dokumente)
.FirstOrDefault(dokument => dokument.Id == id);
}
/// <summary>
/// Umbenennen für das Beispiel «Unterzeilen». Der Aufruf kommt aus dem Umbenennen-Dialog
/// des Grids und erwartet eine Antwort im JSON-Format.
/// </summary>
public static KursDokument BenenneDokumentUm(int id, string dateiname)
{
var dokument = LadeDokument(id);
if (dokument == null || string.IsNullOrWhiteSpace(dateiname))
{
return null;
}
dokument.Dateiname = dateiname;
return dokument;
}
private static KursDokument Dokument(int id, string dateiname, int dateigroesse)
{
return new KursDokument
{
Id = id,
Dateiname = dateiname,
Dateigroesse = dateigroesse,
};
}
private static KursZeile Zeile(
int id,
string bezeichnung,
string anbieter,
string datum,
decimal punkte,
bool istAnerkannt,
string bemerkung,
bool istAbgeschlossen = false,
bool bestanden = false,
IReadOnlyList<KursDokument> dokumente = null)
{
return new KursZeile
{
Id = id,
Bezeichnung = bezeichnung,
Anbieter = anbieter,
Datum = DateTime.Parse(datum),
Punkte = punkte,
IstAnerkannt = istAnerkannt,
Bemerkung = bemerkung,
IstAbgeschlossen = istAbgeschlossen,
Bestanden = bestanden,
Dokumente = dokumente ?? new List<KursDokument>(),
};
}
}
}
using System.Linq;
using FMH.Komponente.Basis.Erweiterungen;
using FMH.Komponente.Basis.Ui.Feedback.Alert;
using FMH.Komponente.WebUI.Areas.Tabelle.Models;
using Microsoft.AspNetCore.Mvc;
namespace FMH.Komponente.WebUI.Areas.Tabelle.Controllers.Beispiele
{
// Die Ziele der Zeilen-Aktionen. In einem Projekt stehen hier die fachlichen Endpunkte; für die
// Demo genügt eine Rückmeldung, aus der hervorgeht, welche Zeile und welche Aktion es war.
//
// Welche HTTP-Methode eine Aktion braucht, bestimmt der Schaltflächen-Typ:
// Ansehen/Bearbeiten/Custom/Toggle rendern einen Link (GET), Entfernen und Dialog laufen über
// den Bestätigungsdialog des Grids und kommen als POST an.
[Area("Tabelle")]
public class TabelleBeispielController : Controller
{
public IActionResult Anbieter(int id)
{
return this.Zurueck(id, "Anbieter angezeigt");
}
public IActionResult Ansehen(int id)
{
return this.Zurueck(id, "Angesehen");
}
[HttpPost]
public IActionResult Auswahl(int[] id)
{
// Das Auswahlkästchen postet unter dem Namen der Spalte aus BulkEditCheckbox — hier
// also "Id". Ohne Auswahl kommt gar kein Wert an, das Feld bleibt leer.
if (id == null || id.Length == 0)
{
this.AddAlert("Kein Kurs ausgewählt.", AlertType.Warning, autoHide: true);
return this.RedirectToAction("Mehrfachauswahl", "Demo", new { area = "Tabelle" });
}
var bezeichnungen = id.Select(
kursId => KursDaten.Laden(kursId)
?.Bezeichnung ?? $"Id {kursId}")
.ToArray();
this.AddAlert($"{bezeichnungen.Length} ausgewählt: {string.Join(", ", bezeichnungen)}", AlertType.Info, autoHide: true);
return this.RedirectToAction("Mehrfachauswahl", "Demo", new { area = "Tabelle" });
}
public IActionResult Bearbeiten(int id)
{
return this.Zurueck(id, "Bearbeiten geöffnet");
}
/// <summary>
/// Ziel des Umschalters. Der aktuelle Zustand kommt als Parameter mit — das Grid hängt ihn
/// unter dem Namen «is» + Eigenschaft aus <c>ToggleStatus(...)</c> an die Adresse an.
/// </summary>
public IActionResult Bestanden(int id, bool isBestanden)
{
var neu = KursDaten.Umschalten(id);
var zeile = KursDaten.Laden(id);
var bezeichnung = zeile?.Bezeichnung ?? $"Id {id}";
this.AddAlert(
$"{bezeichnung}: {(isBestanden ? "war bestanden" : "war offen")} → {(neu ? "bestanden" : "offen")}",
AlertType.Info,
autoHide: true);
return this.Herkunft("Umschalten");
}
[HttpPost]
public IActionResult Dialog(int id)
{
return this.Zurueck(id, "Im Dialog bestätigt");
}
public IActionResult Dokument(int id)
{
var dokument = KursDaten.LadeDokument(id);
this.AddAlert($"Dokument geöffnet: {dokument?.Dateiname ?? $"Id {id}"}", AlertType.Info, autoHide: true);
return this.Herkunft("Unterzeilen");
}
/// <summary>
/// Ziel des Umbenennen-Dialogs. Die Schaltfläche aus <c>FmhGrid.RenameDocumentButton</c>
/// arbeitet fest über Ajax — die Antwort muss deshalb JSON sein, eine Weiterleitung
/// verarbeitet der Dialog nicht.
/// </summary>
[HttpPost]
public IActionResult DokumentUmbenennen(int id, string dateiname)
{
var dokument = KursDaten.BenenneDokumentUm(id, dateiname);
if (dokument == null)
{
// Auch der Fehlerfall geht als JSON zurück: Der Dialog liest responseJSON aus.
return this.BadRequest("Das Dokument konnte nicht umbenannt werden.");
}
return this.Json($"Dokument umbenannt: {dokument.Dateiname}");
}
[HttpPost]
public IActionResult Entfernen(int id)
{
// Nur Rückmeldung: Die Demo-Daten sind statisch und werden nicht verändert.
return this.Zurueck(id, "Entfernen ausgelöst");
}
/// <summary>
/// Ziel des umschliessenden Formulars im Beispiel «Grid in einem Formular». Zeigt, dass die
/// Eingaben des Formulars ankommen – die Schaltfläche der Zeile schickt dasselbe Formular.
/// </summary>
[HttpPost]
public IActionResult FormularSpeichern(string bemerkung)
{
this.AddAlert($"Formular abgeschickt. Bemerkung: «{bemerkung}»", AlertType.Info, autoHide: true);
return this.Herkunft("Formular");
}
/// <summary>
/// Ziel der Zeilen-Schaltfläche mit <c>RenderAsFormAction</c>. Sie ist ein Submit-Button mit
/// formaction: Es kommt die Zeilen-Id aus der Adresse an und zusätzlich der Inhalt des
/// Formulars – anders als bei einer Zeilen-Aktion, die als Link gerendert wird.
/// </summary>
[HttpPost]
public IActionResult FormularZeile(int id, string bemerkung)
{
var zeile = KursDaten.Laden(id);
var bezeichnung = zeile?.Bezeichnung ?? $"Id {id}";
this.AddAlert($"Zeile «{bezeichnung}» abgeschickt. Bemerkung: «{bemerkung}»", AlertType.Info, autoHide: true);
return this.Herkunft("Formular");
}
private IActionResult Zurueck(int id, string aktion)
{
var zeile = KursDaten.Laden(id);
var bezeichnung = zeile?.Bezeichnung ?? $"Id {id}";
this.AddAlert($"{aktion}: {bezeichnung}", AlertType.Info, autoHide: true);
return this.Herkunft("Index");
}
// Zurück auf die Seite, von der die Aktion kam – sonst landet man auf einer leeren Seite.
private IActionResult Herkunft(string ersatzAktion)
{
var herkunft = this.Request.Headers.Referer.ToString();
if (string.IsNullOrEmpty(herkunft))
{
return this.RedirectToAction(ersatzAktion, "Demo", new { area = "Tabelle" });
}
return this.Redirect(herkunft);
}
}
}
Darstellung
| Kurs | Datum | Bestanden | Actions | ||
|---|---|---|---|---|---|
| Grundkurs Notfallmedizin | 14.01.2026 | Yes | |||
| Refresher Innere Medizin | 03.02.2026 | No | |||
| Workshop Gesprächsführung | 19.02.2026 | Yes | |||
| Fortbildung Radiologie | 07.03.2026 | Yes | |||
| Seminar Qualitätssicherung | 22.03.2026 | No | |||
| Kongress Kardiologie | 11.04.2026 | Yes | |||
| Kurs Strahlenschutz | 28.04.2026 | Yes | |||
| Update Pädiatrie | 15.05.2026 | Yes | |||
| Fallseminar Onkologie | 02.06.2026 | No | |||
| Praxiskurs Sonografie | 24.06.2026 | Yes | |||
| Kurs Palliative Care | 09.07.2026 | No | |||
| Symposium Neurologie | 18.08.2026 | Yes | |||
Eine Schaltfläche mit zwei Zuständen. Sie zeigt je nach Zustand ein anderes Symbol und einen anderen Tooltip:
spalten.Button(
x => FmhGrid.ToogleButton("Bestanden", "TabelleBeispiel", new { area = "Tabelle", id = x.Id }, "Prüfungsergebnis")
.IconOnMode(IconType.Done)
.IconOffMode(IconType.HighlightOff)
.ToggleOnTooltip("Kurs bestanden")
.ToggleOffTooltip("Kurs offen"))
.ToggleStatus(x => x.Bestanden);
Zwei Angaben, die nicht fehlen dürfen
Beide fehlen still in der Signatur und melden sich erst zur Laufzeit — als Fehler beim Rendern der Seite, nicht als Compilerfehler:
IconOnMode(...).FmhGrid.ToogleButtonsetzt selbst kein Symbol; ohne die Angabe scheitert das Rendern der Schaltfläche.ToggleStatus(...)an der Spalte. Daher kommt der Zustand. Fehlt die Angabe, findet das Grid keinen Wert und bricht ab. Wichtig:ToggleStatussteht an der Spalte, also hinter der schliessenden Klammer vonButton(...), nicht an der Schaltfläche.
Der Zustand landet in der Adresse
Das Grid hängt den aktuellen Zustand als Parameter an: is + Name der Eigenschaft aus
ToggleStatus, im Beispiel also ?isBestanden=True. Die Aktion kennt damit den Ausgangswert und
muss ihn nicht erst lesen:
public IActionResult Bestanden(int id, bool isBestanden) { … }
Was zu beachten ist
- Bei einer serverseitig gebundenen Tabelle ist die Schaltfläche ein Link: Die Aktion wird per
GET aufgerufen und leitet danach auf die Seite zurück. Erst mit
AjaxDataUrlschaltet das Grid auf einen Ajax-Postback um und lädt nur die Tabelle neu. - Der Name der Methode ist im Paket falsch geschrieben (
ToogleButton) — das ist kein Tippfehler in diesem Beispiel.