Tabelle
Grundfall
Daten über BindTo, Spalten über Columns – mehr braucht eine Tabelle nicht. Sortiert wird im Browser; Blätterleiste und Suchfeld gibt es nur mit einer Ajax-Datenquelle.
@model IReadOnlyList<KursZeile>
@* Der Grundfall: BindTo übergibt die Daten, Columns beschreibt die Spalten. Mehr braucht es nicht –
Sortierung, Blättern und Suche bringt DataTables mit. *@
@Html.Fmh(Fmh<KursZeile>.Grid
.Id("kurse-grundfall")
.BindTo(Model)
.EnableSorting(nameof(KursZeile.Datum), SortDirection.Descending)
.Columns(spalten =>
{
spalten.For(x => x.Bezeichnung)
.Named("Kurs")
.IsSortable();
spalten.For(x => x.Anbieter)
.Named("Anbieter")
.IsSortable();
spalten.For(x => x.Datum)
.Named("Datum")
.Format("{0:dd.MM.yyyy}")
.IsSortable();
spalten.For(x => x.Punkte)
.Named("Punkte")
.Format("{0:N1}")
.IsSortable();
}))
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>(),
};
}
}
}
Darstellung
| Kurs | Anbieter | Datum | Punkte | ||
|---|---|---|---|---|---|
| Grundkurs Notfallmedizin | FMH Bildung | 14.01.2026 | 12.5 | ||
| Refresher Innere Medizin | SIWF Akademie | 03.02.2026 | 8.0 | ||
| Workshop Gesprächsführung | Kantonsspital Bern | 19.02.2026 | 4.0 | ||
| Fortbildung Radiologie | Universität Zürich | 07.03.2026 | 16.0 | ||
| Seminar Qualitätssicherung | FMH Bildung | 22.03.2026 | 6.5 | ||
| Kongress Kardiologie | Swiss Heart | 11.04.2026 | 20.0 | ||
| Kurs Strahlenschutz | Bundesamt für Gesundheit | 28.04.2026 | 9.0 | ||
| Update Pädiatrie | Kinderspital Zürich | 15.05.2026 | 7.5 | ||
| Fallseminar Onkologie | SIWF Akademie | 02.06.2026 | 11.0 | ||
| Praxiskurs Sonografie | Universität Basel | 24.06.2026 | 14.0 | ||
| Kurs Palliative Care | FMH Bildung | 09.07.2026 | 10.0 | ||
| Symposium Neurologie | Inselspital | 18.08.2026 | 18.0 | ||
Die Tabelle wird über ein Fluent-API gebaut und mit @Html.Fmh(...) gerendert. Dahinter steht
DataTables; sortiert wird im Browser.
@Html.Fmh(Fmh<KursZeile>.Grid
.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();
}))
Zwei Arten, an die Daten zu kommen
| Weg | Aufruf | Wann |
|---|---|---|
| Datasource (Server) | .BindTo(daten) |
Die Zeilen stehen beim Rendern bereits fest. Alles Weitere macht der Browser. |
| Ajax-Data Url | .AjaxDataUrl(url) |
Die Zeilen kommen je Seite über einen Endpunkt — sortiert, gefiltert und geblättert wird auf dem Server. |
Diese Gruppe zeigt durchgehend den ersten Weg, die Gruppe Datasource (Ajax) den zweiten. Die
Faustregel: BindTo, solange die Menge in einen Rendervorgang passt; darüber hinaus AjaxDataUrl,
weil sonst jede Seite die ganze Liste überträgt. Welche Angaben auf welchem Weg wirken, steht
gesammelt unter Referenz, Abschnitt «Server oder Ajax: was wo wirkt».
Mit BindTo gibt es weder Blätterleiste noch Suchfeld. Die Tabelle rendert alle Zeilen, und der
clientseitige Aufbau setzt paging, searching und info fest auf false. Die Builder-Aufrufe
dafür (EnableSearchBox, PageSizeMenu, DefaultPageSize, HidePaginationIfSinglePage) wirken
ausschliesslich auf dem Ajax-Weg. Wer über lange Listen blättern oder suchen lassen will, braucht
AjaxDataUrl — nicht BindTo mit mehr Einstellungen.
Alle Methoden des Builders und der Spalten stehen gesammelt unter Referenz — dieser Abschnitt hier erklärt nur den Grundaufbau.
Was zu beachten ist
.Id(...)setzen, sobald zwei Grids auf einer Seite stehen. Ohne eigene Id teilen sie sich clientseitig Zustände wie Sortierung und Seitengrösse; der Zeilenklick wird sogar prozessweit über eine einzige Einstellung gesteuert.Formatwirkt auf die Anzeige, nicht auf die Sortierung. Ein formatiertes Datum sortiert als Zeichenkette, wenn nichtSortByKeyauf den Rohwert zeigt.EnableSortingbestimmt die Spalte über deren Position. Stehen Aktionsspalten vor Datenspalten, verschiebt sich die Zuordnung — Aktionen deshalb ans Ende der Spaltenliste.- Die Gestaltung des Pakets steckt im SCSS-Bundle der Tabelle; ohne
AddBundledAssets()in der Hostanwendung fehlt sie.