Tabelle

Unterzeilen offen halten

Zwei Einstellungen am Aufklappen: die erste Unterzeile mit Inhalt beim Laden öffnen und geöffnete Unterzeilen offen lassen, statt sie gegenseitig zu schliessen.

@model IReadOnlyList<KursZeile>

@* Zwei Einstellungen zum Aufklapp-Verhalten, beide an der ChildFor-Spalte:
   OpenFirstChildRowWithContent öffnet beim Laden die oberste Zeile, sofern sie Unterzeilen hat –
   massgebend ist die Sortierung beim Laden, hier «Kurs» aufsteigend.
   ExpandOpenChildRows(false) lässt bereits geöffnete Unterzeilen offen, wenn eine weitere
   aufgeklappt wird – ohne die Angabe schliesst das Grid die anderen. *@
@Html.Fmh(Fmh<KursZeile>.Grid
    .Id("kurse-unterzeilen-offen")
    .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();

        spalten.ChildFor(
                IconType.InsertDriveFile,
                x => x.Dokumente,
                x => Fmh<KursDokument>.Grid
                    .BindTo(x.Dokumente)
                    .Columns(unterspalten =>
                    {
                        unterspalten.For(dokument => dokument.Dateiname)
                            .Named("Bezeichnung");

                        unterspalten.For(dokument => dokument.Dateigroesse)
                            .Named("Grösse")
                            .Format(GridValueFormat.DataSize);
                    }))
            .OpenFirstChildRowWithContent()
            .ExpandOpenChildRows(false);

        spalten.Button(x => FmhGrid.ViewButton("Ansehen", "TabelleBeispiel", new { area = "Tabelle", id = x.Id }));
    }))
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);
        }
    }
}

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

Zwei Einstellungen zum Aufklapp-Verhalten, beide an der ChildFor-Spalte:

Aufruf Wirkung
.OpenFirstChildRowWithContent() Öffnet beim Laden die oberste Zeile, sofern sie Unterzeilen hat.
.ExpandOpenChildRows(false) Lässt geöffnete Unterzeilen offen, wenn eine weitere aufgeklappt wird.

Das Standardverhalten ist das umgekehrte: Beim Laden ist alles zu, und beim Öffnen einer Unterzeile schliessen sich die anderen.

Was zu beachten ist

  • OpenFirstChildRowWithContent wirkt nicht auf schmalen Geräten. Dort öffnet die Unterzeile in einem Dialog, und ein Dialog beim Seitenaufbau wäre kein sinnvoller Einstieg.
  • Geöffnet wird die erste Zeile der Tabelle — und nur, wenn sie Unterzeilen hat. Massgebend ist die Zeile, die nach der beim Laden aktiven Sortierung zuoberst steht; ohne SortBy ist das die erste Spalte aufsteigend. Steht dort eine Zeile ohne Inhalt, bleibt beim Laden alles zu.