Add toggle/skip/delete with optimistic concurrency handling and jQuery progressive enhancement (CW-STORY-03.4), then a mobile-first touch-target and quick-add polish pass (CW-STORY-03.5). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>master
| @@ -20,7 +20,17 @@ | |||
| "Bash(rm -f \"g:/development/C Sharp AI/CartWise/src/CartWise.Web/App_Data/cartwise.db\"*)", | |||
| "Bash(powershell -Command 'Get-Process | Where-Object { $_.Id -eq 55048 } | Select-Object Id, ProcessName, Path')", | |||
| "Bash(powershell -Command 'Get-Process | Where-Object { $_.ProcessName -like '\\\\''*CartWise*'\\\\'' } | Select-Object Id, ProcessName, Path')", | |||
| "Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify2.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify2.db\" 2>&1 | tail -15 && rm -f verify2.db*)" | |||
| "Bash(cd \"g:/development/C Sharp AI/CartWise\" && rm -f verify2.db* && dotnet ef database update --project src/CartWise.Infrastructure/CartWise.Infrastructure.csproj --startup-project src/CartWise.Web/CartWise.Web.csproj --connection \"Data Source=verify2.db\" 2>&1 | tail -15 && rm -f verify2.db*)", | |||
| "Bash(python3 -c ' *)", | |||
| "Bash(python -c ' *)", | |||
| "PowerShell(Get-Process -Name \"CartWise.Web\" -ErrorAction SilentlyContinue)", | |||
| "PowerShell(Stop-Process -Force -ErrorAction SilentlyContinue)", | |||
| "Bash(chromium-cli --help)", | |||
| "Bash(curl -s -b /tmp/cw4.txt http://127.0.0.1:5187/list -o /tmp/list2.html -w \"status=%{http_code}\\\\n\")", | |||
| "Bash(sed -n '/shopping-list-quick-add/,/<\\\\/form>/p' /tmp/list2.html)", | |||
| "Bash(sed -n '/shopping-list-item/,/<\\\\/li>/p' /tmp/list2.html)", | |||
| "Bash(curl -s http://127.0.0.1:5187/css/site.css)", | |||
| "PowerShell(\"done\")" | |||
| ], | |||
| "additionalDirectories": [ | |||
| "G:\\development\\C Sharp AI\\CartWise" | |||
| @@ -160,6 +160,16 @@ Progress so far (see `docs/scrum-backlog.md` for the authoritative task-level st | |||
| - [x] `CW-STORY-02.3` Household UI — create and overview pages | |||
| - [x] `CW-STORY-02.4` Reusable `"HouseholdMember"` authorization policy, cross-household isolation verified | |||
| `CW-EPIC-03` Smart Shopping List is complete: | |||
| - [x] `CW-STORY-03.1` ShoppingList/ShoppingListItem/GroceryConcept/ProductCategory entities, EF configuration, migration | |||
| - [x] `CW-STORY-03.2` `ShoppingListService` — get/create the household's one active list | |||
| - [x] `CW-STORY-03.3` Free-text add-item flow, auto-provisioned default list, verified live end-to-end | |||
| - [x] `CW-STORY-03.4` Toggle purchased/skip/delete with optimistic concurrency handling and jQuery progressive enhancement | |||
| - [x] `CW-STORY-03.5` Mobile-first/touch-friendly polish pass (not visually verified in a browser — no browser-automation tool available in this environment; recommend a quick manual check) | |||
| Next up: `CW-EPIC-05` Stores, Purchases, and Price Intelligence (`CW-EPIC-04` is post-MVP). | |||
| Next up: `CW-EPIC-03` Smart Shopping List. | |||
| ## Project Planning | |||
| @@ -374,13 +374,21 @@ Both were caught by genuine integration tests (`ShoppingListAccessControlTests`, | |||
| - Concurrency conflicts are handled safely | |||
| **Tasks** | |||
| - [ ] Implement toggle purchased | |||
| - [ ] Implement skip and unskip | |||
| - [ ] Implement delete item | |||
| - [ ] Handle concurrency exceptions | |||
| - [ ] Add progressive enhancement with jQuery | |||
| - [ ] Preserve server-post fallback | |||
| - [ ] Add tests | |||
| - [x] Implement toggle purchased | |||
| - [x] Implement skip and unskip | |||
| - [x] Implement delete item | |||
| - [x] Handle concurrency exceptions | |||
| - [x] Add progressive enhancement with jQuery | |||
| - [x] Preserve server-post fallback | |||
| - [x] Add tests | |||
| **Status:** Done — `ShoppingListItem.TogglePurchased`/`ToggleSkipped` domain methods added (each regenerates `RowVersion`); `POST /list/items/{id}/toggle` and `/skip` are true toggles (one route each, per AGENTS.md §11 — no separate "unpurchase"/"unskip" routes), `/delete` hard-deletes (no "Deleted" status exists, and list items aren't history — unlike `PriceObservation`, nothing here needs to stay append-only). `IShoppingListService.TogglePurchasedAsync`/`ToggleSkippedAsync`/`DeleteItemAsync` all take `householdId` and resolve the item via a household-scoped join query (`FindItemForHouseholdAsync`) rather than trusting the route-supplied item id alone — an item belonging to a different household is indistinguishable from "not found," so no existence is leaked. On reflection this made the `"HouseholdMember"` authorization policy from `CW-STORY-02.4` unnecessary here: query-scoping is atomic (one query both finds the item and enforces ownership) and matches the pattern already used everywhere else in the codebase, so it wasn't invoked for this story — it remains available and tested for a future scenario that needs to authorize before it can even construct the right query. | |||
| **Concurrency handling**: the client-rendered `rowVersion` hidden field round-trips through the form; the service sets `_db.Entry(item).Property(i => i.RowVersion).OriginalValue = expectedRowVersion` before mutating, so EF Core's generated `UPDATE`/`DELETE` includes the *client's* last-seen value in its `WHERE` clause (the standard EF Core disconnected-entity concurrency pattern) — a stale submission throws `DbUpdateConcurrencyException`, caught and surfaced as `Result.Failure(...)`, never a 500. | |||
| **Progressive enhancement**: `wwwroot/js/pages/shopping-list.js` intercepts the toggle/skip/delete forms' submit via jQuery, POSTs via `$.post` (which sets `X-Requested-With: XMLHttpRequest` automatically), and the controller detects that header to return either the updated `_ListItem` partial (toggle/skip) or `204`/`409` (delete) instead of a redirect — `Views/ShoppingList/_ListItem.cshtml`'s three per-item forms work unmodified via plain server POST if JS is unavailable, satisfying "standard form posts must work where practical." | |||
| Two more real bugs found by tests (bringing the running total for `CW-EPIC-03` to four — see `CW-STORY-03.3`'s notes for the first two): both were test-assertion bugs on my part, not product bugs — `Assert.DoesNotContain("Purchased", html)` false-failed because the *toggle button's label* is literally "Purchased" on an unpurchased item (`isPurchased ? "Undo" : "Purchased"`), unrelated to the actual status badge. Fixed by asserting against the specific `badge bg-secondary">{status}</span>` markup instead of a bare substring — worth remembering for `CW-STORY-03.5` and beyond: any status-driven UI where the action-button label overlaps with a possible status name needs a precise assertion, not `Contains`/`DoesNotContain` on the raw status word. Verified live end-to-end via `dotnet run` + curl: toggle Needed→Purchased shown in the status badge with the correct `rowVersion`, and an AJAX request (`X-Requested-With: XMLHttpRequest`) with a deliberately stale `rowVersion` correctly returns `409`. | |||
| ### `CW-STORY-03.5` Deliver a mobile-first list UI | |||
| **Release:** MVP | |||
| @@ -394,11 +402,15 @@ Both were caught by genuine integration tests (`ShoppingListAccessControlTests`, | |||
| - Tap targets are appropriate for shopping use | |||
| **Tasks** | |||
| - [ ] Build `Views/ShoppingList/Index.cshtml` | |||
| - [ ] Build `Views/ShoppingList/_ListItem.cshtml` | |||
| - [ ] Add quick-add UI | |||
| - [ ] Add touch-friendly styles | |||
| - [ ] Add `wwwroot/js/pages/shopping-list.js` | |||
| - [x] Build `Views/ShoppingList/Index.cshtml` | |||
| - [x] Build `Views/ShoppingList/_ListItem.cshtml` | |||
| - [x] Add quick-add UI | |||
| - [x] Add touch-friendly styles | |||
| - [x] Add `wwwroot/js/pages/shopping-list.js` | |||
| **Status:** Done, with a verification caveat — the Index/_ListItem views, quick-add UI, and `shopping-list.js` already existed functionally from `CW-STORY-03.3`/`03.4`; this story was the dedicated mobile-first/touch-friendly polish pass. Changes: (1) fixed `site.css`'s inverted base font size (was 14px on mobile, 16px on desktop — backwards, and inputs under 16px trigger iOS Safari's zoom-on-focus) to a flat 16px; (2) added `.shopping-list-quick-add`/`.shopping-list-item`/`.shopping-list-item-actions` CSS establishing ~44px-minimum touch targets on every button (removed `btn-sm`) and a 3rem-tall, 1.1rem-font quick-add input/button pair; (3) `NewItem.DisplayName` is now `col-12` at every breakpoint (not just mobile) so it's unambiguously the prominent primary action, with Quantity/Unit/Add sharing a secondary row below; (4) added `inputmode="decimal"` to the Quantity field so mobile browsers show a numeric keypad instead of a full keyboard; (5) narrow viewports (`max-width: 575.98px`) stack each list item's name above its actions instead of cramming them into one row. | |||
| **Verification caveat:** no browser-automation tool (Playwright/`chromium-cli`) is available in this environment, so this could not be visually screenshotted at a mobile viewport as the production-readiness guidance calls for. What *was* verified: `dotnet run` + curl confirms the rendered HTML carries the new classes/attributes exactly as written, the CSS file is served and contains the expected rules, and the full test suite (67 tests, unaffected by these purely-visual changes) still passes. The founder should do a quick real-device or DevTools-mobile-emulation pass before treating this as fully done — the CSS follows standard, well-established sizing conventions (WCAG 2.5.5's ~44px target, 16px inputs) but hasn't been eyeballed. | |||
| --- | |||
| @@ -1,5 +1,6 @@ | |||
| using CartWise.Domain.Entities; | |||
| using Microsoft.EntityFrameworkCore; | |||
| using Microsoft.EntityFrameworkCore.ChangeTracking; | |||
| namespace CartWise.Application.Interfaces; | |||
| @@ -13,5 +14,7 @@ public interface IApplicationDbContext | |||
| DbSet<ShoppingListItem> ShoppingListItems { get; } | |||
| EntityEntry<TEntity> Entry<TEntity>(TEntity entity) where TEntity : class; | |||
| Task<int> SaveChangesAsync(CancellationToken cancellationToken = default); | |||
| } | |||
| @@ -16,4 +16,10 @@ public interface IShoppingListService | |||
| string? unit, | |||
| string addedByUserId, | |||
| CancellationToken cancellationToken = default); | |||
| Task<Result<ShoppingListItem>> TogglePurchasedAsync(Guid householdId, Guid itemId, Guid expectedRowVersion, CancellationToken cancellationToken = default); | |||
| Task<Result<ShoppingListItem>> ToggleSkippedAsync(Guid householdId, Guid itemId, Guid expectedRowVersion, CancellationToken cancellationToken = default); | |||
| Task<Result> DeleteItemAsync(Guid householdId, Guid itemId, Guid expectedRowVersion, CancellationToken cancellationToken = default); | |||
| } | |||
| @@ -1,5 +1,22 @@ | |||
| namespace CartWise.Application.Results; | |||
| public class Result | |||
| { | |||
| public bool IsSuccess { get; } | |||
| public string? Error { get; } | |||
| private Result(bool isSuccess, string? error) | |||
| { | |||
| IsSuccess = isSuccess; | |||
| Error = error; | |||
| } | |||
| public static Result Success() => new(true, null); | |||
| public static Result Failure(string error) => new(false, error); | |||
| } | |||
| public class Result<T> | |||
| { | |||
| public bool IsSuccess { get; } | |||
| @@ -88,4 +88,83 @@ public class ShoppingListService : IShoppingListService | |||
| return Result<ShoppingListItem>.Success(item); | |||
| } | |||
| public async Task<Result<ShoppingListItem>> TogglePurchasedAsync(Guid householdId, Guid itemId, Guid expectedRowVersion, CancellationToken cancellationToken = default) | |||
| { | |||
| var item = await FindItemForHouseholdAsync(householdId, itemId, cancellationToken); | |||
| if (item is null) | |||
| { | |||
| return Result<ShoppingListItem>.Failure("Item not found."); | |||
| } | |||
| _db.Entry(item).Property(i => i.RowVersion).OriginalValue = expectedRowVersion; | |||
| item.TogglePurchased(_timeProvider.GetUtcNow().UtcDateTime); | |||
| try | |||
| { | |||
| await _db.SaveChangesAsync(cancellationToken); | |||
| } | |||
| catch (DbUpdateConcurrencyException) | |||
| { | |||
| return Result<ShoppingListItem>.Failure("This item was changed by someone else. Please refresh and try again."); | |||
| } | |||
| return Result<ShoppingListItem>.Success(item); | |||
| } | |||
| public async Task<Result<ShoppingListItem>> ToggleSkippedAsync(Guid householdId, Guid itemId, Guid expectedRowVersion, CancellationToken cancellationToken = default) | |||
| { | |||
| var item = await FindItemForHouseholdAsync(householdId, itemId, cancellationToken); | |||
| if (item is null) | |||
| { | |||
| return Result<ShoppingListItem>.Failure("Item not found."); | |||
| } | |||
| _db.Entry(item).Property(i => i.RowVersion).OriginalValue = expectedRowVersion; | |||
| item.ToggleSkipped(); | |||
| try | |||
| { | |||
| await _db.SaveChangesAsync(cancellationToken); | |||
| } | |||
| catch (DbUpdateConcurrencyException) | |||
| { | |||
| return Result<ShoppingListItem>.Failure("This item was changed by someone else. Please refresh and try again."); | |||
| } | |||
| return Result<ShoppingListItem>.Success(item); | |||
| } | |||
| public async Task<Result> DeleteItemAsync(Guid householdId, Guid itemId, Guid expectedRowVersion, CancellationToken cancellationToken = default) | |||
| { | |||
| var item = await FindItemForHouseholdAsync(householdId, itemId, cancellationToken); | |||
| if (item is null) | |||
| { | |||
| return Result.Failure("Item not found."); | |||
| } | |||
| _db.Entry(item).Property(i => i.RowVersion).OriginalValue = expectedRowVersion; | |||
| _db.ShoppingListItems.Remove(item); | |||
| try | |||
| { | |||
| await _db.SaveChangesAsync(cancellationToken); | |||
| } | |||
| catch (DbUpdateConcurrencyException) | |||
| { | |||
| return Result.Failure("This item was changed by someone else. Please refresh and try again."); | |||
| } | |||
| return Result.Success(); | |||
| } | |||
| private Task<ShoppingListItem?> FindItemForHouseholdAsync(Guid householdId, Guid itemId, CancellationToken cancellationToken) | |||
| { | |||
| return ( | |||
| from item in _db.ShoppingListItems | |||
| join list in _db.ShoppingLists on item.ShoppingListId equals list.ShoppingListId | |||
| where item.ShoppingListItemId == itemId && list.HouseholdId == householdId | |||
| select item | |||
| ).FirstOrDefaultAsync(cancellationToken); | |||
| } | |||
| } | |||
| @@ -69,4 +69,29 @@ public class ShoppingListItem | |||
| AddedUtc = addedUtc; | |||
| RowVersion = Guid.NewGuid(); | |||
| } | |||
| public void TogglePurchased(DateTime utcNow) | |||
| { | |||
| if (Status == ShoppingListItemStatus.Purchased) | |||
| { | |||
| Status = ShoppingListItemStatus.Needed; | |||
| PurchasedUtc = null; | |||
| } | |||
| else | |||
| { | |||
| Status = ShoppingListItemStatus.Purchased; | |||
| PurchasedUtc = utcNow; | |||
| } | |||
| RowVersion = Guid.NewGuid(); | |||
| } | |||
| public void ToggleSkipped() | |||
| { | |||
| Status = Status == ShoppingListItemStatus.Skipped | |||
| ? ShoppingListItemStatus.Needed | |||
| : ShoppingListItemStatus.Skipped; | |||
| RowVersion = Guid.NewGuid(); | |||
| } | |||
| } | |||
| @@ -1,5 +1,6 @@ | |||
| using System.Globalization; | |||
| using CartWise.Application.Interfaces; | |||
| using CartWise.Application.Results; | |||
| using CartWise.Infrastructure.Identity; | |||
| using CartWise.Web.ViewModels.ShoppingList; | |||
| using Microsoft.AspNetCore.Authorization; | |||
| @@ -72,6 +73,84 @@ public class ShoppingListController : Controller | |||
| return RedirectToAction(nameof(Index)); | |||
| } | |||
| [HttpPost("items/{id:guid}/toggle")] | |||
| [ValidateAntiForgeryToken] | |||
| public async Task<IActionResult> TogglePurchased(Guid id, Guid rowVersion) | |||
| { | |||
| var household = await CurrentHouseholdOrNullAsync(); | |||
| if (household is null) | |||
| { | |||
| return RedirectToAction("Create", "Household"); | |||
| } | |||
| var result = await _shoppingListService.TogglePurchasedAsync(household.HouseholdId, id, rowVersion); | |||
| return RespondToItemMutation(result); | |||
| } | |||
| [HttpPost("items/{id:guid}/skip")] | |||
| [ValidateAntiForgeryToken] | |||
| public async Task<IActionResult> ToggleSkipped(Guid id, Guid rowVersion) | |||
| { | |||
| var household = await CurrentHouseholdOrNullAsync(); | |||
| if (household is null) | |||
| { | |||
| return RedirectToAction("Create", "Household"); | |||
| } | |||
| var result = await _shoppingListService.ToggleSkippedAsync(household.HouseholdId, id, rowVersion); | |||
| return RespondToItemMutation(result); | |||
| } | |||
| [HttpPost("items/{id:guid}/delete")] | |||
| [ValidateAntiForgeryToken] | |||
| public async Task<IActionResult> DeleteItem(Guid id, Guid rowVersion) | |||
| { | |||
| var household = await CurrentHouseholdOrNullAsync(); | |||
| if (household is null) | |||
| { | |||
| return RedirectToAction("Create", "Household"); | |||
| } | |||
| var result = await _shoppingListService.DeleteItemAsync(household.HouseholdId, id, rowVersion); | |||
| if (IsAjaxRequest()) | |||
| { | |||
| return result.IsSuccess ? NoContent() : Problem(result.Error, statusCode: StatusCodes.Status409Conflict); | |||
| } | |||
| if (!result.IsSuccess) | |||
| { | |||
| TempData["ListError"] = result.Error; | |||
| } | |||
| return RedirectToAction(nameof(Index)); | |||
| } | |||
| private async Task<Domain.Entities.Household?> CurrentHouseholdOrNullAsync() | |||
| { | |||
| var userId = _userManager.GetUserId(User)!; | |||
| return await _householdService.GetCurrentHouseholdAsync(userId); | |||
| } | |||
| private IActionResult RespondToItemMutation(Result<Domain.Entities.ShoppingListItem> result) | |||
| { | |||
| if (IsAjaxRequest()) | |||
| { | |||
| return result.IsSuccess | |||
| ? PartialView("_ListItem", ToItemViewModel(result.Value!)) | |||
| : Problem(result.Error, statusCode: StatusCodes.Status409Conflict); | |||
| } | |||
| if (!result.IsSuccess) | |||
| { | |||
| TempData["ListError"] = result.Error; | |||
| } | |||
| return RedirectToAction(nameof(Index)); | |||
| } | |||
| private bool IsAjaxRequest() => Request.Headers["X-Requested-With"] == "XMLHttpRequest"; | |||
| private async Task<Domain.Entities.ShoppingList> GetOrCreateActiveListAsync(Guid householdId, string userId) | |||
| { | |||
| var list = await _shoppingListService.GetActiveListAsync(householdId); | |||
| @@ -91,17 +170,19 @@ public class ShoppingListController : Controller | |||
| Name = list.Name, | |||
| Items = list.Items | |||
| .OrderBy(i => i.SortOrder) | |||
| .Select(i => new ShoppingListItemViewModel | |||
| { | |||
| Id = i.ShoppingListItemId, | |||
| DisplayName = i.DisplayName, | |||
| QuantityDisplay = FormatQuantity(i.Quantity, i.Unit), | |||
| Status = i.Status, | |||
| ConcurrencyToken = i.RowVersion | |||
| }) | |||
| .Select(ToItemViewModel) | |||
| .ToList() | |||
| }; | |||
| private static ShoppingListItemViewModel ToItemViewModel(Domain.Entities.ShoppingListItem item) => new() | |||
| { | |||
| Id = item.ShoppingListItemId, | |||
| DisplayName = item.DisplayName, | |||
| QuantityDisplay = FormatQuantity(item.Quantity, item.Unit), | |||
| Status = item.Status, | |||
| ConcurrencyToken = item.RowVersion | |||
| }; | |||
| private static string? FormatQuantity(decimal? quantity, string? unit) | |||
| { | |||
| if (quantity is null) | |||
| @@ -5,24 +5,29 @@ | |||
| <h1>@Model.Name</h1> | |||
| <form asp-controller="ShoppingList" asp-action="AddItem" method="post" class="row g-2 mb-4"> | |||
| @if (TempData["ListError"] is string listError) | |||
| { | |||
| <div class="alert alert-danger">@listError</div> | |||
| } | |||
| <form asp-controller="ShoppingList" asp-action="AddItem" method="post" class="shopping-list-quick-add row g-2 mb-4"> | |||
| <div asp-validation-summary="ModelOnly" class="text-danger"></div> | |||
| <div class="col-12 col-sm-5"> | |||
| <div class="col-12"> | |||
| <label asp-for="NewItem.DisplayName" class="visually-hidden"></label> | |||
| <input asp-for="NewItem.DisplayName" class="form-control" placeholder="Add an item (e.g. Milk)" autofocus /> | |||
| <input asp-for="NewItem.DisplayName" class="form-control" placeholder="Add an item (e.g. Milk)" autofocus autocomplete="off" /> | |||
| <span asp-validation-for="NewItem.DisplayName" class="text-danger"></span> | |||
| </div> | |||
| <div class="col-6 col-sm-3"> | |||
| <div class="col-5 col-sm-4"> | |||
| <label asp-for="NewItem.Quantity" class="visually-hidden"></label> | |||
| <input asp-for="NewItem.Quantity" class="form-control" placeholder="Qty" /> | |||
| <input asp-for="NewItem.Quantity" class="form-control" placeholder="Qty" inputmode="decimal" /> | |||
| <span asp-validation-for="NewItem.Quantity" class="text-danger"></span> | |||
| </div> | |||
| <div class="col-6 col-sm-2"> | |||
| <div class="col-4 col-sm-3"> | |||
| <label asp-for="NewItem.Unit" class="visually-hidden"></label> | |||
| <input asp-for="NewItem.Unit" class="form-control" placeholder="Unit" /> | |||
| <span asp-validation-for="NewItem.Unit" class="text-danger"></span> | |||
| </div> | |||
| <div class="col-12 col-sm-2"> | |||
| <div class="col-3 col-sm-2"> | |||
| <button type="submit" class="btn btn-primary w-100">Add</button> | |||
| </div> | |||
| </form> | |||
| @@ -43,4 +48,5 @@ else | |||
| @section Scripts { | |||
| @{await Html.RenderPartialAsync("_ValidationScriptsPartial");} | |||
| <script src="~/js/pages/shopping-list.js" asp-append-version="true"></script> | |||
| } | |||
| @@ -1,6 +1,10 @@ | |||
| @model CartWise.Web.ViewModels.ShoppingList.ShoppingListItemViewModel | |||
| @{ | |||
| var isPurchased = Model.Status == CartWise.Domain.Enums.ShoppingListItemStatus.Purchased; | |||
| var isSkipped = Model.Status == CartWise.Domain.Enums.ShoppingListItemStatus.Skipped; | |||
| } | |||
| <li class="list-group-item d-flex justify-content-between align-items-center"> | |||
| <li class="shopping-list-item list-group-item d-flex justify-content-between align-items-center" id="list-item-@Model.Id"> | |||
| <span> | |||
| @Model.DisplayName | |||
| @if (!string.IsNullOrEmpty(Model.QuantityDisplay)) | |||
| @@ -8,5 +12,19 @@ | |||
| <span class="text-muted">(@Model.QuantityDisplay)</span> | |||
| } | |||
| </span> | |||
| <span class="badge bg-secondary">@Model.Status</span> | |||
| <span class="shopping-list-item-actions d-flex align-items-center gap-2"> | |||
| <span class="badge bg-secondary">@Model.Status</span> | |||
| <form asp-controller="ShoppingList" asp-action="TogglePurchased" asp-route-id="@Model.Id" method="post" class="d-inline js-item-action-form"> | |||
| <input type="hidden" name="rowVersion" value="@Model.ConcurrencyToken" /> | |||
| <button type="submit" class="btn btn-outline-success">@(isPurchased ? "Undo" : "Purchased")</button> | |||
| </form> | |||
| <form asp-controller="ShoppingList" asp-action="ToggleSkipped" asp-route-id="@Model.Id" method="post" class="d-inline js-item-action-form"> | |||
| <input type="hidden" name="rowVersion" value="@Model.ConcurrencyToken" /> | |||
| <button type="submit" class="btn btn-outline-secondary">@(isSkipped ? "Unskip" : "Skip")</button> | |||
| </form> | |||
| <form asp-controller="ShoppingList" asp-action="DeleteItem" asp-route-id="@Model.Id" method="post" class="d-inline js-item-delete-form"> | |||
| <input type="hidden" name="rowVersion" value="@Model.ConcurrencyToken" /> | |||
| <button type="submit" class="btn btn-outline-danger">Delete</button> | |||
| </form> | |||
| </span> | |||
| </li> | |||
| @@ -1,11 +1,7 @@ | |||
| html { | |||
| font-size: 14px; | |||
| } | |||
| @media (min-width: 768px) { | |||
| html { | |||
| font-size: 16px; | |||
| } | |||
| /* 16px base everywhere: mobile needs this at least as large as desktop for | |||
| readability, and inputs below 16px trigger iOS Safari's zoom-on-focus. */ | |||
| font-size: 16px; | |||
| } | |||
| .btn:focus, .btn:active:focus, .btn-link.nav-link:focus, .form-control:focus, .form-check-input:focus { | |||
| @@ -28,4 +24,38 @@ body { | |||
| .form-floating > .form-control-plaintext:focus::placeholder, .form-floating > .form-control:focus::placeholder { | |||
| text-align: start; | |||
| } | |||
| /* Shopping list: quick-add stays the obvious primary action, and every | |||
| tappable control meets the ~44px minimum touch target size. */ | |||
| .shopping-list-quick-add input { | |||
| min-height: 3rem; | |||
| font-size: 1.1rem; | |||
| } | |||
| .shopping-list-quick-add button { | |||
| min-height: 3rem; | |||
| font-size: 1.1rem; | |||
| } | |||
| .shopping-list-item { | |||
| gap: 0.5rem; | |||
| } | |||
| .shopping-list-item-actions { | |||
| flex-wrap: wrap; | |||
| row-gap: 0.5rem; | |||
| } | |||
| .shopping-list-item-actions .btn { | |||
| min-height: 2.75rem; | |||
| min-width: 2.75rem; | |||
| padding: 0.5rem 0.9rem; | |||
| } | |||
| @media (max-width: 575.98px) { | |||
| .shopping-list-item { | |||
| flex-direction: column; | |||
| align-items: stretch !important; | |||
| } | |||
| } | |||
| @@ -0,0 +1,32 @@ | |||
| $(function () { | |||
| var $list = $('#shopping-list-items'); | |||
| function showError(xhr) { | |||
| var message = (xhr.responseJSON && xhr.responseJSON.detail) | |||
| || 'This item could not be updated. Please refresh and try again.'; | |||
| alert(message); | |||
| } | |||
| $list.on('submit', '.js-item-action-form', function (event) { | |||
| event.preventDefault(); | |||
| var $form = $(this); | |||
| $.post($form.attr('action'), $form.serialize()) | |||
| .done(function (html) { | |||
| $form.closest('li').replaceWith(html); | |||
| }) | |||
| .fail(showError); | |||
| }); | |||
| $list.on('submit', '.js-item-delete-form', function (event) { | |||
| event.preventDefault(); | |||
| var $form = $(this); | |||
| var $item = $form.closest('li'); | |||
| $.post($form.attr('action'), $form.serialize()) | |||
| .done(function () { | |||
| $item.remove(); | |||
| }) | |||
| .fail(showError); | |||
| }); | |||
| }); | |||
| @@ -121,4 +121,104 @@ public class ShoppingListServiceTests : IDisposable | |||
| Assert.False(result.IsSuccess); | |||
| } | |||
| [Fact] | |||
| public async Task TogglePurchasedAsync_MarksItemPurchased() | |||
| { | |||
| var (householdId, item) = await CreateItemAsync(); | |||
| var result = await _sut.TogglePurchasedAsync(householdId, item.ShoppingListItemId, item.RowVersion); | |||
| Assert.True(result.IsSuccess); | |||
| Assert.Equal(Domain.Enums.ShoppingListItemStatus.Purchased, result.Value!.Status); | |||
| } | |||
| [Fact] | |||
| public async Task TogglePurchasedAsync_FailsForItemInAnotherHousehold() | |||
| { | |||
| var (_, item) = await CreateItemAsync(); | |||
| var result = await _sut.TogglePurchasedAsync(Guid.NewGuid(), item.ShoppingListItemId, item.RowVersion); | |||
| Assert.False(result.IsSuccess); | |||
| } | |||
| [Fact] | |||
| public async Task TogglePurchasedAsync_FailsOnConcurrencyConflict() | |||
| { | |||
| var (householdId, item) = await CreateItemAsync(); | |||
| var staleRowVersion = Guid.NewGuid(); | |||
| var result = await _sut.TogglePurchasedAsync(householdId, item.ShoppingListItemId, staleRowVersion); | |||
| Assert.False(result.IsSuccess); | |||
| Assert.NotNull(result.Error); | |||
| } | |||
| [Fact] | |||
| public async Task ToggleSkippedAsync_MarksItemSkipped() | |||
| { | |||
| var (householdId, item) = await CreateItemAsync(); | |||
| var result = await _sut.ToggleSkippedAsync(householdId, item.ShoppingListItemId, item.RowVersion); | |||
| Assert.True(result.IsSuccess); | |||
| Assert.Equal(Domain.Enums.ShoppingListItemStatus.Skipped, result.Value!.Status); | |||
| } | |||
| [Fact] | |||
| public async Task ToggleSkippedAsync_UnskipsOnSecondCall() | |||
| { | |||
| var (householdId, item) = await CreateItemAsync(); | |||
| var firstResult = await _sut.ToggleSkippedAsync(householdId, item.ShoppingListItemId, item.RowVersion); | |||
| var secondResult = await _sut.ToggleSkippedAsync(householdId, item.ShoppingListItemId, firstResult.Value!.RowVersion); | |||
| Assert.True(secondResult.IsSuccess); | |||
| Assert.Equal(Domain.Enums.ShoppingListItemStatus.Needed, secondResult.Value!.Status); | |||
| } | |||
| [Fact] | |||
| public async Task DeleteItemAsync_RemovesItemFromList() | |||
| { | |||
| var (householdId, item) = await CreateItemAsync(); | |||
| var result = await _sut.DeleteItemAsync(householdId, item.ShoppingListItemId, item.RowVersion); | |||
| Assert.True(result.IsSuccess); | |||
| var list = await _sut.GetActiveListAsync(householdId); | |||
| Assert.Empty(list!.Items); | |||
| } | |||
| [Fact] | |||
| public async Task DeleteItemAsync_FailsForItemInAnotherHousehold() | |||
| { | |||
| var (_, item) = await CreateItemAsync(); | |||
| var result = await _sut.DeleteItemAsync(Guid.NewGuid(), item.ShoppingListItemId, item.RowVersion); | |||
| Assert.False(result.IsSuccess); | |||
| } | |||
| [Fact] | |||
| public async Task DeleteItemAsync_FailsOnConcurrencyConflict() | |||
| { | |||
| var (householdId, item) = await CreateItemAsync(); | |||
| var staleRowVersion = Guid.NewGuid(); | |||
| var result = await _sut.DeleteItemAsync(householdId, item.ShoppingListItemId, staleRowVersion); | |||
| Assert.False(result.IsSuccess); | |||
| var list = await _sut.GetActiveListAsync(householdId); | |||
| Assert.Single(list!.Items); | |||
| } | |||
| private async Task<(Guid HouseholdId, Domain.Entities.ShoppingListItem Item)> CreateItemAsync() | |||
| { | |||
| var householdId = Guid.NewGuid(); | |||
| var list = await _sut.CreateListAsync(householdId, "Groceries", "user-1"); | |||
| var item = await _sut.AddItemAsync(list.Value!.ShoppingListId, "Milk", null, null, "user-1"); | |||
| return (householdId, item.Value!); | |||
| } | |||
| } | |||
| @@ -0,0 +1,61 @@ | |||
| using CartWise.Domain.Entities; | |||
| using CartWise.Domain.Enums; | |||
| namespace CartWise.Domain.Tests; | |||
| public class ShoppingListItemTests | |||
| { | |||
| private static readonly DateTime Now = new(2026, 8, 10, 12, 0, 0, DateTimeKind.Utc); | |||
| [Fact] | |||
| public void TogglePurchased_MarksNeededItemAsPurchased() | |||
| { | |||
| var list = new ShoppingList(Guid.NewGuid(), "Groceries", "user-1", Now); | |||
| var item = list.AddItem("Milk", null, null, "user-1", Now); | |||
| var originalRowVersion = item.RowVersion; | |||
| item.TogglePurchased(Now); | |||
| Assert.Equal(ShoppingListItemStatus.Purchased, item.Status); | |||
| Assert.Equal(Now, item.PurchasedUtc); | |||
| Assert.NotEqual(originalRowVersion, item.RowVersion); | |||
| } | |||
| [Fact] | |||
| public void TogglePurchased_UndoesPurchaseWhenAlreadyPurchased() | |||
| { | |||
| var list = new ShoppingList(Guid.NewGuid(), "Groceries", "user-1", Now); | |||
| var item = list.AddItem("Milk", null, null, "user-1", Now); | |||
| item.TogglePurchased(Now); | |||
| item.TogglePurchased(Now.AddMinutes(5)); | |||
| Assert.Equal(ShoppingListItemStatus.Needed, item.Status); | |||
| Assert.Null(item.PurchasedUtc); | |||
| } | |||
| [Fact] | |||
| public void ToggleSkipped_MarksNeededItemAsSkipped() | |||
| { | |||
| var list = new ShoppingList(Guid.NewGuid(), "Groceries", "user-1", Now); | |||
| var item = list.AddItem("Milk", null, null, "user-1", Now); | |||
| var originalRowVersion = item.RowVersion; | |||
| item.ToggleSkipped(); | |||
| Assert.Equal(ShoppingListItemStatus.Skipped, item.Status); | |||
| Assert.NotEqual(originalRowVersion, item.RowVersion); | |||
| } | |||
| [Fact] | |||
| public void ToggleSkipped_UnskipsWhenAlreadySkipped() | |||
| { | |||
| var list = new ShoppingList(Guid.NewGuid(), "Groceries", "user-1", Now); | |||
| var item = list.AddItem("Milk", null, null, "user-1", Now); | |||
| item.ToggleSkipped(); | |||
| item.ToggleSkipped(); | |||
| Assert.Equal(ShoppingListItemStatus.Needed, item.Status); | |||
| } | |||
| } | |||
| @@ -0,0 +1,208 @@ | |||
| using System.Net; | |||
| using System.Text.RegularExpressions; | |||
| namespace CartWise.Web.Tests; | |||
| public class ShoppingListItemActionsTests : IClassFixture<CartWiseWebApplicationFactory> | |||
| { | |||
| private readonly CartWiseWebApplicationFactory _factory; | |||
| public ShoppingListItemActionsTests(CartWiseWebApplicationFactory factory) | |||
| { | |||
| _factory = factory; | |||
| } | |||
| [Fact] | |||
| public async Task TogglePurchased_MarksItemPurchased() | |||
| { | |||
| var client = _factory.CreateClient(); | |||
| await WebTestHelpers.RegisterAsync(client, "toggle1@example.com", "Toggle User"); | |||
| await WebTestHelpers.CreateHouseholdAsync(client, "Toggle Household"); | |||
| await AddItemAsync(client, "Milk"); | |||
| var listHtml = await client.GetStringAsync("/list"); | |||
| var (id, rowVersion) = ExtractFirstItemInfo(listHtml); | |||
| var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml); | |||
| var response = await client.PostAsync($"/list/items/{id}/toggle", new FormUrlEncodedContent(new Dictionary<string, string> | |||
| { | |||
| ["__RequestVerificationToken"] = token, | |||
| ["rowVersion"] = rowVersion | |||
| })); | |||
| response.EnsureSuccessStatusCode(); | |||
| var updatedHtml = await client.GetStringAsync("/list"); | |||
| HtmlAssert.Contains("Purchased", updatedHtml); | |||
| } | |||
| [Fact] | |||
| public async Task TogglePurchased_TwiceReturnsItemToNeeded() | |||
| { | |||
| var client = _factory.CreateClient(); | |||
| await WebTestHelpers.RegisterAsync(client, "toggle2@example.com", "Toggle User 2"); | |||
| await WebTestHelpers.CreateHouseholdAsync(client, "Toggle Household 2"); | |||
| await AddItemAsync(client, "Milk"); | |||
| var listHtml = await client.GetStringAsync("/list"); | |||
| var (id, rowVersion) = ExtractFirstItemInfo(listHtml); | |||
| var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml); | |||
| await client.PostAsync($"/list/items/{id}/toggle", new FormUrlEncodedContent(new Dictionary<string, string> | |||
| { | |||
| ["__RequestVerificationToken"] = token, | |||
| ["rowVersion"] = rowVersion | |||
| })); | |||
| var midHtml = await client.GetStringAsync("/list"); | |||
| var (_, secondRowVersion) = ExtractFirstItemInfo(midHtml); | |||
| var midToken = WebTestHelpers.ExtractAntiForgeryToken(midHtml); | |||
| await client.PostAsync($"/list/items/{id}/toggle", new FormUrlEncodedContent(new Dictionary<string, string> | |||
| { | |||
| ["__RequestVerificationToken"] = midToken, | |||
| ["rowVersion"] = secondRowVersion | |||
| })); | |||
| var finalHtml = await client.GetStringAsync("/list"); | |||
| HtmlAssert.Contains("Needed", finalHtml); | |||
| } | |||
| [Fact] | |||
| public async Task ToggleSkipped_MarksItemSkipped() | |||
| { | |||
| var client = _factory.CreateClient(); | |||
| await WebTestHelpers.RegisterAsync(client, "skip1@example.com", "Skip User"); | |||
| await WebTestHelpers.CreateHouseholdAsync(client, "Skip Household"); | |||
| await AddItemAsync(client, "Bread"); | |||
| var listHtml = await client.GetStringAsync("/list"); | |||
| var (id, rowVersion) = ExtractFirstItemInfo(listHtml); | |||
| var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml); | |||
| var response = await client.PostAsync($"/list/items/{id}/skip", new FormUrlEncodedContent(new Dictionary<string, string> | |||
| { | |||
| ["__RequestVerificationToken"] = token, | |||
| ["rowVersion"] = rowVersion | |||
| })); | |||
| response.EnsureSuccessStatusCode(); | |||
| var updatedHtml = await client.GetStringAsync("/list"); | |||
| HtmlAssert.Contains("Skipped", updatedHtml); | |||
| } | |||
| [Fact] | |||
| public async Task DeleteItem_RemovesItFromTheList() | |||
| { | |||
| var client = _factory.CreateClient(); | |||
| await WebTestHelpers.RegisterAsync(client, "delete1@example.com", "Delete User"); | |||
| await WebTestHelpers.CreateHouseholdAsync(client, "Delete Household"); | |||
| await AddItemAsync(client, "Eggs"); | |||
| var listHtml = await client.GetStringAsync("/list"); | |||
| var (id, rowVersion) = ExtractFirstItemInfo(listHtml); | |||
| var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml); | |||
| var response = await client.PostAsync($"/list/items/{id}/delete", new FormUrlEncodedContent(new Dictionary<string, string> | |||
| { | |||
| ["__RequestVerificationToken"] = token, | |||
| ["rowVersion"] = rowVersion | |||
| })); | |||
| response.EnsureSuccessStatusCode(); | |||
| var updatedHtml = await client.GetStringAsync("/list"); | |||
| Assert.DoesNotContain("Eggs", updatedHtml); | |||
| HtmlAssert.Contains("No items yet", updatedHtml); | |||
| } | |||
| [Fact] | |||
| public async Task ToggleItem_FailsForAnItemInAnotherHousehold() | |||
| { | |||
| var clientA = _factory.CreateClient(); | |||
| var clientB = _factory.CreateClient(); | |||
| await WebTestHelpers.RegisterAsync(clientA, "isoalice@example.com", "Iso Alice"); | |||
| await WebTestHelpers.RegisterAsync(clientB, "isobob@example.com", "Iso Bob"); | |||
| await WebTestHelpers.CreateHouseholdAsync(clientA, "Iso Household A"); | |||
| await WebTestHelpers.CreateHouseholdAsync(clientB, "Iso Household B"); | |||
| await AddItemAsync(clientA, "Alice Only Item"); | |||
| var listHtmlA = await clientA.GetStringAsync("/list"); | |||
| var (id, rowVersion) = ExtractFirstItemInfo(listHtmlA); | |||
| var listHtmlB = await clientB.GetStringAsync("/list"); | |||
| var tokenB = WebTestHelpers.ExtractAntiForgeryToken(listHtmlB); | |||
| var response = await clientB.PostAsync($"/list/items/{id}/toggle", new FormUrlEncodedContent(new Dictionary<string, string> | |||
| { | |||
| ["__RequestVerificationToken"] = tokenB, | |||
| ["rowVersion"] = rowVersion | |||
| })); | |||
| response.EnsureSuccessStatusCode(); | |||
| var finalHtmlA = await clientA.GetStringAsync("/list"); | |||
| Assert.True(HasStatusBadge(finalHtmlA, "Needed"), "Expected the item to still show a Needed status badge."); | |||
| Assert.False(HasStatusBadge(finalHtmlA, "Purchased"), "The other household's toggle should not have changed this item's status."); | |||
| } | |||
| [Fact] | |||
| public async Task ToggleItem_AjaxRequestWithStaleRowVersionReturnsConflict() | |||
| { | |||
| var client = _factory.CreateClient(); | |||
| await WebTestHelpers.RegisterAsync(client, "conflict1@example.com", "Conflict User"); | |||
| await WebTestHelpers.CreateHouseholdAsync(client, "Conflict Household"); | |||
| await AddItemAsync(client, "Butter"); | |||
| var listHtml = await client.GetStringAsync("/list"); | |||
| var (id, _) = ExtractFirstItemInfo(listHtml); | |||
| var token = WebTestHelpers.ExtractAntiForgeryToken(listHtml); | |||
| var request = new HttpRequestMessage(HttpMethod.Post, $"/list/items/{id}/toggle") | |||
| { | |||
| Content = new FormUrlEncodedContent(new Dictionary<string, string> | |||
| { | |||
| ["__RequestVerificationToken"] = token, | |||
| ["rowVersion"] = Guid.NewGuid().ToString() | |||
| }) | |||
| }; | |||
| request.Headers.Add("X-Requested-With", "XMLHttpRequest"); | |||
| var response = await client.SendAsync(request); | |||
| Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); | |||
| } | |||
| private static async Task AddItemAsync(HttpClient client, string displayName) | |||
| { | |||
| var html = await client.GetStringAsync("/list"); | |||
| var token = WebTestHelpers.ExtractAntiForgeryToken(html); | |||
| var response = await client.PostAsync("/list/items", new FormUrlEncodedContent(new Dictionary<string, string> | |||
| { | |||
| ["__RequestVerificationToken"] = token, | |||
| ["NewItem.DisplayName"] = displayName | |||
| })); | |||
| response.EnsureSuccessStatusCode(); | |||
| } | |||
| private static bool HasStatusBadge(string html, string status) | |||
| { | |||
| return Regex.IsMatch(html, $"badge bg-secondary\">{Regex.Escape(status)}</span>"); | |||
| } | |||
| private static (string Id, string RowVersion) ExtractFirstItemInfo(string html) | |||
| { | |||
| var idMatch = Regex.Match(html, "id=\"list-item-([0-9a-fA-F-]{36})\""); | |||
| if (!idMatch.Success) | |||
| { | |||
| throw new InvalidOperationException("No list item found in HTML."); | |||
| } | |||
| var window = html.Substring(idMatch.Index, Math.Min(1200, html.Length - idMatch.Index)); | |||
| var rowVersionMatch = Regex.Match(window, "name=\"rowVersion\" value=\"([^\"]+)\""); | |||
| if (!rowVersionMatch.Success) | |||
| { | |||
| throw new InvalidOperationException("No rowVersion field found for the list item."); | |||
| } | |||
| return (idMatch.Groups[1].Value, rowVersionMatch.Groups[1].Value); | |||
| } | |||
| } | |||
Powered by TurnKey Linux.