DokumentListe

Dokumentliste (Grundfall)

Tabelle der bereits zugeordneten Dokumente mit Download und Löschen, darunter die Ablagefläche für neue Dateien.

@model DokumentListeStandardBeispielViewModel

@* Ein Feld, zwei Bereiche: die Tabelle der bereits zugeordneten Dokumente und darunter die
   Ablagefläche. Anders als beim Template IFormFile braucht es hier kein zusätzliches
   EditorFor für eine Id-Liste — DokumentIds und DokumentLoeschenIds rendert das Template
   selbst als versteckte Felder. Ein umschliessendes <form> ist trotzdem Pflicht. *@

<form asp-area="FileUpload" asp-controller="DokumentListeStandardBeispiel" asp-action="Index" method="post">
    <div asp-validation-summary="All" class="text-danger"></div>
    @Html.EditorFor(m => m.KursBelege)
    @Html.Fmh(Fmh.Button()
        .Text("Absenden")
        .Size(ButtonSize.Sm)
        .Attributes(new { @class = "mt-3" }))
</form>
using System.ComponentModel.DataAnnotations;
using FMH.Komponente.Basis.Ui.Validation.DataAnnotation;
using FMH.Komponente.FileUpload.Models;
using FMH.Komponente.Infrastruktur;

namespace FMH.Komponente.WebUI.Areas.FileUpload.Models
{
    public class DokumentListeStandardBeispielViewModel
    {
        // Das Feld ist vom Typ DokumentListeViewModel — anders als beim Template IFormFile gehört
        // hier keine eigene Id-Liste ins ViewModel: DokumentIds und DokumentLoeschenIds stecken im
        // DokumentListeViewModel und werden vom Template selbst als versteckte Felder gerendert.
        [UIHint("DokumentListe")]
        [Display(Name = "Kursbelege <strong>hochladen</strong>")]
        [DisplaySupportsHtml]
        [HelpText(Name = "Erlaubt sind PDF- und JPG-Dateien bis 1 MB.")]
        [AdditionalMetadata(MvcConst.MetaDataUploadUrl, "/FileUpload/DokumentListeStandardBeispiel/Upload")]
        [AdditionalMetadata(MvcConst.MaxFileSizeInMb, "1")]
        [AdditionalMetadata(MvcConst.MaxFiles, "10")]
        [AdditionalMetadata(MvcConst.FilenameMaxLength, "50")]
        [AdditionalMetadata(MvcConst.MetaDataAllowedFiles, ".pdf, .jpg, .jpeg")]
        [AdditionalMetadata(MvcConst.MetaDataDownloadActionName, "Download")]
        [AdditionalMetadata(MvcConst.MetaDataDownloadControllerName, "DokumentListeStandardBeispiel")]
        public DokumentListeViewModel KursBelege { get; set; } = new DokumentListeViewModel();
    }
}
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using FMH.Komponente.Basis.Erweiterungen;
using FMH.Komponente.Basis.Ui.Feedback.Alert;
using FMH.Komponente.FileUpload.Models;
using FMH.Komponente.WebUI.Areas.FileUpload.Models;
using Microsoft.AspNetCore.Mvc;

namespace FMH.Komponente.WebUI.Areas.FileUpload.Controllers.Beispiele
{
    // Ein Beispiel, ein Controller: Upload-Endpunkt, Download-Endpunkt, Ablage und der
    // Formular-Round-Trip stehen vollständig in dieser Datei — der Grundfall lässt sich damit ohne
    // Sprünge nachlesen.
    [Area("FileUpload")]
    public class DokumentListeStandardBeispielController : Controller
    {
        // Ablage der Demo: In einem Projekt landen die Dateien in der Datenbank oder im
        // Dokumentenspeicher; hier genügt der Dateiname je vergebener Id. Statisch und damit
        // prozessweit, weil die Demo keine Sitzung kennt.
        private static readonly ConcurrentDictionary<int, string> Dokumente =
            new ConcurrentDictionary<int, string>(
                new Dictionary<int, string> { [5] = "Kurs.pdf", [6] = "Kurs2.pdf" });

        private static int letzteId = 100;

