Section defaults on products with prompt-on-divergence

- Add DefaultSection (string?) to Product, FamilyProduct, and
  FamilyProductOverride. Resolution order on item add: per-store memory
  (ProductStoreSection) → family default (override or family product) →
  global product default, matched case-insensitively by name to the
  current store''s StoreSection rows.
- Drop the section dropdown from the add-item form; the backend resolves
  on POST. Section change on a list row syncs the family default:
  silently creates it when none exists, no-op when it matches, returns
  promptSaveDefault=true when it differs so the client confirms before
  updating.
- Expose DefaultSection on the catalog page form. Replace the native
  datalist with a styled TextCombobox component for autocomplete
  suggestions (seeded from typicalSections) while still allowing
  free-text entry. Pattern doc-noted as the sibling of ProductTypeahead.
- Remove the now-unused GET /api/products/{kind}/{id}/section endpoint
  and its tests; the add-form pre-fill they backed is gone.
This commit is contained in:
Josh Rogers
2026-05-15 22:21:56 -05:00
parent 6d84aad94b
commit fa465ac29c
16 changed files with 1899 additions and 275 deletions
@@ -1,6 +1,5 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using YesChef.Api.Entities;
using YesChef.Api.Features.Products;
using YesChef.Api.IntegrationTests.Infrastructure;
@@ -365,130 +364,94 @@ public class ProductEndpointsTests : AuthenticatedIntegrationTest
}
[Test]
public async Task Get_global_product_section_returns_remembered_section()
public async Task Create_family_product_persists_default_section()
{
var familyId = await GetFamilyIdAsync();
var store = await Data.CreateStoreAsync();
var (product, section) = await UseDbAsync(async db =>
{
var p = new Product { Name = "Bananas-Lookup" };
var s = new StoreSection { FamilyId = familyId, StoreId = store.Id, Name = "Produce", SortOrder = 1 };
db.Products.Add(p);
db.StoreSections.Add(s);
await db.SaveChangesAsync();
db.ProductStoreSections.Add(new ProductStoreSection
{
FamilyId = familyId,
StoreId = store.Id,
ProductId = p.Id,
StoreSectionId = s.Id,
});
await db.SaveChangesAsync();
return (p, s);
});
var response = await Client.PostAsJsonAsync("/api/products",
new ProductEndpoints.CreateProductRequest("Sourdough", null, null, UnitCategoryFlags.None, " Bakery "));
var body = await Client.GetFromJsonAsync<JsonElement>(
$"/api/products/global/{product.Id}/section?storeId={store.Id}");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
var dto = await response.Content.ReadFromJsonAsync<ProductEndpoints.ProductDto>();
await Assert.That(dto!.DefaultSection).IsEqualTo("Bakery");
await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(section.Id);
var stored = await UseDbAsync(db => db.FamilyProducts.SingleAsync());
await Assert.That(stored.DefaultSection).IsEqualTo("Bakery");
}
[Test]
public async Task Get_global_product_section_returns_null_when_no_memory()
public async Task Update_family_product_clears_default_section_when_blank()
{
var store = await Data.CreateStoreAsync();
var familyId = await GetFamilyIdAsync();
var product = await UseDbAsync(async db =>
{
var p = new Product { Name = "Unknown-Lookup" };
var p = new FamilyProduct { FamilyId = familyId, Name = "Flour", DefaultSection = "Pantry" };
db.FamilyProducts.Add(p);
await db.SaveChangesAsync();
return p;
});
var response = await Client.PutAsJsonAsync($"/api/products/family/{product.Id}",
new ProductEndpoints.UpdateProductRequest(null, null, null, null, " "));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var refreshed = await UseDbAsync(db => db.FamilyProducts.SingleAsync(p => p.Id == product.Id));
await Assert.That(refreshed.DefaultSection).IsNull();
}
[Test]
public async Task Update_global_product_writes_default_section_to_override()
{
var familyId = await GetFamilyIdAsync();
var product = await UseDbAsync(async db =>
{
var p = new Product { Name = "Bananas", DefaultSection = "Produce" };
db.Products.Add(p);
await db.SaveChangesAsync();
return p;
});
var body = await Client.GetFromJsonAsync<JsonElement>(
$"/api/products/global/{product.Id}/section?storeId={store.Id}");
var response = await Client.PutAsJsonAsync($"/api/products/global/{product.Id}",
new ProductEndpoints.UpdateProductRequest(null, null, null, null, "Frozen"));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var dto = await response.Content.ReadFromJsonAsync<ProductEndpoints.ProductDto>();
await Assert.That(dto!.DefaultSection).IsEqualTo("Frozen");
await Assert.That(body.GetProperty("sectionId").ValueKind).IsEqualTo(JsonValueKind.Null);
// Global row untouched.
var global = await UseDbAsync(db => db.Products.SingleAsync(p => p.Id == product.Id));
await Assert.That(global.DefaultSection).IsEqualTo("Produce");
var ovr = await UseDbAsync(db => db.FamilyProductOverrides
.SingleAsync(o => o.FamilyId == familyId && o.ProductId == product.Id));
await Assert.That(ovr.DefaultSection).IsEqualTo("Frozen");
}
[Test]
public async Task Get_global_product_section_is_scoped_to_store()
public async Task Search_projects_effective_default_section()
{
var familyId = await GetFamilyIdAsync();
var storeA = await Data.CreateStoreAsync(b => b.Named("A"));
var storeB = await Data.CreateStoreAsync(b => b.Named("B"));
var product = await UseDbAsync(async db =>
await UseDbAsync(async db =>
{
var p = new Product { Name = "Apples-Lookup" };
var sectionA = new StoreSection { FamilyId = familyId, StoreId = storeA.Id, Name = "Produce A", SortOrder = 1 };
db.Products.Add(p);
db.StoreSections.Add(sectionA);
db.Products.Add(new Product { Name = "Apples", DefaultSection = "Produce" });
db.Products.Add(new Product { Name = "Berries", DefaultSection = "Produce" });
await db.SaveChangesAsync();
db.ProductStoreSections.Add(new ProductStoreSection
var berries = db.Products.Single(p => p.Name == "Berries");
db.FamilyProductOverrides.Add(new YesChef.Api.Entities.FamilyProductOverride
{
FamilyId = familyId,
StoreId = storeA.Id,
ProductId = p.Id,
StoreSectionId = sectionA.Id,
ProductId = berries.Id,
DefaultSection = "Frozen",
});
await db.SaveChangesAsync();
return p;
});
// Lookup at store B — no memory there even though store A has one.
var body = await Client.GetFromJsonAsync<JsonElement>(
$"/api/products/global/{product.Id}/section?storeId={storeB.Id}");
await Assert.That(body.GetProperty("sectionId").ValueKind).IsEqualTo(JsonValueKind.Null);
}
[Test]
public async Task Get_family_product_section_returns_remembered_section()
{
var familyId = await GetFamilyIdAsync();
var store = await Data.CreateStoreAsync();
var (product, section) = await UseDbAsync(async db =>
{
var p = new FamilyProduct { FamilyId = familyId, Name = "House Bread" };
var s = new StoreSection { FamilyId = familyId, StoreId = store.Id, Name = "Bakery", SortOrder = 1 };
db.FamilyProducts.Add(p);
db.StoreSections.Add(s);
await db.SaveChangesAsync();
db.ProductStoreSections.Add(new ProductStoreSection
db.FamilyProducts.Add(new FamilyProduct
{
FamilyId = familyId,
StoreId = store.Id,
FamilyProductId = p.Id,
StoreSectionId = s.Id,
Name = "House Bread",
DefaultSection = "Bakery",
});
await db.SaveChangesAsync();
return (p, s);
});
var body = await Client.GetFromJsonAsync<JsonElement>(
$"/api/products/family/{product.Id}/section?storeId={store.Id}");
await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(section.Id);
}
[Test]
public async Task Get_family_product_section_returns_404_for_other_family_product()
{
var store = await Data.CreateStoreAsync();
var otherFamilyProductId = await UseDbAsync(async db =>
{
var otherFamily = new Family { Name = "Other", InviteCode = "other-code" };
db.Families.Add(otherFamily);
await db.SaveChangesAsync();
var p = new FamilyProduct { FamilyId = otherFamily.Id, Name = "Their Bread" };
db.FamilyProducts.Add(p);
await db.SaveChangesAsync();
return p.Id;
});
var response = await Client.GetAsync(
$"/api/products/family/{otherFamilyProductId}/section?storeId={store.Id}");
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
var results = (await Client.GetFromJsonAsync<List<ProductEndpoints.ProductDto>>("/api/products?q="))!;
await Assert.That(results.Single(r => r.Name == "Apples").DefaultSection).IsEqualTo("Produce");
await Assert.That(results.Single(r => r.Name == "Berries").DefaultSection).IsEqualTo("Frozen");
await Assert.That(results.Single(r => r.Name == "House Bread").DefaultSection).IsEqualTo("Bakery");
}
}
@@ -207,10 +207,10 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
}
[Test]
public async Task Add_item_records_section_memory_and_auto_assigns_on_next_add()
public async Task Add_item_resolves_section_from_global_product_default()
{
var list = await CreateListAsync();
var section = await UseDbAsync(async db =>
var produce = await UseDbAsync(async db =>
{
var s = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 };
db.StoreSections.Add(s);
@@ -219,99 +219,312 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
});
var product = await UseDbAsync(async db =>
{
var p = new Product { Name = "Bananas-Memory" };
var p = new Product { Name = "Bananas-Default", DefaultSection = "Produce" };
db.Products.Add(p);
await db.SaveChangesAsync();
return p;
});
// First add: explicit section + product → memory recorded.
var first = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("Bananas", SectionId: section.Id, ProductId: product.Id));
await Assert.That(first.StatusCode).IsEqualTo(HttpStatusCode.Created);
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("Bananas", ProductId: product.Id));
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
// Second add: same product, no section → auto-assigned from memory.
var second = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("More Bananas", ProductId: product.Id));
await Assert.That(second.StatusCode).IsEqualTo(HttpStatusCode.Created);
var items = await UseDbAsync(db => db.ShoppingListItems
.Where(i => i.ShoppingListId == list.Id).OrderBy(i => i.Id).ToListAsync());
await Assert.That(items.Count).IsEqualTo(2);
await Assert.That(items[1].SectionId).IsEqualTo(section.Id);
await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(produce.Id);
}
[Test]
public async Task Patch_item_section_updates_memory_for_next_add()
public async Task Add_item_resolves_section_with_case_insensitive_name_match()
{
var list = await CreateListAsync();
var (originalSection, correctedSection) = await UseDbAsync(async db =>
var produce = await UseDbAsync(async db =>
{
var s1 = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Pantry", SortOrder = 1 };
var s2 = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Bakery", SortOrder = 2 };
db.StoreSections.AddRange(s1, s2);
var s = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 };
db.StoreSections.Add(s);
await db.SaveChangesAsync();
return (s1, s2);
return s;
});
var product = await UseDbAsync(async db =>
{
var p = new Product { Name = "Bread-Memory" };
var p = new Product { Name = "Bananas-Case", DefaultSection = " produce " };
db.Products.Add(p);
await db.SaveChangesAsync();
return p;
});
// Add with section A; that records (Product → A) memory.
var addResponse = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("Bread", SectionId: originalSection.Id, ProductId: product.Id));
var addBody = await addResponse.Content.ReadFromJsonAsync<JsonElement>();
var firstItemId = addBody.GetProperty("id").GetInt32();
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("Bananas", ProductId: product.Id));
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
// User corrects to section B → memory should update.
var patchResponse = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{firstItemId}/section",
new ShoppingListEndpoints.SetItemSectionRequest(correctedSection.Id));
await Assert.That(patchResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
// Next add (no section) should now pull section B.
var second = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("More Bread", ProductId: product.Id));
var secondBody = await second.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(secondBody.GetProperty("sectionId").GetInt32()).IsEqualTo(correctedSection.Id);
await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(produce.Id);
}
[Test]
public async Task Section_memory_is_scoped_per_store()
public async Task Add_item_resolves_to_null_when_default_section_does_not_exist_in_store()
{
var listA = await CreateListAsync();
var otherStore = await Data.CreateStoreAsync(b => b.Named("Other Store"));
var listB = await Data.CreateListAsync(b => b.ForStore(otherStore).CreatedBy(User));
var (sectionA, sectionB) = await UseDbAsync(async db =>
{
var a = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce A", SortOrder = 1 };
var b = new StoreSection { FamilyId = otherStore.FamilyId, StoreId = otherStore.Id, Name = "Produce B", SortOrder = 1 };
db.StoreSections.AddRange(a, b);
await db.SaveChangesAsync();
return (a, b);
});
var list = await CreateListAsync();
var product = await UseDbAsync(async db =>
{
var p = new Product { Name = "Apples-Memory" };
var p = new Product { Name = "Imports-Only", DefaultSection = "Specialty" };
db.Products.Add(p);
await db.SaveChangesAsync();
return p;
});
// Memorize at store A only.
await Client.PostAsJsonAsync($"/api/lists/{listA.Id}/items",
new ShoppingListEndpoints.AddItemRequest("Apples", SectionId: sectionA.Id, ProductId: product.Id));
// Add at store B with no section → no memory yet for store B.
var response = await Client.PostAsJsonAsync($"/api/lists/{listB.Id}/items",
new ShoppingListEndpoints.AddItemRequest("Apples", ProductId: product.Id));
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("Saffron", ProductId: product.Id));
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(body.GetProperty("sectionId").ValueKind).IsEqualTo(JsonValueKind.Null);
}
[Test]
public async Task Add_item_prefers_family_override_default_over_global_product_default()
{
var list = await CreateListAsync();
var familyId = Store.FamilyId;
var (produce, frozen) = await UseDbAsync(async db =>
{
var p = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 };
var f = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Frozen", SortOrder = 2 };
db.StoreSections.AddRange(p, f);
await db.SaveChangesAsync();
return (p, f);
});
var product = await UseDbAsync(async db =>
{
var p = new Product { Name = "Berries", DefaultSection = "Produce" };
db.Products.Add(p);
await db.SaveChangesAsync();
db.FamilyProductOverrides.Add(new YesChef.Api.Entities.FamilyProductOverride
{
FamilyId = familyId,
ProductId = p.Id,
DefaultSection = "Frozen",
});
await db.SaveChangesAsync();
return p;
});
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("Berries", ProductId: product.Id));
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(frozen.Id);
}
[Test]
public async Task Add_item_prefers_per_store_memory_over_family_default()
{
var list = await CreateListAsync();
var familyId = Store.FamilyId;
var (produce, pantry) = await UseDbAsync(async db =>
{
var p = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 };
var pn = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Pantry", SortOrder = 2 };
db.StoreSections.AddRange(p, pn);
await db.SaveChangesAsync();
return (p, pn);
});
var product = await UseDbAsync(async db =>
{
var p = new Product { Name = "Garlic", DefaultSection = "Produce" };
db.Products.Add(p);
await db.SaveChangesAsync();
db.ProductStoreSections.Add(new ProductStoreSection
{
FamilyId = familyId,
StoreId = Store.Id,
ProductId = p.Id,
StoreSectionId = pantry.Id,
});
await db.SaveChangesAsync();
return p;
});
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
new ShoppingListEndpoints.AddItemRequest("Garlic", ProductId: product.Id));
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(pantry.Id);
}
[Test]
public async Task Patch_item_section_silently_creates_family_override_when_no_default_exists()
{
var list = await CreateListAsync(b => b.WithItem("Bananas"));
var familyId = Store.FamilyId;
var produce = await UseDbAsync(async db =>
{
var s = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 };
db.StoreSections.Add(s);
await db.SaveChangesAsync();
return s;
});
var (itemId, productId) = await UseDbAsync(async db =>
{
var p = new Product { Name = "Bananas-Silent" };
db.Products.Add(p);
await db.SaveChangesAsync();
var item = await db.ShoppingListItems.SingleAsync(i => i.ShoppingListId == list.Id);
item.ProductId = p.Id;
await db.SaveChangesAsync();
return (item.Id, p.Id);
});
var response = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section",
new ShoppingListEndpoints.SetItemSectionRequest(produce.Id));
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(body.GetProperty("promptSaveDefault").GetBoolean()).IsFalse();
var ovr = await UseDbAsync(db => db.FamilyProductOverrides
.SingleAsync(o => o.FamilyId == familyId && o.ProductId == productId));
await Assert.That(ovr.DefaultSection).IsEqualTo("Produce");
}
[Test]
public async Task Patch_item_section_returns_prompt_when_existing_family_default_differs()
{
var list = await CreateListAsync(b => b.WithItem("Bananas"));
var familyId = Store.FamilyId;
var (produce, bakery) = await UseDbAsync(async db =>
{
var p = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 };
var b = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Bakery", SortOrder = 2 };
db.StoreSections.AddRange(p, b);
await db.SaveChangesAsync();
return (p, b);
});
var (itemId, productId) = await UseDbAsync(async db =>
{
var p = new Product { Name = "Banana-Bread" };
db.Products.Add(p);
await db.SaveChangesAsync();
db.FamilyProductOverrides.Add(new YesChef.Api.Entities.FamilyProductOverride
{
FamilyId = familyId,
ProductId = p.Id,
DefaultSection = "Bakery",
});
var item = await db.ShoppingListItems.SingleAsync(i => i.ShoppingListId == list.Id);
item.ProductId = p.Id;
await db.SaveChangesAsync();
return (item.Id, p.Id);
});
// First call without saveAsDefault — should prompt.
var promptResponse = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section",
new ShoppingListEndpoints.SetItemSectionRequest(produce.Id));
var promptBody = await promptResponse.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(promptBody.GetProperty("promptSaveDefault").GetBoolean()).IsTrue();
await Assert.That(promptBody.GetProperty("currentDefaultSection").GetString()).IsEqualTo("Bakery");
await Assert.That(promptBody.GetProperty("newSectionName").GetString()).IsEqualTo("Produce");
// Override still says Bakery — item section changed but default untouched.
var ovrBefore = await UseDbAsync(db => db.FamilyProductOverrides
.SingleAsync(o => o.FamilyId == familyId && o.ProductId == productId));
await Assert.That(ovrBefore.DefaultSection).IsEqualTo("Bakery");
var itemBefore = await UseDbAsync(db => db.ShoppingListItems.SingleAsync(i => i.Id == itemId));
await Assert.That(itemBefore.SectionId).IsEqualTo(produce.Id);
// Second call with saveAsDefault=true — override updates.
var acceptResponse = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section",
new ShoppingListEndpoints.SetItemSectionRequest(produce.Id, SaveAsDefault: true));
await Assert.That(acceptResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
var ovrAfter = await UseDbAsync(db => db.FamilyProductOverrides
.SingleAsync(o => o.FamilyId == familyId && o.ProductId == productId));
await Assert.That(ovrAfter.DefaultSection).IsEqualTo("Produce");
}
[Test]
public async Task Patch_item_section_no_prompt_when_default_already_matches()
{
var list = await CreateListAsync(b => b.WithItem("Bananas"));
var familyId = Store.FamilyId;
var produce = await UseDbAsync(async db =>
{
var s = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 };
db.StoreSections.Add(s);
await db.SaveChangesAsync();
return s;
});
var itemId = await UseDbAsync(async db =>
{
var p = new Product { Name = "Bananas-Match" };
db.Products.Add(p);
await db.SaveChangesAsync();
db.FamilyProductOverrides.Add(new YesChef.Api.Entities.FamilyProductOverride
{
FamilyId = familyId,
ProductId = p.Id,
DefaultSection = "Produce",
});
var item = await db.ShoppingListItems.SingleAsync(i => i.ShoppingListId == list.Id);
item.ProductId = p.Id;
await db.SaveChangesAsync();
return item.Id;
});
var response = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section",
new ShoppingListEndpoints.SetItemSectionRequest(produce.Id));
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(body.GetProperty("promptSaveDefault").GetBoolean()).IsFalse();
}
[Test]
public async Task Patch_item_section_sets_family_product_default_for_family_products()
{
var list = await CreateListAsync(b => b.WithItem("House Bread"));
var familyId = Store.FamilyId;
var bakery = await UseDbAsync(async db =>
{
var s = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Bakery", SortOrder = 1 };
db.StoreSections.Add(s);
await db.SaveChangesAsync();
return s;
});
var (itemId, productId) = await UseDbAsync(async db =>
{
var p = new FamilyProduct { FamilyId = familyId, Name = "House Bread" };
db.FamilyProducts.Add(p);
await db.SaveChangesAsync();
var item = await db.ShoppingListItems.SingleAsync(i => i.ShoppingListId == list.Id);
item.FamilyProductId = p.Id;
await db.SaveChangesAsync();
return (item.Id, p.Id);
});
var response = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section",
new ShoppingListEndpoints.SetItemSectionRequest(bakery.Id));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var fp = await UseDbAsync(db => db.FamilyProducts.SingleAsync(p => p.Id == productId));
await Assert.That(fp.DefaultSection).IsEqualTo("Bakery");
}
[Test]
public async Task Patch_item_section_without_product_link_does_not_touch_defaults()
{
var list = await CreateListAsync(b => b.WithItem("free-form"));
var familyId = Store.FamilyId;
var produce = await UseDbAsync(async db =>
{
var s = new StoreSection { FamilyId = familyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 };
db.StoreSections.Add(s);
await db.SaveChangesAsync();
return s;
});
var itemId = await UseDbAsync(db => db.ShoppingListItems
.Where(i => i.ShoppingListId == list.Id).Select(i => i.Id).SingleAsync());
var response = await Client.PatchAsJsonAsync($"/api/lists/{list.Id}/items/{itemId}/section",
new ShoppingListEndpoints.SetItemSectionRequest(produce.Id));
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
await Assert.That(body.GetProperty("promptSaveDefault").GetBoolean()).IsFalse();
await Assert.That(await UseDbAsync(db => db.FamilyProductOverrides.AnyAsync())).IsFalse();
}
[Test]
public async Task Add_item_rejects_other_familys_family_product()
{
@@ -141,6 +141,7 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
e.Property(p => p.Brand).HasMaxLength(200);
e.Property(p => p.Notes).HasMaxLength(1000);
e.Property(p => p.AllowedUnitCategories).HasConversion<int>();
e.Property(p => p.DefaultSection).HasMaxLength(100);
e.HasIndex(p => p.Name).IsUnique();
});
@@ -150,6 +151,7 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
e.Property(p => p.Brand).HasMaxLength(200);
e.Property(p => p.Notes).HasMaxLength(1000);
e.Property(p => p.AllowedUnitCategories).HasConversion<int>();
e.Property(p => p.DefaultSection).HasMaxLength(100);
e.HasOne(p => p.Family).WithMany().HasForeignKey(p => p.FamilyId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(p => new { p.FamilyId, p.Name }).IsUnique();
});
@@ -161,6 +163,7 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
e.Property(o => o.Brand).HasMaxLength(200);
e.Property(o => o.Notes).HasMaxLength(1000);
e.Property(o => o.AllowedUnitCategories).HasConversion<int?>();
e.Property(o => o.DefaultSection).HasMaxLength(100);
e.HasOne(o => o.Family).WithMany().HasForeignKey(o => o.FamilyId).OnDelete(DeleteBehavior.Cascade);
e.HasOne(o => o.Product).WithMany().HasForeignKey(o => o.ProductId).OnDelete(DeleteBehavior.Cascade);
});
@@ -13,5 +13,6 @@ public class FamilyProduct
public string? Brand { get; set; }
public string? Notes { get; set; }
public UnitCategoryFlags AllowedUnitCategories { get; set; }
public string? DefaultSection { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -18,5 +18,8 @@ public class FamilyProductOverride
// Nullable so "inherit global" (null) is distinguishable from
// "explicitly None / any unit" (UnitCategoryFlags.None).
public UnitCategoryFlags? AllowedUnitCategories { get; set; }
// Null = inherit Product.DefaultSection. Non-null = family override of
// the recommended section name.
public string? DefaultSection { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -14,5 +14,10 @@ public class Product
// None = "any unit". Non-zero narrows the unit-dropdown suggestions to the
// flagged categories. Families can replace this with FamilyProductOverride.
public UnitCategoryFlags AllowedUnitCategories { get; set; }
// Recommended section name (e.g. "Produce"). Resolved at runtime against
// the active store's StoreSection rows by case-insensitive name match —
// sections are family/store-scoped so there's no FK relationship. Family
// overrides take precedence; per-store memory takes precedence over both.
public string? DefaultSection { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -3,7 +3,6 @@ using Microsoft.EntityFrameworkCore;
using YesChef.Api.Auth;
using YesChef.Api.Data;
using YesChef.Api.Entities;
using YesChef.Api.Features.ShoppingLists;
namespace YesChef.Api.Features.Products;
@@ -22,10 +21,11 @@ public static class ProductEndpoints
string? Brand,
string? Notes,
bool IsOverridden,
UnitCategoryFlags AllowedUnitCategories);
UnitCategoryFlags AllowedUnitCategories,
string? DefaultSection);
public record CreateProductRequest(string Name, string? Brand, string? Notes, UnitCategoryFlags AllowedUnitCategories = UnitCategoryFlags.None);
public record UpdateProductRequest(string? Name, string? Brand, string? Notes, UnitCategoryFlags? AllowedUnitCategories = null);
public record CreateProductRequest(string Name, string? Brand, string? Notes, UnitCategoryFlags AllowedUnitCategories = UnitCategoryFlags.None, string? DefaultSection = null);
public record UpdateProductRequest(string? Name, string? Brand, string? Notes, UnitCategoryFlags? AllowedUnitCategories = null, string? DefaultSection = null);
private const int SearchResultLimit = 50;
@@ -59,10 +59,12 @@ public static class ProductEndpoints
GlobalBrand = p.Brand,
GlobalNotes = p.Notes,
GlobalAllowedUnitCategories = p.AllowedUnitCategories,
GlobalDefaultSection = p.DefaultSection,
OverrideName = o != null ? o.Name : null,
OverrideBrand = o != null ? o.Brand : null,
OverrideNotes = o != null ? o.Notes : null,
OverrideAllowedUnitCategories = o != null ? o.AllowedUnitCategories : null,
OverrideDefaultSection = o != null ? o.DefaultSection : null,
HasOverride = o != null,
})
.Take(SearchResultLimit)
@@ -89,11 +91,12 @@ public static class ProductEndpoints
r.OverrideBrand ?? r.GlobalBrand,
r.OverrideNotes ?? r.GlobalNotes,
r.HasOverride,
r.OverrideAllowedUnitCategories ?? r.GlobalAllowedUnitCategories);
r.OverrideAllowedUnitCategories ?? r.GlobalAllowedUnitCategories,
r.OverrideDefaultSection ?? r.GlobalDefaultSection);
}).Where(d => d is not null).Cast<ProductDto>();
var familyDtos = familyRows.Select(p =>
new ProductDto(p.Id, ProductKind.Family, p.Name, p.Brand, p.Notes, IsOverridden: false, p.AllowedUnitCategories));
new ProductDto(p.Id, ProductKind.Family, p.Name, p.Brand, p.Notes, IsOverridden: false, p.AllowedUnitCategories, p.DefaultSection));
var results = globalDtos.Concat(familyDtos)
.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase)
@@ -122,6 +125,7 @@ public static class ProductEndpoints
Brand = request.Brand,
Notes = request.Notes,
AllowedUnitCategories = request.AllowedUnitCategories,
DefaultSection = NormalizeSection(request.DefaultSection),
};
db.FamilyProducts.Add(product);
await db.SaveChangesAsync();
@@ -151,6 +155,7 @@ public static class ProductEndpoints
product.Brand = request.Brand;
product.Notes = request.Notes;
if (request.AllowedUnitCategories is { } cats) product.AllowedUnitCategories = cats;
product.DefaultSection = NormalizeSection(request.DefaultSection);
await db.SaveChangesAsync();
return Results.Ok(ToDto(product));
@@ -195,7 +200,8 @@ public static class ProductEndpoints
product.Brand,
product.Notes,
IsOverridden: false,
product.AllowedUnitCategories));
product.AllowedUnitCategories,
product.DefaultSection));
});
// Update a global product for this family by upserting an override.
@@ -229,6 +235,7 @@ public static class ProductEndpoints
// itself be null, i.e. "inherit global"). Pass an explicit value to
// either narrow categories or restore "any" (UnitCategoryFlags.None).
if (request.AllowedUnitCategories is { } cats) ovr.AllowedUnitCategories = cats;
ovr.DefaultSection = NormalizeSection(request.DefaultSection);
ovr.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
@@ -239,36 +246,26 @@ public static class ProductEndpoints
ovr.Brand ?? product.Brand,
ovr.Notes ?? product.Notes,
IsOverridden: true,
ovr.AllowedUnitCategories ?? product.AllowedUnitCategories));
});
// Look up the per-store section memory for a product so the add-item
// UI can pre-fill the dropdown the moment the user picks a product
// from the typeahead. The backend already applies this memory on POST
// when no section is supplied — this endpoint just lets the client
// mirror that decision so the user can see (and override) it.
// Returns { sectionId: int | null }. No memory → null, not 404.
group.MapGet("/global/{id:int}/section", async (int id, int storeId, YesChefDb db, HttpContext http) =>
{
var familyId = http.User.GetFamilyId();
var sectionId = await ShoppingListEndpoints.LookupRememberedSectionAsync(db, familyId, storeId, id, null);
return Results.Ok(new { sectionId });
});
group.MapGet("/family/{id:int}/section", async (int id, int storeId, YesChefDb db, HttpContext http) =>
{
var familyId = http.User.GetFamilyId();
// FamilyProducts are tenant-scoped — refuse to leak even the
// existence of another family's product id.
if (!await db.FamilyProducts.AnyAsync(p => p.Id == id && p.FamilyId == familyId))
return Results.NotFound();
var sectionId = await ShoppingListEndpoints.LookupRememberedSectionAsync(db, familyId, storeId, null, id);
return Results.Ok(new { sectionId });
ovr.AllowedUnitCategories ?? product.AllowedUnitCategories,
ovr.DefaultSection ?? product.DefaultSection));
});
return group;
}
private static ProductDto ToDto(FamilyProduct p) =>
new(p.Id, ProductKind.Family, p.Name, p.Brand, p.Notes, IsOverridden: false, p.AllowedUnitCategories);
new(p.Id, ProductKind.Family, p.Name, p.Brand, p.Notes, IsOverridden: false, p.AllowedUnitCategories, p.DefaultSection);
/// <summary>
/// Strip whitespace; turn empty into null so "no default" round-trips
/// consistently regardless of whether the client sends `""` or omits the
/// field. Storing whitespace would also break the case-insensitive match
/// against StoreSection names.
/// </summary>
private static string? NormalizeSection(string? value)
{
if (value is null) return null;
var trimmed = value.Trim();
return trimmed.Length == 0 ? null : trimmed;
}
}
@@ -22,7 +22,19 @@ public static class ShoppingListEndpoints
int? FamilyUnitOfMeasureId = null,
bool IsApproximate = false,
string? QuantityNote = null);
public record SetItemSectionRequest(int? SectionId);
public record SetItemSectionRequest(int? SectionId, bool SaveAsDefault = false);
/// <summary>
/// Response shape for the section PATCH. When the item is linked to a
/// product and the family-level default exists with a different name,
/// <see cref="PromptSaveDefault"/> is true and the client should ask the
/// user whether to update the default. Re-call with SaveAsDefault=true to
/// accept.
/// </summary>
public record SetItemSectionResponse(
bool PromptSaveDefault,
string? CurrentDefaultSection,
string? NewSectionName);
private static string OverviewGroup(int familyId) => $"lists-overview-{familyId}";
@@ -216,11 +228,10 @@ public static class ShoppingListEndpoints
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."
// No explicit section → resolve through the product's defaults
// (per-store memory → family default → global default).
var resolvedSectionId = request.SectionId
?? await LookupRememberedSectionAsync(db, familyId, list.StoreId, request.ProductId, request.FamilyProductId);
?? await ResolveSectionAsync(db, familyId, list.StoreId, request.ProductId, request.FamilyProductId);
var item = new ShoppingListItem
{
@@ -240,12 +251,6 @@ public static class ShoppingListEndpoints
db.ShoppingListItems.Add(item);
list.UpdatedAt = DateTime.UtcNow;
// If the caller explicitly chose a section, record/update memory
// for next time. Auto-assigned sections don't need a write back —
// the existing memory row already says exactly this.
if (request.SectionId.HasValue)
await RememberSectionAsync(db, familyId, list.StoreId, request.ProductId, request.FamilyProductId, request.SectionId);
await AllowedUnitCategoryLearner.LearnAsync(db, familyId, new[]
{
new AllowedUnitCategoryLearner.Pair(request.ProductId, request.FamilyProductId, item.UnitOfMeasureId, item.FamilyUnitOfMeasureId),
@@ -266,18 +271,47 @@ public static class ShoppingListEndpoints
.FirstOrDefaultAsync(i => i.Id == itemId && i.ShoppingListId == listId && i.FamilyId == familyId && i.RemovedAt == null);
if (item is null) return Results.NotFound();
if (request.SectionId is int sectionId &&
!await db.StoreSections.AnyAsync(s => s.Id == sectionId && s.StoreId == item.ShoppingList.StoreId && s.FamilyId == familyId))
return Results.BadRequest(new { error = "Unknown section." });
string? newSectionName = null;
if (request.SectionId is int sectionId)
{
var section = await db.StoreSections
.Where(s => s.Id == sectionId && s.StoreId == item.ShoppingList.StoreId && s.FamilyId == familyId)
.Select(s => new { s.Name })
.FirstOrDefaultAsync();
if (section is null) return Results.BadRequest(new { error = "Unknown section." });
newSectionName = section.Name;
}
item.SectionId = request.SectionId;
// Manual section change is the user correcting the memory: persist
// the new mapping so future adds at this store learn from it.
await RememberSectionAsync(db, familyId, item.ShoppingList.StoreId, item.ProductId, item.FamilyProductId, request.SectionId);
// For product-linked items, sync the family-level default. The
// rules per spec:
// - No existing default (override or family product) → set it
// silently. Creates a FamilyProductOverride for globals.
// - Existing default matches → no-op.
// - Existing default differs:
// * SaveAsDefault=true → update.
// * Otherwise → return PromptSaveDefault=true so the client
// can ask the user. The item section change still sticks.
var prompt = false;
string? currentDefault = null;
if (newSectionName is not null && (item.ProductId.HasValue || item.FamilyProductId.HasValue))
{
currentDefault = await GetFamilyDefaultSectionNameAsync(db, familyId, item.ProductId, item.FamilyProductId);
var matches = currentDefault is not null
&& string.Equals(currentDefault.Trim(), newSectionName.Trim(), StringComparison.OrdinalIgnoreCase);
var noDefaultYet = string.IsNullOrWhiteSpace(currentDefault);
if (noDefaultYet || request.SaveAsDefault)
await SetFamilyDefaultSectionAsync(db, familyId, item.ProductId, item.FamilyProductId, newSectionName);
else if (!matches)
prompt = true;
}
await db.SaveChangesAsync();
await hub.Clients.Group($"list-{listId}").SendAsync("ItemSectionChanged", new { item.Id, item.SectionId });
return Results.Ok();
return Results.Ok(new SetItemSectionResponse(prompt, currentDefault, newSectionName));
});
group.MapPatch("/{listId:int}/items/{itemId:int}/check", async (int listId, int itemId, YesChefDb db, IHubContext<ShoppingListHub> hub, HttpContext http) =>
@@ -370,9 +404,9 @@ public static class ShoppingListEndpoints
var idx = 0;
foreach (var ing in recipe.Ingredients)
{
// Carry the ingredient's product link onto the list item, and
// use the per-store memory to assign a section if we have one.
var rememberedSectionId = await LookupRememberedSectionAsync(db, familyId, list.StoreId, ing.ProductId, ing.FamilyProductId);
// Carry the ingredient's product link onto the list item and
// resolve a section through the product's defaults if any.
var rememberedSectionId = await ResolveSectionAsync(db, familyId, list.StoreId, ing.ProductId, ing.FamilyProductId);
newItems.Add(new ShoppingListItem
{
@@ -470,50 +504,127 @@ public static class ShoppingListEndpoints
}
/// <summary>
/// Look up the section a product was last placed in at this store, for
/// this family. Returns null if no memory exists or no product link was
/// supplied.
/// Resolve the effective section for a product-linked item at this store.
/// Tiers, highest priority first:
/// 1. ProductStoreSection — per-store explicit override.
/// 2. Family default — FamilyProductOverride.DefaultSection (global product)
/// or FamilyProduct.DefaultSection (family product), matched by name
/// against this store's sections.
/// 3. Global default — Product.DefaultSection, matched by name.
/// Returns null when no tier produces a section (or no product is linked).
/// </summary>
internal static async Task<int?> LookupRememberedSectionAsync(YesChefDb db, int familyId, int storeId, int? productId, int? familyProductId)
internal static async Task<int?> ResolveSectionAsync(YesChefDb db, int familyId, int storeId, int? productId, int? familyProductId)
{
if (!productId.HasValue && !familyProductId.HasValue) return null;
return await db.ProductStoreSections
var perStore = await db.ProductStoreSections
.Where(p => p.FamilyId == familyId && p.StoreId == storeId
&& p.ProductId == productId && p.FamilyProductId == familyProductId)
.Select(p => (int?)p.StoreSectionId)
.FirstOrDefaultAsync();
if (perStore.HasValue) return perStore;
var defaultName = await GetEffectiveDefaultSectionNameAsync(db, familyId, productId, familyProductId);
if (string.IsNullOrWhiteSpace(defaultName)) return null;
return await db.StoreSections
.Where(s => s.StoreId == storeId && s.FamilyId == familyId
&& EF.Functions.ILike(s.Name, defaultName.Trim()))
.Select(s => (int?)s.Id)
.FirstOrDefaultAsync();
}
/// <summary>
/// Upsert the (Family, Store, Product) → Section memory. No-op if no
/// product link or no section is supplied. Caller must ensure section
/// belongs to the same store and family.
/// Family-level default section name only — FamilyProductOverride for a
/// global product, or FamilyProduct.DefaultSection for a family product.
/// Does NOT fall back to the global Product default. Use this when
/// deciding whether the family has its own override and whether a prompt
/// is needed before changing it.
/// </summary>
internal static async Task RememberSectionAsync(YesChefDb db, int familyId, int storeId, int? productId, int? familyProductId, int? sectionId)
internal static async Task<string?> GetFamilyDefaultSectionNameAsync(YesChefDb db, int familyId, int? productId, int? familyProductId)
{
if (!sectionId.HasValue) return;
if (!productId.HasValue && !familyProductId.HasValue) return;
var existing = await db.ProductStoreSections
.FirstOrDefaultAsync(p => p.FamilyId == familyId && p.StoreId == storeId
&& p.ProductId == productId && p.FamilyProductId == familyProductId);
if (existing is null)
if (productId.HasValue)
{
db.ProductStoreSections.Add(new ProductStoreSection
{
FamilyId = familyId,
StoreId = storeId,
ProductId = productId,
FamilyProductId = familyProductId,
StoreSectionId = sectionId.Value,
});
return await db.FamilyProductOverrides.AsNoTracking()
.Where(o => o.FamilyId == familyId && o.ProductId == productId)
.Select(o => o.DefaultSection)
.FirstOrDefaultAsync();
}
else if (existing.StoreSectionId != sectionId.Value)
if (familyProductId.HasValue)
{
existing.StoreSectionId = sectionId.Value;
existing.UpdatedAt = DateTime.UtcNow;
return await db.FamilyProducts.AsNoTracking()
.Where(p => p.Id == familyProductId && p.FamilyId == familyId)
.Select(p => p.DefaultSection)
.FirstOrDefaultAsync();
}
return null;
}
/// <summary>
/// Persist the family-level default section. For global products, upserts
/// a FamilyProductOverride; for family products, mutates the row directly.
/// Caller is responsible for SaveChangesAsync.
/// </summary>
internal static async Task SetFamilyDefaultSectionAsync(YesChefDb db, int familyId, int? productId, int? familyProductId, string sectionName)
{
if (productId.HasValue)
{
var ovr = await db.FamilyProductOverrides
.FirstOrDefaultAsync(o => o.FamilyId == familyId && o.ProductId == productId);
if (ovr is null)
{
db.FamilyProductOverrides.Add(new Entities.FamilyProductOverride
{
FamilyId = familyId,
ProductId = productId.Value,
DefaultSection = sectionName,
});
}
else
{
ovr.DefaultSection = sectionName;
ovr.UpdatedAt = DateTime.UtcNow;
}
return;
}
if (familyProductId.HasValue)
{
var fp = await db.FamilyProducts
.FirstOrDefaultAsync(p => p.Id == familyProductId && p.FamilyId == familyId);
if (fp is not null)
fp.DefaultSection = sectionName;
}
}
/// <summary>
/// Effective default section name for a product, walking the family
/// override (or family product) and falling back to the global product.
/// Pure read — does not consider per-store memory or section availability.
/// </summary>
internal static async Task<string?> GetEffectiveDefaultSectionNameAsync(YesChefDb db, int familyId, int? productId, int? familyProductId)
{
if (productId.HasValue)
{
var row = await (
from p in db.Products.AsNoTracking()
where p.Id == productId
join o in db.FamilyProductOverrides.Where(o => o.FamilyId == familyId)
on p.Id equals o.ProductId into overrides
from o in overrides.DefaultIfEmpty()
select new { Override = o != null ? o.DefaultSection : null, Global = p.DefaultSection }
).FirstOrDefaultAsync();
return row?.Override ?? row?.Global;
}
if (familyProductId.HasValue)
{
return await db.FamilyProducts.AsNoTracking()
.Where(p => p.Id == familyProductId && p.FamilyId == familyId)
.Select(p => p.DefaultSection)
.FirstOrDefaultAsync();
}
return null;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace YesChef.Api.Migrations
{
/// <inheritdoc />
public partial class AddProductDefaultSection : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "DefaultSection",
table: "Products",
type: "character varying(100)",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DefaultSection",
table: "FamilyProducts",
type: "character varying(100)",
maxLength: 100,
nullable: true);
migrationBuilder.AddColumn<string>(
name: "DefaultSection",
table: "FamilyProductOverrides",
type: "character varying(100)",
maxLength: 100,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DefaultSection",
table: "Products");
migrationBuilder.DropColumn(
name: "DefaultSection",
table: "FamilyProducts");
migrationBuilder.DropColumn(
name: "DefaultSection",
table: "FamilyProductOverrides");
}
}
}
@@ -90,6 +90,10 @@ namespace YesChef.Api.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DefaultSection")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<int>("FamilyId")
.HasColumnType("integer");
@@ -125,6 +129,10 @@ namespace YesChef.Api.Migrations
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("DefaultSection")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Name")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
@@ -289,6 +297,10 @@ namespace YesChef.Api.Migrations
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DefaultSection")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
@@ -17,6 +17,7 @@
notes: string | null;
isOverridden: boolean;
allowedUnitCategories: number;
defaultSection: string | null;
}
</script>
+124
View File
@@ -0,0 +1,124 @@
<script lang="ts">
// Free-text input with a styled dropdown of static suggestions. Users can
// pick from the list or type a custom value — whatever's in the input is
// what binds.
//
// Use this when the suggestion set is a small, static list of strings
// (e.g. common section names, predefined tags). For API-driven typeahead
// returning structured objects (products, users, etc.), use
// ProductTypeahead.svelte as the pattern. Look & keyboard behavior here
// intentionally mirror ProductTypeahead so the two feel like siblings.
interface Props {
value: string;
suggestions: readonly string[];
placeholder?: string;
ariaLabel?: string;
inputClass?: string;
}
let {
value = $bindable(''),
suggestions,
placeholder = '',
ariaLabel,
inputClass = '',
}: Props = $props();
let showDropdown = $state(false);
let activeIndex = $state(-1);
const listboxId = `text-combobox-${crypto.randomUUID().slice(0, 8)}`;
// Filter on substring (case-insensitive). Empty input shows everything so
// the user can scan the seeded list without typing first.
const filtered = $derived.by(() => {
const q = value.trim().toLowerCase();
if (q.length === 0) return [...suggestions];
return suggestions.filter((s) => s.toLowerCase().includes(q));
});
function selectSuggestion(s: string) {
value = s;
showDropdown = false;
activeIndex = -1;
}
function onInput() {
showDropdown = filtered.length > 0;
activeIndex = -1;
}
function onKeydown(e: KeyboardEvent) {
if (!showDropdown || filtered.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
activeIndex = (activeIndex + 1) % filtered.length;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
activeIndex = activeIndex <= 0 ? filtered.length - 1 : activeIndex - 1;
} else if (e.key === 'Enter') {
if (activeIndex >= 0) {
e.preventDefault();
selectSuggestion(filtered[activeIndex]);
}
} else if (e.key === 'Escape') {
showDropdown = false;
activeIndex = -1;
}
}
function onBlur() {
// Delay so a mousedown-then-mouseup click on a suggestion is recorded
// before the dropdown closes.
setTimeout(() => {
showDropdown = false;
activeIndex = -1;
}, 120);
}
</script>
<div class="relative">
<input
type="text"
bind:value
oninput={onInput}
onkeydown={onKeydown}
onblur={onBlur}
onfocus={() => {
if (filtered.length > 0) showDropdown = true;
}}
{placeholder}
aria-label={ariaLabel}
aria-autocomplete="list"
aria-expanded={showDropdown}
aria-controls={listboxId}
role="combobox"
class={inputClass}
autocomplete="off"
/>
{#if showDropdown}
<ul
id={listboxId}
class="absolute left-0 right-0 z-10 mt-1 max-h-64 overflow-auto rounded-lg border border-gray-200 bg-white shadow-lg"
role="listbox"
>
{#each filtered as suggestion, i (suggestion)}
<li
role="option"
aria-selected={i === activeIndex}
class="cursor-pointer px-3 py-2 text-sm {i === activeIndex
? 'bg-primary/10'
: 'hover:bg-gray-50'}"
onmousedown={(e) => {
// mousedown rather than click so we beat the blur handler.
e.preventDefault();
selectSuggestion(suggestion);
}}
>
{suggestion}
</li>
{/each}
</ul>
{/if}
</div>
+15
View File
@@ -0,0 +1,15 @@
// Suggested grocery store section names — used to seed autocomplete for the
// product default-section field. Mirrors StoreSectionDefaults.Names on the
// backend; kept here as a static constant since it changes rarely and the
// list is tiny. Custom entries are still allowed.
export const typicalSections = [
'Produce',
'Meat & Seafood',
'Dairy',
'Bakery',
'Frozen',
'Pantry',
'Condiments',
'Beverages',
'Other',
] as const;
+25 -39
View File
@@ -47,7 +47,6 @@
let items = $state<ListItem[]>([]);
let sections = $state<Section[]>([]);
let newItemName = $state('');
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 });
@@ -56,9 +55,6 @@
let newItemAllowedUnitCategories = $state(0);
let loading = $state(true);
let connection: HubConnection | null = null;
// Seq guard so a slow section lookup can't overwrite the section a
// later (faster) product pick already set.
let sectionLookupSeq = 0;
const listId = $derived(Number(page.params.id));
const uncheckedItems = $derived(items.filter((i) => !i.isChecked));
@@ -173,12 +169,14 @@
async function addItem() {
if (!newItemName.trim()) return;
const maxSort = items.length > 0 ? Math.max(...items.map((i) => i.sortOrder)) : 0;
// No explicit section is sent — the backend resolves one from the
// product's defaults (per-store memory → family default → global
// default). The user adjusts after the fact on the list row.
await api('/api/lists/' + listId + '/items', {
method: 'POST',
body: JSON.stringify({
name: newItemName,
sortOrder: maxSort + 1,
sectionId: newItemSectionId,
productId: newItemProductId,
familyProductId: newItemFamilyProductId,
quantity: newItemIsApproximate ? null : newItemQuantity.quantity,
@@ -206,14 +204,11 @@
}
}
async function onItemProductChange(product: ProductSuggestion | null) {
const seq = ++sectionLookupSeq;
function onItemProductChange(product: ProductSuggestion | null) {
if (product === null) {
newItemProductId = null;
newItemFamilyProductId = null;
newItemAllowedUnitCategories = 0;
// Don't clobber the user's section choice when they edit the
// name and the link clears — they may have set it deliberately.
return;
}
if (product.kind === 'Global') {
@@ -224,22 +219,6 @@
newItemFamilyProductId = product.id;
}
newItemAllowedUnitCategories = product.allowedUnitCategories;
// Mirror the backend's auto-assign rule: pre-fill the section dropdown
// from per-store memory so the user can see (and override) what's
// about to happen. Null result means no memory — reset to "No section".
if (!list) return;
const path = product.kind === 'Global' ? 'global' : 'family';
try {
const res = await api<{ sectionId: number | null }>(
`/api/products/${path}/${product.id}/section?storeId=${list.store.id}`
);
if (seq !== sectionLookupSeq) return;
newItemSectionId = res.sectionId;
} catch {
// Lookup failure shouldn't block adding an item — leave the
// section alone and let the backend auto-assign on POST.
}
}
async function toggleItem(itemId: number) {
@@ -250,10 +229,29 @@
// Optimistic — the SignalR ItemSectionChanged echo will reconcile.
items = items.map((i) => (i.id === itemId ? { ...i, sectionId } : i));
try {
await api(`/api/lists/${listId}/items/${itemId}/section`, {
const res = await api<{
promptSaveDefault: boolean;
currentDefaultSection: string | null;
newSectionName: string | null;
}>(`/api/lists/${listId}/items/${itemId}/section`, {
method: 'PATCH',
body: JSON.stringify({ sectionId })
});
// Product had an existing family default that differs from this
// new choice. Ask whether to update the default — accepting
// re-PATCHes with saveAsDefault, declining leaves it as a one-off.
if (res.promptSaveDefault && res.newSectionName) {
const productName = items.find((i) => i.id === itemId)?.name ?? 'this product';
const message = `Save “${productName}” → ${res.newSectionName} as the default? ` +
`(currently ${res.currentDefaultSection})`;
if (confirm(message)) {
await api(`/api/lists/${listId}/items/${itemId}/section`, {
method: 'PATCH',
body: JSON.stringify({ sectionId, saveAsDefault: true })
});
}
}
} catch (e) {
toast.error(e instanceof Error ? e.message : 'Failed to update section');
}
@@ -319,7 +317,7 @@
onsubmit={addItem}
onProductChange={onItemProductChange}
/>
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap items-start gap-2">
{#if newItemIsApproximate}
<input
type="text"
@@ -333,18 +331,6 @@
allowedUnitCategories={newItemAllowedUnitCategories}
/>
{/if}
{#if sections.length > 0}
<select
bind:value={newItemSectionId}
class="select max-w-36 px-2"
aria-label="Section"
>
<option value={null}>No section</option>
{#each sections as section (section.id)}
<option value={section.id}>{section.name}</option>
{/each}
</select>
{/if}
<button
type="submit"
class="btn-primary"
+28 -1
View File
@@ -3,6 +3,8 @@
import { api } from '$lib/api';
import { toast } from '$lib/toast.svelte';
import { UnitCategoryFlag, type ProductSuggestion } from '$lib/ProductTypeahead.svelte';
import TextCombobox from '$lib/TextCombobox.svelte';
import { typicalSections } from '$lib/typicalSections';
let products = $state<ProductSuggestion[]>([]);
let query = $state('');
@@ -17,6 +19,7 @@
let formName = $state('');
let formBrand = $state('');
let formNotes = $state('');
let formDefaultSection = $state('');
// Bitfield matching the backend UnitCategoryFlags. 0 = "any unit allowed".
let formCategories = $state(0);
let saving = $state(false);
@@ -63,6 +66,7 @@
formName = '';
formBrand = '';
formNotes = '';
formDefaultSection = '';
formCategories = 0;
formOpen = true;
}
@@ -72,6 +76,7 @@
formName = p.name;
formBrand = p.brand ?? '';
formNotes = p.notes ?? '';
formDefaultSection = p.defaultSection ?? '';
formCategories = p.allowedUnitCategories;
formOpen = true;
}
@@ -94,7 +99,8 @@
name,
brand: formBrand.trim() || null,
notes: formNotes.trim() || null,
allowedUnitCategories: formCategories
allowedUnitCategories: formCategories,
defaultSection: formDefaultSection.trim() || null
};
if (editingProduct === null) {
await api('/api/products', { method: 'POST', body: JSON.stringify(body) });
@@ -203,6 +209,9 @@
{#if product.brand}
<div class="text-xs text-gray-400">{product.brand}</div>
{/if}
{#if product.defaultSection}
<div class="text-xs text-gray-400">Section: {product.defaultSection}</div>
{/if}
</div>
<button onclick={() => startEdit(product)} class="text-sm text-gray-400">Edit</button>
</li>
@@ -255,6 +264,24 @@
>
<textarea bind:value={formNotes} rows="2" class="field mt-1 w-full"></textarea>
</label>
<div class="block">
<span class="text-xs font-medium text-gray-700"
>Default section <span class="text-gray-400">(optional)</span></span
>
<div class="mt-1">
<TextCombobox
bind:value={formDefaultSection}
suggestions={typicalSections}
placeholder="e.g. Produce"
ariaLabel="Default section"
inputClass="field w-full"
/>
</div>
<span class="mt-0.5 block text-xs text-gray-400">
Used to slot this item into the matching section at each store. Pick a common
name or type your own.
</span>
</div>
<fieldset>
<legend class="text-xs font-medium text-gray-700">Allowed units</legend>
<p class="mt-0.5 text-xs text-gray-400">Leave all unchecked to allow any unit.</p>