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(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
{
@@ -33,6 +40,8 @@ public sealed class RecipeBuilder
Quantity = quantity,
UnitOfMeasureId = unitOfMeasureId,
FamilyUnitOfMeasureId = familyUnitOfMeasureId,
ProductId = productId,
FamilyProductId = familyProductId,
});
return this;
}
@@ -336,6 +336,100 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
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]
public async Task Create_accumulates_distinct_categories_across_ingredients()
{
@@ -107,12 +107,6 @@
>
Edit
</button>
<button
onclick={deleteRecipe}
class="rounded-lg border border-danger px-4 py-2.5 text-sm text-danger"
>
Delete
</button>
</div>
{#if showAddToList}
@@ -162,5 +156,14 @@
</div>
</div>
{/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>
{/if}
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { goto, beforeNavigate } from '$app/navigation';
import { api } from '$lib/api';
import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte';
import QuantityInput, { type QuantityValue } from '$lib/QuantityInput.svelte';
@@ -45,35 +45,64 @@
let description = $state('');
let instructions = $state('');
let servings = $state<number | undefined>();
let sourceUrl = $state<string | null>(null);
let ingredients = $state<IngredientForm[]>([]);
let loading = $state(true);
let loadError = $state<string | null>(null);
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));
onMount(async () => {
const recipe = await api<RecipeResponse>(`/api/recipes/${recipeId}`);
title = recipe.title;
description = recipe.description ?? '';
instructions = recipe.instructions ?? '';
servings = recipe.servings ?? undefined;
ingredients = recipe.ingredients.length === 0
? [emptyIngredient()]
: recipe.ingredients.map((i) => ({
name: i.name,
initialName: i.productId !== null || i.familyProductId !== null ? i.name : null,
quantity: {
quantity: i.quantity,
unitOfMeasureId: i.unitOfMeasureId,
familyUnitOfMeasureId: i.familyUnitOfMeasureId,
},
isApproximate: i.isApproximate,
quantityNote: i.quantityNote ?? '',
productId: i.productId,
familyProductId: i.familyProductId,
allowedUnitCategories: i.allowedUnitCategories,
}));
loading = false;
try {
const recipe = await api<RecipeResponse>(`/api/recipes/${recipeId}`);
title = recipe.title;
description = recipe.description ?? '';
instructions = recipe.instructions ?? '';
servings = recipe.servings ?? undefined;
sourceUrl = recipe.sourceUrl;
ingredients = recipe.ingredients.length === 0
? [emptyIngredient()]
: recipe.ingredients.map((i) => ({
name: i.name,
initialName: i.productId !== null || i.familyProductId !== null ? i.name : null,
quantity: {
quantity: i.quantity,
unitOfMeasureId: i.unitOfMeasureId,
familyUnitOfMeasureId: i.familyUnitOfMeasureId,
},
isApproximate: i.isApproximate,
quantityNote: i.quantityNote ?? '',
productId: i.productId,
familyProductId: i.familyProductId,
allowedUnitCategories: i.allowedUnitCategories,
}));
} catch (err) {
loadError = err instanceof Error ? err.message : 'Failed to load recipe.';
} finally {
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 {
@@ -127,6 +156,7 @@
async function save() {
if (!title.trim()) return;
saving = true;
saveError = null;
try {
await api(`/api/recipes/${recipeId}`, {
method: 'PUT',
@@ -135,7 +165,7 @@
description: description || null,
instructions: instructions || null,
servings: servings || null,
sourceUrl: null,
sourceUrl,
ingredients: ingredients
.filter((i) => i.name.trim())
.map((i, idx) => ({
@@ -151,7 +181,10 @@
}))
})
});
justSaved = true;
goto(`/recipes/${recipeId}`);
} catch (err) {
saveError = err instanceof Error ? err.message : 'Failed to save recipe.';
} finally {
saving = false;
}
@@ -160,6 +193,11 @@
{#if loading}
<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}
<div>
<button onclick={() => goto(`/recipes/${recipeId}`)} class="mb-2 text-sm text-gray-500">&larr; Back</button>
@@ -169,14 +207,14 @@
<input
type="text"
bind:value={title}
placeholder="Recipe title"
placeholder="Recipe title"
required
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-lg focus:border-primary focus:outline-none"
/>
<textarea
bind:value={description}
placeholder="Short description (optional)"
placeholder="Short description (optional)"
rows={2}
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary focus:outline-none"
></textarea>
@@ -187,7 +225,7 @@
<input
type="number"
bind:value={servings}
min={1}
min={1}
placeholder="e.g. 4"
class="mt-1 block w-24 rounded-lg border border-gray-300 px-3 py-2 focus:border-primary focus:outline-none"
/>
@@ -203,7 +241,7 @@
<input
type="text"
bind:value={ingredient.quantityNote}
placeholder="e.g. to taste"
placeholder="e.g. to taste"
class="w-44 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
/>
{:else}
@@ -258,13 +296,27 @@
class="w-full rounded-lg border border-gray-300 px-3 py-2.5 focus:border-primary focus:outline-none"
></textarea>
<button
type="submit"
disabled={saving}
class="w-full rounded-lg bg-primary py-3 font-semibold text-white disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
{#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
type="submit"
disabled={saving}
class="flex-[2] rounded-lg bg-primary py-3 font-semibold text-white disabled:opacity-50"
>
{saving ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</div>
{/if}