        // Baut das Modell für die Anzeige: die bereits zugeordneten Dokumente kommen als
        // DokumentEintrag-Liste in das DokumentListeViewModel.
        // Auch die Doku-Seite zeigt die gefüllte Liste — deshalb öffentlich.
        public static DokumentListeStandardBeispielViewModel ErstelleModell()
        {
            var modell = new DokumentListeStandardBeispielViewModel();

            modell.KursBelege.Dokumente = Dokumente
                .OrderBy(eintrag => eintrag.Key)
                .Select(eintrag => new DokumentEintrag
                                   {
                                       DokumentEintragId = eintrag.Key,
                                       Name = eintrag.Value,
                                       Groesse = 30000 + (eintrag.Key * 100),
                                   })
                .ToList();

            return modell;
        }

        [HttpGet]
        public IActionResult Index()
        {
            return this.View(ErstelleModell());
        }

        [HttpPost]
        [ValidateAntiForgeryToken]
        public IActionResult Index(DokumentListeStandardBeispielViewModel modell)
        {
            // Die Dateien sind zu diesem Zeitpunkt längst übertragen; das Formular bringt nur noch
            // die Ids mit: DokumentIds die neu hochgeladenen, DokumentLoeschenIds die zum Löschen
            // markierten. Das endgültige Löschen ist Sache dieser Action.
            // PRG-Pattern (Post-Redirect-Get): Meldung merken und per Redirect auf die GET-Action.
            this.AddAlert(
                $"Formular übertragen – {BeschreibeAenderungen(modell.KursBelege)}",
                AlertType.Success,
                autoHide: true);

            return this.RedirectToAction(nameof(this.Index));
        }

        /// <summary>
        /// Der Upload-Endpunkt dieses Beispiels; seine Adresse steht als
        /// <see cref="FMH.Komponente.Infrastruktur.MvcConst.MetaDataUploadUrl" /> am ViewModel.
        /// Die Dropzone schickt jede Datei einzeln hierher, sobald der Dialog bestätigt wurde.
        /// Antwort muss ein Json mit der Eigenschaft <c>id</c> sein — das Template legt daraus ein
        /// verstecktes Feld <c>DokumentIds</c> im Formular an. Fehlt die Id, lädt die Seite neu.
        /// </summary>
        /// <returns>Json mit der vergebenen Id.</returns>
        [HttpPost]
        public IActionResult Upload()
        {
            // Die Dropzone meldet sich mit diesem Kopfzeilen-Eintrag. In einem Projekt lässt sich
            // daran ein normaler Formular-Post von einem Dropzone-Upload unterscheiden.
            if (this.Request.Headers.ContainsKey("IsDropzoneRequest") == false || this.Request.Form.Files.Count == 0)
            {
                return this.Json(new { id = 0 });
            }

            var datei = this.Request.Form.Files[0];

            // Der im Dialog erfasste Dateiname kommt als eigenes Formularfeld mit, der Inhalt als Datei.
            var bezeichnung = this.Request.Form["DateiBezeichnung"].ToString();
            var name = string.IsNullOrWhiteSpace(bezeichnung) ? datei.FileName : bezeichnung;

            var id = Interlocked.Increment(ref letzteId);
            Dokumente[id] = name;

            return this.Json(new { id });
        }

        /// <summary>
        /// Der Download-Endpunkt: Action- und Controller-Name stehen als
        /// <see cref="FMH.Komponente.Infrastruktur.MvcConst.MetaDataDownloadActionName" /> bzw.
        /// <see cref="FMH.Komponente.Infrastruktur.MvcConst.MetaDataDownloadControllerName" /> am
        /// ViewModel; das Template baut daraus je Tabellenzeile einen Link mit der Dokument-Id.
        /// </summary>
        /// <param name="id">Die Id des Dokuments aus der Tabellenzeile.</param>
        /// <returns>Die Datei zum Herunterladen.</returns>
        [HttpGet]
        public IActionResult Download(int id)
        {
            if (Dokumente.TryGetValue(id, out var name) == false)
            {
                return this.NotFound();
            }

            // Die Demo speichert keine Inhalte, sondern gibt einen Platzhalter zurück – der Link in
            // der Tabelle soll trotzdem eine echte Datei liefern.
            var inhalt = Encoding.UTF8.GetBytes($"Platzhalter-Inhalt für «{name}» (Id {id}).");

            return this.File(inhalt, "text/plain", name);
        }

