Tabelle

Unterzeilen

ChildFor macht aus einer Liste in der Zeile eine aufklappbare Untertabelle – hier ein zweites Grid mit den Dokumenten des Kurses, inklusive Umbenennen-Dialog.

@model IReadOnlyList<KursZeile>

@* ChildFor macht aus einer Liste in der Zeile eine aufklappbare Unterzeile. Die Spalte zeigt eine
   Schaltfläche, dahinter steckt ein zweites Grid mit den Dokumenten des Kurses.
   Kurse ohne Dokumente bekommen keine Schaltfläche, sondern das Zeichen für leere Zellen. *@
@Html.Fmh(Fmh<KursZeile>.Grid
    .Id("kurse-unterzeilen")
    .BindTo(Model)
    .Columns(spalten =>
    {
        spalten.For(x => x.Bezeichnung)
            .Named("Kurs")
            .IsSortable();

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

        @* Das innere Grid bekommt bewusst KEIN eigenes Id: Der Builder vergibt dann je Zeile eine
           eindeutige Id. Eine feste Id wäre in jeder Zeile dieselbe – das Skript findet die
           Untertabelle über ihre Id und würde immer nur die erste bedienen. *@
        spalten.ChildFor(
                IconType.InsertDriveFile,
                x => x.Dokumente,
                x => Fmh<KursDokument>.Grid
                    .BindTo(x.Dokumente)
                    .Columns(unterspalten =>
                    {
                        unterspalten.Custom(
                            dokument => Html.ActionLink(
                                dokument.Dateiname,
                                "Dokument",
                                "TabelleBeispiel",
                                new { area = "Tabelle", id = dokument.Id },
                                new { @class = "fmh-link" }),
                            "Bezeichnung");

                        @* DataSize rechnet die Bytes in eine lesbare Grösse um. *@
                        unterspalten.For(dokument => dokument.Dateigroesse)
                            .Named("Grösse")
                            .Format(GridValueFormat.DataSize);

                        @* Umbenennen öffnet einen Dialog und schreibt den neuen Namen per Ajax.
                           Der letzte Parameter ist der Titel der Spalte, in der der Dateiname
                           steht – darüber findet der Dialog den aktuellen Wert. *@
                        unterspalten.Button(
                            dokument => FmhGrid.RenameDocumentButton(
                                "DokumentUmbenennen",
                                "TabelleBeispiel",
                                new { area = "Tabelle", id = dokument.Id },
                                "Bezeichnung"));
                    }))
            @* Ohne eigenen Tooltip steht dort «<Anzahl> <Spaltenname>». *@
            .Tooltip(x => $"{x.AnzahlDokumente} Dokumente zu «{x.Bezeichnung}»");

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

<p class="small mt-3 mb-0" data-unterzeilen-ausgabe>Noch keine Unterzeile geöffnet.</p>

@* Der Listener steht im Tab «JavaScript» (wwwroot/js/demos/tabelle-unterzeilen.js). *@
<script src="~/js/demos/tabelle-unterzeilen.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>
    /// Ein Dokument zu einem Kurs — die Zeile der aufklappbaren Untertabelle.
    /// </summary>
    public class KursDokument
    {
        public int Id { get; set; }

        public string Dateiname { get; set; }

        /// <summary>
        /// Grösse in Bytes. Bewusst <c>int</c>: <c>GridValueFormat.DataSize</c> castet den Wert hart
        /// auf <c>int</c>, ein <c>long</c> würde zur Laufzeit scheitern.
        /// </summary>
        public int Dateigroesse { 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.Linq;
using FMH.Komponente.Basis.Erweiterungen;
using FMH.Komponente.Basis.Ui.Feedback.Alert;
using FMH.Komponente.WebUI.Areas.Tabelle.Models;
using Microsoft.AspNetCore.Mvc;

namespace FMH.Komponente.WebUI.Areas.Tabelle.Controllers.Beispiele
{
    // Die Ziele der Zeilen-Aktionen. In einem Projekt stehen hier die fachlichen Endpunkte; für die
    // Demo genügt eine Rückmeldung, aus der hervorgeht, welche Zeile und welche Aktion es war.
    //
    // Welche HTTP-Methode eine Aktion braucht, bestimmt der Schaltflächen-Typ:
    // Ansehen/Bearbeiten/Custom/Toggle rendern einen Link (GET), Entfernen und Dialog laufen über
    // den Bestätigungsdialog des Grids und kommen als POST an.
    [Area("Tabelle")]
    public class TabelleBeispielController : Controller
    {
        public IActionResult Anbieter(int id)
        {
            return this.Zurueck(id, "Anbieter angezeigt");
        }

        public IActionResult Ansehen(int id)
        {
            return this.Zurueck(id, "Angesehen");
        }

        [HttpPost]
        public IActionResult Auswahl(int[] id)
        {
            // Das Auswahlkästchen postet unter dem Namen der Spalte aus BulkEditCheckbox — hier
            // also "Id". Ohne Auswahl kommt gar kein Wert an, das Feld bleibt leer.
            if (id == null || id.Length == 0)
            {
                this.AddAlert("Kein Kurs ausgewählt.", AlertType.Warning, autoHide: true);

                return this.RedirectToAction("Mehrfachauswahl", "Demo", new { area = "Tabelle" });
            }

            var bezeichnungen = id.Select(
                    kursId => KursDaten.Laden(kursId)
                        ?.Bezeichnung ?? $"Id {kursId}")
                .ToArray();

            this.AddAlert($"{bezeichnungen.Length} ausgewählt: {string.Join(", ", bezeichnungen)}", AlertType.Info, autoHide: true);

            return this.RedirectToAction("Mehrfachauswahl", "Demo", new { area = "Tabelle" });
        }

        public IActionResult Bearbeiten(int id)
        {
            return this.Zurueck(id, "Bearbeiten geöffnet");
        }

        /// <summary>
        /// Ziel des Umschalters. Der aktuelle Zustand kommt als Parameter mit — das Grid hängt ihn
        /// unter dem Namen «is» + Eigenschaft aus <c>ToggleStatus(...)</c> an die Adresse an.
        /// </summary>
        public IActionResult Bestanden(int id, bool isBestanden)
        {
            var neu = KursDaten.Umschalten(id);
            var zeile = KursDaten.Laden(id);
            var bezeichnung = zeile?.Bezeichnung ?? $"Id {id}";

            this.AddAlert(
                $"{bezeichnung}: {(isBestanden ? "war bestanden" : "war offen")} → {(neu ? "bestanden" : "offen")}",
                AlertType.Info,
                autoHide: true);

            return this.Herkunft("Umschalten");
        }

        [HttpPost]
        public IActionResult Dialog(int id)
        {
            return this.Zurueck(id, "Im Dialog bestätigt");
        }

        public IActionResult Dokument(int id)
        {
            var dokument = KursDaten.LadeDokument(id);

            this.AddAlert($"Dokument geöffnet: {dokument?.Dateiname ?? $"Id {id}"}", AlertType.Info, autoHide: true);

            return this.Herkunft("Unterzeilen");
        }

        /// <summary>
        /// Ziel des Umbenennen-Dialogs. Die Schaltfläche aus <c>FmhGrid.RenameDocumentButton</c>
        /// arbeitet fest über Ajax — die Antwort muss deshalb JSON sein, eine Weiterleitung
        /// verarbeitet der Dialog nicht.
        /// </summary>
        [HttpPost]
        public IActionResult DokumentUmbenennen(int id, string dateiname)
        {
            var dokument = KursDaten.BenenneDokumentUm(id, dateiname);

            if (dokument == null)
            {
                // Auch der Fehlerfall geht als JSON zurück: Der Dialog liest responseJSON aus.
                return this.BadRequest("Das Dokument konnte nicht umbenannt werden.");
            }

            return this.Json($"Dokument umbenannt: {dokument.Dateiname}");
        }

        [HttpPost]
        public IActionResult Entfernen(int id)
        {
            // Nur Rückmeldung: Die Demo-Daten sind statisch und werden nicht verändert.
            return this.Zurueck(id, "Entfernen ausgelöst");
        }

        /// <summary>
        /// Ziel des umschliessenden Formulars im Beispiel «Grid in einem Formular». Zeigt, dass die
        /// Eingaben des Formulars ankommen – die Schaltfläche der Zeile schickt dasselbe Formular.
        /// </summary>
        [HttpPost]
        public IActionResult FormularSpeichern(string bemerkung)
        {
            this.AddAlert($"Formular abgeschickt. Bemerkung: «{bemerkung}»", AlertType.Info, autoHide: true);

            return this.Herkunft("Formular");
        }

        /// <summary>
        /// Ziel der Zeilen-Schaltfläche mit <c>RenderAsFormAction</c>. Sie ist ein Submit-Button mit
        /// formaction: Es kommt die Zeilen-Id aus der Adresse an und zusätzlich der Inhalt des
        /// Formulars – anders als bei einer Zeilen-Aktion, die als Link gerendert wird.
        /// </summary>
        [HttpPost]
        public IActionResult FormularZeile(int id, string bemerkung)
        {
            var zeile = KursDaten.Laden(id);
            var bezeichnung = zeile?.Bezeichnung ?? $"Id {id}";

            this.AddAlert($"Zeile «{bezeichnung}» abgeschickt. Bemerkung: «{bemerkung}»", AlertType.Info, autoHide: true);

            return this.Herkunft("Formular");
        }

        private IActionResult Zurueck(int id, string aktion)
        {
            var zeile = KursDaten.Laden(id);
            var bezeichnung = zeile?.Bezeichnung ?? $"Id {id}";

            this.AddAlert($"{aktion}: {bezeichnung}", AlertType.Info, autoHide: true);

            return this.Herkunft("Index");
        }

        // Zurück auf die Seite, von der die Aktion kam – sonst landet man auf einer leeren Seite.
        private IActionResult Herkunft(string ersatzAktion)
        {
            var herkunft = this.Request.Headers.Referer.ToString();

            if (string.IsNullOrEmpty(herkunft))
            {
                return this.RedirectToAction(ersatzAktion, "Demo", new { area = "Tabelle" });
            }

            return this.Redirect(herkunft);
        }
    }
}
// Demo "Tabelle › Ui › Grid › Unterzeilen": Auswertung des Aufklapp-Ereignisses.
//
// Jeder Klick auf die Aufklapp-Schaltfläche löst das Ereignis "childrowclick" aus. Es steigt bis
// zum document auf, ein Listener dort genügt also auch für Zeilen, die erst beim Blättern in den
// DOM kommen. Das Ereignis meldet sowohl das Öffnen als auch das Schliessen.
//
// Im detail stehen:
//   id            Id der äusseren Tabelle, mit vorangestelltem "#"
//   childTableId  Id der Untertabelle – je Zeile eine andere, weil das innere Grid keine feste Id hat
//   rowIndex      Position der Zeile in der aktuellen Ansicht
//   row           Zeilendaten; bei serverseitig gerenderten Tabellen undefined
document.addEventListener('childrowclick', function (event) {
    var detail = event.detail;

    if (detail.id !== '#kurse-unterzeilen') {
        return;
    }

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

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

    ausgabe.textContent = 'Unterzeile in Zeile ' + detail.rowIndex + ' von ' + detail.id
        + ' – Untertabelle ' + detail.childTableId;
});
// jQuery-Variante zum Beispiel «Unterzeilen» – 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('childrowclick', 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;

    if (detail.id !== '#kurse-unterzeilen') {
        return;
    }

    $('[data-unterzeilen-ausgabe]').text(
        'Unterzeile in Zeile ' + detail.rowIndex + ' von ' + detail.id
        + ' – Untertabelle ' + detail.childTableId);
});

Darstellung

Kurs Datum Dokumente Actions
Grundkurs Notfallmedizin 14.01.2026
Refresher Innere Medizin 03.02.2026 -
Workshop Gesprächsführung 19.02.2026
Fortbildung Radiologie 07.03.2026 -
Seminar Qualitätssicherung 22.03.2026 -
Kongress Kardiologie 11.04.2026
Kurs Strahlenschutz 28.04.2026 -
Update Pädiatrie 15.05.2026
Fallseminar Onkologie 02.06.2026
Praxiskurs Sonografie 24.06.2026 -
Kurs Palliative Care 09.07.2026 -
Symposium Neurologie 18.08.2026

Noch keine Unterzeile geöffnet.

ChildFor macht aus einer Liste in der Zeile eine aufklappbare Unterzeile. Der letzte Parameter liefert deren Inhalt — hier ein zweites Grid:

spalten.ChildFor(
        IconType.InsertDriveFile,
        x => x.Dokumente,
        x => Fmh<KursDokument>.Grid
            .BindTo(x.Dokumente)
            .Columns(unterspalten => { … }))
    .Tooltip(x => $"{x.AnzahlDokumente} Dokumente zu «{x.Bezeichnung}»");

Was zu beachten ist

  • Dem inneren Grid kein festes .Id(...) geben. Ohne Angabe vergibt der Builder je Zeile eine eindeutige Id. Eine feste Id wäre in jeder Zeile dieselbe; das Skript sucht die Untertabelle über ihre Id und bediente immer nur die erste.
  • Leere Liste = keine Schaltfläche. Zeilen ohne Einträge zeigen stattdessen das Zeichen aus EmptyColumnChar. Das ist auch der Grund, weshalb ChildFor mit einer Sammlung arbeiten muss und nicht mit einem einzelnen Wert.
  • Der Tooltip ist optional. Ohne .Tooltip(...) steht dort «Anzahl Spaltenname».
  • Auf schmalen Geräten öffnet die Unterzeile nicht in der Tabelle, sondern in einem Dialog.

Umbenennen

FmhGrid.RenameDocumentButton(action, controller, routeValues, spaltentitel) öffnet einen Dialog mit dem aktuellen Namen. Der letzte Parameter ist der Titel der Spalte, in der der Name steht — darüber findet der Dialog den Wert in der Zeile; er muss also genau mit Named(...) übereinstimmen.

Diese Schaltfläche arbeitet immer über Ajax, unabhängig von der Art der Datenbindung. Der Endpunkt muss deshalb JSON zurückgeben; eine Weiterleitung verarbeitet der Dialog nicht:

[HttpPost]
public IActionResult DokumentUmbenennen(int id, string dateiname)
{
    …
    return this.Json($"Dokument umbenannt: {dokument.Dateiname}");
}

Der neue Name kommt als Abfrageparameter dateiname an. Im Fehlerfall gehört die Meldung ebenfalls als JSON zurück (BadRequest("…")) — der Dialog liest sie aus responseJSON.

Aufklappen im Browser auswerten

Jeder Klick auf die Aufklapp-Schaltfläche löst das Ereignis childrowclick aus — sowohl beim Öffnen als auch beim Schliessen:

Feld in detail Bedeutung
id Id der äusseren Tabelle, mit vorangestelltem #
childTableId Id der Untertabelle — je Zeile eine andere
rowIndex Position der Zeile in der aktuellen Ansicht
row Zeilendaten; bei serverseitig gerenderten Tabellen undefined