feat: dashboard info tile UI with edit dialog, quick-add confirmation, tests
This commit is contained in:
1 parent
545162479c
commit
8dc923cd6e
10 files changed
+525
-4
No files matched your search
@@ -0,0 +1,62 @@
|
||||
@using FamilyPlanner.Domain.Dashboard
|
||||
@using FamilyPlanner.Shared.Dashboard
|
||||
@inject IDashboardApi Api
|
||||
|
||||
<MudDialog>
|
||||
<DialogContent>
|
||||
@if (_error is not null)
|
||||
{
|
||||
<MudAlert Severity="Severity.Error" Class="mb-3" role="alert">@_error</MudAlert>
|
||||
}
|
||||
<MudTextField @bind-Value="_title" Label="Titel" Variant="Variant.Outlined" Margin="Margin.Dense" MaxLength="DashboardNote.TitleMaxLength"
|
||||
Class="fp-note-title" Error="@(_errors.ContainsKey("Title"))" ErrorText="@_errors.GetValueOrDefault("Title")" />
|
||||
<MudTextField @bind-Value="_text" Label="Text" Variant="Variant.Outlined" Margin="Margin.Dense" Lines="4" Sizing="InputSizing.Auto" MaxLength="DashboardNote.TextMaxLength"
|
||||
Class="fp-note-text mt-3" Counter="DashboardNote.TextMaxLength" Error="@(_errors.ContainsKey("Text"))" ErrorText="@_errors.GetValueOrDefault("Text")" />
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<MudButton OnClick="@(() => Dialog?.Cancel())">Abbrechen</MudButton>
|
||||
<MudButton Color="Color.Primary" Variant="Variant.Filled" OnClick="SaveAsync" Disabled="_busy" Class="fp-note-save">Speichern</MudButton>
|
||||
</DialogActions>
|
||||
</MudDialog>
|
||||
|
||||
@code {
|
||||
[CascadingParameter]
|
||||
private IMudDialogInstance? Dialog { get; set; }
|
||||
|
||||
/// <summary>Zu bearbeitende Notiz; <c>null</c> legt eine neue an.</summary>
|
||||
[Parameter]
|
||||
public DashboardNoteDto? Note { get; set; }
|
||||
|
||||
private string _title = string.Empty;
|
||||
private string _text = string.Empty;
|
||||
private Dictionary<string, string> _errors = new(StringComparer.OrdinalIgnoreCase);
|
||||
private string? _error;
|
||||
private bool _busy;
|
||||
|
||||
protected override void OnInitialized() => (_title, _text) = (Note?.Title ?? string.Empty, Note?.Text ?? string.Empty);
|
||||
|
||||
private async Task SaveAsync()
|
||||
{
|
||||
_error = null;
|
||||
var request = new SaveDashboardNoteRequest(_title.Trim(), _text.Trim());
|
||||
var validation = new SaveDashboardNoteRequestValidator().Validate(request);
|
||||
_errors = validation.Errors
|
||||
.GroupBy(e => e.PropertyName)
|
||||
.ToDictionary(g => g.Key, g => string.Join(" ", g.Select(e => e.ErrorMessage)), StringComparer.OrdinalIgnoreCase);
|
||||
if (!validation.IsValid)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_busy = true;
|
||||
var result = Note is null ? await Api.CreateNoteAsync(request) : await Api.UpdateNoteAsync(Note.Id, request);
|
||||
_busy = false;
|
||||
if (result.Problem is { } problem)
|
||||
{
|
||||
_error = problem.Message;
|
||||
return;
|
||||
}
|
||||
|
||||
Dialog?.Close(DialogResult.Ok(result.Value));
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
@inject IShoppingApi ShoppingApi
|
||||
@inject ILiveConnection Live
|
||||
@inject ISnackbar Snackbar
|
||||
@inject IDialogService DialogService
|
||||
@inject TimeProvider TimeProvider
|
||||
|
||||
<PageTitle>Übersicht – Familienplaner</PageTitle>
|
||||
@@ -284,6 +285,52 @@ else
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
|
||||
@if (_data.Notes is { } notes && (notes.Items.Count > 0 || notes.CanEdit))
|
||||
{
|
||||
<MudItem xs="12" sm="6" lg="4">
|
||||
<MudCard Outlined="true" Class="fp-tile fp-dash-notes">
|
||||
<MudCardHeader>
|
||||
<CardHeaderAvatar><MudIcon Icon="@Icons.Material.Filled.Info" Color="Color.Primary" /></CardHeaderAvatar>
|
||||
<CardHeaderContent><MudText Typo="Typo.h6" HtmlTag="h2">Infos</MudText></CardHeaderContent>
|
||||
</MudCardHeader>
|
||||
<MudCardContent>
|
||||
@if (notes.Items.Count == 0)
|
||||
{
|
||||
<MudText Typo="Typo.body2" Class="fp-dash-empty">Noch keine Infos – z. B. Wlan-Passwort oder Ferientermine.</MudText>
|
||||
}
|
||||
else
|
||||
{
|
||||
<ul class="fp-dash-list fp-dash-notes-list">
|
||||
@foreach (var note in notes.Items)
|
||||
{
|
||||
<li class="fp-dash-note">
|
||||
<span class="fp-dash-label fp-dash-note-title">@note.Title</span>
|
||||
<span class="fp-dash-main fp-dash-note-text">@note.Text</span>
|
||||
@if (notes.CanEdit)
|
||||
{
|
||||
<span class="fp-dash-note-actions">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Edit" Size="Size.Small" OnClick="@(() => EditNoteAsync(note))"
|
||||
aria-label="@($"{note.Title} bearbeiten")" Class="fp-note-edit" />
|
||||
<MudIconButton Icon="@Icons.Material.Filled.Delete" Size="Size.Small" OnClick="@(() => DeleteNoteAsync(note))"
|
||||
aria-label="@($"{note.Title} löschen")" Class="fp-note-delete" />
|
||||
</span>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</MudCardContent>
|
||||
@if (notes.CanEdit)
|
||||
{
|
||||
<MudCardActions>
|
||||
<MudButton Variant="Variant.Text" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Add" OnClick="@(() => EditNoteAsync(null))"
|
||||
Class="fp-note-add">Info hinzufügen</MudButton>
|
||||
</MudCardActions>
|
||||
}
|
||||
</MudCard>
|
||||
</MudItem>
|
||||
}
|
||||
</MudGrid>
|
||||
}
|
||||
|
||||
@@ -403,6 +450,36 @@ else
|
||||
|
||||
private Task OnChatRead(Guid _, Guid __, DateTimeOffset ___) => ScheduleRefreshAsync();
|
||||
|
||||
private async Task EditNoteAsync(DashboardNoteDto? note)
|
||||
{
|
||||
var dialog = await DialogService.ShowAsync<DashboardNoteDialog>(
|
||||
note is null ? "Info hinzufügen" : "Info bearbeiten",
|
||||
new DialogParameters<DashboardNoteDialog> { { d => d.Note, note } },
|
||||
new DialogOptions { MaxWidth = MaxWidth.Small, FullWidth = true, CloseButton = true, CloseOnEscapeKey = true });
|
||||
if (await dialog.Result is { Canceled: false })
|
||||
{
|
||||
await RefreshAsync();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DeleteNoteAsync(DashboardNoteDto note)
|
||||
{
|
||||
var confirmed = await DialogService.ShowMessageBoxAsync("Info löschen?", $"„{note.Title}“ wird für alle entfernt.", yesText: "Löschen", cancelText: "Abbrechen");
|
||||
if (confirmed != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var result = await DashboardApi.DeleteNoteAsync(note.Id);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
Snackbar.Add(result.Problem?.Message ?? "Die Info konnte nicht gelöscht werden.", Severity.Warning);
|
||||
return;
|
||||
}
|
||||
|
||||
await RefreshAsync();
|
||||
}
|
||||
|
||||
private Task OnQuickAddKeyAsync(KeyboardEventArgs args) => args.Key == "Enter" ? QuickAddAsync() : Task.CompletedTask;
|
||||
|
||||
private async Task QuickAddAsync()
|
||||
@@ -416,7 +493,24 @@ else
|
||||
_adding = true;
|
||||
try
|
||||
{
|
||||
var result = await ShoppingApi.AddItemAsync(listId, new FamilyPlanner.Shared.Shopping.AddShoppingItemRequest(Guid.CreateVersion7(), text));
|
||||
var request = new FamilyPlanner.Shared.Shopping.AddShoppingItemRequest(Guid.CreateVersion7(), text);
|
||||
var result = await ShoppingApi.AddItemAsync(listId, request);
|
||||
if (result.Problem is { Status: 409, NeedsConfirmation: true })
|
||||
{
|
||||
// Plausibilitätsprüfung für Kinder: nachfragen und mit Bestätigung erneut senden.
|
||||
var confirmed = await DialogService.ShowMessageBoxAsync(
|
||||
"Wirklich hinzufügen?",
|
||||
$"„{text}“ sieht nicht nach einem Artikel aus. Soll es trotzdem auf die Einkaufsliste?",
|
||||
yesText: "Ja, hinzufügen",
|
||||
cancelText: "Nein");
|
||||
if (confirmed != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
result = await ShoppingApi.AddItemAsync(listId, request with { Confirm = true });
|
||||
}
|
||||
|
||||
if (result.Succeeded)
|
||||
{
|
||||
_quickText = null;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Net.Http.Json;
|
||||
using FamilyPlanner.Shared.Dashboard;
|
||||
|
||||
namespace FamilyPlanner.Web.Client.Services;
|
||||
@@ -5,10 +6,27 @@ namespace FamilyPlanner.Web.Client.Services;
|
||||
public interface IDashboardApi
|
||||
{
|
||||
Task<ApiResult<DashboardDto>> GetAsync(CancellationToken ct = default);
|
||||
|
||||
Task<ApiResult<DashboardNoteDto>> CreateNoteAsync(SaveDashboardNoteRequest request, CancellationToken ct = default);
|
||||
|
||||
Task<ApiResult<DashboardNoteDto>> UpdateNoteAsync(Guid id, SaveDashboardNoteRequest request, CancellationToken ct = default);
|
||||
|
||||
Task<ApiResult<bool>> DeleteNoteAsync(Guid id, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
public sealed class DashboardApi(HttpClient http) : IDashboardApi
|
||||
{
|
||||
private const string NotesBase = "api/dashboard/notes";
|
||||
|
||||
public Task<ApiResult<DashboardDto>> GetAsync(CancellationToken ct = default) =>
|
||||
ApiCall.SendAsync<DashboardDto>(() => http.GetAsync("api/dashboard", ct), ct);
|
||||
|
||||
public Task<ApiResult<DashboardNoteDto>> CreateNoteAsync(SaveDashboardNoteRequest request, CancellationToken ct = default) =>
|
||||
ApiCall.SendAsync<DashboardNoteDto>(() => http.PostAsJsonAsync(NotesBase, request, ct), ct);
|
||||
|
||||
public Task<ApiResult<DashboardNoteDto>> UpdateNoteAsync(Guid id, SaveDashboardNoteRequest request, CancellationToken ct = default) =>
|
||||
ApiCall.SendAsync<DashboardNoteDto>(() => http.PutAsJsonAsync($"{NotesBase}/{id}", request, ct), ct);
|
||||
|
||||
public Task<ApiResult<bool>> DeleteNoteAsync(Guid id, CancellationToken ct = default) =>
|
||||
ApiCall.SendAsync(() => http.DeleteAsync($"{NotesBase}/{id}", ct), ct);
|
||||
}
|
||||
@@ -189,6 +189,15 @@ body:has(.fp-app) .app-loading {
|
||||
color: var(--mud-palette-text-secondary);
|
||||
}
|
||||
|
||||
.fp-dash-note-text {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.fp-dash-note-actions {
|
||||
grid-column: 2;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
/* Einkaufsliste */
|
||||
.fp-items {
|
||||
list-style: none;
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
using FamilyPlanner.Domain;
|
||||
using FamilyPlanner.Domain.Authorization;
|
||||
using FamilyPlanner.Shared.Auth;
|
||||
using FamilyPlanner.Shared.Dashboard;
|
||||
using FamilyPlanner.Web.Client.Auth;
|
||||
using FamilyPlanner.Web.Client.Pages;
|
||||
using FamilyPlanner.Web.Client.Services;
|
||||
using FamilyPlanner.Web.Tests.Shopping;
|
||||
using FamilyPlanner.Web.Tests.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using MudBlazor.Services;
|
||||
|
||||
namespace FamilyPlanner.Web.Tests.Dashboard;
|
||||
|
||||
/// <summary>Allgemeine Info-Kachel der Startseite (Freitext-Notizen, Eltern pflegen).</summary>
|
||||
public sealed class DashboardNotesTests : BunitContext
|
||||
{
|
||||
private static readonly DateOnly Today = new(2026, 10, 12);
|
||||
|
||||
private readonly FakeDashboardApi _api = new();
|
||||
private readonly FakeShoppingApi _shopping = new();
|
||||
|
||||
public DashboardNotesTests()
|
||||
{
|
||||
JSInterop.Mode = JSRuntimeMode.Loose;
|
||||
Home.RefreshDelay = TimeSpan.FromMilliseconds(10);
|
||||
Services.AddMudServices();
|
||||
Services.AddSingleton<IDashboardApi>(_api);
|
||||
Services.AddSingleton<IShoppingApi>(_shopping);
|
||||
Services.AddSingleton<ILiveConnection, FakeLiveConnection>();
|
||||
Services.AddSingleton<IAuthService, NoopAuthService>();
|
||||
Services.AddSingleton<ApiAuthenticationStateProvider>();
|
||||
Services.AddSingleton<TimeProvider>(new FixedTimeProvider(new DateTimeOffset(2026, 10, 12, 7, 30, 0, TimeSpan.Zero)));
|
||||
}
|
||||
|
||||
private void SignInAs(string role) =>
|
||||
Services.GetRequiredService<ApiAuthenticationStateProvider>().SetUser(new CurrentUserDto(
|
||||
Guid.NewGuid(), "test", "Mia", null, "#1E88E5", [role], false, null, null, PermissionMatrix.Effective([role])));
|
||||
|
||||
private void Seed(bool canEdit, params DashboardNoteDto[] notes) =>
|
||||
_api.Data = new DashboardDto(Today, null, null, null, null, null, null, Notes: new DashboardNotesDto(notes, canEdit));
|
||||
|
||||
[Fact]
|
||||
public void Everyone_sees_the_notes_but_only_parents_get_edit_buttons()
|
||||
{
|
||||
Seed(canEdit: false, new DashboardNoteDto(Guid.NewGuid(), "Wlan-Passwort", "Sommer2026"));
|
||||
SignInAs(Roles.Child);
|
||||
|
||||
var cut = Render<DashboardHost>();
|
||||
|
||||
Assert.Equal("Wlan-Passwort", cut.Find(".fp-dash-note-title").TextContent);
|
||||
Assert.Equal("Sommer2026", cut.Find(".fp-dash-note-text").TextContent);
|
||||
Assert.Empty(cut.FindAll(".fp-note-edit"));
|
||||
Assert.Empty(cut.FindAll(".fp-note-add"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tile_is_hidden_without_notes_for_non_editors()
|
||||
{
|
||||
Seed(canEdit: false);
|
||||
SignInAs(Roles.Guest);
|
||||
|
||||
var cut = Render<DashboardHost>();
|
||||
|
||||
Assert.Empty(cut.FindAll(".fp-dash-notes"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Tile_is_shown_for_parents_even_without_notes()
|
||||
{
|
||||
Seed(canEdit: true);
|
||||
SignInAs(Roles.Family);
|
||||
var cut = Render<DashboardHost>();
|
||||
Assert.NotEmpty(cut.FindAll(".fp-dash-notes"));
|
||||
Assert.NotEmpty(cut.FindAll(".fp-note-add"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Note_text_is_rendered_as_plain_text()
|
||||
{
|
||||
Seed(canEdit: false, new DashboardNoteDto(Guid.NewGuid(), "<b>Titel</b>", "<script>alert(1)</script>"));
|
||||
SignInAs(Roles.Family);
|
||||
|
||||
var cut = Render<DashboardHost>();
|
||||
|
||||
Assert.Equal("<b>Titel</b>", cut.Find(".fp-dash-note-title").TextContent);
|
||||
Assert.Empty(cut.FindAll(".fp-dash-notes script"));
|
||||
Assert.Empty(cut.FindAll(".fp-dash-note-title b"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parent_can_add_a_note_through_the_dialog()
|
||||
{
|
||||
Seed(canEdit: true);
|
||||
SignInAs(Roles.Family);
|
||||
var cut = Render<DashboardHost>();
|
||||
|
||||
cut.Find(".fp-note-add").Click();
|
||||
cut.WaitForElement(".fp-note-title input").Change("Ferien");
|
||||
cut.Find(".fp-note-text textarea").Change("bis 7. November");
|
||||
cut.Find(".fp-note-save").Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal("Ferien", cut.Find(".fp-dash-note-title").TextContent));
|
||||
var created = Assert.Single(_api.Created);
|
||||
Assert.Equal(("Ferien", "bis 7. November"), (created.Title, created.Text));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dialog_validates_before_saving()
|
||||
{
|
||||
Seed(canEdit: true);
|
||||
SignInAs(Roles.Family);
|
||||
var cut = Render<DashboardHost>();
|
||||
|
||||
cut.Find(".fp-note-add").Click();
|
||||
cut.WaitForElement(".fp-note-save").Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("Bitte einen Titel eingeben.", cut.Markup));
|
||||
Assert.Empty(_api.Created);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parent_can_edit_and_delete_a_note()
|
||||
{
|
||||
var note = new DashboardNoteDto(Guid.NewGuid(), "Wlan", "alt");
|
||||
Seed(canEdit: true, note);
|
||||
SignInAs(Roles.Admin);
|
||||
var cut = Render<DashboardHost>();
|
||||
|
||||
cut.Find(".fp-note-edit").Click();
|
||||
cut.WaitForElement(".fp-note-text textarea").Change("neu");
|
||||
cut.Find(".fp-note-save").Click();
|
||||
cut.WaitForAssertion(() => Assert.Equal("neu", cut.Find(".fp-dash-note-text").TextContent));
|
||||
Assert.Equal((note.Id, "Wlan", "neu"), (_api.Updated[0].Id, _api.Updated[0].Request.Title, _api.Updated[0].Request.Text));
|
||||
|
||||
cut.Find(".fp-note-delete").Click();
|
||||
cut.WaitForElement(".mud-message-box button.mud-button-filled, .mud-dialog button.mud-button-text");
|
||||
cut.FindAll("button").Single(b => b.TextContent.Trim() == "Löschen" && b.ClassList.Contains("mud-button-root") && !b.ClassList.Contains("fp-note-delete")).Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal([note.Id], _api.DeletedNotes));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Quick_add_asks_children_when_the_server_wants_a_confirmation()
|
||||
{
|
||||
_shopping.RejectSuspiciousWithoutConfirm = true;
|
||||
_api.Data = new DashboardDto(Today, null, null, new DashboardShoppingDto(FakeShoppingApi.SupermarketId, "Supermarkt", 0, 0, true), null, null, null);
|
||||
SignInAs(Roles.Child);
|
||||
var cut = Render<DashboardHost>();
|
||||
|
||||
var input = cut.Find(".fp-dash-quickadd input");
|
||||
input.Input("asdf");
|
||||
input.KeyUp(new Microsoft.AspNetCore.Components.Web.KeyboardEventArgs { Key = "Enter" });
|
||||
cut.WaitForAssertion(() => Assert.Contains("sieht nicht nach einem Artikel aus", cut.Markup));
|
||||
Assert.Empty(_shopping.Items);
|
||||
cut.FindAll("button").Single(b => b.TextContent.Trim() == "Ja, hinzufügen").Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Single(_shopping.Items));
|
||||
Assert.True(_shopping.AddRequests[^1].Confirm);
|
||||
}
|
||||
}
|
||||
@@ -16,4 +16,38 @@ internal sealed class FakeDashboardApi : IDashboardApi
|
||||
Calls++;
|
||||
return Task.FromResult(Offline ? ApiResult<DashboardDto>.Fail(ApiProblem.Offline) : ApiResult<DashboardDto>.Ok(Data));
|
||||
}
|
||||
|
||||
public List<SaveDashboardNoteRequest> Created { get; } = [];
|
||||
|
||||
public List<(Guid Id, SaveDashboardNoteRequest Request)> Updated { get; } = [];
|
||||
|
||||
public List<Guid> DeletedNotes { get; } = [];
|
||||
|
||||
private List<DashboardNoteDto> Notes => [.. Data.Notes?.Items ?? []];
|
||||
|
||||
private void SetNotes(List<DashboardNoteDto> notes) =>
|
||||
Data = Data with { Notes = new DashboardNotesDto(notes, Data.Notes?.CanEdit ?? false) };
|
||||
|
||||
public Task<ApiResult<DashboardNoteDto>> CreateNoteAsync(SaveDashboardNoteRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Created.Add(request);
|
||||
var note = new DashboardNoteDto(Guid.NewGuid(), request.Title, request.Text);
|
||||
SetNotes([.. Notes, note]);
|
||||
return Task.FromResult(ApiResult<DashboardNoteDto>.Ok(note));
|
||||
}
|
||||
|
||||
public Task<ApiResult<DashboardNoteDto>> UpdateNoteAsync(Guid id, SaveDashboardNoteRequest request, CancellationToken ct = default)
|
||||
{
|
||||
Updated.Add((id, request));
|
||||
var note = new DashboardNoteDto(id, request.Title, request.Text);
|
||||
SetNotes(Notes.Select(n => n.Id == id ? note : n).ToList());
|
||||
return Task.FromResult(ApiResult<DashboardNoteDto>.Ok(note));
|
||||
}
|
||||
|
||||
public Task<ApiResult<bool>> DeleteNoteAsync(Guid id, CancellationToken ct = default)
|
||||
{
|
||||
DeletedNotes.Add(id);
|
||||
SetNotes(Notes.Where(n => n.Id != id).ToList());
|
||||
return Task.FromResult(ApiResult<bool>.Ok(true));
|
||||
}
|
||||
}
|
||||
@@ -28,15 +28,20 @@ internal sealed class FakeShoppingApi : IShoppingApi
|
||||
|
||||
public int AddCalls { get; private set; }
|
||||
|
||||
/// <summary>Wie die Api bei Kindern: verdächtige Einträge werden ohne <c>Confirm</c> mit 409 abgelehnt.</summary>
|
||||
public bool RejectSuspiciousWithoutConfirm { get; set; }
|
||||
|
||||
public List<AddShoppingItemRequest> AddRequests { get; } = [];
|
||||
|
||||
/// <summary>Ids, deren Löschung mit 403 abgelehnt wird.</summary>
|
||||
public HashSet<Guid> ForbiddenDeletes { get; } = [];
|
||||
|
||||
private int _version;
|
||||
|
||||
public ShoppingItemDto Seed(string name, string category, bool done = false, Guid? createdBy = null, Guid? listId = null)
|
||||
public ShoppingItemDto Seed(string name, string category, bool done = false, Guid? createdBy = null, Guid? listId = null, string? createdByName = null)
|
||||
{
|
||||
var item = new ShoppingItemDto(Guid.NewGuid(), listId ?? SupermarketId, name, null, category, done,
|
||||
done ? DateTimeOffset.UtcNow : null, createdBy, DateTimeOffset.UtcNow.AddMinutes(-Items.Count), NextVersion());
|
||||
done ? DateTimeOffset.UtcNow : null, createdBy, DateTimeOffset.UtcNow.AddMinutes(-Items.Count), NextVersion(), createdByName);
|
||||
Items[item.Id] = item;
|
||||
return item;
|
||||
}
|
||||
@@ -60,7 +65,21 @@ internal sealed class FakeShoppingApi : IShoppingApi
|
||||
public Task<ApiResult<List<ShoppingItemDto>>> GetItemsAsync(Guid listId, CancellationToken ct = default) =>
|
||||
Result(() => Items.Values.Where(i => i.ListId == listId).ToList());
|
||||
|
||||
public Task<ApiResult<ShoppingItemDto>> AddItemAsync(Guid listId, AddShoppingItemRequest request, CancellationToken ct = default) =>
|
||||
public Task<ApiResult<ShoppingItemDto>> AddItemAsync(Guid listId, AddShoppingItemRequest request, CancellationToken ct = default)
|
||||
{
|
||||
if (!Offline)
|
||||
{
|
||||
AddRequests.Add(request);
|
||||
if (RejectSuspiciousWithoutConfirm && !request.Confirm && ShoppingPlausibility.IsSuspicious(request.Text))
|
||||
{
|
||||
return Task.FromResult(ApiResult<ShoppingItemDto>.Fail(new ApiProblem { Status = 409, NeedsConfirmation = true, Detail = "Rückfrage nötig" }));
|
||||
}
|
||||
}
|
||||
|
||||
return AddItemCoreAsync(listId, request);
|
||||
}
|
||||
|
||||
private Task<ApiResult<ShoppingItemDto>> AddItemCoreAsync(Guid listId, AddShoppingItemRequest request) =>
|
||||
Result<ShoppingItemDto>(() =>
|
||||
{
|
||||
AddCalls++;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
@* Testhülle: Dialog-Provider + Einkaufsseite, damit Rückfragen gerendert werden. *@
|
||||
<MudPopoverProvider />
|
||||
<MudDialogProvider />
|
||||
<MudSnackbarProvider />
|
||||
<FamilyPlanner.Web.Client.Pages.Shopping />
|
||||
@@ -138,6 +138,78 @@ public sealed class ShoppingPageTests : BunitContext
|
||||
Assert.Contains("Brot", cut.Find(".fp-item-name").TextContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Item_shows_who_added_it()
|
||||
{
|
||||
_api.Seed("Milch", ShoppingCategories.Dairy, createdBy: UserId, createdByName: "Mia");
|
||||
_api.Seed("Brot", ShoppingCategories.Bakery);
|
||||
SignInAs(Roles.Family);
|
||||
|
||||
var cut = Render<ShoppingPage>();
|
||||
|
||||
var hints = cut.FindAll(".fp-item-addedby").Select(h => h.TextContent.Trim()).ToList();
|
||||
Assert.Equal(["hinzugefügt von Mia"], hints);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Child_is_asked_before_nonsense_is_added_and_can_decline()
|
||||
{
|
||||
SignInAs(Roles.Child);
|
||||
var cut = Render<ShoppingHost>();
|
||||
|
||||
cut.Find("#shopping-input").Input("asdfgh");
|
||||
cut.Find("form.fp-quick-add").Submit();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("sieht nicht nach einem Artikel aus", cut.Markup));
|
||||
Assert.Empty(_api.Items);
|
||||
cut.FindAll("button").Single(b => b.TextContent.Trim() == "Nein").Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.DoesNotContain("sieht nicht nach einem Artikel aus", cut.Markup));
|
||||
Assert.Empty(_api.Items);
|
||||
Assert.Equal("asdfgh", cut.Find("#shopping-input").GetAttribute("value"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Child_can_confirm_nonsense_and_it_is_sent_with_confirm()
|
||||
{
|
||||
SignInAs(Roles.Child);
|
||||
var cut = Render<ShoppingHost>();
|
||||
|
||||
cut.Find("#shopping-input").Input("asdfgh");
|
||||
cut.Find("form.fp-quick-add").Submit();
|
||||
cut.WaitForAssertion(() => Assert.Contains("sieht nicht nach einem Artikel aus", cut.Markup));
|
||||
cut.FindAll("button").Single(b => b.TextContent.Trim() == "Ja, hinzufügen").Click();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal("Asdfgh", Assert.Single(_api.Items.Values).Name));
|
||||
Assert.True(Assert.Single(_api.AddRequests).Confirm);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Child_is_not_asked_for_normal_items()
|
||||
{
|
||||
SignInAs(Roles.Child);
|
||||
var cut = Render<ShoppingHost>();
|
||||
|
||||
cut.Find("#shopping-input").Input("Milch");
|
||||
cut.Find("form.fp-quick-add").Submit();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Single(_api.Items));
|
||||
Assert.DoesNotContain("sieht nicht nach einem Artikel aus", cut.Markup);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parents_are_never_asked()
|
||||
{
|
||||
SignInAs(Roles.Family);
|
||||
var cut = Render<ShoppingHost>();
|
||||
|
||||
cut.Find("#shopping-input").Input("asdfgh");
|
||||
cut.Find("form.fp-quick-add").Submit();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Single(_api.Items));
|
||||
Assert.DoesNotContain("sieht nicht nach einem Artikel aus", cut.Markup);
|
||||
}
|
||||
|
||||
private sealed class NoopAuthService : IAuthService
|
||||
{
|
||||
public Task<CurrentUserDto?> GetCurrentUserAsync(CancellationToken cancellationToken = default) => Task.FromResult<CurrentUserDto?>(null);
|
||||
|
||||
@@ -182,4 +182,51 @@ public sealed class ShoppingStoreTests
|
||||
Assert.Equal(ShoppingCategories.Other, _api.Items.Values.Single().Category);
|
||||
Assert.Equal(ShoppingCategories.Other, store.Items.Single().Category);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Server_confirmation_request_is_asked_and_resent_with_confirm()
|
||||
{
|
||||
_api.RejectSuspiciousWithoutConfirm = true;
|
||||
var store = CreateStore();
|
||||
var asked = new List<string>();
|
||||
store.ConfirmSuspicious = text =>
|
||||
{
|
||||
asked.Add(text);
|
||||
return Task.FromResult(true);
|
||||
};
|
||||
await store.InitializeAsync();
|
||||
|
||||
await store.AddAsync("asdf", UserId);
|
||||
|
||||
Assert.Equal(["asdf"], asked);
|
||||
Assert.Equal(["Asdf"], _api.Items.Values.Select(i => i.Name));
|
||||
Assert.True(_api.AddRequests[^1].Confirm);
|
||||
Assert.Equal(0, store.PendingCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Declined_confirmation_drops_the_entry()
|
||||
{
|
||||
_api.RejectSuspiciousWithoutConfirm = true;
|
||||
var store = CreateStore();
|
||||
store.ConfirmSuspicious = _ => Task.FromResult(false);
|
||||
await store.InitializeAsync();
|
||||
|
||||
await store.AddAsync("asdf", UserId);
|
||||
|
||||
Assert.Empty(_api.Items);
|
||||
Assert.Empty(store.Items);
|
||||
Assert.Equal(0, store.PendingCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Entries_confirmed_up_front_are_sent_with_confirm()
|
||||
{
|
||||
var store = CreateStore();
|
||||
await store.InitializeAsync();
|
||||
|
||||
await store.AddAsync("asdf", UserId, "Mia", confirmed: true);
|
||||
|
||||
Assert.True(Assert.Single(_api.AddRequests).Confirm);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user