        // Nur für die Rückmeldung: macht aus den Ids wieder die Dateinamen.
        private static string BeschreibeAenderungen(DokumentListeViewModel liste)
        {
            var teile = new List<string>();

            if (liste.DokumentIds != null && liste.DokumentIds.Length > 0)
            {
                teile.Add($"neu: {string.Join(", ", liste.DokumentIds.Select(NameZuId))}");
            }

            if (liste.DokumentLoeschenIds != null && liste.DokumentLoeschenIds.Length > 0)
            {
                teile.Add($"zum Löschen markiert: {string.Join(", ", liste.DokumentLoeschenIds.Select(NameZuId))}");
            }

            return teile.Count == 0 ? "keine Änderungen." : string.Join(" | ", teile);
        }

        private static string NameZuId(int id)
        {
            return Dokumente.TryGetValue(id, out var name) ? name : $"Id {id}";
        }
    }
}

Darstellung

Description Size Actions
Kurs.pdf 29.79 KB
Kurs2.pdf 29.88 KB
attach_file

Drag files here or click here to upload.

report_problem

You can not upload any more files.

Erlaubt sind PDF- und JPG-Dateien bis 1 MB.

Das EditorTemplate DokumentListe verbindet zwei Dinge in einem Feld: eine Tabelle der bereits zugeordneten Dokumente (Download, Grösse, Löschen) und darunter eine Ablagefläche (Dropzone) für neue Dateien. Jede neue Datei geht sofort an einen Endpunkt der Anwendung — nicht erst beim Absenden des Formulars.

[UIHint("DokumentListe")]
[Display(Name = "Kursbelege <strong>hochladen</strong>")]
[DisplaySupportsHtml]
[HelpText("Erlaubt sind PDF- und JPG-Dateien bis 1 MB.")]
[AdditionalMetadata(MvcConst.MetaDataUploadUrl, "/FileUpload/DokumentListeStandardBeispiel/Upload")]
[AdditionalMetadata(MvcConst.MaxFileSizeInMb, "1")]
[AdditionalMetadata(MvcConst.MaxFiles, "10")]
[AdditionalMetadata(MvcConst.FilenameMaxLength, "50")]
[AdditionalMetadata(MvcConst.MetaDataAllowedFiles, ".pdf, .jpg, .jpeg")]
[AdditionalMetadata(MvcConst.MetaDataDownloadActionName, "Download")]
[AdditionalMetadata(MvcConst.MetaDataDownloadControllerName, "DokumentListeStandardBeispiel")]
public DokumentListeViewModel KursBelege { get; set; } = new DokumentListeViewModel();
<form asp-controller="DokumentListeStandardBeispiel" asp-action="Index" method="post">
    @Html.EditorFor(m => m.KursBelege)
</form>

Kein zusätzliches Id-Feld nötig

Anders als beim Template IFormFile gehört keine eigene [UIHint("HiddenList")]-Eigenschaft ins ViewModel: DokumentIds und DokumentLoeschenIds stecken bereits im DokumentListeViewModel, und das Template rendert sie selbst als versteckte Felder. Es genügt also die eine Eigenschaft vom Typ DokumentListeViewModel.

Die Eigenschaften des DokumentListeViewModel

Eigenschaft Richtung Bedeutung
Dokumente Controller → View Die bereits zugeordneten Dokumente (DokumentEintrag mit DokumentEintragId, Name, Groesse). Ist die Liste leer, entfällt die Tabelle.
DokumentIds View → Controller Die Ids der in dieser Sitzung hochgeladenen Dateien.
DokumentLoeschenIds View → Controller Die zum Löschen markierten Dokumente.
DokumenteUmbenennen View → Controller Umbenannte Dokumente; nur mit MetaDataRename befüllt.
DokumentTypen Controller → View Auswahl für den Dialog; nur mit DropdownListSelection wirksam.

Der Ablauf beim Hochladen

  1. Der Benutzer legt eine Datei ab. Ein Dialog fragt nach der Dateibezeichnung (und, wenn konfiguriert, nach dem Dokumenttyp).
  2. Nach dem Bestätigen überträgt die Dropzone die Datei einzeln an die Upload-Url. Die Anfrage trägt den Kopfzeilen-Eintrag IsDropzoneRequest: true, die Datei liegt in Request.Form.Files[0], die erfasste Bezeichnung im Formularfeld DateiBezeichnung.
  3. Der Endpunkt speichert die Datei und antwortet mit Json, das eine Eigenschaft id enthält. Das Template legt daraufhin ein verstecktes Feld mit dieser Id ins Formular.
  4. Beim Absenden kommen nur noch diese Ids beim Server an.

