From 4e4d80410ce5a7166fc04c754c7fb7f2c80aeed8 Mon Sep 17 00:00:00 2001 From: Josh Rogers Date: Thu, 14 May 2026 18:44:52 -0500 Subject: [PATCH] Add recipe edit page Recipe GET now returns effective AllowedUnitCategories per ingredient so QuantityInput can filter units when editing. ProductTypeahead accepts an optional initialSelectedName so it can detect when a pre-linked ingredient name has been edited and clear the stale product link. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Features/Recipes/RecipeEndpoints.cs | 34 ++- src/frontend/src/lib/ProductTypeahead.svelte | 12 +- .../src/routes/recipes/[id]/+page.svelte | 9 + .../src/routes/recipes/[id]/edit/+page.svelte | 270 ++++++++++++++++++ 4 files changed, 322 insertions(+), 3 deletions(-) create mode 100644 src/frontend/src/routes/recipes/[id]/edit/+page.svelte diff --git a/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs b/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs index b721dea..81f7b9c 100644 --- a/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs +++ b/src/backend/YesChef.Api/Features/Recipes/RecipeEndpoints.cs @@ -81,6 +81,37 @@ public static class RecipeEndpoints if (recipe is null) return Results.NotFound(); + var globalProductIds = recipe.Ingredients + .Where(i => i.ProductId.HasValue).Select(i => i.ProductId!.Value).Distinct().ToList(); + var familyProductIds = recipe.Ingredients + .Where(i => i.FamilyProductId.HasValue).Select(i => i.FamilyProductId!.Value).Distinct().ToList(); + + var globalCats = globalProductIds.Count == 0 + ? new Dictionary() + : await db.Products + .Where(p => globalProductIds.Contains(p.Id)) + .GroupJoin( + db.FamilyProductOverrides.Where(o => o.FamilyId == familyId), + p => p.Id, + o => o.ProductId, + (p, os) => new { p.Id, Base = p.AllowedUnitCategories, Override = os.Select(o => (UnitCategoryFlags?)o.AllowedUnitCategories).FirstOrDefault() }) + .ToDictionaryAsync(x => x.Id, x => (x.Base, x.Override)); + + var familyCats = familyProductIds.Count == 0 + ? new Dictionary() + : await db.FamilyProducts + .Where(p => p.FamilyId == familyId && familyProductIds.Contains(p.Id)) + .ToDictionaryAsync(p => p.Id, p => p.AllowedUnitCategories); + + UnitCategoryFlags EffectiveCats(int? globalId, int? familyId) + { + if (globalId.HasValue && globalCats.TryGetValue(globalId.Value, out var g)) + return g.Override ?? g.Base; + if (familyId.HasValue && familyCats.TryGetValue(familyId.Value, out var f)) + return f; + return UnitCategoryFlags.None; + } + return Results.Ok(new { recipe.Id, @@ -102,7 +133,8 @@ public static class RecipeEndpoints i.IsApproximate, i.QuantityNote, i.ProductId, - i.FamilyProductId + i.FamilyProductId, + AllowedUnitCategories = (int)EffectiveCats(i.ProductId, i.FamilyProductId) }) }); }); diff --git a/src/frontend/src/lib/ProductTypeahead.svelte b/src/frontend/src/lib/ProductTypeahead.svelte index 118499e..1d39b97 100644 --- a/src/frontend/src/lib/ProductTypeahead.svelte +++ b/src/frontend/src/lib/ProductTypeahead.svelte @@ -33,6 +33,10 @@ // or when they edit the input after a selection (with null) — the // caller uses this to track / clear an associated product link. onProductChange?: (product: ProductSuggestion | null) => void; + // When the input is pre-populated with a name linked to an existing + // product (e.g. editing a saved ingredient), pass that name here so we + // can fire onProductChange(null) the moment the user diverges from it. + initialSelectedName?: string | null; } let { @@ -42,11 +46,15 @@ inputClass = '', onsubmit, onProductChange, + initialSelectedName = null, }: Props = $props(); // Tracks the most recent picked product so we can detect when the user - // edits the input afterward (which invalidates the link). - let lastSelectedName: string | null = null; + // edits the input afterward (which invalidates the link). We intentionally + // only capture the initial value of the prop — later changes from the + // parent shouldn't re-seed it. + // svelte-ignore state_referenced_locally + let lastSelectedName: string | null = initialSelectedName; let suggestions = $state([]); let showDropdown = $state(false); diff --git a/src/frontend/src/routes/recipes/[id]/+page.svelte b/src/frontend/src/routes/recipes/[id]/+page.svelte index 830836a..49a088d 100644 --- a/src/frontend/src/routes/recipes/[id]/+page.svelte +++ b/src/frontend/src/routes/recipes/[id]/+page.svelte @@ -15,6 +15,9 @@ familyUnitOfMeasureId: number | null; isApproximate: boolean; quantityNote: string | null; + productId: number | null; + familyProductId: number | null; + allowedUnitCategories: number; } interface Recipe { @@ -98,6 +101,12 @@ > Add to list + +

Edit Recipe

+ +
{ e.preventDefault(); save(); }} class="space-y-4"> + + + + +
+ +
+ +
+ Ingredients + {#each ingredients as ingredient, idx} +
+
+ {#if ingredient.isApproximate} + + {:else} + + {/if} +
+ onIngredientProductChange(idx, p)} + /> +
+ {#if ingredients.length > 1} + + {/if} +
+ +
+ {/each} + +
+ + + + +
+ +{/if}