feat(ui): constrain decimal input and add stacked timed toasts with uniform actions
This commit is contained in:
@@ -49,3 +49,13 @@ Prüfung: 40/40 Tests in Debug und Release. Verzögerte normale Buchung und Umbu
|
||||
## Abschluss und Grenzen
|
||||
|
||||
Desktop-Projekte in Debug und Release gebaut, jeweils ohne Warnungen und Fehler. PDF-Inhalte und gerenderte Seiten sind geprüft. Die neue Windows-Debug-Anwendung wurde gestartet und ihr Accessibility-Baum mit den neuen Beschriftungen gelesen. **Die native visuelle/Interaktionsprüfung konnte nicht abgeschlossen werden: Windows war gesperrt; die Aufnahme zeigte den Sperrbildschirm.** Es wurden dort keine Buchungen angelegt oder Sicherungen zurückgespielt. Desktop-Sichtprüfung der umbrechenden Leisten, native Dateidialoge und reale Hilfstechnologien bleiben manuelle Abnahmepunkte. Die automatisierten Formular-/Navigationsprüfungen ersetzen keinen Test mit der Zielgruppe und keine vollständige U08-/U13-Abnahme.
|
||||
|
||||
## Nacharbeit vom 2. November – Eingabe, Toasts und Aktionsleiste
|
||||
|
||||
Beträge verwenden ein gemeinsames Control: Browser-beforeinput/Paste und die Komponentenbindung weisen mehr als zwei Dezimalstellen vollständig ab. Kein Kürzen oder Runden; der vorherige Wert bleibt erhalten. Der dauerhafte Nachkommastellen-Kommentar entfällt. Fortschritt, Dateiergebnisse und Speichermeldungen stehen in einem globalen Toast-Stapel oben rechts. Laufende Vorgänge bleiben bis zum Abschluss sichtbar; Ergebnisse laufen nach 9 Sekunden, Fehler nach 15 Sekunden ab. Hover/Fokus pausiert, Schließen bleibt möglich. Feldbezogene Validierung bleibt am Feld. Aktionsbuttons haben feste gemeinsame Höhe/Breite mit reservierter Symbolzeile und zwei Textzeilen.
|
||||
|
||||
Prüfung: 42/42 Debug-Tests, Desktop-Build ohne Warnungen. Computer Use in separatem Debug-Datenordner: Übersicht und Konto geprüft; Eingabe `12,501` vollständig abgewiesen, `12,50` angenommen, künstliche Einnahme gespeichert, Kontostand von 88,30 auf 100,80 € erhöht und Toast oben rechts beobachtet. Stapeln, unabhängiges Ablaufen und Pausieren sind automatisiert geprüft. Keine Vereinsdaten geändert. Der zusätzliche Debug-Startparameter `--data-directory` isoliert Datenbank, Vereinsname und Fensterkonfiguration; Release erhält ihn nicht.
|
||||
|
||||
## Weiterer Umsetzungsplan
|
||||
|
||||
U08: gemeinsame native Modaldialoge mit Fokus/Escape/Verlustschutz, semantische Kontolinks. U09: lesbare Zeilenaktionen mit größeren Treffern. U10: Löschstatus, Bestätigung beider Umbuchungsseiten und gezieltes Rückgängig. U11: Richtung und Auswirkungen von beiden Seiten identisch erklären. U12: bestehende gemeinsame Anfangsbestands-Semantik beibehalten und Korrektur vorab erklären/bestätigen. Danach U13: skalierbare lesbare Darstellung/Kontrast; U14: lokale Hilfe und überspringbare Einführung; U15: Suche und Sortierung mit getrennten Treffermengen; U16: lokale Darstellungsressourcen und sichere Fehlerauswege. Nach jeder Maßnahme bauen und testen; Paketgrenzen pushen und PR aktualisieren.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<input id="@Id" type="text" inputmode="decimal" class="form-control" data-amount-guard
|
||||
data-allow-negative="@AllowNegative.ToString().ToLowerInvariant()"
|
||||
@bind:get="Value" @bind:set="SetValue" @bind:event="oninput" disabled="@Disabled"
|
||||
aria-describedby="@(Id + "-rejected")" />
|
||||
<span id="@(Id + "-rejected")" class="input-rejection text-danger" role="status">@rejection</span>
|
||||
|
||||
@code {
|
||||
[Parameter] public string Id { get; set; } = "amount";
|
||||
[Parameter] public string Value { get; set; } = string.Empty;
|
||||
[Parameter] public EventCallback<string> ValueChanged { get; set; }
|
||||
[Parameter] public bool Disabled { get; set; }
|
||||
[Parameter] public bool AllowNegative { get; set; }
|
||||
private string? rejection;
|
||||
private async Task SetValue(string value)
|
||||
{
|
||||
if (!BookingInput.IsAmountDraft(value, AllowNegative))
|
||||
{
|
||||
rejection = "Diese Eingabe wurde nicht übernommen. Bitte einen Betrag wie 12,50 eingeben.";
|
||||
return;
|
||||
}
|
||||
rejection = null;
|
||||
await ValueChanged.InvokeAsync(value);
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
@inject ToastService Toasts
|
||||
@inject IEntryService EntryService
|
||||
|
||||
<div class="dialog-backdrop">
|
||||
@@ -36,8 +37,8 @@
|
||||
|
||||
<label class="form-label" for="entry-amount">Betrag (€)</label>
|
||||
<div>
|
||||
<input id="entry-amount" type="text" inputmode="decimal" class="form-control" @bind="amountText" @bind:event="oninput" placeholder="12,50" aria-describedby="entry-amount-help" aria-invalid="@(!ValidAmount ? "true" : "false")" />
|
||||
<p id="entry-amount-help" class="@(!ValidAmount ? "text-danger" : "text-muted")">Betrag ohne Minuszeichen, größer als 0. Komma oder Punkt, höchstens zwei Nachkommastellen. Keine Tausendertrenner oder Währungszeichen, auch beim Einfügen. Es wird nicht gerundet.</p>
|
||||
<AmountInput Id="entry-amount" @bind-Value="amountText" />
|
||||
<p class="text-muted">Betrag ohne Minuszeichen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,13 +51,6 @@
|
||||
}
|
||||
|
||||
</fieldset>
|
||||
@if (isSaving) { <p role="status" aria-live="polite">Wird gespeichert …</p> }
|
||||
@if (saveError != null)
|
||||
{
|
||||
<div class="alert alert-danger" role="alert">@saveError
|
||||
<details><summary>Technische Details</summary>@saveDetails</details>
|
||||
</div>
|
||||
}
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button type="submit" class="btn btn-success" disabled="@(isSaving || completed || (committedResult == null && !CanSave))">
|
||||
@(isSaving ? "Wird gespeichert …" : committedResult != null ? "Ansicht aktualisieren" : "Speichern")
|
||||
|
||||
@@ -73,6 +73,7 @@ public partial class AddEntryDialog
|
||||
private async Task Save()
|
||||
{
|
||||
if (isSaving || completed || (committedResult == null && !CanSave)) return;
|
||||
var busyToast = Toasts.Show("Wird gespeichert …", persistent: true);
|
||||
isSaving = true;
|
||||
saveError = null;
|
||||
saveDetails = null;
|
||||
@@ -98,8 +99,9 @@ public partial class AddEntryDialog
|
||||
? "Speichern fehlgeschlagen. Die Eingaben bleiben erhalten. Bitte erneut versuchen."
|
||||
: "Die Buchung ist gespeichert. Die Ansicht konnte nicht aktualisiert werden. Bitte Ansicht aktualisieren; es wird keine weitere Buchung erzeugt.";
|
||||
saveDetails = ex.Message;
|
||||
Toasts.Show(saveError, Duempelkas.App.Services.ToastKind.Error, saveDetails);
|
||||
}
|
||||
finally { isSaving = false; }
|
||||
finally { isSaving = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private async Task Cancel()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@inject ToastService Toasts
|
||||
@inject IEntryService EntryService
|
||||
@inject IAccountService AccountService
|
||||
|
||||
@@ -26,8 +27,8 @@
|
||||
<input id="transfer-title" type="text" class="form-control" @bind="title" @bind:event="oninput" placeholder="Beschreibung der Umbuchung" />
|
||||
|
||||
<label class="form-label">Betrag (€)</label>
|
||||
<div><input id="transfer-amount" type="text" inputmode="decimal" class="form-control" @bind="amountText" @bind:event="oninput" aria-describedby="transfer-amount-help" />
|
||||
<p id="transfer-amount-help" class="@(!ValidAmount ? "text-danger" : "text-muted")">Betrag ohne Minuszeichen, größer als 0; Komma oder Punkt, höchstens zwei Nachkommastellen. Keine Tausendertrenner oder Währungszeichen; keine Rundung.</p></div>
|
||||
<div><AmountInput Id="transfer-amount" @bind-Value="amountText" />
|
||||
<p class="text-muted">Betrag ohne Minuszeichen.</p></div>
|
||||
</div>
|
||||
|
||||
@if (OutsideYear)
|
||||
@@ -39,13 +40,6 @@
|
||||
}
|
||||
|
||||
</fieldset>
|
||||
@if (isSaving) { <p role="status" aria-live="polite">Wird gespeichert …</p> }
|
||||
@if (saveError != null)
|
||||
{
|
||||
<div class="alert alert-danger" role="alert">@saveError
|
||||
<details><summary>Technische Details</summary>@saveDetails</details>
|
||||
</div>
|
||||
}
|
||||
<div class="d-flex justify-content-end gap-2">
|
||||
<button type="submit" class="btn btn-success" disabled="@(isSaving || completed || (committedResult == null && !CanSave))">
|
||||
@(isSaving ? "Wird gespeichert …" : committedResult != null ? "Ansicht aktualisieren" : "Speichern")
|
||||
|
||||
@@ -81,6 +81,7 @@ public partial class AddTransferDialog
|
||||
private async Task Save()
|
||||
{
|
||||
if (isSaving || completed || (committedResult == null && !CanSave)) return;
|
||||
var busyToast = Toasts.Show("Wird gespeichert …", persistent: true);
|
||||
isSaving = true;
|
||||
saveError = null;
|
||||
saveDetails = null;
|
||||
@@ -106,8 +107,9 @@ public partial class AddTransferDialog
|
||||
? "Speichern fehlgeschlagen. Die Eingaben bleiben erhalten. Bitte erneut versuchen."
|
||||
: "Die Buchung ist gespeichert. Die Ansicht konnte nicht aktualisiert werden. Bitte Ansicht aktualisieren; es wird keine weitere Buchung erzeugt.";
|
||||
saveDetails = ex.Message;
|
||||
Toasts.Show(saveError, Duempelkas.App.Services.ToastKind.Error, saveDetails);
|
||||
}
|
||||
finally { isSaving = false; }
|
||||
finally { isSaving = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private async Task Cancel()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
@inherits LayoutComponentBase
|
||||
|
||||
<ToastHost />
|
||||
|
||||
<div class="d-flex flex-column vh-100">
|
||||
<nav class="app-navbar d-flex justify-content-between align-items-center">
|
||||
<a class="navbar-brand" href="">
|
||||
|
||||
@@ -1,40 +1,19 @@
|
||||
@inject ToastService Toasts
|
||||
@inject IFileSaveService FileSaveService
|
||||
|
||||
@if (Result != null)
|
||||
{
|
||||
<div class="alert @(Result.Status == FileOperationStatus.Error ? "alert-danger" : Result.Status == FileOperationStatus.Success ? "alert-success" : "alert-info")" role="status" aria-live="polite" aria-atomic="true">
|
||||
<p>@Result.Message</p>
|
||||
@if (Result.Path != null)
|
||||
{
|
||||
<p class="text-break">@Result.Path</p>
|
||||
<button class="btn btn-outline-secondary" disabled="@busy" @onclick="OpenFolder">Ordner öffnen</button>
|
||||
}
|
||||
@if (Result.Details != null)
|
||||
{
|
||||
<details><summary>Technische Details</summary><p class="text-break">@Result.Details</p></details>
|
||||
}
|
||||
@if (openResult != null)
|
||||
{
|
||||
<p role="alert">@openResult.Message</p>
|
||||
@if (openResult.Details != null) { <details><summary>Technische Details</summary>@openResult.Details</details> }
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter] public FileOperationResult? Result { get; set; }
|
||||
private FileOperationResult? openResult;
|
||||
private FileOperationResult? previous;
|
||||
private bool busy;
|
||||
protected override void OnParametersSet()
|
||||
{
|
||||
if (previous != Result) { openResult = null; previous = Result; }
|
||||
}
|
||||
private async Task OpenFolder()
|
||||
{
|
||||
if (busy || Result?.Path == null) return;
|
||||
busy = true;
|
||||
try { openResult = await FileSaveService.OpenFolderAsync(Result.Path); }
|
||||
finally { busy = false; }
|
||||
if (Result == null || ReferenceEquals(previous, Result)) return;
|
||||
previous = Result;
|
||||
var result = Result;
|
||||
Toasts.Show(result.Message + (result.Path != null ? " " + result.Path : ""),
|
||||
result.Status == FileOperationStatus.Error ? ToastKind.Error : result.Status == FileOperationStatus.Success ? ToastKind.Success : ToastKind.Info,
|
||||
result.Details, result.Path != null ? "Ordner öffnen" : null,
|
||||
result.Path != null ? async () => {
|
||||
var opened = await FileSaveService.OpenFolderAsync(result.Path);
|
||||
Toasts.Show(opened.Message, opened.Status == FileOperationStatus.Error ? ToastKind.Error : ToastKind.Info, opened.Details);
|
||||
} : null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
@implements IDisposable
|
||||
@inject ToastService Toasts
|
||||
|
||||
<div class="toast-stack" aria-label="Meldungen">
|
||||
@foreach (var toast in Toasts.Messages)
|
||||
{
|
||||
<section @key="toast.Id" class="app-toast toast-@toast.Kind.ToString().ToLowerInvariant()"
|
||||
role="@(toast.Kind == ToastKind.Error ? "alert" : "status")" aria-live="@(toast.Kind == ToastKind.Error ? "assertive" : "polite")" aria-atomic="true"
|
||||
@onmouseenter="() => Toasts.Pause(toast.Id)" @onmouseleave="() => Toasts.Resume(toast.Id)"
|
||||
@onfocusin="() => Toasts.Pause(toast.Id)" @onfocusout="() => Toasts.Resume(toast.Id)">
|
||||
<button type="button" class="toast-close" aria-label="Meldung schließen" @onclick="() => Toasts.Dismiss(toast.Id)">×</button>
|
||||
<p>@toast.Message</p>
|
||||
@if (toast.Action != null)
|
||||
{
|
||||
<button type="button" class="btn btn-outline-secondary" @onclick="() => RunAction(toast)">@toast.ActionLabel</button>
|
||||
}
|
||||
@if (toast.Details != null)
|
||||
{
|
||||
<details><summary>Technische Details</summary><p class="text-break">@toast.Details</p></details>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private readonly HashSet<Guid> running = [];
|
||||
protected override void OnInitialized() => Toasts.Changed += Refresh;
|
||||
private void Refresh() => _ = InvokeAsync(StateHasChanged);
|
||||
private async Task RunAction(ToastMessage toast)
|
||||
{
|
||||
if (!running.Add(toast.Id)) return;
|
||||
Toasts.Pause(toast.Id);
|
||||
try { await toast.Action!(); }
|
||||
catch (Exception ex) { Toasts.Show("Die Aktion konnte nicht abgeschlossen werden. Bitte erneut versuchen.", ToastKind.Error, ex.Message); }
|
||||
finally { running.Remove(toast.Id); Toasts.Resume(toast.Id); }
|
||||
}
|
||||
public void Dispose() => Toasts.Changed -= Refresh;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
@inject ToastService Toasts
|
||||
@inject ReportingPeriod Period
|
||||
@page "/accounts/{AccountId:int}"
|
||||
@inject IAccountService AccountService
|
||||
@@ -9,21 +10,7 @@
|
||||
|
||||
<div class="container-fluid">
|
||||
<OperationNotice Result="fileResult" />
|
||||
@if (fileBusy) { <p role="status" aria-live="polite">Dateiaktion läuft … Bitte warten.</p> }
|
||||
|
||||
@if (lastSaved != null)
|
||||
{
|
||||
<div class="alert alert-success" role="status" aria-live="polite">
|
||||
@lastSaved.Kind über @FormatAmount(lastSaved.Amount) @(lastSaved.Edited ? "aktualisiert" : "gespeichert").
|
||||
</div>
|
||||
}
|
||||
@if (savedOutsideYear.HasValue)
|
||||
{
|
||||
<div class="alert alert-info" role="status">
|
||||
Die Buchung wurde im Jahr @savedOutsideYear gespeichert und liegt außerhalb der aktuellen Ansicht.
|
||||
<button class="btn btn-outline-primary" @onclick="ShowSavedYear">Buchung im Jahr @savedOutsideYear anzeigen</button>
|
||||
</div>
|
||||
}
|
||||
@if (account == null)
|
||||
{
|
||||
<div class="text-center py-5">
|
||||
@@ -41,21 +28,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-2 py-3 flex-wrap">
|
||||
<div class="action-toolbar">
|
||||
<button class="btn btn-secondary btn-nav" @onclick="NavigateBack">
|
||||
<i class="bi bi-arrow-left"></i> Zurück
|
||||
<i class="bi bi-arrow-left"></i><span>Zurück</span>
|
||||
</button>
|
||||
<button class="btn btn-success btn-nav" @onclick="() => showAddEntry = true">
|
||||
<i class="bi bi-plus-lg"></i> Einnahme oder Ausgabe erfassen
|
||||
<i class="bi bi-plus-lg"></i> <span>Einnahme oder<br /> Ausgabe erfassen</span>
|
||||
</button>
|
||||
<button class="btn btn-info btn-nav" @onclick="() => showAddTransfer = true">
|
||||
<i class="bi bi-arrow-left-right"></i> Geld zwischen Konten verschieben
|
||||
<i class="bi bi-arrow-left-right"></i> <span>Geld zwischen<br /> Konten verschieben</span>
|
||||
</button>
|
||||
<button class="btn btn-dark btn-nav" @onclick="HandleExport" disabled="@fileBusy">
|
||||
<i class="bi bi-file-earmark-pdf"></i> Als PDF speichern
|
||||
<i class="bi bi-file-earmark-pdf"></i> <span>Als PDF<br /> speichern</span>
|
||||
</button>
|
||||
<button class="btn btn-nav btn-primary" @onclick="OpenYearFilterDialog">
|
||||
<i class="bi bi-funnel"></i> @GetFilterLabel()
|
||||
<i class="bi bi-funnel"></i><span>@GetFilterLabel()</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Duempelkas.App.Services;
|
||||
using Duempelkas.App.Services.Models;
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
@@ -10,6 +11,10 @@ public partial class AccountDetail
|
||||
[Parameter]
|
||||
public int AccountId { get; set; }
|
||||
|
||||
[SupplyParameterFromQuery(Name = "jahr")]
|
||||
public int? QueryYear { get; set; }
|
||||
private int? previousQueryYear;
|
||||
|
||||
#endregion
|
||||
|
||||
#region Fields
|
||||
@@ -53,6 +58,8 @@ public partial class AccountDetail
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
if (QueryYear.HasValue && QueryYear != previousQueryYear) selectedYear = QueryYear;
|
||||
previousQueryYear = QueryYear;
|
||||
await LoadAll();
|
||||
}
|
||||
|
||||
@@ -115,6 +122,12 @@ public partial class AccountDetail
|
||||
await LoadAll();
|
||||
lastSaved = saved;
|
||||
savedOutsideYear = selectedYear.HasValue && selectedYear.Value != saved.Date.Year ? saved.Date.Year : null;
|
||||
var nav = Navigation;
|
||||
var period = Period;
|
||||
var accountId = AccountId;
|
||||
Toasts.Show($"{saved.Kind} über {FormatAmount(saved.Amount)} {(saved.Edited ? "aktualisiert" : "gespeichert")}.", ToastKind.Success,
|
||||
actionLabel: savedOutsideYear.HasValue ? $"Buchung im Jahr {saved.Date.Year} anzeigen" : null,
|
||||
action: savedOutsideYear.HasValue ? () => { period.Year = saved.Date.Year; nav.NavigateTo($"/accounts/{accountId}?jahr={saved.Date.Year}"); return Task.CompletedTask; } : null);
|
||||
showAddEntry = false;
|
||||
showAddTransfer = false;
|
||||
editingEntry = null;
|
||||
@@ -187,6 +200,7 @@ public partial class AccountDetail
|
||||
private async Task HandleExport()
|
||||
{
|
||||
if (fileBusy) return;
|
||||
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true);
|
||||
fileBusy = true;
|
||||
fileResult = null;
|
||||
try
|
||||
@@ -199,12 +213,13 @@ public partial class AccountDetail
|
||||
|
||||
}
|
||||
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||
finally { fileBusy = false; }
|
||||
finally { fileBusy = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private async Task HandleOpenSavedPdf()
|
||||
{
|
||||
if (fileBusy) return;
|
||||
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true);
|
||||
fileBusy = true;
|
||||
fileResult = null;
|
||||
try
|
||||
@@ -218,7 +233,7 @@ public partial class AccountDetail
|
||||
|
||||
}
|
||||
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||
finally { fileBusy = false; }
|
||||
finally { fileBusy = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private void CancelOpenSavedPdf()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
@inject ToastService Toasts
|
||||
@inject ReportingPeriod Period
|
||||
@page "/"
|
||||
@inject IAccountService AccountService
|
||||
@@ -10,7 +11,6 @@
|
||||
|
||||
<div class="container-fluid">
|
||||
<OperationNotice Result="fileResult" />
|
||||
@if (fileBusy) { <p role="status" aria-live="polite">Dateiaktion läuft … Bitte warten.</p> }
|
||||
|
||||
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
@@ -24,25 +24,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex align-items-center gap-2 py-3 flex-wrap">
|
||||
<div class="action-toolbar">
|
||||
<button class="btn-nav btn-primary" @onclick="() => showAddAccount = true">
|
||||
<i class="bi bi-plus-lg"></i> Neues Konto
|
||||
<i class="bi bi-plus-lg"></i><span>Neues Konto</span>
|
||||
</button>
|
||||
<button class="btn-nav btn-success" @onclick="HandleBackupAsync" disabled="@fileBusy">
|
||||
<i class="bi bi-save"></i> Sicherung erstellen
|
||||
<i class="bi bi-save"></i> <span>Sicherung<br /> erstellen</span>
|
||||
</button>
|
||||
|
||||
<button class="btn-nav btn-dark" @onclick="HandleDashboardExportAsync" disabled="@fileBusy">
|
||||
<i class="bi bi-file-earmark-pdf"></i> Als PDF speichern
|
||||
<i class="bi bi-file-earmark-pdf"></i> <span>Als PDF<br /> speichern</span>
|
||||
</button>
|
||||
<button class="btn btn-nav btn-primary" @onclick="OpenYearFilterDialog">
|
||||
<i class="bi bi-funnel"></i> @GetFilterLabel()
|
||||
<i class="bi bi-funnel"></i><span>@GetFilterLabel()</span>
|
||||
</button>
|
||||
<button class="btn btn-nav btn-secondary" @onclick="ToggleThemeAsync" title="Farbmodus wechseln">
|
||||
<i class="@ThemeButtonIconClass"></i> Modus<br>@ThemeButtonLabel
|
||||
<i class="@ThemeButtonIconClass"></i><span>Modus<br />@ThemeButtonLabel</span>
|
||||
</button>
|
||||
<details class="more-actions">
|
||||
<summary class="btn btn-outline-secondary">Weitere Aktionen</summary>
|
||||
<summary class="btn btn-nav btn-outline-secondary"><i class="bi bi-three-dots"></i><span>Weitere<br /> Aktionen</span></summary>
|
||||
<button class="btn btn-warning mt-2" @onclick="HandleRestoreAsync" disabled="@fileBusy">
|
||||
<i class="bi bi-arrow-counterclockwise"></i> Sicherung zurückspielen
|
||||
</button>
|
||||
|
||||
@@ -101,6 +101,7 @@ public partial class Dashboard
|
||||
private async Task HandleBackupAsync()
|
||||
{
|
||||
if (fileBusy) return;
|
||||
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true);
|
||||
fileBusy = true;
|
||||
fileResult = null;
|
||||
try
|
||||
@@ -109,7 +110,7 @@ public partial class Dashboard
|
||||
|
||||
}
|
||||
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||
finally { fileBusy = false; }
|
||||
finally { fileBusy = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private Task HandleRestoreAsync()
|
||||
@@ -126,6 +127,7 @@ public partial class Dashboard
|
||||
private async Task HandleConfirmRestore()
|
||||
{
|
||||
if (fileBusy) return;
|
||||
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true);
|
||||
fileBusy = true;
|
||||
fileResult = null;
|
||||
try
|
||||
@@ -136,12 +138,13 @@ public partial class Dashboard
|
||||
|
||||
}
|
||||
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||
finally { fileBusy = false; }
|
||||
finally { fileBusy = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private async Task HandleDashboardExportAsync()
|
||||
{
|
||||
if (fileBusy) return;
|
||||
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true);
|
||||
fileBusy = true;
|
||||
fileResult = null;
|
||||
try
|
||||
@@ -154,7 +157,7 @@ public partial class Dashboard
|
||||
|
||||
}
|
||||
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||
finally { fileBusy = false; }
|
||||
finally { fileBusy = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private void OpenYearFilterDialog()
|
||||
@@ -177,6 +180,7 @@ public partial class Dashboard
|
||||
private async Task HandleOpenSavedPdf()
|
||||
{
|
||||
if (fileBusy) return;
|
||||
var busyToast = Toasts.Show("Dateiaktion läuft … Bitte warten.", persistent: true);
|
||||
fileBusy = true;
|
||||
fileResult = null;
|
||||
try
|
||||
@@ -190,7 +194,7 @@ public partial class Dashboard
|
||||
|
||||
}
|
||||
catch (Exception ex) { fileResult = FileOperationResult.Error(ex); }
|
||||
finally { fileBusy = false; }
|
||||
finally { fileBusy = false; Toasts.Dismiss(busyToast); }
|
||||
}
|
||||
|
||||
private void CancelOpenSavedPdf()
|
||||
|
||||
@@ -8,6 +8,9 @@ public static partial class BookingInput
|
||||
[GeneratedRegex(@"^\d+([,.]\d{1,2})?$", RegexOptions.CultureInvariant)]
|
||||
private static partial Regex AmountPattern();
|
||||
|
||||
public static bool IsAmountDraft(string value, bool allowNegative = false) =>
|
||||
Regex.IsMatch(value, allowNegative ? @"^-?\d*([,.]\d{0,2})?$" : @"^\d*([,.]\d{0,2})?$", RegexOptions.CultureInvariant);
|
||||
|
||||
public static bool TryAmount(string text, out decimal amount)
|
||||
{
|
||||
amount = 0;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
namespace Duempelkas.App.Services;
|
||||
|
||||
public enum ToastKind { Info, Success, Error }
|
||||
public sealed record ToastMessage(Guid Id, string Message, ToastKind Kind, string? Details,
|
||||
string? ActionLabel, Func<Task>? Action);
|
||||
|
||||
public sealed class ToastService : IDisposable
|
||||
{
|
||||
private readonly object sync = new();
|
||||
private readonly Dictionary<Guid, (ToastMessage Message, Timer? Timer, TimeSpan? Remaining, DateTimeOffset Started)> items = [];
|
||||
public event Action? Changed;
|
||||
public IReadOnlyList<ToastMessage> Messages { get { lock (sync) return items.Values.Select(x => x.Message).ToArray(); } }
|
||||
|
||||
public Guid Show(string message, ToastKind kind = ToastKind.Info, string? details = null,
|
||||
string? actionLabel = null, Func<Task>? action = null, TimeSpan? duration = null, bool persistent = false)
|
||||
{
|
||||
var id = Guid.NewGuid();
|
||||
var lifetime = persistent ? (TimeSpan?)null : duration ?? TimeSpan.FromSeconds(kind == ToastKind.Error ? 15 : 9);
|
||||
var toast = new ToastMessage(id, message, kind, details, actionLabel, action);
|
||||
lock (sync)
|
||||
{
|
||||
Timer? timer = lifetime.HasValue ? new Timer(_ => Dismiss(id), null, Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan) : null;
|
||||
items[id] = (toast, timer, lifetime, DateTimeOffset.UtcNow);
|
||||
if (lifetime.HasValue) timer!.Change(lifetime.Value, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
Changed?.Invoke();
|
||||
return id;
|
||||
}
|
||||
|
||||
public void Pause(Guid id)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
if (!items.TryGetValue(id, out var item) || item.Remaining == null || item.Started == default) return;
|
||||
item.Timer?.Change(Timeout.InfiniteTimeSpan, Timeout.InfiniteTimeSpan);
|
||||
var left = item.Remaining.Value - (DateTimeOffset.UtcNow - item.Started);
|
||||
items[id] = (item.Message, item.Timer, left > TimeSpan.Zero ? left : TimeSpan.FromMilliseconds(1), default);
|
||||
}
|
||||
}
|
||||
|
||||
public void Resume(Guid id)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
if (!items.TryGetValue(id, out var item) || item.Remaining == null || item.Started != default) return;
|
||||
items[id] = (item.Message, item.Timer, item.Remaining, DateTimeOffset.UtcNow);
|
||||
item.Timer?.Change(item.Remaining.Value, Timeout.InfiniteTimeSpan);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dismiss(Guid id)
|
||||
{
|
||||
lock (sync)
|
||||
{
|
||||
if (!items.Remove(id, out var item)) return;
|
||||
item.Timer?.Dispose();
|
||||
}
|
||||
Changed?.Invoke();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
lock (sync) { foreach (var item in items.Values) item.Timer?.Dispose(); items.Clear(); }
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,16 @@ class Program
|
||||
var appDataDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"Duempelkas");
|
||||
var isolated = false;
|
||||
#if DEBUG
|
||||
var directoryIndex = Array.IndexOf(args, "--data-directory");
|
||||
if (directoryIndex >= 0)
|
||||
{
|
||||
if (directoryIndex + 1 >= args.Length) throw new ArgumentException("Datenordner fehlt.");
|
||||
appDataDir = Path.GetFullPath(args[directoryIndex + 1]);
|
||||
isolated = true;
|
||||
}
|
||||
#endif
|
||||
Directory.CreateDirectory(appDataDir);
|
||||
|
||||
var dbPath = Path.Combine(appDataDir, "duempelkas.db");
|
||||
@@ -34,7 +44,7 @@ class Program
|
||||
// One-time compatibility: keep existing data if older versions stored DB next to the app executable.
|
||||
var legacyExeDirectory = Path.GetDirectoryName(Environment.ProcessPath ?? AppContext.BaseDirectory) ?? AppContext.BaseDirectory;
|
||||
var legacyDbPath = Path.Combine(legacyExeDirectory, "duempelkas.db");
|
||||
if (!File.Exists(dbPath) && File.Exists(legacyDbPath))
|
||||
if (!isolated && !File.Exists(dbPath) && File.Exists(legacyDbPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -50,7 +60,7 @@ class Program
|
||||
}
|
||||
}
|
||||
|
||||
appBuilder.Services.AddInfrastructure($"Data Source={dbPath}");
|
||||
appBuilder.Services.AddInfrastructure(new Microsoft.Data.Sqlite.SqliteConnectionStringBuilder { DataSource = dbPath }.ToString(), appDataDir);
|
||||
|
||||
appBuilder.RootComponents.Add<Duempelkas.App.Components.App>("app");
|
||||
|
||||
@@ -72,9 +82,7 @@ class Program
|
||||
}
|
||||
|
||||
// Load saved window size or use defaults
|
||||
var settingsPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"Duempelkas", "window.json");
|
||||
var settingsPath = Path.Combine(appDataDir, "window.json");
|
||||
|
||||
int savedWidth = 1280, savedHeight = 800;
|
||||
if (File.Exists(settingsPath))
|
||||
@@ -90,7 +98,7 @@ class Program
|
||||
}
|
||||
|
||||
app.MainWindow
|
||||
.SetTitle("Dümpelkas – Kassenbuch")
|
||||
.SetTitle(isolated ? "Dümpelkas – Testdaten" : "Dümpelkas – Kassenbuch")
|
||||
.SetSize(savedWidth, savedHeight)
|
||||
.SetUseOsDefaultSize(false);
|
||||
|
||||
|
||||
@@ -612,3 +612,21 @@ fieldset { min-width: 0; }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.entry-recent > td { animation: none; border-bottom: 2px solid var(--color-accent); }
|
||||
}
|
||||
|
||||
/* One icon row plus two reserved text rows on every page. */
|
||||
.action-toolbar { display: flex; flex-wrap: wrap; gap: .5rem; padding: 1rem 0; align-items: flex-start; }
|
||||
.action-toolbar .btn-nav { box-sizing: border-box; width: 10.5rem; min-width: 10.5rem; max-width: 10.5rem; height: 6.25rem; min-height: 6.25rem; padding: .45rem .6rem; font-size: .9rem !important; line-height: 1.15rem; gap: .2rem; border-radius: .5rem; border: 1px solid transparent; overflow-wrap: normal; }
|
||||
.action-toolbar .btn-nav i { display: block; font-size: 1.65rem; line-height: 1.85rem; }
|
||||
.action-toolbar .btn-nav span { display: block; min-height: 2.3rem; }
|
||||
.action-toolbar .more-actions { align-self: flex-start; position: relative; }
|
||||
.action-toolbar .more-actions[open] { padding: 0; border: 0; }
|
||||
.more-actions > button { position: absolute; z-index: 20; right: 0; min-width: 15rem; box-shadow: 0 4px 16px #0004; }
|
||||
.toast-stack { position: fixed; z-index: 3000; inset: .75rem .75rem auto auto; width: min(28rem, calc(100vw - 1.5rem)); max-height: calc(100vh - 1.5rem); overflow-y: auto; display: flex; flex-direction: column; gap: .65rem; pointer-events: none; }
|
||||
.app-toast { pointer-events: auto; padding: 1rem 2.5rem 1rem 1rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-left: .35rem solid var(--color-accent); border-radius: .65rem; box-shadow: 0 5px 22px #0004; position: relative; animation: toast-enter .2s ease-out; overflow-wrap: anywhere; }
|
||||
.toast-success { border-left-color: var(--color-income); }
|
||||
.toast-error { border-left-color: var(--color-expense); }
|
||||
.app-toast p { margin: 0 0 .5rem; }
|
||||
.toast-close { position: absolute; right: .3rem; top: .3rem; background: transparent; border: 0; color: inherit; width: 2rem; height: 2rem; font-size: 1.5rem; }
|
||||
@keyframes toast-enter { from { transform: translateY(-2rem); opacity: 0; } to { transform: translateY(0); opacity: 1; } }
|
||||
@media (prefers-reduced-motion: reduce) { .app-toast { animation: none; } }
|
||||
.input-rejection:empty { display: none; }
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
<a class="dismiss">×</a>
|
||||
</div>
|
||||
|
||||
<script src="js/app.js"></script>
|
||||
<script src="_framework/blazor.webview.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// Keep decimal editing predictable: reject the complete invalid edit/paste, never round it.
|
||||
(() => {
|
||||
function allowed(input, value) {
|
||||
return (input.dataset.allowNegative === 'true' ? /^-?\d*([,.]\d{0,2})?$/ : /^\d*([,.]\d{0,2})?$/).test(value);
|
||||
}
|
||||
function accept(input, text) {
|
||||
const next = input.value.slice(0, input.selectionStart) + text + input.value.slice(input.selectionEnd);
|
||||
const ok = allowed(input, next);
|
||||
const hint = document.getElementById(input.id + '-rejected');
|
||||
if (hint) hint.textContent = ok ? '' : 'Diese Eingabe wurde nicht übernommen. Bitte einen Betrag wie 12,50 eingeben.';
|
||||
return ok;
|
||||
}
|
||||
document.addEventListener('beforeinput', e => {
|
||||
if (e.target.matches('[data-amount-guard]') && e.data != null && !accept(e.target, e.data)) e.preventDefault();
|
||||
});
|
||||
document.addEventListener('paste', e => {
|
||||
if (e.target.matches('[data-amount-guard]') && !accept(e.target, e.clipboardData.getData('text').trim())) e.preventDefault();
|
||||
});
|
||||
})();
|
||||
@@ -8,7 +8,7 @@ namespace Duempelkas.Infrastructure;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString)
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services, string connectionString, string? settingsDirectory = null)
|
||||
{
|
||||
services.AddSingleton(new DatabaseLocation(connectionString));
|
||||
// The factory also registers FinanceDbContext as scoped. Registering
|
||||
@@ -23,8 +23,9 @@ public static class DependencyInjection
|
||||
services.AddSingleton<IFileDialogService, FileDialogService>();
|
||||
services.AddScoped<IFileSaveService, FileSaveService>();
|
||||
services.AddSingleton<IBackupService, BackupService>();
|
||||
services.AddSingleton<ISettingsService, SettingsService>();
|
||||
services.AddSingleton<ISettingsService>(new SettingsService(settingsDirectory));
|
||||
services.AddSingleton<ReportingPeriod>();
|
||||
services.AddSingleton<ToastService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -5,9 +5,10 @@ namespace Duempelkas.Infrastructure.Services;
|
||||
|
||||
public class SettingsService : ISettingsService
|
||||
{
|
||||
private static readonly string SettingsPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"Duempelkas", "settings.json");
|
||||
private readonly string SettingsPath;
|
||||
|
||||
public SettingsService(string? directory = null) => SettingsPath = Path.Combine(directory ?? Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Duempelkas"), "settings.json");
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true };
|
||||
|
||||
@@ -24,7 +25,7 @@ public class SettingsService : ISettingsService
|
||||
await SaveAsync(settings);
|
||||
}
|
||||
|
||||
private static async Task<AppSettings> LoadAsync()
|
||||
private async Task<AppSettings> LoadAsync()
|
||||
{
|
||||
if (!File.Exists(SettingsPath))
|
||||
return new AppSettings();
|
||||
@@ -40,7 +41,7 @@ public class SettingsService : ISettingsService
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task SaveAsync(AppSettings settings)
|
||||
private async Task SaveAsync(AppSettings settings)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(SettingsPath)!;
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
@@ -15,6 +15,7 @@ public class BookingFormTests
|
||||
public void Form_ExplainsMissingFields_AndDoesNotRoundInvalidInput()
|
||||
{
|
||||
using var context = new BunitContext();
|
||||
context.Services.AddSingleton<ToastService>();
|
||||
context.Services.AddSingleton<IEntryService>(new EntryService(null!));
|
||||
var dialog = context.Render<AddEntryDialog>(p => p.Add(x => x.AccountName, "Barkasse"));
|
||||
Assert.Contains("Barkasse", dialog.Find("h5").TextContent);
|
||||
@@ -22,7 +23,7 @@ public class BookingFormTests
|
||||
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
dialog.Find("#entry-title").Input("Porto");
|
||||
dialog.Find("#entry-amount").Input("12,501");
|
||||
Assert.Equal("12,501", dialog.Find("#entry-amount").GetAttribute("value"));
|
||||
Assert.Equal("", dialog.Find("#entry-amount").GetAttribute("value"));
|
||||
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
dialog.Find("#entry-amount").Input("-12,50");
|
||||
Assert.True(dialog.Find("button.btn-success").HasAttribute("disabled"));
|
||||
@@ -39,6 +40,7 @@ public class BookingFormTests
|
||||
public void Edit_ShowsFixedTypeAndCorrectionPath_AndRerenderKeepsInput()
|
||||
{
|
||||
using var context = new BunitContext();
|
||||
context.Services.AddSingleton<ToastService>();
|
||||
context.Services.AddSingleton<IEntryService>(new EntryService(null!));
|
||||
var entry = new EntryDto(1, 1, "2026-001", EntryType.Expense, new(2026, 1, 1), "Porto", 4.20m, false, false, null, null, null);
|
||||
var dialog = context.Render<AddEntryDialog>(p => p.Add(x => x.EditEntry, entry));
|
||||
|
||||
@@ -37,7 +37,7 @@ public class BookingSaveTests
|
||||
Assert.Equal(1, fixture.Entries.WriteCalls);
|
||||
delayed.SetResult();
|
||||
await pending;
|
||||
page.WaitForAssertion(() => Assert.Contains("gespeichert", page.Find(".alert-success").TextContent));
|
||||
page.WaitForAssertion(() => Assert.Contains("gespeichert", page.Find(".toast-success").TextContent));
|
||||
Assert.Single(await fixture.Entries.GetEntriesAsync(1, (int?)null));
|
||||
Assert.Single(page.FindAll("tr.entry-recent"));
|
||||
if (transfer) Assert.Single(await fixture.Entries.GetEntriesAsync(2, (int?)null));
|
||||
@@ -62,7 +62,7 @@ public class BookingSaveTests
|
||||
fixture.Entries.BeforeWrite = () => Task.FromException(new IOException("Simulierter Speicherfehler"));
|
||||
await page.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
|
||||
Assert.Contains("Eingaben bleiben erhalten", page.Markup);
|
||||
Assert.Empty(page.FindAll(".alert-success"));
|
||||
Assert.Empty(page.FindAll(".toast-success"));
|
||||
Assert.Equal("Testbuchung", page.Find(transfer ? "#transfer-title" : "#entry-title").GetAttribute("value"));
|
||||
Assert.Equal("12,50", page.Find(transfer ? "#transfer-amount" : "#entry-amount").GetAttribute("value"));
|
||||
Assert.Empty(await fixture.Entries.GetEntriesAsync(1, (int?)null));
|
||||
@@ -86,7 +86,7 @@ public class BookingSaveTests
|
||||
dialog.Find("#entry-title").Input("Testbuchung");
|
||||
dialog.Find("#entry-amount").Input("12,50");
|
||||
await dialog.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
|
||||
Assert.Contains("Die Buchung ist gespeichert", dialog.Markup);
|
||||
Assert.Contains(fixture.Context.Services.GetRequiredService<ToastService>().Messages, t => t.Message.Contains("Die Buchung ist gespeichert"));
|
||||
await dialog.Find("form").TriggerEventAsync("onsubmit", EventArgs.Empty);
|
||||
Assert.Equal(1, fixture.Entries.WriteCalls);
|
||||
Assert.Equal(2, notifications);
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
using Bunit;
|
||||
using Duempelkas.App.Components;
|
||||
using Duempelkas.App.Services;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Xunit;
|
||||
|
||||
namespace Duempelkas.Tests;
|
||||
|
||||
public class ToastTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ToastsStack_ExpireIndependently_AndPauseDuringInteraction()
|
||||
{
|
||||
using var context = new BunitContext();
|
||||
context.Services.AddSingleton<ToastService>();
|
||||
var host = context.Render<ToastHost>();
|
||||
var service = context.Services.GetRequiredService<ToastService>();
|
||||
var first = service.Show("Erste Meldung", duration: TimeSpan.FromMilliseconds(100));
|
||||
service.Pause(first);
|
||||
service.Show("Zweite Meldung", ToastKind.Success, persistent: true);
|
||||
host.WaitForAssertion(() => Assert.Equal(2, host.FindAll(".app-toast").Count));
|
||||
await Task.Delay(200);
|
||||
Assert.Equal(2, service.Messages.Count);
|
||||
service.Resume(first);
|
||||
host.WaitForAssertion(() => Assert.Single(host.FindAll(".app-toast")));
|
||||
Assert.Contains("Zweite Meldung", host.Markup);
|
||||
host.Find(".toast-close").Click();
|
||||
Assert.Empty(service.Messages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AmountControl_RejectsWholePasteWithoutRounding()
|
||||
{
|
||||
using var context = new BunitContext();
|
||||
string amount = "12,50";
|
||||
var control = context.Render<AmountInput>(p => p.Add(x => x.Value, amount).Add(x => x.ValueChanged, value => amount = value));
|
||||
control.Find("input").Input("12,501");
|
||||
Assert.Equal("12,50", amount);
|
||||
Assert.Contains("nicht übernommen", control.Markup);
|
||||
control.Find("input").Input("12.25");
|
||||
Assert.Equal("12.25", amount);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user