Tabelle

Suchfeld anpassen (DataTable.ext.classes)

Die Klassen von Suchfeld und Seitengrössen-Auswahl setzt die Komponente über DataTable.ext.classes. Ein Skript nach den Bundles überschreibt sie, hier für ein kompaktes Suchfeld; die Anpassung gilt für alle Tabellen der Seite.

@* Dieselbe Tabelle wie im Beispiel «Suchfeld über der Tabelle (EnableSearchBox)», nur mit einem
   kompakten Suchfeld. Am Grid ändert sich dafür nichts: Die Klassen von Suchfeld und
   Seitengrössen-Auswahl stehen in DataTable.ext.classes und werden im Tab «JavaScript»
   überschrieben.

   Die Anpassung gilt für alle Tabellen der Seite, nicht nur für diese. *@
@Html.Fmh(Fmh<KursZeile>.Grid
    .Id("kurse-ajax-suchfeld-klassen")
    .ClientDataKey(nameof(KursZeile.Id))
    .AjaxDataUrl(Url.Action("Daten", "TabelleAjax", new { area = "Tabelle" }))
    .EnableSearchBox()
    .DefaultPageSize(5)
    .PageSizeMenu(5, 10, 25)
    .EnableSorting(nameof(KursZeile.Bezeichnung), SortDirection.Ascending)
    .NoRecordsText("Kein Kurs zu diesem Suchbegriff gefunden.")
    .Columns(spalten =>
    {
        spalten.For(x => x.Bezeichnung)
            .Named("Kurs")
            .IsSortable();

        spalten.For(x => x.Anbieter)
            .Named("Anbieter")
            .IsSortable();

        spalten.For(x => x.Bemerkung)
            .Named("Bemerkung");

        spalten.Button(x => FmhGrid.ViewButton("Ansehen", "TabelleAjax", new { area = "Tabelle", id = x.Id }));
    }))

@* Nach den Bundles, aber vor dem Aufbau der Tabelle: Html.PostScript läuft hinter den Skripten der
   Komponenten und noch vor deren jQuery-ready. *@
