Add structured quantities + units to shopping list items
Mirrors the Phase 2 work on RecipeIngredient: ShoppingListItem grows Quantity (decimal), UnitOfMeasureId / FamilyUnitOfMeasureId, IsApproximate, and QuantityNote. The recipe-to-list copy now carries structured fields verbatim instead of folding them into the free-form Name, and the unit-in-use guard now also blocks deleting a family unit that's referenced by a shopping list item. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,9 +21,35 @@ public sealed class ShoppingListBuilder
|
||||
public ShoppingListBuilder ForFamily(Family family) { _familyId = family.Id; return this; }
|
||||
public ShoppingListBuilder Archived() { _archived = true; return this; }
|
||||
|
||||
public ShoppingListBuilder WithItem(string name, bool isChecked = false, int sortOrder = 0)
|
||||
public ShoppingListBuilder WithItem(
|
||||
string name,
|
||||
bool isChecked = false,
|
||||
int sortOrder = 0,
|
||||
decimal? quantity = null,
|
||||
int? unitOfMeasureId = null,
|
||||
int? familyUnitOfMeasureId = null)
|
||||
{
|
||||
_items.Add(new ShoppingListItem { Name = name, IsChecked = isChecked, SortOrder = sortOrder });
|
||||
_items.Add(new ShoppingListItem
|
||||
{
|
||||
Name = name,
|
||||
IsChecked = isChecked,
|
||||
SortOrder = sortOrder,
|
||||
Quantity = quantity,
|
||||
UnitOfMeasureId = unitOfMeasureId,
|
||||
FamilyUnitOfMeasureId = familyUnitOfMeasureId,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
public ShoppingListBuilder WithApproximateItem(string name, string quantityNote, int sortOrder = 0)
|
||||
{
|
||||
_items.Add(new ShoppingListItem
|
||||
{
|
||||
Name = name,
|
||||
SortOrder = sortOrder,
|
||||
IsApproximate = true,
|
||||
QuantityNote = quantityNote,
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@@ -441,7 +441,7 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Add_recipe_appends_all_ingredients_with_quantity_prefix()
|
||||
public async Task Add_recipe_copies_structured_quantity_to_items()
|
||||
{
|
||||
var cupId = await UseDbAsync(async db =>
|
||||
{
|
||||
@@ -471,10 +471,134 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
|
||||
await Assert.That(items.Count).IsEqualTo(4);
|
||||
await Assert.That(items.Select(i => i.Name)).IsEquivalentTo(new[]
|
||||
{
|
||||
"existing", "2 cup flour", "2 eggs", "salt (to taste)"
|
||||
"existing", "flour", "eggs", "salt"
|
||||
});
|
||||
await Assert.That(items.Where(i => i.RecipeId == recipe.Id).Count()).IsEqualTo(3);
|
||||
await Assert.That(items[3].SortOrder).IsGreaterThan(5);
|
||||
|
||||
var flour = items.Single(i => i.Name == "flour");
|
||||
await Assert.That(flour.Quantity).IsEqualTo(2m);
|
||||
await Assert.That(flour.UnitOfMeasureId).IsEqualTo(cupId);
|
||||
await Assert.That(flour.IsApproximate).IsFalse();
|
||||
|
||||
var eggs = items.Single(i => i.Name == "eggs");
|
||||
await Assert.That(eggs.Quantity).IsEqualTo(2m);
|
||||
await Assert.That(eggs.UnitOfMeasureId).IsNull();
|
||||
|
||||
var salt = items.Single(i => i.Name == "salt");
|
||||
await Assert.That(salt.IsApproximate).IsTrue();
|
||||
await Assert.That(salt.QuantityNote).IsEqualTo("to taste");
|
||||
await Assert.That(salt.Quantity).IsNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Add_item_persists_structured_quantity()
|
||||
{
|
||||
var cupId = await UseDbAsync(async db =>
|
||||
{
|
||||
var u = new UnitOfMeasure
|
||||
{
|
||||
Code = "cup", SingularName = "cup", PluralName = "cups",
|
||||
Abbreviation = "cup", Category = UnitCategory.Volume,
|
||||
};
|
||||
db.UnitsOfMeasure.Add(u);
|
||||
await db.SaveChangesAsync();
|
||||
return u.Id;
|
||||
});
|
||||
var list = await CreateListAsync();
|
||||
|
||||
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
|
||||
new ShoppingListEndpoints.AddItemRequest("flour", Quantity: 2.5m, UnitOfMeasureId: cupId));
|
||||
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
|
||||
var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync());
|
||||
await Assert.That(item.Quantity).IsEqualTo(2.5m);
|
||||
await Assert.That(item.UnitOfMeasureId).IsEqualTo(cupId);
|
||||
await Assert.That(item.IsApproximate).IsFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Add_item_persists_approximate_note_and_clears_structured_fields()
|
||||
{
|
||||
var list = await CreateListAsync();
|
||||
|
||||
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
|
||||
new ShoppingListEndpoints.AddItemRequest("salt", Quantity: 1m, IsApproximate: true, QuantityNote: "to taste"));
|
||||
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
|
||||
var item = await UseDbAsync(db => db.ShoppingListItems.SingleAsync());
|
||||
await Assert.That(item.IsApproximate).IsTrue();
|
||||
await Assert.That(item.QuantityNote).IsEqualTo("to taste");
|
||||
await Assert.That(item.Quantity).IsNull();
|
||||
await Assert.That(item.UnitOfMeasureId).IsNull();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Add_item_rejects_setting_both_unit_ids()
|
||||
{
|
||||
var list = await CreateListAsync();
|
||||
|
||||
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
|
||||
new ShoppingListEndpoints.AddItemRequest("flour", UnitOfMeasureId: 1, FamilyUnitOfMeasureId: 1));
|
||||
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Add_item_rejects_other_familys_family_unit()
|
||||
{
|
||||
var list = await CreateListAsync();
|
||||
var foreignUnitId = await UseDbAsync(async db =>
|
||||
{
|
||||
var otherFamily = new Family { Name = "Other-Unit", InviteCode = "other-unit-code" };
|
||||
db.Families.Add(otherFamily);
|
||||
await db.SaveChangesAsync();
|
||||
var u = new FamilyUnitOfMeasure
|
||||
{
|
||||
FamilyId = otherFamily.Id,
|
||||
SingularName = "scoop", PluralName = "scoops",
|
||||
Abbreviation = "scp", Category = UnitCategory.Count,
|
||||
};
|
||||
db.FamilyUnitsOfMeasure.Add(u);
|
||||
await db.SaveChangesAsync();
|
||||
return u.Id;
|
||||
});
|
||||
|
||||
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
|
||||
new ShoppingListEndpoints.AddItemRequest("flour", FamilyUnitOfMeasureId: foreignUnitId));
|
||||
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Get_list_projects_structured_quantity_fields()
|
||||
{
|
||||
var cupId = await UseDbAsync(async db =>
|
||||
{
|
||||
var u = new UnitOfMeasure
|
||||
{
|
||||
Code = "cup-proj", SingularName = "cup", PluralName = "cups",
|
||||
Abbreviation = "cup-proj", Category = UnitCategory.Volume,
|
||||
};
|
||||
db.UnitsOfMeasure.Add(u);
|
||||
await db.SaveChangesAsync();
|
||||
return u.Id;
|
||||
});
|
||||
var list = await CreateListAsync(b => b
|
||||
.WithItem("flour", quantity: 3m, unitOfMeasureId: cupId)
|
||||
.WithApproximateItem("salt", "to taste", sortOrder: 2));
|
||||
|
||||
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/lists/{list.Id}");
|
||||
var items = body.GetProperty("items").EnumerateArray().ToArray();
|
||||
|
||||
var flour = items.Single(i => i.GetProperty("name").GetString() == "flour");
|
||||
await Assert.That(flour.GetProperty("quantity").GetDecimal()).IsEqualTo(3m);
|
||||
await Assert.That(flour.GetProperty("unitOfMeasureId").GetInt32()).IsEqualTo(cupId);
|
||||
await Assert.That(flour.GetProperty("isApproximate").GetBoolean()).IsFalse();
|
||||
|
||||
var salt = items.Single(i => i.GetProperty("name").GetString() == "salt");
|
||||
await Assert.That(salt.GetProperty("isApproximate").GetBoolean()).IsTrue();
|
||||
await Assert.That(salt.GetProperty("quantityNote").GetString()).IsEqualTo("to taste");
|
||||
await Assert.That(salt.GetProperty("quantity").ValueKind).IsEqualTo(JsonValueKind.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
@@ -234,6 +234,43 @@ public class UnitEndpointsTests : AuthenticatedIntegrationTest
|
||||
await Assert.That(remaining).IsEqualTo(1);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Delete_family_unit_returns_409_when_referenced_by_shopping_list_item()
|
||||
{
|
||||
var familyId = await GetFamilyIdAsync();
|
||||
var store = await Data.CreateStoreAsync();
|
||||
var unit = await UseDbAsync(async db =>
|
||||
{
|
||||
var u = new FamilyUnitOfMeasure
|
||||
{
|
||||
FamilyId = familyId,
|
||||
SingularName = "scoop", PluralName = "scoops",
|
||||
Abbreviation = "scp", Category = UnitCategory.Count,
|
||||
};
|
||||
db.FamilyUnitsOfMeasure.Add(u);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var list = new ShoppingList
|
||||
{
|
||||
FamilyId = familyId,
|
||||
Name = "List with unit",
|
||||
StoreId = store.Id,
|
||||
CreatedByUserId = User.Id,
|
||||
Items = new List<ShoppingListItem>
|
||||
{
|
||||
new() { FamilyId = familyId, Name = "ice cream", Quantity = 2m, FamilyUnitOfMeasureId = u.Id },
|
||||
},
|
||||
};
|
||||
db.ShoppingLists.Add(list);
|
||||
await db.SaveChangesAsync();
|
||||
return u;
|
||||
});
|
||||
|
||||
var response = await Client.DeleteAsync($"/api/units/family/{unit.Id}");
|
||||
|
||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Endpoints_require_authentication()
|
||||
{
|
||||
|
||||
@@ -95,6 +95,8 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
|
||||
modelBuilder.Entity<ShoppingListItem>(e =>
|
||||
{
|
||||
e.Property(i => i.Name).HasMaxLength(300);
|
||||
e.Property(i => i.Quantity).HasPrecision(12, 4);
|
||||
e.Property(i => i.QuantityNote).HasMaxLength(200);
|
||||
e.HasOne(i => i.Family).WithMany().HasForeignKey(i => i.FamilyId).OnDelete(DeleteBehavior.Cascade);
|
||||
e.HasOne(i => i.CheckedByUser).WithMany().HasForeignKey(i => i.CheckedByUserId).OnDelete(DeleteBehavior.SetNull);
|
||||
e.HasOne(i => i.RemovedByUser).WithMany().HasForeignKey(i => i.RemovedByUserId).OnDelete(DeleteBehavior.SetNull);
|
||||
@@ -102,6 +104,10 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
|
||||
e.HasOne(i => i.Section).WithMany().HasForeignKey(i => i.SectionId).OnDelete(DeleteBehavior.SetNull);
|
||||
e.HasOne(i => i.Product).WithMany().HasForeignKey(i => i.ProductId).OnDelete(DeleteBehavior.SetNull);
|
||||
e.HasOne(i => i.FamilyProduct).WithMany().HasForeignKey(i => i.FamilyProductId).OnDelete(DeleteBehavior.SetNull);
|
||||
// Restrict on delete: the unit-delete endpoint already blocks
|
||||
// deletion when an item references the unit; this is defense in depth.
|
||||
e.HasOne(i => i.UnitOfMeasure).WithMany().HasForeignKey(i => i.UnitOfMeasureId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(i => i.FamilyUnitOfMeasure).WithMany().HasForeignKey(i => i.FamilyUnitOfMeasureId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasIndex(i => i.FamilyId);
|
||||
});
|
||||
|
||||
|
||||
@@ -22,6 +22,17 @@ public class ShoppingListItem
|
||||
public Product? Product { get; set; }
|
||||
public int? FamilyProductId { get; set; }
|
||||
public FamilyProduct? FamilyProduct { get; set; }
|
||||
// Structured quantity. Null = unspecified (free-form Name carries the meaning).
|
||||
public decimal? Quantity { get; set; }
|
||||
// At most one of UnitOfMeasureId / FamilyUnitOfMeasureId is set on a row.
|
||||
public int? UnitOfMeasureId { get; set; }
|
||||
public UnitOfMeasure? UnitOfMeasure { get; set; }
|
||||
public int? FamilyUnitOfMeasureId { get; set; }
|
||||
public FamilyUnitOfMeasure? FamilyUnitOfMeasure { get; set; }
|
||||
// Approximations like "to taste" / "a pinch" — render QuantityNote instead
|
||||
// of the structured Quantity/Unit pair when this flag is set.
|
||||
public bool IsApproximate { get; set; }
|
||||
public string? QuantityNote { get; set; }
|
||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
public DateTime? RemovedAt { get; set; }
|
||||
public int? RemovedByUserId { get; set; }
|
||||
|
||||
@@ -10,7 +10,17 @@ public static class ShoppingListEndpoints
|
||||
{
|
||||
public record CreateListRequest(string Name, int StoreId);
|
||||
public record UpdateListRequest(string Name, int StoreId);
|
||||
public record AddItemRequest(string Name, int SortOrder = 0, int? SectionId = null, int? ProductId = null, int? FamilyProductId = null);
|
||||
public record AddItemRequest(
|
||||
string Name,
|
||||
int SortOrder = 0,
|
||||
int? SectionId = null,
|
||||
int? ProductId = null,
|
||||
int? FamilyProductId = null,
|
||||
decimal? Quantity = null,
|
||||
int? UnitOfMeasureId = null,
|
||||
int? FamilyUnitOfMeasureId = null,
|
||||
bool IsApproximate = false,
|
||||
string? QuantityNote = null);
|
||||
public record SetItemSectionRequest(int? SectionId);
|
||||
|
||||
private static string OverviewGroup(int familyId) => $"lists-overview-{familyId}";
|
||||
@@ -129,7 +139,12 @@ public static class ShoppingListEndpoints
|
||||
i.SectionId,
|
||||
RecipeTitle = i.Recipe?.Title,
|
||||
i.ProductId,
|
||||
i.FamilyProductId
|
||||
i.FamilyProductId,
|
||||
i.Quantity,
|
||||
i.UnitOfMeasureId,
|
||||
i.FamilyUnitOfMeasureId,
|
||||
i.IsApproximate,
|
||||
i.QuantityNote
|
||||
})
|
||||
});
|
||||
});
|
||||
@@ -183,6 +198,9 @@ public static class ShoppingListEndpoints
|
||||
if (await ValidateProductLink(db, familyId, request.ProductId, request.FamilyProductId) is { } productError)
|
||||
return productError;
|
||||
|
||||
if (await ValidateUnitLink(db, familyId, request.UnitOfMeasureId, request.FamilyUnitOfMeasureId) is { } unitError)
|
||||
return unitError;
|
||||
|
||||
// Auto-assign a section from memory when caller didn't pick one
|
||||
// but supplied a product link — "we put bananas in Produce last
|
||||
// time we shopped here, do it again."
|
||||
@@ -198,6 +216,11 @@ public static class ShoppingListEndpoints
|
||||
SectionId = resolvedSectionId,
|
||||
ProductId = request.ProductId,
|
||||
FamilyProductId = request.FamilyProductId,
|
||||
Quantity = request.IsApproximate ? null : request.Quantity,
|
||||
UnitOfMeasureId = request.IsApproximate ? null : request.UnitOfMeasureId,
|
||||
FamilyUnitOfMeasureId = request.IsApproximate ? null : request.FamilyUnitOfMeasureId,
|
||||
IsApproximate = request.IsApproximate,
|
||||
QuantityNote = request.IsApproximate ? request.QuantityNote : null,
|
||||
};
|
||||
db.ShoppingListItems.Add(item);
|
||||
list.UpdatedAt = DateTime.UtcNow;
|
||||
@@ -210,9 +233,9 @@ public static class ShoppingListEndpoints
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, item.SectionId, item.ProductId, item.FamilyProductId });
|
||||
await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", ItemAddedPayload(item, recipeTitle: null));
|
||||
await BroadcastListSummary(hub, db, listId, familyId);
|
||||
return Results.Created($"/api/lists/{listId}/items/{item.Id}", new { item.Id, item.Name, item.SortOrder, item.SectionId, item.ProductId, item.FamilyProductId });
|
||||
return Results.Created($"/api/lists/{listId}/items/{item.Id}", ItemAddedPayload(item, recipeTitle: null));
|
||||
});
|
||||
|
||||
group.MapPatch("/{listId:int}/items/{itemId:int}/section", async (int listId, int itemId, SetItemSectionRequest request, YesChefDb db, HttpContext http, IHubContext<ShoppingListHub> hub) =>
|
||||
@@ -297,7 +320,12 @@ public static class ShoppingListEndpoints
|
||||
item.SectionId,
|
||||
RecipeTitle = item.Recipe?.Title,
|
||||
item.ProductId,
|
||||
item.FamilyProductId
|
||||
item.FamilyProductId,
|
||||
item.Quantity,
|
||||
item.UnitOfMeasureId,
|
||||
item.FamilyUnitOfMeasureId,
|
||||
item.IsApproximate,
|
||||
item.QuantityNote
|
||||
});
|
||||
await BroadcastListSummary(hub, db, listId, familyId);
|
||||
return Results.NoContent();
|
||||
@@ -330,16 +358,17 @@ public static class ShoppingListEndpoints
|
||||
{
|
||||
FamilyId = familyId,
|
||||
ShoppingListId = listId,
|
||||
// Phase 3 will give ShoppingListItem its own structured
|
||||
// quantity/unit fields. Until then, fold the recipe's
|
||||
// structured quantity into the free-form Name so the
|
||||
// list still reads "2 cup flour" / "salt to taste".
|
||||
Name = FormatIngredientForList(ing),
|
||||
Name = ing.Name,
|
||||
SortOrder = maxSort + idx + 1,
|
||||
RecipeId = recipeId,
|
||||
ProductId = ing.ProductId,
|
||||
FamilyProductId = ing.FamilyProductId,
|
||||
SectionId = rememberedSectionId,
|
||||
Quantity = ing.Quantity,
|
||||
UnitOfMeasureId = ing.UnitOfMeasureId,
|
||||
FamilyUnitOfMeasureId = ing.FamilyUnitOfMeasureId,
|
||||
IsApproximate = ing.IsApproximate,
|
||||
QuantityNote = ing.QuantityNote,
|
||||
});
|
||||
idx++;
|
||||
}
|
||||
@@ -350,7 +379,7 @@ public static class ShoppingListEndpoints
|
||||
|
||||
foreach (var item in newItems)
|
||||
{
|
||||
await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", new { item.Id, item.Name, item.SortOrder, item.SectionId, item.ProductId, item.FamilyProductId, RecipeTitle = recipe.Title });
|
||||
await hub.Clients.Group($"list-{listId}").SendAsync("ItemAdded", ItemAddedPayload(item, recipe.Title));
|
||||
}
|
||||
|
||||
await BroadcastListSummary(hub, db, listId, familyId);
|
||||
@@ -380,24 +409,25 @@ public static class ShoppingListEndpoints
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Render an ingredient's structured quantity into a free-form display
|
||||
/// string for use as a shopping list item Name in Phase 2. Phase 3 will
|
||||
/// remove this once ShoppingListItem grows its own structured fields.
|
||||
/// Shape the wire payload for ItemAdded / Created responses so clients
|
||||
/// can render the structured quantity. Single source of truth so the
|
||||
/// list endpoint and recipe-add fan-out stay aligned.
|
||||
/// </summary>
|
||||
private static string FormatIngredientForList(RecipeIngredient ing)
|
||||
private static object ItemAddedPayload(ShoppingListItem item, string? recipeTitle) => new
|
||||
{
|
||||
if (ing.IsApproximate && !string.IsNullOrWhiteSpace(ing.QuantityNote))
|
||||
return $"{ing.Name} ({ing.QuantityNote})";
|
||||
|
||||
var abbrev = ing.UnitOfMeasure?.Abbreviation ?? ing.FamilyUnitOfMeasure?.Abbreviation;
|
||||
if (ing.Quantity is { } q)
|
||||
{
|
||||
// Trim trailing zeros so "2.0000" renders as "2".
|
||||
var qty = q.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture);
|
||||
return string.IsNullOrEmpty(abbrev) ? $"{qty} {ing.Name}" : $"{qty} {abbrev} {ing.Name}";
|
||||
}
|
||||
return ing.Name;
|
||||
}
|
||||
item.Id,
|
||||
item.Name,
|
||||
item.SortOrder,
|
||||
item.SectionId,
|
||||
item.ProductId,
|
||||
item.FamilyProductId,
|
||||
item.Quantity,
|
||||
item.UnitOfMeasureId,
|
||||
item.FamilyUnitOfMeasureId,
|
||||
item.IsApproximate,
|
||||
item.QuantityNote,
|
||||
RecipeTitle = recipeTitle,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Validates the unit-of-measure FK pair on an ingredient/item payload.
|
||||
|
||||
@@ -119,10 +119,12 @@ public static class UnitEndpoints
|
||||
var unit = await db.FamilyUnitsOfMeasure.FirstOrDefaultAsync(u => u.Id == id && u.FamilyId == familyId);
|
||||
if (unit is null) return Results.NotFound();
|
||||
|
||||
// Block deletion when any recipe ingredient still references the
|
||||
// unit. (ShoppingListItem will gain the same check in Phase 3.)
|
||||
var inUse = await db.RecipeIngredients.AnyAsync(i => i.FamilyUnitOfMeasureId == id);
|
||||
if (inUse) return Results.Conflict(new { error = "Unit is in use by one or more recipes." });
|
||||
// Block deletion when any recipe ingredient or shopping list item
|
||||
// still references the unit.
|
||||
var inUseByRecipe = await db.RecipeIngredients.AnyAsync(i => i.FamilyUnitOfMeasureId == id);
|
||||
if (inUseByRecipe) return Results.Conflict(new { error = "Unit is in use by one or more recipes." });
|
||||
var inUseByList = await db.ShoppingListItems.AnyAsync(i => i.FamilyUnitOfMeasureId == id);
|
||||
if (inUseByList) return Results.Conflict(new { error = "Unit is in use by one or more shopping lists." });
|
||||
|
||||
db.FamilyUnitsOfMeasure.Remove(unit);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
+1091
File diff suppressed because it is too large
Load Diff
+114
@@ -0,0 +1,114 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace YesChef.Api.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddStructuredShoppingListItemQuantities : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "FamilyUnitOfMeasureId",
|
||||
table: "ShoppingListItems",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsApproximate",
|
||||
table: "ShoppingListItems",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<decimal>(
|
||||
name: "Quantity",
|
||||
table: "ShoppingListItems",
|
||||
type: "numeric(12,4)",
|
||||
precision: 12,
|
||||
scale: 4,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "QuantityNote",
|
||||
table: "ShoppingListItems",
|
||||
type: "character varying(200)",
|
||||
maxLength: 200,
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "UnitOfMeasureId",
|
||||
table: "ShoppingListItems",
|
||||
type: "integer",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ShoppingListItems_FamilyUnitOfMeasureId",
|
||||
table: "ShoppingListItems",
|
||||
column: "FamilyUnitOfMeasureId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_ShoppingListItems_UnitOfMeasureId",
|
||||
table: "ShoppingListItems",
|
||||
column: "UnitOfMeasureId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ShoppingListItems_FamilyUnitsOfMeasure_FamilyUnitOfMeasureId",
|
||||
table: "ShoppingListItems",
|
||||
column: "FamilyUnitOfMeasureId",
|
||||
principalTable: "FamilyUnitsOfMeasure",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_ShoppingListItems_UnitsOfMeasure_UnitOfMeasureId",
|
||||
table: "ShoppingListItems",
|
||||
column: "UnitOfMeasureId",
|
||||
principalTable: "UnitsOfMeasure",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ShoppingListItems_FamilyUnitsOfMeasure_FamilyUnitOfMeasureId",
|
||||
table: "ShoppingListItems");
|
||||
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_ShoppingListItems_UnitsOfMeasure_UnitOfMeasureId",
|
||||
table: "ShoppingListItems");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ShoppingListItems_FamilyUnitOfMeasureId",
|
||||
table: "ShoppingListItems");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_ShoppingListItems_UnitOfMeasureId",
|
||||
table: "ShoppingListItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "FamilyUnitOfMeasureId",
|
||||
table: "ShoppingListItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsApproximate",
|
||||
table: "ShoppingListItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Quantity",
|
||||
table: "ShoppingListItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "QuantityNote",
|
||||
table: "ShoppingListItems");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "UnitOfMeasureId",
|
||||
table: "ShoppingListItems");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -514,6 +514,12 @@ namespace YesChef.Api.Migrations
|
||||
b.Property<int?>("FamilyProductId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("FamilyUnitOfMeasureId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsApproximate")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsChecked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
@@ -525,6 +531,14 @@ namespace YesChef.Api.Migrations
|
||||
b.Property<int?>("ProductId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal?>("Quantity")
|
||||
.HasPrecision(12, 4)
|
||||
.HasColumnType("numeric(12,4)");
|
||||
|
||||
b.Property<string>("QuantityNote")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int?>("RecipeId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -543,6 +557,9 @@ namespace YesChef.Api.Migrations
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int?>("UnitOfMeasureId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CheckedByUserId");
|
||||
@@ -551,6 +568,8 @@ namespace YesChef.Api.Migrations
|
||||
|
||||
b.HasIndex("FamilyProductId");
|
||||
|
||||
b.HasIndex("FamilyUnitOfMeasureId");
|
||||
|
||||
b.HasIndex("ProductId");
|
||||
|
||||
b.HasIndex("RecipeId");
|
||||
@@ -561,6 +580,8 @@ namespace YesChef.Api.Migrations
|
||||
|
||||
b.HasIndex("ShoppingListId");
|
||||
|
||||
b.HasIndex("UnitOfMeasureId");
|
||||
|
||||
b.ToTable("ShoppingListItems");
|
||||
});
|
||||
|
||||
@@ -965,6 +986,11 @@ namespace YesChef.Api.Migrations
|
||||
.HasForeignKey("FamilyProductId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("YesChef.Api.Entities.FamilyUnitOfMeasure", "FamilyUnitOfMeasure")
|
||||
.WithMany()
|
||||
.HasForeignKey("FamilyUnitOfMeasureId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("YesChef.Api.Entities.Product", "Product")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProductId")
|
||||
@@ -991,12 +1017,19 @@ namespace YesChef.Api.Migrations
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("YesChef.Api.Entities.UnitOfMeasure", "UnitOfMeasure")
|
||||
.WithMany()
|
||||
.HasForeignKey("UnitOfMeasureId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.Navigation("CheckedByUser");
|
||||
|
||||
b.Navigation("Family");
|
||||
|
||||
b.Navigation("FamilyProduct");
|
||||
|
||||
b.Navigation("FamilyUnitOfMeasure");
|
||||
|
||||
b.Navigation("Product");
|
||||
|
||||
b.Navigation("Recipe");
|
||||
@@ -1006,6 +1039,8 @@ namespace YesChef.Api.Migrations
|
||||
b.Navigation("Section");
|
||||
|
||||
b.Navigation("ShoppingList");
|
||||
|
||||
b.Navigation("UnitOfMeasure");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("YesChef.Api.Entities.Store", b =>
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
import { startConnection, stopConnection } from '$lib/signalr';
|
||||
import { toast } from '$lib/toast.svelte';
|
||||
import ProductTypeahead, { type ProductSuggestion } from '$lib/ProductTypeahead.svelte';
|
||||
import QuantityInput, { type QuantityValue } from '$lib/QuantityInput.svelte';
|
||||
import { units } from '$lib/units.svelte';
|
||||
import { formatQuantity } from '$lib/formatQuantity';
|
||||
import type { HubConnection } from '@microsoft/signalr';
|
||||
|
||||
interface ListItem {
|
||||
@@ -16,6 +19,11 @@
|
||||
sortOrder: number;
|
||||
sectionId: number | null;
|
||||
recipeTitle: string | null;
|
||||
quantity: number | null;
|
||||
unitOfMeasureId: number | null;
|
||||
familyUnitOfMeasureId: number | null;
|
||||
isApproximate: boolean;
|
||||
quantityNote: string | null;
|
||||
}
|
||||
|
||||
interface Section {
|
||||
@@ -42,6 +50,9 @@
|
||||
let newItemSectionId = $state<number | null>(null);
|
||||
let newItemProductId = $state<number | null>(null);
|
||||
let newItemFamilyProductId = $state<number | null>(null);
|
||||
let newItemQuantity = $state<QuantityValue>({ quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null });
|
||||
let newItemIsApproximate = $state(false);
|
||||
let newItemQuantityNote = $state('');
|
||||
let loading = $state(true);
|
||||
let connection: HubConnection | null = null;
|
||||
|
||||
@@ -83,12 +94,18 @@
|
||||
list = data;
|
||||
items = data.items;
|
||||
sections = data.sections;
|
||||
// Touch the unit catalog so abbreviations are available for display.
|
||||
void units.all;
|
||||
loading = false;
|
||||
|
||||
connection = await startConnection();
|
||||
await connection.invoke('JoinList', listId);
|
||||
|
||||
connection.on('ItemAdded', (data: { id: number; name: string; sortOrder: number; sectionId: number | null; recipeTitle?: string }) => {
|
||||
connection.on('ItemAdded', (data: {
|
||||
id: number; name: string; sortOrder: number; sectionId: number | null; recipeTitle?: string;
|
||||
quantity: number | null; unitOfMeasureId: number | null; familyUnitOfMeasureId: number | null;
|
||||
isApproximate: boolean; quantityNote: string | null;
|
||||
}) => {
|
||||
if (!items.find((i) => i.id === data.id)) {
|
||||
items = [
|
||||
...items,
|
||||
@@ -99,7 +116,12 @@
|
||||
checkedByUserName: null,
|
||||
sortOrder: data.sortOrder,
|
||||
sectionId: data.sectionId ?? null,
|
||||
recipeTitle: data.recipeTitle ?? null
|
||||
recipeTitle: data.recipeTitle ?? null,
|
||||
quantity: data.quantity,
|
||||
unitOfMeasureId: data.unitOfMeasureId,
|
||||
familyUnitOfMeasureId: data.familyUnitOfMeasureId,
|
||||
isApproximate: data.isApproximate,
|
||||
quantityNote: data.quantityNote
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -154,12 +176,29 @@
|
||||
sortOrder: maxSort + 1,
|
||||
sectionId: newItemSectionId,
|
||||
productId: newItemProductId,
|
||||
familyProductId: newItemFamilyProductId
|
||||
familyProductId: newItemFamilyProductId,
|
||||
quantity: newItemIsApproximate ? null : newItemQuantity.quantity,
|
||||
unitOfMeasureId: newItemIsApproximate ? null : newItemQuantity.unitOfMeasureId,
|
||||
familyUnitOfMeasureId: newItemIsApproximate ? null : newItemQuantity.familyUnitOfMeasureId,
|
||||
isApproximate: newItemIsApproximate,
|
||||
quantityNote: newItemIsApproximate ? (newItemQuantityNote || null) : null
|
||||
})
|
||||
});
|
||||
newItemName = '';
|
||||
newItemProductId = null;
|
||||
newItemFamilyProductId = null;
|
||||
newItemQuantity = { quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null };
|
||||
newItemIsApproximate = false;
|
||||
newItemQuantityNote = '';
|
||||
}
|
||||
|
||||
function toggleApproximateNewItem() {
|
||||
newItemIsApproximate = !newItemIsApproximate;
|
||||
if (newItemIsApproximate) {
|
||||
newItemQuantity = { quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null };
|
||||
} else {
|
||||
newItemQuantityNote = '';
|
||||
}
|
||||
}
|
||||
|
||||
function onItemProductChange(product: ProductSuggestion | null) {
|
||||
@@ -229,34 +268,53 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onsubmit={e => { e.preventDefault(); addItem(); }} class="mb-4 flex flex-wrap gap-2">
|
||||
<div class="min-w-0 flex-1">
|
||||
<ProductTypeahead
|
||||
bind:value={newItemName}
|
||||
placeholder="Add an item..."
|
||||
ariaLabel="Item name"
|
||||
inputClass="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-base focus:border-primary focus:outline-none"
|
||||
onsubmit={addItem}
|
||||
onProductChange={onItemProductChange}
|
||||
/>
|
||||
</div>
|
||||
{#if sections.length > 0}
|
||||
<select
|
||||
bind:value={newItemSectionId}
|
||||
class="rounded-lg border border-gray-300 bg-white px-2 py-2.5 text-sm focus:border-primary focus:outline-none"
|
||||
aria-label="Section"
|
||||
<form onsubmit={e => { e.preventDefault(); addItem(); }} class="mb-4 space-y-1">
|
||||
<div class="flex flex-wrap gap-2">
|
||||
{#if newItemIsApproximate}
|
||||
<input
|
||||
type="text"
|
||||
bind:value={newItemQuantityNote}
|
||||
placeholder="e.g. to taste"
|
||||
class="w-36 rounded-lg border border-gray-300 px-2 py-2.5 text-sm focus:border-primary focus:outline-none"
|
||||
/>
|
||||
{:else}
|
||||
<QuantityInput bind:value={newItemQuantity} />
|
||||
{/if}
|
||||
<div class="min-w-0 flex-1">
|
||||
<ProductTypeahead
|
||||
bind:value={newItemName}
|
||||
placeholder="Add an item..."
|
||||
ariaLabel="Item name"
|
||||
inputClass="w-full rounded-lg border border-gray-300 px-3 py-2.5 text-base focus:border-primary focus:outline-none"
|
||||
onsubmit={addItem}
|
||||
onProductChange={onItemProductChange}
|
||||
/>
|
||||
</div>
|
||||
{#if sections.length > 0}
|
||||
<select
|
||||
bind:value={newItemSectionId}
|
||||
class="rounded-lg border border-gray-300 bg-white px-2 py-2.5 text-sm focus:border-primary focus:outline-none"
|
||||
aria-label="Section"
|
||||
>
|
||||
<option value={null}>Uncategorized</option>
|
||||
{#each sections as section (section.id)}
|
||||
<option value={section.id}>{section.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-primary px-4 py-2.5 font-semibold text-white"
|
||||
>
|
||||
<option value={null}>Uncategorized</option>
|
||||
{#each sections as section (section.id)}
|
||||
<option value={section.id}>{section.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-lg bg-primary px-4 py-2.5 font-semibold text-white"
|
||||
type="button"
|
||||
onclick={toggleApproximateNewItem}
|
||||
class="text-xs text-gray-500 hover:text-primary"
|
||||
>
|
||||
Add
|
||||
{newItemIsApproximate ? '↩ use measured amount' : '~ approximate (e.g. to taste)'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -269,6 +327,7 @@
|
||||
</h3>
|
||||
<ul class="space-y-1">
|
||||
{#each group.items as item (item.id)}
|
||||
{@const qty = formatQuantity(item, units.all)}
|
||||
<li class="flex items-center gap-3 rounded-lg bg-white px-3 py-3 shadow-sm">
|
||||
<button
|
||||
onclick={() => toggleItem(item.id)}
|
||||
@@ -276,6 +335,9 @@
|
||||
aria-label="Check {item.name}"
|
||||
></button>
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if qty}
|
||||
<span class="text-base font-medium text-primary">{qty}</span>
|
||||
{/if}
|
||||
<span class="text-base">{item.name}</span>
|
||||
{#if item.recipeTitle}
|
||||
<span class="ml-1 text-xs text-gray-400">from {item.recipeTitle}</span>
|
||||
@@ -324,6 +386,7 @@
|
||||
</h4>
|
||||
<ul class="space-y-1">
|
||||
{#each group.items as item (item.id)}
|
||||
{@const qty = formatQuantity(item, units.all)}
|
||||
<li class="flex items-center gap-3 rounded-lg bg-white/60 px-3 py-3 shadow-sm">
|
||||
<button
|
||||
onclick={() => toggleItem(item.id)}
|
||||
@@ -333,6 +396,9 @@
|
||||
✓
|
||||
</button>
|
||||
<div class="min-w-0 flex-1">
|
||||
{#if qty}
|
||||
<span class="text-base text-gray-400 line-through">{qty}</span>
|
||||
{/if}
|
||||
<span class="text-base text-gray-400 line-through">{item.name}</span>
|
||||
{#if item.checkedByUserName}
|
||||
<span class="ml-1 text-xs text-gray-300">{item.checkedByUserName}</span>
|
||||
|
||||
Reference in New Issue
Block a user