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 => 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 indata-title, die Zeilen-Aktionen tragen die Id in ihrer Adresse.rowmit 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.orderprüfen. - Verschoben wird nur innerhalb der angezeigten Zeilen. Mit
BindTosind das alle — dieser Weg kennt keine Blätterleiste.