@Html.PostScript(@<script src="~/js/demos/tabelle-ajax-suchfeld-klassen.js" asp-append-version="true"></script>)
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>
        /// Childrows 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>";

        /// <summary>
        /// Aus «Universität Zürich» wird «universitaet-zuerich»: Umlaute und Leerzeichen haben in
        /// einer Mailadresse nichts verloren.
        /// </summary>
        /// <param name="anbieter">Der Name des Anbieters.</param>
        /// <returns>Der kleingeschriebene Name ohne Umlaute und Leerzeichen.</returns>
        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
    /// «Childrows» 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>
        /// <returns>Eine Liste ohne Zeilen.</returns>
        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>
        /// <param name="id">Die Id der Zeile.</param>
        /// <returns>Der Zustand nach dem Umschalten.</returns>
        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 «Childrows». Der Aufruf kommt aus dem Umbenennen-Dialog
        /// des Grids und erwartet eine Antwort im JSON-Format.
        /// </summary>
        /// <param name="id">Die Id des Dokuments.</param>
        /// <param name="dateiname">Der neue Name; ein leerer Wert lässt den bisherigen stehen.</param>
        /// <returns>Das umbenannte Dokument oder <c>null</c>, wenn es die Id nicht gibt.</returns>
        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
{
    /// <summary>
    /// Die Gegenstelle der Gruppe «Datasource (Ajax)»: der Endpunkt für die Zeilen und die Ziele der
    /// Zeilen-Aktionen.
    /// </summary>
    /// <remarks>
    /// Zwei Dinge unterscheiden diesen Controller von TabelleBeispielController (dem der
    /// serverseitig gebundenen Beispiele):
    /// <para>
    /// 1. Der Endpunkt für die Daten nimmt GridDatatableParameter entgegen und antwortet mit
    /// GridDataSource&lt;T&gt;, 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.
    /// </para>
    /// </remarks>
    [Area("Tabelle")]
    public class TabelleAjaxController : Controller
    {
        /// <summary>
        /// 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.
        /// </summary>
        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>
        /// <param name="parameter">Die Angaben von DataTables: Suchtext, Sortierung und Seitenausschnitt.</param>
        /// <returns>Die angeforderte Seite als JSON in Pascal Case.</returns>
        [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>
        /// <param name="parameter">Die Angaben von DataTables: Suchtext, Sortierung und Seitenausschnitt.</param>
        /// <param name="filter">Die Werte des eigenen Suchformulars.</param>
        /// <returns>Die gefilterte Seite als JSON in Pascal Case.</returns>
        [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>
        /// <param name="parameter">Die Angaben von DataTables: Suchtext, Sortierung und Seitenausschnitt.</param>
        /// <param name="filter">Die festen Filterwerte aus der Adresse.</param>
        /// <returns>Die gefilterte Seite als JSON in Pascal Case.</returns>
        [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>
        /// <param name="id">Die Ids der ausgewählten Zeilen; ohne Auswahl leer.</param>
        /// <returns>Die Umleitung zurück auf die Demo-Seite.</returns>
        [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
        /// Childrow; das Grid schiebt es unter die Zeile. Der Aufruf kommt als POST.
        /// </summary>
        /// <param name="id">Die Id der aufgeklappten Zeile.</param>
        /// <returns>Das Markup der Childrow.</returns>
        [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>
        /// <param name="id">Die Id der Zeile.</param>
        /// <param name="isBestanden">Der Zustand vor dem Umschalten, angehängt vom Grid.</param>
        /// <returns>Die Meldung, die das Grid nach dem Neuladen einblendet.</returns>
        [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>
        /// <param name="id">Die Id der Zeile.</param>
        /// <returns>Die Umleitung zurück auf die Demo-Seite.</returns>
        [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>
        /// <param name="id">Die Id der Zeile.</param>
        /// <returns>Die Umleitung auf die Seite, von der der Klick kam.</returns>
        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);
        }

        /// <summary>
        /// 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.
        /// </summary>
        /// <param name="parameter">Die Angaben von DataTables: Suchtext, Sortierung und Seitenausschnitt.</param>
        /// <param name="filter">Die Filterwerte aus dem Formular beziehungsweise aus der Adresse.</param>
        /// <returns>Die angeforderte Seite samt Zaehlwerten.</returns>
        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,
                   };
        }

        /// <summary>
        /// Ein leeres Feld bedeutet «nicht filtern», bei einer Suche über der Tabelle ist das der
        /// Regelfall und kein Sonderfall.
        /// </summary>
        /// <param name="zeilen">Die Zeilen, die eingeschraenkt werden.</param>
        /// <param name="filter">Die Filterwerte aus dem Formular beziehungsweise aus der Adresse.</param>
        /// <returns>Die eingeschraenkten Zeilen.</returns>
        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}";
        }

        /// <summary>
        /// Der Suchbegriff aus dem Suchfeld kommt in GridDatatableParameter.Search an. Über welche
        /// Felder gesucht wird, entscheidet der Endpunkt, das Grid weiss davon nichts.
        /// </summary>
        /// <param name="zeilen">Die Zeilen, die durchsucht werden.</param>
        /// <param name="suchbegriff">Der Suchbegriff aus dem Suchfeld des Grids.</param>
        /// <returns>Die Zeilen, die den Suchbegriff enthalten.</returns>
        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);
        }

        /// <summary>
        /// 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.
        /// </summary>
        /// <param name="zeilen">Die Zeilen, die sortiert werden.</param>
        /// <param name="spalte">Der Name der Spalte, nach der sortiert wird.</param>
        /// <param name="richtung">Die Sortierrichtung.</param>
        /// <returns>Die sortierten Zeilen.</returns>
        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);
        }
    }
}
// Demo "Tabelle › Ui › Grid › Datasource (Ajax) › Filtern › Suchfeld anpassen (DataTable.ext.classes)".
//
// Die Tabellen-Komponente gibt dem Suchfeld die Klassen "dt-input form-control" und der
// Seitengrössen-Auswahl "dt-input form-select form-select-sm". Beides steht in
// DataTable.ext.classes, und DataTables liest die Klassen beim Aufbau jeder Tabelle. Wer sie
// ändern will, überschreibt sie deshalb
//   - NACH dem Bundle der Komponenten (sonst überschreibt die Komponente die Anpassung wieder) und
//   - VOR dem Aufbau der Tabelle (das Grid baut sich im jQuery-ready auf).
// Html.PostScript erfüllt beides: Die Datei läuft hinter den Bundles, aber sofort beim Laden und
// nicht erst im ready.
//
// Die Zuweisung gilt für ALLE Tabellen der Seite. Für eine ganze Anwendung gehört sie deshalb in
// deren eigenes Skript, das auf jeder Seite nach den Bundles geladen wird.

