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 =&gt; 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. Ein Format(...) 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 SortByKey steht, 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 aus ClientDataKey — in der Zelle stünde die Id.
  • Leere Werte bleiben leer: Ohne Platzhalter aus der Datenquelle steht in der Zelle nichts.