Tabelle

Suchformular über der Tabelle

Das Grid hängt an jede Datenabfrage die Felder aller Formulare der Seite an. Ein eigenes Suchformular braucht deshalb nur passende Feldnamen und einen Auslöser, der die Tabelle neu lädt.

@* Werte, die sich der Anwender aussuchen soll, können nicht in der Adresse stehen — sie ändern sich
   nach dem Aufbau der Seite. Dafür gibt es keinen eigenen Builder-Aufruf, sondern ein Verhalten des
   Grids: Vor JEDER Datenabfrage serialisiert es sämtliche <form>-Elemente der Seite und hängt deren
   Felder an die Anfrage an.

   Ein Suchformular über der Tabelle braucht deshalb nur zweierlei:
     - Felder mit den Namen, die der Endpunkt erwartet,
     - einen Auslöser, der die Tabelle neu lädt: gridReload('#id') im Tab «JavaScript».

   Das Formular selbst wird nie abgeschickt; sein Submit wird abgefangen. *@
@{
    var anbieter = KursDaten.Laden()
        .Select(kurs => kurs.Anbieter)
        .Distinct()
        .OrderBy(name => name)
        .ToList();
}

<form id="kurse-ajax-suchformular-suche" class="mb-3">
    <div class="row g-2 align-items-end">
        <div class="col-sm-5">
            @* Der Feldname entscheidet, wo der Wert im Endpunkt ankommt – hier in KursFilter.Bezeichnung. *@
            <label class="form-label" for="Bezeichnung">Kurs</label>
            <input class="form-control" type="search" id="Bezeichnung" name="Bezeichnung" placeholder="Teil der Bezeichnung" />
        </div>
        <div class="col-sm-4">
            <label class="form-label" for="Anbieter">Anbieter</label>
            <select class="form-select" id="Anbieter" name="Anbieter">
                <option value="">(alle)</option>
                @foreach (var name in anbieter)
                {
                    <option value="@name">@name</option>
                }
            </select>
        </div>
        <div class="col-sm-3">
            <button class="btn btn-primary" type="submit">Suchen</button>
            <button class="btn btn-outline-secondary" type="reset">Zurücksetzen</button>
        </div>
    </div>
</form>

@Html.Fmh(Fmh<KursZeile>.Grid
    .Id("kurse-ajax-suchformular")
    .ClientDataKey(nameof(KursZeile.Id))
    @* Ohne zweiten Parameter fragt das Grid per POST an; die Felder des Formulars kommen dann als
       Formularwerte an und werden im Endpunkt über [FromForm] gelesen. *@
    .AjaxDataUrl(Url.Action("DatenGefiltert", "TabelleAjax", new { area = "Tabelle" }), FormMethod.Post)
    .DefaultPageSize(5)
    .PageSizeMenu(5, 10, 25)
    .EnableSorting(nameof(KursZeile.Datum), SortDirection.Ascending)
    .NoRecordsText("Kein Kurs zu dieser Suche gefunden.")
    .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")
            .IsSortable();

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

@* Der Auslöser steht im Tab «JavaScript» (wwwroot/js/demos/tabelle-ajax-suchformular.js). *@
<script src="~/js/demos/tabelle-ajax-suchformular.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>
        /// 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(" ", "-");
        }
    }
}
namespace FMH.Komponente.WebUI.Areas.Tabelle.Models
{
    /// <summary>
    /// Zusatzwerte, die neben den Angaben von DataTables beim Endpunkt der Ajax-Tabelle ankommen.
    /// Grundlage der Beispiele «Feste Filterwerte in der Adresse» (als Abfrageparameter) und
    /// «Suchformular über der Tabelle» (als Formularfelder).
    /// </summary>
    /// <remarks>
    /// Bewusst nur Zeichenketten: Auf beiden Wegen kommen die Werte als Text an, und ein leeres Feld
    /// bedeutet «nicht filtern». Ein <c>bool</c> wäre nicht unterscheidbar von «nicht angegeben».
    /// </remarks>
    public class KursFilter
    {
        public string Bezeichnung { get; set; }

        public string Anbieter { get; set; }
    }
}
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);
        }
    }
}
// Demo "Tabelle › Ui › Grid › Datasource (Ajax) › Filtern › Suchformular über der Tabelle".
//
// Die Felder des Formulars hängt das Grid von sich aus an jede Datenabfrage an – es serialisiert
// vor jeder Anfrage sämtliche <form>-Elemente der Seite. Zu tun bleibt nur eines: die Tabelle nach
// einer Änderung neu laden.
//
// gridReload(id) ist eine globale Funktion der Tabellen-Komponente. Sie erwartet die Id MIT
// vorangestelltem "#" und lädt die aktuelle Seite der Tabelle neu (DataTables: ajax.reload()).
var FORMULAR = 'kurse-ajax-suchformular-suche';
var TABELLE = '#kurse-ajax-suchformular';

// Delegiert an document, damit der Zeitpunkt des Renderns keine Rolle spielt.
document.addEventListener('submit', function (event) {
    if (event.target.id !== FORMULAR) {
        return;
    }

    // Das Formular wird nie abgeschickt: Es dient allein als Behälter für die Felder, die mit der
    // Datenabfrage mitgehen. Ohne preventDefault würde die Seite neu geladen.
    event.preventDefault();

    gridReload(TABELLE);
});

