Tabelle

Zeilen umsortieren (gridrowmoved)

EnableRowDragAndDrop stellt eine Anfasser-Spalte voran. Verschoben wird nur die Anzeige; wer die Reihenfolge übernehmen will, wertet das Ereignis gridrowmoved aus.

@model IReadOnlyList<KursZeile>

@* EnableRowDragAndDrop stellt eine Anfasser-Spalte voran, mit der sich Zeilen mit der Maus oder per
   Finger verschieben lassen. Das Grid verschiebt die Zeile nur in der Anzeige – gespeichert wird
   nichts. Wer die neue Reihenfolge übernehmen will, wertet das Ereignis gridrowmoved aus.
   Die Spalten sind hier bewusst nicht sortierbar: Eine aktive Sortierung und eine von Hand
   gesetzte Reihenfolge widersprechen sich. *@
@Html.Fmh(Fmh<KursZeile>.Grid
    .Id("kurse-umsortieren")
    .BindTo(Model)
    .EnableRowDragAndDrop()
    .Columns(spalten =>
    {
        spalten.For(x => x.Bezeichnung)
            .Named("Kurs");

        spalten.For(x => x.Datum)
            .Named("Datum")
            .Format("{0:dd.MM.yyyy}");

        spalten.For(x => x.Punkte)
            .Named("Punkte");
    }))

<p class="small mt-3 mb-0" data-umsortieren-ausgabe>Noch keine Zeile verschoben.</p>

@* Der Listener steht im Tab «JavaScript» (wwwroot/js/demos/tabelle-umsortieren.js). *@
<script src="~/js/demos/tabelle-umsortieren.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(" ", "-");
        }
    }
}
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>(),
            };
        }
    }
}
// Demo "Tabelle › Ui › Grid › Zeilen umsortieren": die neue Reihenfolge im Browser auswerten.
//
// Nach dem Ablegen einer Zeile löst das Grid das Ereignis "gridrowmoved" an der Tabelle aus. Es
// steigt bis zum document auf, ein Listener dort genügt also.
//
// Im detail stehen:
//   rowIndex        Position nach dem Verschieben (0-basiert)
//   rowIndexBefore  Position vor dem Verschieben
//   element         das <tr> der verschobenen Zeile
//   row             die Zeilendaten von DataTables – bei einer im HTML gerenderten Tabelle ist das
//                   ein Array mit dem HTML je Zelle, nicht das Objekt der Datenquelle
//   tableId         Id der Tabelle
//   order           die aktive Sortierung von DataTables
//
// Die Datensatz-Id steht nicht im detail. Verlässlich kommt man an sie über die Zeile selbst:
// jede Zelle trägt ihren Spaltentitel in data-title, die Aktions-Schaltflächen tragen die Id in
// ihrer Adresse. Hier genügt der Kursname aus der Zelle.
document.addEventListener('gridrowmoved', function (event) {
    var detail = event.detail;

    if (detail.tableId !== 'kurse-umsortieren') {
        return;
    }

    var ausgabe = document.querySelector('[data-umsortieren-ausgabe]');

    if (ausgabe === null) {
        return;
    }

    var zelle = detail.element.querySelector('td[data-title="Kurs"]');
    var kurs = zelle === null ? 'Zeile' : zelle.textContent.trim();

    // Angezeigt wird 1-basiert – die Positionen im Ereignis sind 0-basiert.
    ausgabe.textContent = '«' + kurs + '» von Position ' + (detail.rowIndexBefore + 1)
        + ' auf ' + (detail.rowIndex + 1) + ' verschoben.';
});
// jQuery-Variante zum Beispiel «Zeilen umsortieren» – dasselbe wie im Tab "JavaScript", nur mit
// jQuery geschrieben. Diese Datei wird von der Demo-Seite NICHT geladen; sonst liefe der Listener
// doppelt. Sie ist als Vorlage zum Übernehmen gedacht.
$(document).on('gridrowmoved', '#kurse-umsortieren', function (event) {
    // jQuery 3 kopiert detail vom nativen Event auf sein eigenes Event-Objekt; bei älteren
    // Versionen führt der Weg über originalEvent.
    var detail = event.detail || event.originalEvent.detail;

    // $(detail.element) ist das <tr> der verschobenen Zeile; die Zelle findet man über den
    // Spaltentitel in data-title.
    var kurs = $(detail.element).find('td[data-title="Kurs"]').text().trim() || 'Zeile';

    // Angezeigt wird 1-basiert – die Positionen im Ereignis sind 0-basiert.
    $('[data-umsortieren-ausgabe]').text(
        '«' + kurs + '» von Position ' + (detail.rowIndexBefore + 1)
        + ' auf ' + (detail.rowIndex + 1) + ' verschoben.');
});

Darstellung

Kurs Datum Punkte
Grundkurs Notfallmedizin 14.01.2026 12.5
Refresher Innere Medizin 03.02.2026 8
Workshop Gesprächsführung 19.02.2026 4
Fortbildung Radiologie 07.03.2026 16
Seminar Qualitätssicherung 22.03.2026 6.5
Kongress Kardiologie 11.04.2026 20
Kurs Strahlenschutz 28.04.2026 9
Update Pädiatrie 15.05.2026 7.5
Fallseminar Onkologie 02.06.2026 11
Praxiskurs Sonografie 24.06.2026 14
Kurs Palliative Care 09.07.2026 10
Symposium Neurologie 18.08.2026 18

Noch keine Zeile verschoben.

.EnableRowDragAndDrop() stellt eine Anfasser-Spalte voran. Damit lassen sich Zeilen mit der Maus oder per Finger verschieben.

Das Grid verschiebt nur die Anzeige. Es speichert nichts und schickt nichts an den Server. Wer die neue Reihenfolge übernehmen will, wertet das Ereignis gridrowmoved aus und schickt sie selbst.

Die neue Reihenfolge im Browser auswerten

Das Ereignis wird an der Tabelle ausgelöst und steigt bis zum document auf. Im detail stehen:

Feld Inhalt
rowIndex Position nach dem Verschieben, 0-basiert.
rowIndexBefore Position davor.
element Das <tr> der verschobenen Zeile.
row Die Zeilendaten von DataTables — bei einer im HTML gerenderten Tabelle ein Array mit dem HTML je Zelle, nicht das Objekt der Datenquelle.
tableId Id der Tabelle.
order Die aktive Sortierung von DataTables.

Was zu beachten ist

  • Die Datensatz-Id steht nicht im detail. Verlässlich kommt man über die Zeile selbst an sie: Jede Zelle trägt ihren Spaltentitel in data-title, die Zeilen-Aktionen tragen die Id in ihrer Adresse. row mit festen Indizes auszulesen ist heikel — Anfasser- und Auswahlspalte verschieben die Nummerierung.
  • Sortierung und Handreihenfolge widersprechen sich. Wird nach einer Spalte sortiert, ordnet DataTables die Zeilen neu und die von Hand gesetzte Reihenfolge ist verloren. Entweder die Spalten nicht sortierbar machen oder beim Übernehmen detail.order prüfen.
  • Verschoben wird nur innerhalb der angezeigten Zeilen. Mit BindTo sind das alle — dieser Weg kennt keine Blätterleiste.