Tabelle
Spalten aufbereiten
Format, Custom, IsHtmlEncoded und EmptyColumnChar wirken beim serverseitigen Rendern – auf dem Ajax-Weg kommt der fertige Wert aus der Datenquelle. Sortiert wird über SortByKey nach dem Rohwert.
@* Auf dem Ajax-Weg rendert der Browser die Zellen aus dem JSON des Endpunkts. Alles, was das Grid
sonst beim Rendern mit dem Wert macht, entfällt deshalb: Format, Custom, IsHtmlEncoded und
EmptyColumnChar bleiben ohne Wirkung.
Der Ersatz ist immer derselbe: Der Wert kommt fertig aus der Datenquelle. Im Modell stehen dafür
PunkteText, BestandenText und StatusHtml neben den Rohwerten — der Tab «KursZeile» zeigt sie. *@
@Html.Fmh(Fmh<KursZeile>.Grid
.Id("kurse-ajax-spalten")
.ClientDataKey(nameof(KursZeile.Id))
.AjaxDataUrl(Url.Action("Daten", "TabelleAjax", new { area = "Tabelle" }))
.DefaultPageSize(5)
.PageSizeMenu(5, 10, 25)
.EnableSorting(nameof(KursZeile.Datum), SortDirection.Ascending)
.Columns(spalten =>
{
spalten.For(x => x.Bezeichnung)
.Named("Kurs")
.IsSortable();
@* Die einzige Ausnahme: Datumsspalten stellt das Grid im Browser selbst dar, fest als
dd.MM.yyyy. Sortiert wird über den Rohwert, den der Endpunkt schickt. *@
spalten.For(x => x.Datum)
.Named("Datum")
.IsSortable();
@* Statt Format("{0:0.0}"): Der Endpunkt liefert den fertigen Text. Sortiert wird über
SortByKey nach der Zahl — sonst stünde «9.0» hinter «16.0». *@
spalten.For(x => x.PunkteText)
.Named("Aufwand")
.SortByKey(x => x.Punkte)
.IsSortable()
.WidthInPixel(120);
@* Statt Format(GridValueFormat.YesNo): «Ja»/«Nein» kommt aus dem Modell, sortiert wird nach
dem Wahrheitswert. *@
spalten.For(x => x.BestandenText)
.Named("Bestanden")
.SortByKey(x => x.Bestanden)
.IsSortable()
.WidthInPixel(110);
@* Statt einer Custom-Spalte: Das Markup entsteht im Modell und kommt als Wert mit. Der
Browser setzt es unverändert in die Zelle — der Inhalt muss deshalb aus vertrauenswürdiger
Quelle stammen. *@
spalten.For(x => x.StatusHtml)
.Named("Status")
.WidthInPixel(140);
@* Ohne Wert bleibt die Zelle leer: EmptyColumnChar wirkt nur beim serverseitigen Rendern.
Wo ein Platzhalter gewünscht ist, gehört er in die Datenquelle. *@
spalten.For(x => x.Bemerkung)
.Named("Bemerkung")
.ResponsivePriority(10);
spalten.Button(x => FmhGrid.ViewButton("Ansehen", "TabelleAjax", 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;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using FMH.Komponente.Basis.Erweiterungen;
using FMH.Komponente.Basis.Ui.Feedback.Alert;
using FMH.Komponente.Tabelle.Ui.Grid.Datatable;
using FMH.Komponente.WebUI.Areas.Tabelle.Models;
using Microsoft.AspNetCore.Mvc;
namespace FMH.Komponente.WebUI.Areas.Tabelle.Controllers.Beispiele
{
// Die Gegenstelle der Gruppe «Datasource (Ajax)»: der Endpunkt für die Zeilen und die Ziele der
// Zeilen-Aktionen.
//
// Zwei Dinge unterscheiden diesen Controller von TabelleBeispielController (dem der
// serverseitig gebundenen Beispiele):
//
// 1. Der Endpunkt für die Daten nimmt GridDatatableParameter entgegen und antwortet mit
// GridDataSource<T> — beides in Pascal Case, weil das Grid die Felder so ausliest.
// 2. Die Aktionen antworten mit JSON statt mit einer Weiterleitung: Entfernen, Dialog und
// Umschalten laufen mit einer Ajax-Datenquelle als Postback im Hintergrund. Die Antwort ist
// die Meldung, die das Grid danach einblendet.
[Area("Tabelle")]
public class TabelleAjaxController : Controller
{
// Pflicht: Der Json-Serializer arbeitet standardmässig mit Camel Case, das Grid liest die
// Antwort aber in Pascal Case (Datasource, TotalRecords, TotalDisplayRecords, Draw). Ohne
// diese Einstellung bleibt die Tabelle leer.
private static readonly JsonSerializerOptions JsonPascalCase = new JsonSerializerOptions { PropertyNamingPolicy = null };
/// <summary>
/// Der Endpunkt aus <c>AjaxDataUrl(...)</c>. Sortieren, Suchen und Blättern passieren hier —
/// der Browser bekommt nur die angeforderte Seite.
/// </summary>
[HttpPost]
public IActionResult Daten(GridDatatableParameter parameter)
{
var alle = KursDaten.Laden();
var gefiltert = Suchen(alle, parameter.Search);
var sortiert = Sortieren(gefiltert, parameter.SortColumnName, parameter.SortDirection);
var seite = sortiert.Skip(parameter.Skip)
.Take(parameter.Take)
.ToList();
var quelle = new GridDataSource<List<KursZeile>>
{
Datasource = seite,
TotalRecords = alle.Count,
TotalDisplayRecords = gefiltert.Count,
// Draw zurückgeben: DataTables verwirft eine Antwort, deren Draw nicht
// zur Anfrage passt — die Tabelle bliebe sonst beim Blättern stehen.
Draw = parameter.Draw,
};
return this.Json(quelle, JsonPascalCase);
}
/// <summary>
/// Endpunkt des Beispiels «Suchformular über der Tabelle». Die Felder des Formulars hängt
/// das Grid vor jeder Abfrage selbst an — hier kommen sie als Formularwerte an.
/// </summary>
[HttpPost]
public IActionResult DatenGefiltert(GridDatatableParameter parameter, [FromForm] KursFilter filter)
{
return this.Json(Seite(parameter, filter), JsonPascalCase);
}
/// <summary>
/// Endpunkt des Beispiels «Feste Filterwerte in der Adresse». Die Werte stehen als
/// Abfrageparameter in der Adresse aus <c>AjaxDataUrl</c> — deshalb <c>[FromQuery]</c> und,
/// passend zu <c>FormMethod.Get</c>, <c>[HttpGet]</c>.
/// </summary>
[HttpGet]
public IActionResult DatenMitFilter(GridDatatableParameter parameter, [FromQuery] KursFilter filter)
{
return this.Json(Seite(parameter, filter), JsonPascalCase);
}
/// <summary>
/// Ziel des Formulars aus dem Beispiel «Mehrfachauswahl». Die Auswahlkästchen posten unter
/// dem Namen aus <c>ClientDataKey</c> — auf dem Ajax-Weg also nicht unter dem Spaltennamen.
/// </summary>
[HttpPost]
public IActionResult Auswahl(int[] id)
{
if (id == null || id.Length == 0)
{
this.AddAlert("Kein Kurs ausgewählt.", AlertType.Warning, autoHide: true);
return this.RedirectToAction("AjaxMehrfachauswahl", "Demo", new { area = "Tabelle" });
}
var bezeichnungen = id.Select(kursId => Bezeichnung(kursId))
.ToArray();
this.AddAlert($"{bezeichnungen.Length} ausgewählt: {string.Join(", ", bezeichnungen)}", AlertType.Info, autoHide: true);
return this.RedirectToAction("AjaxMehrfachauswahl", "Demo", new { area = "Tabelle" });
}
/// <summary>
/// Ziel der Aufklapp-Spalte aus <c>ChildForAsync</c>. Die Antwort ist das fertige Markup der
/// Unterzeile; das Grid schiebt es unter die Zeile. Der Aufruf kommt als POST.
/// </summary>
[HttpPost]
public IActionResult Dokumente(int id)
{
var zeile = KursDaten.Laden(id);
return this.PartialView("_Dokumente", zeile?.Dokumente ?? new List<KursDokument>());
}
/// <summary>
/// Ziel des Umschalters. Wie bei der serverseitig gebundenen Tabelle kommt der bisherige
/// Zustand als Parameter mit — die Antwort ist hier aber JSON, kein Redirect.
/// </summary>
[HttpPost]
public IActionResult Bestanden(int id, bool isBestanden)
{
var neu = KursDaten.Umschalten(id);
return this.Json($"{Bezeichnung(id)}: {(isBestanden ? "war bestanden" : "war offen")} → {(neu ? "bestanden" : "offen")}", JsonPascalCase);
}
[HttpPost]
public IActionResult Dialog(int id)
{
return this.Json($"Im Dialog bestätigt: {Bezeichnung(id)}", JsonPascalCase);
}
[HttpPost]
public IActionResult Entfernen(int id)
{
// Nur Rückmeldung: Die Demo-Daten sind für alle Beispiele dieselben und werden nicht
// verändert. Der Rückgabewert ist der Text, den das Grid nach dem Neuladen einblendet.
return this.Json($"Entfernen ausgelöst: {Bezeichnung(id)}", JsonPascalCase);
}
/// <summary>
/// Ziel einer Aktion mit <c>data-asyncactiondisabled="true"</c>. Diese Schaltfläche schickt
/// ein gewöhnliches Formular ab — die Aktion muss deshalb selbst zurück auf die Seite
/// leiten, sonst bleibt der Anwender auf einer leeren Antwort sitzen.
/// </summary>
[HttpPost]
public IActionResult EntfernenMitSeitenwechsel(int id)
{
this.AddAlert($"Entfernen mit Seitenwechsel: {Bezeichnung(id)}", AlertType.Info, autoHide: true);
return this.RedirectToAction("AjaxAktionen", "Demo", new { area = "Tabelle" });
}
/// <summary>
/// Ziel der Ansehen-Aktion: ein gewöhnlicher Link, der die Seite wechselt — auch bei einer
/// Ajax-Datenquelle.
/// </summary>
public IActionResult Ansehen(int id)
{
this.AddAlert($"Angesehen: {Bezeichnung(id)}", AlertType.Info, autoHide: true);
var herkunft = this.Request.Headers.Referer.ToString();
if (string.IsNullOrEmpty(herkunft))
{
return this.RedirectToAction("AjaxGrundfall", "Demo", new { area = "Tabelle" });
}
return this.Redirect(herkunft);
}
// Die Zusatzwerte ändern nichts am Ablauf des Endpunkts: Sie schränken die Menge ein, bevor
// gesucht, sortiert und geblättert wird. TotalRecords meint danach den Bestand, den der
// Filter übrig lässt — sonst nennt die Blätterleiste eine Zahl, die es auf dieser Seite gar
// nicht gibt.
private static GridDataSource<List<KursZeile>> Seite(GridDatatableParameter parameter, KursFilter filter)
{
var bestand = Filtern(KursDaten.Laden(), filter);
var gefiltert = Suchen(bestand, parameter.Search);
return new GridDataSource<List<KursZeile>>
{
Datasource = Sortieren(gefiltert, parameter.SortColumnName, parameter.SortDirection)
.Skip(parameter.Skip)
.Take(parameter.Take)
.ToList(),
TotalRecords = bestand.Count,
TotalDisplayRecords = gefiltert.Count,
Draw = parameter.Draw,
};
}
// Ein leeres Feld bedeutet «nicht filtern» — bei einer Suche über der Tabelle ist das der
// Regelfall und kein Sonderfall.
private static List<KursZeile> Filtern(IEnumerable<KursZeile> zeilen, KursFilter filter)
{
if (filter == null)
{
return zeilen.ToList();
}
if (string.IsNullOrWhiteSpace(filter.Bezeichnung) == false)
{
zeilen = zeilen.Where(zeile => Enthaelt(zeile.Bezeichnung, filter.Bezeichnung));
}
if (string.IsNullOrWhiteSpace(filter.Anbieter) == false)
{
zeilen = zeilen.Where(zeile => string.Equals(zeile.Anbieter, filter.Anbieter, StringComparison.OrdinalIgnoreCase));
}
return zeilen.ToList();
}
private static string Bezeichnung(int id)
{
return KursDaten.Laden(id)
?.Bezeichnung ?? $"Id {id}";
}
// Der Suchbegriff aus dem Suchfeld kommt in GridDatatableParameter.Search an. Über welche
// Felder gesucht wird, entscheidet der Endpunkt — das Grid weiss davon nichts.
private static List<KursZeile> Suchen(IEnumerable<KursZeile> zeilen, string suchbegriff)
{
if (string.IsNullOrWhiteSpace(suchbegriff))
{
return zeilen.ToList();
}
return zeilen.Where(
zeile => Enthaelt(zeile.Bezeichnung, suchbegriff)
|| Enthaelt(zeile.Anbieter, suchbegriff)
|| Enthaelt(zeile.Bemerkung, suchbegriff))
.ToList();
}
private static bool Enthaelt(string wert, string suchbegriff)
{
return wert != null && wert.Contains(suchbegriff, StringComparison.OrdinalIgnoreCase);
}
// SortColumnName ist der Name der Eigenschaft, an die die Spalte gebunden ist — bei einer
// Spalte mit SortByKey der Name des Sortierschlüssels. Der Endpunkt muss die Namen kennen;
// was er nicht kennt, bekommt eine vernünftige Vorgabe.
private static IEnumerable<KursZeile> Sortieren(List<KursZeile> zeilen, string spalte, string richtung)
{
var absteigend = string.Equals(richtung, "desc", StringComparison.OrdinalIgnoreCase);
Func<KursZeile, object> schluessel = spalte switch
{
nameof(KursZeile.Bezeichnung) => zeile => zeile.Bezeichnung,
nameof(KursZeile.Anbieter) => zeile => zeile.Anbieter,
nameof(KursZeile.Punkte) => zeile => zeile.Punkte,
nameof(KursZeile.PunkteText) => zeile => zeile.Punkte,
nameof(KursZeile.Bestanden) => zeile => zeile.Bestanden,
nameof(KursZeile.BestandenText) => zeile => zeile.Bestanden,
nameof(KursZeile.IstAbgeschlossen) => zeile => zeile.IstAbgeschlossen,
nameof(KursZeile.AnzahlDokumente) => zeile => zeile.AnzahlDokumente,
_ => zeile => zeile.Datum,
};
return absteigend
? zeilen.OrderByDescending(schluessel)
: zeilen.OrderBy(schluessel);
}
}
}
Darstellung
| Kurs | Datum | Aufwand | Bestanden | Status | Bemerkung | Actions | ||
|---|---|---|---|---|---|---|---|---|
| No entries available | ||||||||
Auf dem Ajax-Weg entsteht jede Zelle im Browser aus dem JSON des Endpunkts. Alles, was das Grid
sonst beim Rendern mit dem Wert macht, entfällt: Format, Custom, IsHtmlEncoded und
EmptyColumnChar bleiben ohne Wirkung.
Der Ersatz ist immer derselbe — der Wert kommt fertig aus der Datenquelle:
| Statt | Auf dem Ajax-Weg |
|---|---|
.Format("{0:0.0}") |
Eigenschaft mit dem fertigen Text (PunkteText), sortiert über .SortByKey(x => x.Punkte). |
.Format(GridValueFormat.YesNo) |
Eigenschaft mit «Ja»/«Nein» (BestandenText), sortiert über .SortByKey(x => x.Bestanden). |
spalten.Custom(x => Html.Raw(…), "Status") |
Eigenschaft mit dem Markup (StatusHtml), gebunden mit For. |
.CellAttributes(…) |
Ereignis gridCellCreated — siehe «Zellen auszeichnen (gridCellCreated)». |
.EmptyColumnChar("–") |
Platzhalter im Endpunkt setzen oder über gridCellCreated nachtragen — siehe «Zellen auszeichnen (gridCellCreated)». |
public string BestandenText => this.Bestanden ? "Ja" : "Nein";
spalten.For(x => x.BestandenText)
.Named("Bestanden")
.SortByKey(x => x.Bestanden)
.IsSortable();
Anzeigen und Sortieren trennen
SortByKey hat auf diesem Weg eine zusätzliche Aufgabe: Es bestimmt, welchen Namen der Endpunkt
als SortColumnName bekommt. Ohne die Angabe würde nach dem Anzeigetext sortiert — «9.0 Punkte»
stünde dann hinter «16.0 Punkte». Der Endpunkt muss den Namen kennen und auf den Rohwert abbilden.
Was zu beachten ist
- Datumsspalten sind die Ausnahme. Sie stellt das Grid im Browser selbst dar, fest als
dd.MM.yyyy. EinFormat(...)daran ändert nichts. - Markup aus dem Endpunkt wird nicht geprüft. Der Browser setzt den Wert unverändert in die
Zelle; er muss deshalb aus vertrauenswürdiger Quelle stammen. Sobald an der Spalte ein
SortByKeysteht, läuft der Wert vorher durch den clientseitigen Sanitizer — dann verschwinden Tags, die dessen Liste nicht kennt. - Eine
Custom-Spalte bleibt nicht einfach leer, sie zeigt etwas Falsches. Ohne Eigenschaft bindet das Grid die Spalte an den Wert ausClientDataKey— in der Zelle stünde die Id. - Leere Werte bleiben leer: Ohne Platzhalter aus der Datenquelle steht in der Zelle nichts.