Fehlt die id in der Antwort, meldet das Skript eine Warnung ins Log und lädt die Seite neu — ein stiller Datenverlust wird so vermieden.

Optionen am Feld

Attribut Wirkung
[UIHint("DokumentListe")] Wählt das Template. Zwingend, da der Typ DokumentListeViewModel allein keine Zuordnung ergibt.
[Display(Name = "…")] Beschriftung über der Ablagefläche.
[DisplaySupportsHtml] Lässt HTML in der Beschriftung zu (z. B. <strong>).
[HelpText("…")] Hilfetext unterhalb der Ablagefläche.
[AdditionalMetadata(MvcConst.MetaDataUploadUrl, "…")] Adresse des Upload-Endpunkts. Standard upload.
[AdditionalMetadata(MvcConst.MaxFileSizeInMb, "1")] Maximale Grösse je Datei in MB.
[AdditionalMetadata(MvcConst.MaxFiles, "10")] Maximale Anzahl Dokumente insgesamt; Standard 100.
[AdditionalMetadata(MvcConst.MetaDataAllowedFiles, ".pdf, .jpg")] Erlaubte Endungen. Standard .pdf, .jpg, .jpeg, .png, .docx, .msg.
[AdditionalMetadata(MvcConst.FilenameMaxLength, "50")] Maximale Länge der Dateibezeichnung im Dialog; Standard 150.
[AdditionalMetadata(MvcConst.MetaDataDownloadActionName, "Download")] Action des Download-Endpunkts. Standard download.
[AdditionalMetadata(MvcConst.MetaDataDownloadControllerName, "…")] Controller des Download-Endpunkts. Standard dokument.
[AdditionalMetadata(MvcConst.MetaDataRename, "true")] Ergänzt je Zeile das Umbenennen (siehe Beispiel «Dokumente bearbeiten»).
[AdditionalMetadata(MvcConst.MetaDataBulkDelete, "true")] Ergänzt Auswahlspalte und Sammel-Löschen.
[AdditionalMetadata(MvcConst.DropdownListSelection, "DokumentTypen")] Ergänzt den Dialog um die Auswahl des Dokumenttyps.
[DocumentUploadRequired] Macht mindestens ein Dokument zur Pflicht (siehe Beispiel «Pflicht-Upload»).
[UniqueId] Hängt ein Zufalls-Suffix an die Html-Id, falls dasselbe Formular mehrfach gerendert wird.

Upload-Url: relativ oder absolut

Enthält der Wert keinen Schrägstrich, setzt das Template die Adresse aus der aktuellen Route zusammen: /<Area>/<Controller>/<Wert>. Mit einem Schrägstrich im Wert gilt die Angabe unverändert — so lässt sich ein Endpunkt in einem anderen Controller ansprechen (wie in dieser Demo).

Die Grössenbeschränkung wird zusätzlich an der Server-Grenze gekappt: Das Template liest KestrelServerOptions.Limits.MaxRequestBodySize und nimmt davon 2 MB Reserve. Eine grössere Angabe im Attribut wird auf diesen Wert reduziert — die Prüfung im Browser meldet also nie mehr, als der Server annimmt.

Das Template baut den Link je Tabellenzeile aus Action- und Controller-Namen plus der DokumentEintragId als Routenwert id. Der Link entsteht ohne Area-Angabe und erbt damit die Area der aktuellen Seite — der Download-Endpunkt muss also in derselben Area liegen. Für Routen, die sich so nicht abbilden lassen, gibt es die Vorlage dokumentDownloadUrl (siehe Beispiel «Download-Url zur Laufzeit»).

Was zu beachten ist

  • Ein Formular ist Pflicht. Das Skript hängt die versteckten Id-Felder an form — ohne umschliessendes <form> gehen die Ids verloren.
  • Die Ids werden an alle Formulare der Seite angehängt. Ein Formular je Seite ist der sichere Aufbau; mehrere Felder in einem Formular sind dagegen unproblematisch.
  • Während eines laufenden Uploads sperrt das Skript die Schaltflächen des Formulars und gibt sie danach wieder frei.
  • Die Prüfung von Grösse, Anzahl und Dateityp läuft im Browser. Der Endpunkt muss dieselben Regeln serverseitig noch einmal anwenden — er ist von aussen direkt erreichbar.
  • Das Löschen in der Tabelle ist nur eine Markierung; ausgeführt wird es in der POST-Action.