Harden recipe edit page and cover allowedUnitCategories projection

Edit page now surfaces load and save errors instead of failing silently,
round-trips sourceUrl through PUT, warns before discarding unsaved
changes, and offers an explicit Cancel button. Delete moved off the
detail page's primary action row into a less-prominent footer link so a
mis-tap on Edit can no longer destroy the recipe. Added integration
tests covering AllowedUnitCategories in the recipe GET projection for
all four product-link shapes (global no-override, global with override,
family product, unlinked).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Josh Rogers
2026-05-14 19:01:06 -05:00
parent 4e4d80410c
commit 68292c2906
4 changed files with 200 additions and 42 deletions
@@ -24,7 +24,14 @@ public sealed class RecipeBuilder
public RecipeBuilder ForFamily(int familyId) { _familyId = familyId; return this; } public RecipeBuilder ForFamily(int familyId) { _familyId = familyId; return this; }
public RecipeBuilder ForFamily(Family family) { _familyId = family.Id; return this; } public RecipeBuilder ForFamily(Family family) { _familyId = family.Id; return this; }
public RecipeBuilder WithIngredient(string name, int sortOrder = 0, decimal? quantity = null, int? unitOfMeasureId = null, int? familyUnitOfMeasureId = null) public RecipeBuilder WithIngredient(
string name,
int sortOrder = 0,
decimal? quantity = null,
int? unitOfMeasureId = null,
int? familyUnitOfMeasureId = null,
int? productId = null,
int? familyProductId = null)
{ {
_ingredients.Add(new RecipeIngredient _ingredients.Add(new RecipeIngredient
{ {
@@ -33,6 +40,8 @@ public sealed class RecipeBuilder
Quantity = quantity, Quantity = quantity,
UnitOfMeasureId = unitOfMeasureId, UnitOfMeasureId = unitOfMeasureId,
FamilyUnitOfMeasureId = familyUnitOfMeasureId, FamilyUnitOfMeasureId = familyUnitOfMeasureId,
ProductId = productId,
FamilyProductId = familyProductId,
}); });
return this; return this;
} }
@@ -336,6 +336,100 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
await Assert.That(stored.AllowedUnitCategories & UnitCategoryFlags.Volume).IsEqualTo(UnitCategoryFlags.Volume); await Assert.That(stored.AllowedUnitCategories & UnitCategoryFlags.Volume).IsEqualTo(UnitCategoryFlags.Volume);
} }
[Test]
public async Task Get_returns_allowed_unit_categories_from_global_product_when_no_override()
{
var familyId = await UseDbAsync(db =>
db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync());
var productId = await UseDbAsync(async db =>
{
var p = new Product { Name = "Flour-base", AllowedUnitCategories = UnitCategoryFlags.Weight | UnitCategoryFlags.Volume };
db.Products.Add(p);
await db.SaveChangesAsync();
return p.Id;
});
var recipe = await CreateRecipeAsync(b => b
.Titled("Bread")
.WithIngredient("flour", sortOrder: 1, quantity: 2m, productId: productId));
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/recipes/{recipe.Id}");
var ingredient = body.GetProperty("ingredients").EnumerateArray().Single();
var cats = (UnitCategoryFlags)ingredient.GetProperty("allowedUnitCategories").GetInt32();
await Assert.That(cats).IsEqualTo(UnitCategoryFlags.Weight | UnitCategoryFlags.Volume);
}
[Test]
public async Task Get_returns_override_allowed_unit_categories_when_family_overrides_product()
{
var familyId = await UseDbAsync(db =>
db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync());
var productId = await UseDbAsync(async db =>
{
var p = new Product { Name = "Flour-ovr", AllowedUnitCategories = UnitCategoryFlags.Weight };
db.Products.Add(p);
await db.SaveChangesAsync();
db.Set<FamilyProductOverride>().Add(new FamilyProductOverride
{
FamilyId = familyId,
ProductId = p.Id,
AllowedUnitCategories = UnitCategoryFlags.Volume | UnitCategoryFlags.Count,
});
await db.SaveChangesAsync();
return p.Id;
});
var recipe = await CreateRecipeAsync(b => b
.Titled("Pancakes-ovr")
.WithIngredient("flour", sortOrder: 1, quantity: 1m, productId: productId));
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/recipes/{recipe.Id}");
var ingredient = body.GetProperty("ingredients").EnumerateArray().Single();
var cats = (UnitCategoryFlags)ingredient.GetProperty("allowedUnitCategories").GetInt32();
await Assert.That(cats).IsEqualTo(UnitCategoryFlags.Volume | UnitCategoryFlags.Count);
}
[Test]
public async Task Get_returns_allowed_unit_categories_from_family_product()
{
var familyId = await UseDbAsync(db =>
db.FamilyMemberships.Where(m => m.UserId == User.Id).Select(m => m.FamilyId).SingleAsync());
var familyProductId = await UseDbAsync(async db =>
{
var fp = new FamilyProduct
{
FamilyId = familyId,
Name = "House Flour-fp",
AllowedUnitCategories = UnitCategoryFlags.Weight | UnitCategoryFlags.Packaging,
};
db.FamilyProducts.Add(fp);
await db.SaveChangesAsync();
return fp.Id;
});
var recipe = await CreateRecipeAsync(b => b
.Titled("Loaf")
.WithIngredient("house flour", sortOrder: 1, quantity: 1m, familyProductId: familyProductId));
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/recipes/{recipe.Id}");
var ingredient = body.GetProperty("ingredients").EnumerateArray().Single();
var cats = (UnitCategoryFlags)ingredient.GetProperty("allowedUnitCategories").GetInt32();
await Assert.That(cats).IsEqualTo(UnitCategoryFlags.Weight | UnitCategoryFlags.Packaging);
}
[Test]
public async Task Get_returns_zero_allowed_unit_categories_for_unlinked_ingredient()
{
var recipe = await CreateRecipeAsync(b => b
.Titled("Mystery")
.WithIngredient("salt", sortOrder: 1));
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/recipes/{recipe.Id}");
var ingredient = body.GetProperty("ingredients").EnumerateArray().Single();
await Assert.That(ingredient.GetProperty("allowedUnitCategories").GetInt32()).IsEqualTo(0);
}
[Test] [Test]
public async Task Create_accumulates_distinct_categories_across_ingredients() public async Task Create_accumulates_distinct_categories_across_ingredients()
{ {
@@ -107,12 +107,6 @@
> >
Edit Edit
</button> </button>
<button
onclick={deleteRecipe}
class="rounded-lg border border-danger px-4 py-2.5 text-sm text-danger"
>
Delete
</button>
</div> </div>
{#if showAddToList} {#if showAddToList}
@@ -162,5 +156,14 @@
</div> </div>
</div> </div>
{/if} {/if}
<div class="mt-10 border-t border-gray-100 pt-4">
<button
onclick={deleteRecipe}
class="text-sm text-danger hover:underline"
>
Delete this recipe
</button>
</div>
</div> </div>
{/if} {/if}
@@ -1,7 +1,7 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { goto } from '$app/navigation'; import { goto, beforeNavigate } from '$app/navigation';
import { api } from '$lib/api'; import { api } from '$lib/api';
import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte'; import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte';
import QuantityInput, { type QuantityValue } from '$lib/QuantityInput.svelte'; import QuantityInput, { type QuantityValue } from '$lib/QuantityInput.svelte';
@@ -45,18 +45,25 @@
let description = $state(''); let description = $state('');
let instructions = $state(''); let instructions = $state('');
let servings = $state<number | undefined>(); let servings = $state<number | undefined>();
let sourceUrl = $state<string | null>(null);
let ingredients = $state<IngredientForm[]>([]); let ingredients = $state<IngredientForm[]>([]);
let loading = $state(true); let loading = $state(true);
let loadError = $state<string | null>(null);
let saving = $state(false); let saving = $state(false);
let saveError = $state<string | null>(null);
let dirty = $state(false);
let justSaved = $state(false);
const recipeId = $derived(Number(page.params.id)); const recipeId = $derived(Number(page.params.id));
onMount(async () => { onMount(async () => {
try {
const recipe = await api<RecipeResponse>(`/api/recipes/${recipeId}`); const recipe = await api<RecipeResponse>(`/api/recipes/${recipeId}`);
title = recipe.title; title = recipe.title;
description = recipe.description ?? ''; description = recipe.description ?? '';
instructions = recipe.instructions ?? ''; instructions = recipe.instructions ?? '';
servings = recipe.servings ?? undefined; servings = recipe.servings ?? undefined;
sourceUrl = recipe.sourceUrl;
ingredients = recipe.ingredients.length === 0 ingredients = recipe.ingredients.length === 0
? [emptyIngredient()] ? [emptyIngredient()]
: recipe.ingredients.map((i) => ({ : recipe.ingredients.map((i) => ({
@@ -73,7 +80,29 @@
familyProductId: i.familyProductId, familyProductId: i.familyProductId,
allowedUnitCategories: i.allowedUnitCategories, allowedUnitCategories: i.allowedUnitCategories,
})); }));
} catch (err) {
loadError = err instanceof Error ? err.message : 'Failed to load recipe.';
} finally {
loading = false; loading = false;
loaded = !loadError;
}
});
// Becomes true once the initial fetch has populated the form. The dirty
// $effect uses this to ignore the load-time writes and only flip on real
// user edits afterward.
let loaded = $state(false);
$effect(() => {
// Touch every piece of editable state so any change re-runs this.
title; description; instructions; servings; sourceUrl;
$state.snapshot(ingredients);
if (loaded) dirty = true;
});
beforeNavigate(({ cancel }) => {
if (!dirty || justSaved) return;
if (!confirm('You have unsaved changes. Leave without saving?')) cancel();
}); });
function emptyIngredient(): IngredientForm { function emptyIngredient(): IngredientForm {
@@ -127,6 +156,7 @@
async function save() { async function save() {
if (!title.trim()) return; if (!title.trim()) return;
saving = true; saving = true;
saveError = null;
try { try {
await api(`/api/recipes/${recipeId}`, { await api(`/api/recipes/${recipeId}`, {
method: 'PUT', method: 'PUT',
@@ -135,7 +165,7 @@
description: description || null, description: description || null,
instructions: instructions || null, instructions: instructions || null,
servings: servings || null, servings: servings || null,
sourceUrl: null, sourceUrl,
ingredients: ingredients ingredients: ingredients
.filter((i) => i.name.trim()) .filter((i) => i.name.trim())
.map((i, idx) => ({ .map((i, idx) => ({
@@ -151,7 +181,10 @@
})) }))
}) })
}); });
justSaved = true;
goto(`/recipes/${recipeId}`); goto(`/recipes/${recipeId}`);
} catch (err) {
saveError = err instanceof Error ? err.message : 'Failed to save recipe.';
} finally { } finally {
saving = false; saving = false;
} }
@@ -160,6 +193,11 @@
{#if loading} {#if loading}
<p class="py-8 text-center text-gray-400">Loading...</p> <p class="py-8 text-center text-gray-400">Loading...</p>
{:else if loadError}
<div class="py-8 text-center">
<p class="mb-3 text-danger">{loadError}</p>
<button onclick={() => goto(`/recipes/${recipeId}`)} class="text-sm text-gray-500">&larr; Back to recipe</button>
</div>
{:else} {:else}
<div> <div>
<button onclick={() => goto(`/recipes/${recipeId}`)} class="mb-2 text-sm text-gray-500">&larr; Back</button> <button onclick={() => goto(`/recipes/${recipeId}`)} class="mb-2 text-sm text-gray-500">&larr; Back</button>
@@ -169,14 +207,14 @@
<input <input
type="text" type="text"
bind:value={title} bind:value={title}
placeholder="Recipe title" placeholder="Recipe title"
required required
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-lg focus:border-primary focus:outline-none" class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-lg focus:border-primary focus:outline-none"
/> />
<textarea <textarea
bind:value={description} bind:value={description}
placeholder="Short description (optional)" placeholder="Short description (optional)"
rows={2} rows={2}
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary focus:outline-none" class="w-full rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary focus:outline-none"
></textarea> ></textarea>
@@ -258,13 +296,27 @@
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary focus:outline-none" class="w-full rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary focus:outline-none"
></textarea> ></textarea>
{#if saveError}
<p class="text-sm text-danger" role="alert">{saveError}</p>
{/if}
<div class="flex gap-2">
<button
type="button"
onclick={() => goto(`/recipes/${recipeId}`)}
disabled={saving}
class="flex-1 rounded-lg border border-gray-300 py-3 font-semibold text-gray-700 disabled:opacity-50"
>
Cancel
</button>
<button <button
type="submit" type="submit"
disabled={saving} disabled={saving}
class="w-full rounded-lg bg-primary py-3 font-semibold text-white disabled:opacity-50" class="flex-[2] rounded-lg bg-primary py-3 font-semibold text-white disabled:opacity-50"
> >
{saving ? 'Saving...' : 'Save Changes'} {saving ? 'Saving...' : 'Save Changes'}
</button> </button>
</div>
</form> </form>
</div> </div>
{/if} {/if}