// Kompaktes Suchfeld. "dt-input" stehen lassen, DataTables und eigene Selektoren hängen daran.
DataTable.ext.classes.search.input = 'dt-input form-control form-control-sm';

// Weitere Schlüssel nach demselben Muster, hier unverändert:
//   DataTable.ext.classes.length.select: die Seitengrössen-Auswahl
//   DataTable.ext.classes.search.container: das <div> um Beschriftung und Feld ("dt-search")
//   DataTable.ext.classes.length.container: das <div> um die Auswahl ("dt-length")
// Bei den beiden Containern "dt-search" bzw. "dt-length" stehen lassen, sonst greift die Anordnung
// aus der Tabellen-Komponente nicht mehr.
// jQuery-Variante zum Beispiel «Suchfeld anpassen (DataTable.ext.classes)», dasselbe wie im Tab
// "JavaScript", nur über $.fn.dataTable statt über das globale DataTable. Diese Datei wird von der
// Demo-Seite NICHT geladen; sie ist als Vorlage zum Übernehmen gedacht.
//
// Bewusst NICHT in $(function () { … }): Das Grid baut sich selbst im jQuery-ready auf, und seine
// ready-Handler stehen vor diesem. Die Klassen müssen schon gesetzt sein, wenn sie laufen.

// Kompaktes Suchfeld. "dt-input" stehen lassen, DataTables und eigene Selektoren hängen daran.
$.fn.dataTable.ext.classes.search.input = 'dt-input form-control form-control-sm';

// Weitere Schlüssel nach demselben Muster, hier unverändert:
//   $.fn.dataTable.ext.classes.length.select: die Seitengrössen-Auswahl
//   $.fn.dataTable.ext.classes.search.container: das <div> um Beschriftung und Feld ("dt-search")
//   $.fn.dataTable.ext.classes.length.container: das <div> um die Auswahl ("dt-length")
// Bei den beiden Containern "dt-search" bzw. "dt-length" stehen lassen, sonst greift die Anordnung
// aus der Tabellen-Komponente nicht mehr.

Darstellung

Kurs Anbieter Bemerkung Actions
Kein Kurs zu diesem Suchbegriff gefunden.

Die Klassen von Suchfeld und Seitengrössen-Auswahl erzeugt DataTables beim Aufbau jeder Tabelle aus DataTable.ext.classes. Die Tabellen-Komponente setzt dort im eigenen Bundle:

Schlüssel Klassen der Komponente Element
length.container dt-length <div> um die Seitengrössen-Auswahl
length.select dt-input form-select form-select-sm die Seitengrössen-Auswahl
search.container dt-search <div> um Beschriftung und Suchfeld
search.input dt-input form-control das Suchfeld

Eine Anwendung überschreibt die Werte mit einer Zuweisung:

DataTable.ext.classes.search.input = 'dt-input form-control form-control-sm';

Wann das Skript laufen muss

  • Nach dem Bundle der Komponenten, sonst setzt die Komponente ihre Klassen wieder darüber.
  • Vor dem Aufbau der Tabelle. Das Grid baut sich im jQuery-ready auf, bereits aufgebaute Tabellen ändern sich nicht mehr.

Html.PostScript erfüllt beides: Die Datei steht hinter den Bundles und läuft sofort beim Laden, nicht erst im ready. Die jQuery-Fassung gehört deshalb nicht in $(function () { … }).

@Html.PostScript(@<script src="~/js/demos/tabelle-ajax-suchfeld-klassen.js" asp-append-version="true"></script>)

Was zu beachten ist

  • Die Zuweisung gilt für alle Tabellen der Seite. Einzelne Tabellen lassen sich auf diesem Weg nicht unterscheiden. Soll die Anpassung in der ganzen Anwendung gelten, gehört sie in deren eigenes Skript, das auf jeder Seite nach den Bundles geladen wird.
  • dt-input stehen lassen. DataTables und eigene Selektoren hängen an der Klasse.
  • Die Container-Klassen dt-search und dt-length stehen lassen. Anordnung, Abstand zur Tabelle und Rand kommen aus _datatables.scss der Komponente und greifen über diese Klassen.
  • Weitere Klassen ergänzen statt ersetzen. Wer form-control weglässt, fällt auf das Aussehen des Browsers zurück, und das Suchfeld passt nicht mehr zu den übrigen Eingabefeldern.