document.addEventListener('reset', function (event) {
    if (event.target.id !== FORMULAR) {
        return;
    }

    // Beim reset-Ereignis stehen die alten Werte noch in den Feldern – der Browser leert sie erst
    // danach. Das Neuladen deshalb ans Ende der Warteschlange stellen, sonst würde noch einmal mit
    // dem alten Filter abgefragt.
    window.setTimeout(function () {
        gridReload(TABELLE);
    }, 0);
});
// jQuery-Variante zum Beispiel «Suchformular über der Tabelle» – dasselbe wie im Tab "JavaScript",
// nur mit jQuery geschrieben. Diese Datei wird von der Demo-Seite NICHT geladen; sonst liefen beide
// Fassungen gleichzeitig und die Tabelle würde je Suche zweimal abgefragt. Sie ist als Vorlage zum
// Übernehmen gedacht.
$(function () {
    var formular = '#kurse-ajax-suchformular-suche';
    var tabelle = '#kurse-ajax-suchformular';

    // Das Formular wird nie abgeschickt: Es dient allein als Behälter für die Felder, die das Grid
    // an jede Datenabfrage anhängt. "return false" verhindert das Absenden.
    $(document).on('submit', formular, function () {
        gridReload(tabelle);

        return false;
    });

    // Beim reset-Ereignis stehen die alten Werte noch in den Feldern – der Browser leert sie erst
    // danach. Das Neuladen deshalb ans Ende der Warteschlange stellen, sonst würde noch einmal mit
    // dem alten Filter abgefragt.
    $(document).on('reset', formular, function () {
        window.setTimeout(function () {
            gridReload(tabelle);
        }, 0);
    });
});

Darstellung

Kurs Anbieter Datum Actions
Kein Kurs zu dieser Suche gefunden.

Werte, die sich der Anwender aussuchen soll, können nicht in der Adresse stehen — sie ändern sich nach dem Aufbau der Seite. Dafür gibt es keinen Builder-Aufruf, sondern ein Verhalten des Grids:

Vor jeder Datenabfrage serialisiert das Grid sämtliche <form>-Elemente der Seite und hängt deren Felder an die Anfrage an.

Ein Suchformular über der Tabelle braucht deshalb nur zweierlei: Felder mit den Namen, die der Endpunkt erwartet, und einen Auslöser, der die Tabelle neu lädt.

<form id="kurse-ajax-suchformular-suche">
    <input type="search" id="Bezeichnung" name="Bezeichnung" />
    <select id="Anbieter" name="Anbieter">…</select>
    <button type="submit">Suchen</button>
</form>

@Html.Fmh(Fmh<KursZeile>.Grid
    .Id("kurse-ajax-suchformular")
    .AjaxDataUrl(Url.Action("DatenGefiltert", "TabelleAjax", new { area = "Tabelle" }), FormMethod.Post)
    …)
document.addEventListener('submit', function (event) {
    if (event.target.id !== 'kurse-ajax-suchformular-suche') {
        return;
    }

    event.preventDefault();          // Das Formular wird nie abgeschickt.
    gridReload('#kurse-ajax-suchformular');
});

gridReload(id) ist eine globale Funktion der Tabellen-Komponente; sie erwartet die Id mit vorangestelltem # und lädt die Zeilen neu, samt Sprung zurück auf die erste Seite. Der Endpunkt liest die Felder über [FromForm]:

[HttpPost]
public IActionResult DatenGefiltert(GridDatatableParameter parameter, [FromForm] KursFilter filter)

Was zu beachten ist

  • Das Formular wird nie abgeschickt. Es ist allein der Behälter für die Felder; ohne preventDefault() würde die Seite neu geladen. Ein <button type="button"> täte es auch — mit einem echten Formular funktioniert zusätzlich die Eingabetaste.
  • Es sind wirklich alle Formulare der Seite. Steht daneben ein Erfassungsformular, geht dessen Inhalt bei jeder Abfrage mit. Zwei gleich benannte Felder auf derselben Seite kommen als zwei Werte an — Feldnamen deshalb eindeutig halten und keine grossen Formulare zur Tabelle stellen.
  • Ein leeres Feld heisst «nicht filtern». Es kommt trotzdem an, nur eben leer; die Auswertung im Endpunkt muss den Fall abdecken.
  • Ein Präfix ist möglich. Führt das ViewModel der Seite die Suche als Teilmodell, heissen die Felder Suche.Bezeichnung — der Endpunkt liest sie dann mit [Bind(Prefix = "Suche")] KursFilter filter.
  • Das eingebaute Suchfeld bleibt möglich. EnableSearchBox liefert seinen Begriff weiterhin in Search; Formularfilter und Suchfeld lassen sich kombinieren.
  • Zurücksetzen braucht einen Moment. Beim reset-Ereignis stehen die alten Werte noch in den Feldern — das Neuladen gehört über setTimeout(…, 0) ans Ende der Warteschlange, sonst fragt das Grid noch einmal mit dem alten Filter ab.