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
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
- Der Benutzer legt eine Datei ab. Ein Dialog fragt nach der Dateibezeichnung (und, wenn konfiguriert, nach dem Dokumenttyp).
- 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 inRequest.Form.Files[0], die erfasste Bezeichnung im FormularfeldDateiBezeichnung. - Der Endpunkt speichert die Datei und antwortet mit Json, das eine Eigenschaft
identhält. Das Template legt daraufhin ein verstecktes Feld mit dieser Id ins Formular. - 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.
Der Download-Link
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.