Compare commits
5 Commits
f38530cf81
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ca6c7d401 | |||
| c7ba88f447 | |||
| 0e59fd5bad | |||
| fa465ac29c | |||
| 6d84aad94b |
+2
-15
@@ -55,10 +55,10 @@ The product-catalog foundation has shipped. A `Product` is the canonical thing b
|
|||||||
- **Catalog management page (`/products`):** search the effective catalog, add a family product, edit a global product (writes an override) or family product, "Reset to catalog default" for an overridden global, and delete a family product. Linked from the lists page.
|
- **Catalog management page (`/products`):** search the effective catalog, add a family product, edit a global product (writes an override) or family product, "Reset to catalog default" for an overridden global, and delete a family product. Linked from the lists page.
|
||||||
- **Override indicator:** "Edited" pill on overridden rows in both the catalog page and the typeahead dropdown.
|
- **Override indicator:** "Edited" pill on overridden rows in both the catalog page and the typeahead dropdown.
|
||||||
- **`ProductStoreSection` write path:** when an item is saved/checked with `(productId, sectionId)`, the mapping is remembered for `(family, store, product)`.
|
- **`ProductStoreSection` write path:** when an item is saved/checked with `(productId, sectionId)`, the mapping is remembered for `(family, store, product)`.
|
||||||
|
- **Auto-assign section from product on add.** `GET /api/products/{kind}/{id}/section?storeId=...` exposes the remembered mapping; `onItemProductChange` on the list page calls it and pre-fills the section dropdown so the user can see (and override) what the backend would auto-assign. The recipe → list copy path already lands ingredients in remembered sections server-side.
|
||||||
- **Seed data:** ~50 hand-curated common-groceries items in `Data/Seed/products.json`, applied by `CatalogSeeder` on startup (idempotent on `Name`).
|
- **Seed data:** ~50 hand-curated common-groceries items in `Data/Seed/products.json`, applied by `CatalogSeeder` on startup (idempotent on `Name`).
|
||||||
|
|
||||||
#### Remaining
|
#### Remaining
|
||||||
- **Auto-assign section from product on add.** The write path remembers `(product, store) → section` and the backend has a helper that reads it back, but the add-item form doesn't call it on product pick yet. Described in detail in *Auto-assign section from product* further down.
|
|
||||||
- **"Add '<text>' as a new product" affordance in the typeahead.** Today the typeahead only suggests existing catalog rows; an unmatched name becomes a pure-text item. Adding an explicit affordance to promote that name into a `FamilyProduct` from the same dropdown is still open.
|
- **"Add '<text>' as a new product" affordance in the typeahead.** Today the typeahead only suggests existing catalog rows; an unmatched name becomes a pure-text item. Adding an explicit affordance to promote that name into a `FamilyProduct` from the same dropdown is still open.
|
||||||
- **Seed expansion to ~2–3k curated items.** Current seed file has ~50 entries. Growth is a data exercise, not a code one — keep `products.json` the source of truth, keep the seeder idempotent on `Name`.
|
- **Seed expansion to ~2–3k curated items.** Current seed file has ~50 entries. Growth is a data exercise, not a code one — keep `products.json` the source of truth, keep the seeder idempotent on `Name`.
|
||||||
- **Catalog ingestion tooling (future).** When the curated list starts feeling limiting, build re-runnable importers for public datasets so we don't grow by hand-typing.
|
- **Catalog ingestion tooling (future).** When the curated list starts feeling limiting, build re-runnable importers for public datasets so we don't grow by hand-typing.
|
||||||
@@ -133,22 +133,9 @@ Replace free-form `Quantity` strings with a structured `(Quantity, UnitOfMeasure
|
|||||||
- "A few", "to taste", "some" — these are real recipe quantities that don't fit `(decimal, unit)`. Probably modeled as a special `IsApproximate` flag with an optional `QuantityNote` rather than forcing them into the structured shape.
|
- "A few", "to taste", "some" — these are real recipe quantities that don't fit `(decimal, unit)`. Probably modeled as a special `IsApproximate` flag with an optional `QuantityNote` rather than forcing them into the structured shape.
|
||||||
|
|
||||||
### Per-store sections — remaining polish
|
### Per-store sections — remaining polish
|
||||||
The base feature is shipped (entity, default seed on store create, list view groups by section). Remaining nice-to-haves:
|
The base feature is shipped (entity, default seed on store create, list view groups by section, per-store ingredient memory via `ProductStoreSection` auto-assigning on item add and recipe-to-list copy). Remaining nice-to-haves:
|
||||||
- **Per-store ingredient memory:** remember "last time `Bananas` was bought at Kroger it was in Produce" and auto-assign on next add at that store. Adds an `IngredientSection` mapping table per store. Pairs naturally with the product catalog.
|
|
||||||
- **Recipes → sections:** when pulling recipe ingredients into a list, map them to the list's store's sections (only meaningful once the per-store ingredient memory or product catalog lands).
|
|
||||||
- **Section drag-to-reorder** in the store edit UI — section walk order matters, but reordering today only works by editing `SortOrder` numbers manually.
|
- **Section drag-to-reorder** in the store edit UI — section walk order matters, but reordering today only works by editing `SortOrder` numbers manually.
|
||||||
|
|
||||||
### Auto-assign section from product
|
|
||||||
When a user picks a product from the typeahead on the shopping list add form, pre-populate the section dropdown with that product's known section for the current store — rather than leaving it as "Uncategorized".
|
|
||||||
|
|
||||||
This is the "per-store ingredient memory" item above, stated from the user's perspective: choosing "Spaghetti" should already know it belongs in Pasta/Dry Goods at this store.
|
|
||||||
|
|
||||||
**State of play:** `ProductStoreSection` is shipped — table, indexes, and the write path that records `(family, store, product) → section` whenever an item with a product link is saved with a chosen section. A backend helper in `ShoppingListEndpoints` already reads the effective section back. **The missing piece is the frontend:** `onItemProductChange` in `lists/[id]/+page.svelte` doesn't call the read path on product pick yet, so the section dropdown still defaults to "Uncategorized" until the user sets it.
|
|
||||||
|
|
||||||
**To finish:** expose the lookup via an endpoint (`GET /api/products/{id}/section?storeId=...` or roll it into the typeahead response when a `storeId` is supplied), call it on `onItemProductChange`, and pre-select the returned section. Same treatment for the recipe → list copy path so adding a recipe to a list lands ingredients in the right sections.
|
|
||||||
|
|
||||||
**Scope note:** the section pre-fill is family-scoped memory (`ProductStoreSection` rows they've created), not a global default — different families organize differently.
|
|
||||||
|
|
||||||
## Recipes
|
## Recipes
|
||||||
|
|
||||||
### Structured multi-step instructions
|
### Structured multi-step instructions
|
||||||
|
|||||||
Vendored
+223
@@ -0,0 +1,223 @@
|
|||||||
|
// Jenkins pipeline for YesChef.
|
||||||
|
//
|
||||||
|
// Stages:
|
||||||
|
// 1. Restore + build the .NET backend solution
|
||||||
|
// 2. Run backend unit tests
|
||||||
|
// 3. Run backend integration tests against a sidecar Postgres container named `postgres`
|
||||||
|
// (Testcontainers also works because the docker socket is mounted)
|
||||||
|
// 4. Install, type-check, unit-test, and build the SvelteKit frontend
|
||||||
|
// 5. Build the backend and frontend Docker images from their existing Dockerfiles
|
||||||
|
//
|
||||||
|
// Requires the Jenkins Docker Pipeline plugin, an agent with Docker available, and a
|
||||||
|
// workspace user that can talk to the Docker daemon (the host docker socket is mounted
|
||||||
|
// into the build containers so Testcontainers can spawn its own DB instances).
|
||||||
|
|
||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
options {
|
||||||
|
timestamps()
|
||||||
|
timeout(time: 45, unit: 'MINUTES')
|
||||||
|
buildDiscarder(logRotator(numToKeepStr: '20'))
|
||||||
|
disableConcurrentBuilds()
|
||||||
|
}
|
||||||
|
|
||||||
|
environment {
|
||||||
|
DOTNET_SDK_IMAGE = 'mcr.microsoft.com/dotnet/sdk:10.0'
|
||||||
|
NODE_IMAGE = 'node:22-slim'
|
||||||
|
POSTGRES_IMAGE = 'postgres:17'
|
||||||
|
|
||||||
|
POSTGRES_DB = 'yeschef'
|
||||||
|
POSTGRES_USER = 'yeschef'
|
||||||
|
POSTGRES_PASSWORD = 'yeschef'
|
||||||
|
|
||||||
|
// Gitea container registry target. The registry lives on the same host as
|
||||||
|
// Gitea itself; the owner is the Gitea user/org that owns the images.
|
||||||
|
GITEA_REGISTRY = 'git.therogersfamily.tech'
|
||||||
|
GITEA_OWNER = 'josh'
|
||||||
|
// Jenkins credential ID for the Gitea push. Type: giteaPersonalAccessToken
|
||||||
|
// (the gitea-personal-access-token plugin). The PAT must have at least
|
||||||
|
// `write:package` scope. The token is bound as a Secret String at push time
|
||||||
|
// and fed to `docker login --password-stdin` using GITEA_OWNER as the
|
||||||
|
// username.
|
||||||
|
GITEA_CREDENTIALS = 'gitea-ci'
|
||||||
|
|
||||||
|
BACKEND_IMAGE = "${GITEA_REGISTRY}/${GITEA_OWNER}/yeschef-api"
|
||||||
|
FRONTEND_IMAGE = "${GITEA_REGISTRY}/${GITEA_OWNER}/yeschef-web"
|
||||||
|
IMAGE_TAG = "${env.BUILD_NUMBER}"
|
||||||
|
|
||||||
|
// Keep NuGet / npm caches inside the workspace so they survive across
|
||||||
|
// docker.inside containers without needing a shared host mount.
|
||||||
|
DOTNET_CLI_TELEMETRY_OPTOUT = '1'
|
||||||
|
NUGET_PACKAGES = "${env.WORKSPACE}/.nuget"
|
||||||
|
npm_config_cache = "${env.WORKSPACE}/.npm"
|
||||||
|
}
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('Checkout') {
|
||||||
|
steps {
|
||||||
|
checkout scm
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Backend: restore & build') {
|
||||||
|
agent {
|
||||||
|
docker {
|
||||||
|
image "${DOTNET_SDK_IMAGE}"
|
||||||
|
reuseNode true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
sh 'dotnet --info'
|
||||||
|
sh 'dotnet restore src/backend/YesChef.slnx'
|
||||||
|
sh 'dotnet build src/backend/YesChef.slnx -c Release --no-restore'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Backend: unit tests') {
|
||||||
|
agent {
|
||||||
|
docker {
|
||||||
|
image "${DOTNET_SDK_IMAGE}"
|
||||||
|
reuseNode true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
// global.json lives in src/backend and switches dotnet test to MTP mode,
|
||||||
|
// which requires --project (positional project paths are rejected).
|
||||||
|
dir('src/backend') {
|
||||||
|
sh '''
|
||||||
|
dotnet test \
|
||||||
|
--project YesChef.Api.UnitTests/YesChef.Api.UnitTests.csproj \
|
||||||
|
-c Release --no-build \
|
||||||
|
--report-trx --results-directory ../../TestResults/backend-unit
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
junit allowEmptyResults: true, testResults: 'TestResults/backend-unit/**/*.trx'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Backend: integration tests') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
// Sidecar Postgres reachable from the build container at host `postgres`.
|
||||||
|
// Testcontainers can still spin up additional DBs because the host docker
|
||||||
|
// socket is mounted into the SDK container below.
|
||||||
|
docker.image("${POSTGRES_IMAGE}").withRun(
|
||||||
|
"-e POSTGRES_DB=${POSTGRES_DB}" +
|
||||||
|
" -e POSTGRES_USER=${POSTGRES_USER}" +
|
||||||
|
" -e POSTGRES_PASSWORD=${POSTGRES_PASSWORD}"
|
||||||
|
) { pg ->
|
||||||
|
|
||||||
|
// Wait for the DB to accept connections.
|
||||||
|
docker.image("${POSTGRES_IMAGE}").inside("--link ${pg.id}:postgres") {
|
||||||
|
sh '''
|
||||||
|
for i in $(seq 1 30); do
|
||||||
|
if pg_isready -h postgres -U yeschef >/dev/null 2>&1; then
|
||||||
|
echo "postgres is ready"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
echo "postgres did not become ready in time" >&2
|
||||||
|
exit 1
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
|
||||||
|
docker.image("${DOTNET_SDK_IMAGE}").inside(
|
||||||
|
"--link ${pg.id}:postgres" +
|
||||||
|
" -v /var/run/docker.sock:/var/run/docker.sock" +
|
||||||
|
" -e ConnectionStrings__DefaultConnection=Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
|
||||||
|
) {
|
||||||
|
dir('src/backend') {
|
||||||
|
sh '''
|
||||||
|
dotnet test \
|
||||||
|
--project YesChef.Api.IntegrationTests/YesChef.Api.IntegrationTests.csproj \
|
||||||
|
-c Release \
|
||||||
|
--report-trx --results-directory ../../TestResults/backend-integration
|
||||||
|
'''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
junit allowEmptyResults: true, testResults: 'TestResults/backend-integration/**/*.trx'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Frontend') {
|
||||||
|
agent {
|
||||||
|
docker {
|
||||||
|
image "${NODE_IMAGE}"
|
||||||
|
reuseNode true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stages {
|
||||||
|
stage('Install') {
|
||||||
|
steps {
|
||||||
|
dir('src/frontend') { sh 'npm ci' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Type check') {
|
||||||
|
steps {
|
||||||
|
dir('src/frontend') { sh 'npm run check' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Unit tests') {
|
||||||
|
steps {
|
||||||
|
dir('src/frontend') { sh 'npm run test:unit' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stage('Build') {
|
||||||
|
steps {
|
||||||
|
dir('src/frontend') { sh 'npm run build' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('Docker images') {
|
||||||
|
steps {
|
||||||
|
script {
|
||||||
|
def backendImg = docker.build("${BACKEND_IMAGE}:${IMAGE_TAG}", 'src/backend/YesChef.Api')
|
||||||
|
def frontendImg = docker.build("${FRONTEND_IMAGE}:${IMAGE_TAG}", 'src/frontend')
|
||||||
|
|
||||||
|
backendImg.tag('latest')
|
||||||
|
frontendImg.tag('latest')
|
||||||
|
|
||||||
|
// Gitea PAT credential (giteaPersonalAccessToken) — exposes the
|
||||||
|
// token as a Secret String, so bind with `string` and feed it to
|
||||||
|
// docker login on stdin. Username for the package registry is the
|
||||||
|
// Gitea owner.
|
||||||
|
withCredentials([string(credentialsId: "${GITEA_CREDENTIALS}", variable: 'GITEA_TOKEN')]) {
|
||||||
|
sh """
|
||||||
|
set +x
|
||||||
|
echo "\$GITEA_TOKEN" | docker login ${GITEA_REGISTRY} -u ${GITEA_OWNER} --password-stdin
|
||||||
|
"""
|
||||||
|
try {
|
||||||
|
sh "docker push ${BACKEND_IMAGE}:${IMAGE_TAG}"
|
||||||
|
sh "docker push ${BACKEND_IMAGE}:latest"
|
||||||
|
sh "docker push ${FRONTEND_IMAGE}:${IMAGE_TAG}"
|
||||||
|
sh "docker push ${FRONTEND_IMAGE}:latest"
|
||||||
|
} finally {
|
||||||
|
sh "docker logout ${GITEA_REGISTRY} || true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
always {
|
||||||
|
archiveArtifacts artifacts: 'TestResults/**/*.trx', allowEmptyArchive: true, fingerprint: false
|
||||||
|
cleanWs(deleteDirs: true, notFailBuild: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -343,9 +343,9 @@ public class ProductEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
});
|
});
|
||||||
|
|
||||||
var results = await Client.GetFromJsonAsync<List<ProductEndpoints.ProductDto>>("/api/products?q=");
|
var results = (await Client.GetFromJsonAsync<List<ProductEndpoints.ProductDto>>("/api/products?q="))!;
|
||||||
|
|
||||||
var milk = results!.Single(r => r.Name == "Milk");
|
var milk = results.Single(r => r.Name == "Milk");
|
||||||
await Assert.That(milk.AllowedUnitCategories).IsEqualTo(UnitCategoryFlags.Volume);
|
await Assert.That(milk.AllowedUnitCategories).IsEqualTo(UnitCategoryFlags.Volume);
|
||||||
|
|
||||||
var eggs = results.Single(r => r.Name == "Eggs");
|
var eggs = results.Single(r => r.Name == "Eggs");
|
||||||
@@ -362,4 +362,96 @@ public class ProductEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
var response = await AnonymousClient.GetAsync("/api/products");
|
var response = await AnonymousClient.GetAsync("/api/products");
|
||||||
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Unauthorized);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Create_family_product_persists_default_section()
|
||||||
|
{
|
||||||
|
var response = await Client.PostAsJsonAsync("/api/products",
|
||||||
|
new ProductEndpoints.CreateProductRequest("Sourdough", null, null, UnitCategoryFlags.None, " Bakery "));
|
||||||
|
|
||||||
|
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
|
||||||
|
var dto = await response.Content.ReadFromJsonAsync<ProductEndpoints.ProductDto>();
|
||||||
|
await Assert.That(dto!.DefaultSection).IsEqualTo("Bakery");
|
||||||
|
|
||||||
|
var stored = await UseDbAsync(db => db.FamilyProducts.SingleAsync());
|
||||||
|
await Assert.That(stored.DefaultSection).IsEqualTo("Bakery");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public async Task Update_family_product_clears_default_section_when_blank()
|
||||||
|
{
|
||||||
|
var familyId = await GetFamilyIdAsync();
|
||||||
|
var product = await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
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 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");
|
||||||
|
|
||||||
|
// 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 Search_projects_effective_default_section()
|
||||||
|
{
|
||||||
|
var familyId = await GetFamilyIdAsync();
|
||||||
|
await UseDbAsync(async db =>
|
||||||
|
{
|
||||||
|
db.Products.Add(new Product { Name = "Apples", DefaultSection = "Produce" });
|
||||||
|
db.Products.Add(new Product { Name = "Berries", DefaultSection = "Produce" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var berries = db.Products.Single(p => p.Name == "Berries");
|
||||||
|
db.FamilyProductOverrides.Add(new YesChef.Api.Entities.FamilyProductOverride
|
||||||
|
{
|
||||||
|
FamilyId = familyId,
|
||||||
|
ProductId = berries.Id,
|
||||||
|
DefaultSection = "Frozen",
|
||||||
|
});
|
||||||
|
db.FamilyProducts.Add(new FamilyProduct
|
||||||
|
{
|
||||||
|
FamilyId = familyId,
|
||||||
|
Name = "House Bread",
|
||||||
|
DefaultSection = "Bakery",
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
});
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
await Assert.That(body.GetProperty("title").GetString()).IsEqualTo("Soup");
|
await Assert.That(body.GetProperty("title").GetString()).IsEqualTo("Soup");
|
||||||
await Assert.That(body.GetProperty("createdBy").GetString()).IsEqualTo(User.Name);
|
await Assert.That(body.GetProperty("createdBy").GetString()).IsEqualTo(User.Name);
|
||||||
var ingredientNames = body.GetProperty("ingredients").EnumerateArray()
|
var ingredientNames = body.GetProperty("ingredients").EnumerateArray()
|
||||||
.Select(i => i.GetProperty("name").GetString()).ToArray();
|
.Select(i => i.GetProperty("name").GetString()!).ToArray();
|
||||||
await Assert.That(ingredientNames).IsEquivalentTo(new[] { "broth cube", "water" });
|
await Assert.That(ingredientNames).IsEquivalentTo(new[] { "broth cube", "water" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +131,7 @@ public class RecipeEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
|
|
||||||
var hits = await Client.GetFromJsonAsync<List<JsonElement>>("/api/recipes?q=Pa");
|
var hits = await Client.GetFromJsonAsync<List<JsonElement>>("/api/recipes?q=Pa");
|
||||||
|
|
||||||
await Assert.That(hits!.Select(h => h.GetProperty("title").GetString()))
|
await Assert.That(hits!.Select(h => h.GetProperty("title").GetString()!))
|
||||||
.IsEquivalentTo(new[] { "Pancakes", "Pad Thai" });
|
.IsEquivalentTo(new[] { "Pancakes", "Pad Thai" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
|
|
||||||
await Assert.That(body.GetProperty("name").GetString()).IsEqualTo("groceries");
|
await Assert.That(body.GetProperty("name").GetString()).IsEqualTo("groceries");
|
||||||
var items = body.GetProperty("items").EnumerateArray()
|
var items = body.GetProperty("items").EnumerateArray()
|
||||||
.Select(i => i.GetProperty("name").GetString()).ToArray();
|
.Select(i => i.GetProperty("name").GetString()!).ToArray();
|
||||||
await Assert.That(items).IsEquivalentTo(new[] { "bread", "milk" });
|
await Assert.That(items).IsEquivalentTo(new[] { "bread", "milk" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,10 +207,10 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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 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 };
|
var s = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 };
|
||||||
db.StoreSections.Add(s);
|
db.StoreSections.Add(s);
|
||||||
@@ -219,99 +219,312 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
});
|
});
|
||||||
var product = await UseDbAsync(async db =>
|
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);
|
db.Products.Add(p);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return p;
|
return p;
|
||||||
});
|
});
|
||||||
|
|
||||||
// First add: explicit section + product → memory recorded.
|
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
|
||||||
var first = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
|
new ShoppingListEndpoints.AddItemRequest("Bananas", ProductId: product.Id));
|
||||||
new ShoppingListEndpoints.AddItemRequest("Bananas", SectionId: section.Id, ProductId: product.Id));
|
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
await Assert.That(first.StatusCode).IsEqualTo(HttpStatusCode.Created);
|
|
||||||
|
|
||||||
// Second add: same product, no section → auto-assigned from memory.
|
await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(produce.Id);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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 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 s = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Produce", SortOrder = 1 };
|
||||||
var s2 = new StoreSection { FamilyId = Store.FamilyId, StoreId = Store.Id, Name = "Bakery", SortOrder = 2 };
|
db.StoreSections.Add(s);
|
||||||
db.StoreSections.AddRange(s1, s2);
|
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return (s1, s2);
|
return s;
|
||||||
});
|
});
|
||||||
var product = await UseDbAsync(async db =>
|
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);
|
db.Products.Add(p);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return p;
|
return p;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add with section A; that records (Product → A) memory.
|
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
|
||||||
var addResponse = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
|
new ShoppingListEndpoints.AddItemRequest("Bananas", ProductId: product.Id));
|
||||||
new ShoppingListEndpoints.AddItemRequest("Bread", SectionId: originalSection.Id, ProductId: product.Id));
|
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
var addBody = await addResponse.Content.ReadFromJsonAsync<JsonElement>();
|
|
||||||
var firstItemId = addBody.GetProperty("id").GetInt32();
|
|
||||||
|
|
||||||
// User corrects to section B → memory should update.
|
await Assert.That(body.GetProperty("sectionId").GetInt32()).IsEqualTo(produce.Id);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Test]
|
[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 list = 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 product = await UseDbAsync(async db =>
|
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);
|
db.Products.Add(p);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
return p;
|
return p;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Memorize at store A only.
|
var response = await Client.PostAsJsonAsync($"/api/lists/{list.Id}/items",
|
||||||
await Client.PostAsJsonAsync($"/api/lists/{listA.Id}/items",
|
new ShoppingListEndpoints.AddItemRequest("Saffron", ProductId: product.Id));
|
||||||
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 body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||||
|
|
||||||
await Assert.That(body.GetProperty("sectionId").ValueKind).IsEqualTo(JsonValueKind.Null);
|
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]
|
[Test]
|
||||||
public async Task Add_item_rejects_other_familys_family_product()
|
public async Task Add_item_rejects_other_familys_family_product()
|
||||||
{
|
{
|
||||||
@@ -377,7 +590,7 @@ public class ShoppingListEndpointsTests : AuthenticatedIntegrationTest
|
|||||||
|
|
||||||
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/lists/{list.Id}");
|
var body = await Client.GetFromJsonAsync<JsonElement>($"/api/lists/{list.Id}");
|
||||||
var items = body.GetProperty("items").EnumerateArray()
|
var items = body.GetProperty("items").EnumerateArray()
|
||||||
.Select(i => i.GetProperty("name").GetString()).ToArray();
|
.Select(i => i.GetProperty("name").GetString()!).ToArray();
|
||||||
await Assert.That(items).IsEquivalentTo(new[] { "kept" });
|
await Assert.That(items).IsEquivalentTo(new[] { "kept" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Npgsql;
|
using Npgsql;
|
||||||
using Testcontainers.PostgreSql;
|
using Testcontainers.PostgreSql;
|
||||||
using TUnit.Core.Interfaces;
|
using TUnit.Core.Interfaces;
|
||||||
@@ -16,8 +15,7 @@ public sealed class PostgresFixture : IAsyncInitializer, IAsyncDisposable
|
|||||||
{
|
{
|
||||||
private const string TemplateDbName = "yeschef_template";
|
private const string TemplateDbName = "yeschef_template";
|
||||||
|
|
||||||
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder()
|
private readonly PostgreSqlContainer _container = new PostgreSqlBuilder("postgres:17")
|
||||||
.WithImage("postgres:17")
|
|
||||||
.WithDatabase("postgres")
|
.WithDatabase("postgres")
|
||||||
.WithUsername("postgres")
|
.WithUsername("postgres")
|
||||||
.WithPassword("postgres")
|
.WithPassword("postgres")
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
|
|||||||
e.Property(p => p.Brand).HasMaxLength(200);
|
e.Property(p => p.Brand).HasMaxLength(200);
|
||||||
e.Property(p => p.Notes).HasMaxLength(1000);
|
e.Property(p => p.Notes).HasMaxLength(1000);
|
||||||
e.Property(p => p.AllowedUnitCategories).HasConversion<int>();
|
e.Property(p => p.AllowedUnitCategories).HasConversion<int>();
|
||||||
|
e.Property(p => p.DefaultSection).HasMaxLength(100);
|
||||||
e.HasIndex(p => p.Name).IsUnique();
|
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.Brand).HasMaxLength(200);
|
||||||
e.Property(p => p.Notes).HasMaxLength(1000);
|
e.Property(p => p.Notes).HasMaxLength(1000);
|
||||||
e.Property(p => p.AllowedUnitCategories).HasConversion<int>();
|
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.HasOne(p => p.Family).WithMany().HasForeignKey(p => p.FamilyId).OnDelete(DeleteBehavior.Cascade);
|
||||||
e.HasIndex(p => new { p.FamilyId, p.Name }).IsUnique();
|
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.Brand).HasMaxLength(200);
|
||||||
e.Property(o => o.Notes).HasMaxLength(1000);
|
e.Property(o => o.Notes).HasMaxLength(1000);
|
||||||
e.Property(o => o.AllowedUnitCategories).HasConversion<int?>();
|
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.Family).WithMany().HasForeignKey(o => o.FamilyId).OnDelete(DeleteBehavior.Cascade);
|
||||||
e.HasOne(o => o.Product).WithMany().HasForeignKey(o => o.ProductId).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? Brand { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public UnitCategoryFlags AllowedUnitCategories { get; set; }
|
public UnitCategoryFlags AllowedUnitCategories { get; set; }
|
||||||
|
public string? DefaultSection { get; set; }
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,5 +18,8 @@ public class FamilyProductOverride
|
|||||||
// Nullable so "inherit global" (null) is distinguishable from
|
// Nullable so "inherit global" (null) is distinguishable from
|
||||||
// "explicitly None / any unit" (UnitCategoryFlags.None).
|
// "explicitly None / any unit" (UnitCategoryFlags.None).
|
||||||
public UnitCategoryFlags? AllowedUnitCategories { get; set; }
|
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;
|
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
|
// None = "any unit". Non-zero narrows the unit-dropdown suggestions to the
|
||||||
// flagged categories. Families can replace this with FamilyProductOverride.
|
// flagged categories. Families can replace this with FamilyProductOverride.
|
||||||
public UnitCategoryFlags AllowedUnitCategories { get; set; }
|
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;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace YesChef.Api.Entities;
|
namespace YesChef.Api.Entities;
|
||||||
|
|
||||||
|
[JsonConverter(typeof(JsonStringEnumConverter<UnitCategory>))]
|
||||||
public enum UnitCategory
|
public enum UnitCategory
|
||||||
{
|
{
|
||||||
Count = 0,
|
Count = 0,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
namespace YesChef.Api.Entities;
|
namespace YesChef.Api.Entities;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -7,6 +9,7 @@ namespace YesChef.Api.Entities;
|
|||||||
/// 32-bit integer so families can OR in additional categories over time.
|
/// 32-bit integer so families can OR in additional categories over time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[System.Flags]
|
[System.Flags]
|
||||||
|
[JsonConverter(typeof(JsonStringEnumConverter<UnitCategoryFlags>))]
|
||||||
public enum UnitCategoryFlags
|
public enum UnitCategoryFlags
|
||||||
{
|
{
|
||||||
None = 0,
|
None = 0,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using YesChef.Api.Auth;
|
using YesChef.Api.Auth;
|
||||||
using YesChef.Api.Data;
|
using YesChef.Api.Data;
|
||||||
@@ -10,6 +11,7 @@ public static class ProductEndpoints
|
|||||||
/// <summary>Discriminator on product DTOs so the client can route subsequent
|
/// <summary>Discriminator on product DTOs so the client can route subsequent
|
||||||
/// PUT/POST calls to the right code path. The catalog UI itself doesn't
|
/// PUT/POST calls to the right code path. The catalog UI itself doesn't
|
||||||
/// need to surface this — both kinds render the same way.</summary>
|
/// need to surface this — both kinds render the same way.</summary>
|
||||||
|
[JsonConverter(typeof(JsonStringEnumConverter<ProductKind>))]
|
||||||
public enum ProductKind { Global, Family }
|
public enum ProductKind { Global, Family }
|
||||||
|
|
||||||
public record ProductDto(
|
public record ProductDto(
|
||||||
@@ -19,10 +21,11 @@ public static class ProductEndpoints
|
|||||||
string? Brand,
|
string? Brand,
|
||||||
string? Notes,
|
string? Notes,
|
||||||
bool IsOverridden,
|
bool IsOverridden,
|
||||||
UnitCategoryFlags AllowedUnitCategories);
|
UnitCategoryFlags AllowedUnitCategories,
|
||||||
|
string? DefaultSection);
|
||||||
|
|
||||||
public record CreateProductRequest(string Name, string? Brand, string? Notes, UnitCategoryFlags AllowedUnitCategories = UnitCategoryFlags.None);
|
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);
|
public record UpdateProductRequest(string? Name, string? Brand, string? Notes, UnitCategoryFlags? AllowedUnitCategories = null, string? DefaultSection = null);
|
||||||
|
|
||||||
private const int SearchResultLimit = 50;
|
private const int SearchResultLimit = 50;
|
||||||
|
|
||||||
@@ -56,10 +59,12 @@ public static class ProductEndpoints
|
|||||||
GlobalBrand = p.Brand,
|
GlobalBrand = p.Brand,
|
||||||
GlobalNotes = p.Notes,
|
GlobalNotes = p.Notes,
|
||||||
GlobalAllowedUnitCategories = p.AllowedUnitCategories,
|
GlobalAllowedUnitCategories = p.AllowedUnitCategories,
|
||||||
|
GlobalDefaultSection = p.DefaultSection,
|
||||||
OverrideName = o != null ? o.Name : null,
|
OverrideName = o != null ? o.Name : null,
|
||||||
OverrideBrand = o != null ? o.Brand : null,
|
OverrideBrand = o != null ? o.Brand : null,
|
||||||
OverrideNotes = o != null ? o.Notes : null,
|
OverrideNotes = o != null ? o.Notes : null,
|
||||||
OverrideAllowedUnitCategories = o != null ? o.AllowedUnitCategories : null,
|
OverrideAllowedUnitCategories = o != null ? o.AllowedUnitCategories : null,
|
||||||
|
OverrideDefaultSection = o != null ? o.DefaultSection : null,
|
||||||
HasOverride = o != null,
|
HasOverride = o != null,
|
||||||
})
|
})
|
||||||
.Take(SearchResultLimit)
|
.Take(SearchResultLimit)
|
||||||
@@ -86,11 +91,12 @@ public static class ProductEndpoints
|
|||||||
r.OverrideBrand ?? r.GlobalBrand,
|
r.OverrideBrand ?? r.GlobalBrand,
|
||||||
r.OverrideNotes ?? r.GlobalNotes,
|
r.OverrideNotes ?? r.GlobalNotes,
|
||||||
r.HasOverride,
|
r.HasOverride,
|
||||||
r.OverrideAllowedUnitCategories ?? r.GlobalAllowedUnitCategories);
|
r.OverrideAllowedUnitCategories ?? r.GlobalAllowedUnitCategories,
|
||||||
|
r.OverrideDefaultSection ?? r.GlobalDefaultSection);
|
||||||
}).Where(d => d is not null).Cast<ProductDto>();
|
}).Where(d => d is not null).Cast<ProductDto>();
|
||||||
|
|
||||||
var familyDtos = familyRows.Select(p =>
|
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)
|
var results = globalDtos.Concat(familyDtos)
|
||||||
.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase)
|
.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
@@ -119,6 +125,7 @@ public static class ProductEndpoints
|
|||||||
Brand = request.Brand,
|
Brand = request.Brand,
|
||||||
Notes = request.Notes,
|
Notes = request.Notes,
|
||||||
AllowedUnitCategories = request.AllowedUnitCategories,
|
AllowedUnitCategories = request.AllowedUnitCategories,
|
||||||
|
DefaultSection = NormalizeSection(request.DefaultSection),
|
||||||
};
|
};
|
||||||
db.FamilyProducts.Add(product);
|
db.FamilyProducts.Add(product);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
@@ -148,6 +155,7 @@ public static class ProductEndpoints
|
|||||||
product.Brand = request.Brand;
|
product.Brand = request.Brand;
|
||||||
product.Notes = request.Notes;
|
product.Notes = request.Notes;
|
||||||
if (request.AllowedUnitCategories is { } cats) product.AllowedUnitCategories = cats;
|
if (request.AllowedUnitCategories is { } cats) product.AllowedUnitCategories = cats;
|
||||||
|
product.DefaultSection = NormalizeSection(request.DefaultSection);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
return Results.Ok(ToDto(product));
|
return Results.Ok(ToDto(product));
|
||||||
@@ -192,7 +200,8 @@ public static class ProductEndpoints
|
|||||||
product.Brand,
|
product.Brand,
|
||||||
product.Notes,
|
product.Notes,
|
||||||
IsOverridden: false,
|
IsOverridden: false,
|
||||||
product.AllowedUnitCategories));
|
product.AllowedUnitCategories,
|
||||||
|
product.DefaultSection));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update a global product for this family by upserting an override.
|
// Update a global product for this family by upserting an override.
|
||||||
@@ -226,6 +235,7 @@ public static class ProductEndpoints
|
|||||||
// itself be null, i.e. "inherit global"). Pass an explicit value to
|
// itself be null, i.e. "inherit global"). Pass an explicit value to
|
||||||
// either narrow categories or restore "any" (UnitCategoryFlags.None).
|
// either narrow categories or restore "any" (UnitCategoryFlags.None).
|
||||||
if (request.AllowedUnitCategories is { } cats) ovr.AllowedUnitCategories = cats;
|
if (request.AllowedUnitCategories is { } cats) ovr.AllowedUnitCategories = cats;
|
||||||
|
ovr.DefaultSection = NormalizeSection(request.DefaultSection);
|
||||||
ovr.UpdatedAt = DateTime.UtcNow;
|
ovr.UpdatedAt = DateTime.UtcNow;
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
@@ -236,12 +246,26 @@ public static class ProductEndpoints
|
|||||||
ovr.Brand ?? product.Brand,
|
ovr.Brand ?? product.Brand,
|
||||||
ovr.Notes ?? product.Notes,
|
ovr.Notes ?? product.Notes,
|
||||||
IsOverridden: true,
|
IsOverridden: true,
|
||||||
ovr.AllowedUnitCategories ?? product.AllowedUnitCategories));
|
ovr.AllowedUnitCategories ?? product.AllowedUnitCategories,
|
||||||
|
ovr.DefaultSection ?? product.DefaultSection));
|
||||||
});
|
});
|
||||||
|
|
||||||
return group;
|
return group;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static ProductDto ToDto(FamilyProduct p) =>
|
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,
|
int? FamilyUnitOfMeasureId = null,
|
||||||
bool IsApproximate = false,
|
bool IsApproximate = false,
|
||||||
string? QuantityNote = null);
|
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}";
|
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)
|
if (await ValidateUnitLink(db, familyId, request.UnitOfMeasureId, request.FamilyUnitOfMeasureId) is { } unitError)
|
||||||
return unitError;
|
return unitError;
|
||||||
|
|
||||||
// Auto-assign a section from memory when caller didn't pick one
|
// No explicit section → resolve through the product's defaults
|
||||||
// but supplied a product link — "we put bananas in Produce last
|
// (per-store memory → family default → global default).
|
||||||
// time we shopped here, do it again."
|
|
||||||
var resolvedSectionId = request.SectionId
|
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
|
var item = new ShoppingListItem
|
||||||
{
|
{
|
||||||
@@ -240,12 +251,6 @@ public static class ShoppingListEndpoints
|
|||||||
db.ShoppingListItems.Add(item);
|
db.ShoppingListItems.Add(item);
|
||||||
list.UpdatedAt = DateTime.UtcNow;
|
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[]
|
await AllowedUnitCategoryLearner.LearnAsync(db, familyId, new[]
|
||||||
{
|
{
|
||||||
new AllowedUnitCategoryLearner.Pair(request.ProductId, request.FamilyProductId, item.UnitOfMeasureId, item.FamilyUnitOfMeasureId),
|
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);
|
.FirstOrDefaultAsync(i => i.Id == itemId && i.ShoppingListId == listId && i.FamilyId == familyId && i.RemovedAt == null);
|
||||||
if (item is null) return Results.NotFound();
|
if (item is null) return Results.NotFound();
|
||||||
|
|
||||||
if (request.SectionId is int sectionId &&
|
string? newSectionName = null;
|
||||||
!await db.StoreSections.AnyAsync(s => s.Id == sectionId && s.StoreId == item.ShoppingList.StoreId && s.FamilyId == familyId))
|
if (request.SectionId is int sectionId)
|
||||||
return Results.BadRequest(new { error = "Unknown section." });
|
{
|
||||||
|
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;
|
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.
|
// For product-linked items, sync the family-level default. The
|
||||||
await RememberSectionAsync(db, familyId, item.ShoppingList.StoreId, item.ProductId, item.FamilyProductId, request.SectionId);
|
// 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 db.SaveChangesAsync();
|
||||||
|
|
||||||
await hub.Clients.Group($"list-{listId}").SendAsync("ItemSectionChanged", new { item.Id, item.SectionId });
|
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) =>
|
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;
|
var idx = 0;
|
||||||
foreach (var ing in recipe.Ingredients)
|
foreach (var ing in recipe.Ingredients)
|
||||||
{
|
{
|
||||||
// Carry the ingredient's product link onto the list item, and
|
// Carry the ingredient's product link onto the list item and
|
||||||
// use the per-store memory to assign a section if we have one.
|
// resolve a section through the product's defaults if any.
|
||||||
var rememberedSectionId = await LookupRememberedSectionAsync(db, familyId, list.StoreId, ing.ProductId, ing.FamilyProductId);
|
var rememberedSectionId = await ResolveSectionAsync(db, familyId, list.StoreId, ing.ProductId, ing.FamilyProductId);
|
||||||
|
|
||||||
newItems.Add(new ShoppingListItem
|
newItems.Add(new ShoppingListItem
|
||||||
{
|
{
|
||||||
@@ -470,50 +504,127 @@ public static class ShoppingListEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Look up the section a product was last placed in at this store, for
|
/// Resolve the effective section for a product-linked item at this store.
|
||||||
/// this family. Returns null if no memory exists or no product link was
|
/// Tiers, highest priority first:
|
||||||
/// supplied.
|
/// 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>
|
/// </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;
|
if (!productId.HasValue && !familyProductId.HasValue) return null;
|
||||||
|
|
||||||
return await db.ProductStoreSections
|
var perStore = await db.ProductStoreSections
|
||||||
.Where(p => p.FamilyId == familyId && p.StoreId == storeId
|
.Where(p => p.FamilyId == familyId && p.StoreId == storeId
|
||||||
&& p.ProductId == productId && p.FamilyProductId == familyProductId)
|
&& p.ProductId == productId && p.FamilyProductId == familyProductId)
|
||||||
.Select(p => (int?)p.StoreSectionId)
|
.Select(p => (int?)p.StoreSectionId)
|
||||||
.FirstOrDefaultAsync();
|
.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>
|
/// <summary>
|
||||||
/// Upsert the (Family, Store, Product) → Section memory. No-op if no
|
/// Family-level default section name only — FamilyProductOverride for a
|
||||||
/// product link or no section is supplied. Caller must ensure section
|
/// global product, or FamilyProduct.DefaultSection for a family product.
|
||||||
/// belongs to the same store and family.
|
/// 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>
|
/// </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)
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
db.ProductStoreSections.Add(new ProductStoreSection
|
return await db.FamilyProductOverrides.AsNoTracking()
|
||||||
|
.Where(o => o.FamilyId == familyId && o.ProductId == productId)
|
||||||
|
.Select(o => o.DefaultSection)
|
||||||
|
.FirstOrDefaultAsync();
|
||||||
|
}
|
||||||
|
if (familyProductId.HasValue)
|
||||||
|
{
|
||||||
|
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,
|
FamilyId = familyId,
|
||||||
StoreId = storeId,
|
ProductId = productId.Value,
|
||||||
ProductId = productId,
|
DefaultSection = sectionName,
|
||||||
FamilyProductId = familyProductId,
|
|
||||||
StoreSectionId = sectionId.Value,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
else if (existing.StoreSectionId != sectionId.Value)
|
else
|
||||||
{
|
{
|
||||||
existing.StoreSectionId = sectionId.Value;
|
ovr.DefaultSection = sectionName;
|
||||||
existing.UpdatedAt = DateTime.UtcNow;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using YesChef.Api.Auth;
|
using YesChef.Api.Auth;
|
||||||
using YesChef.Api.Data;
|
using YesChef.Api.Data;
|
||||||
@@ -9,6 +10,7 @@ public static class UnitEndpoints
|
|||||||
{
|
{
|
||||||
/// <summary>Discriminator so the client can route subsequent PUT/DELETE
|
/// <summary>Discriminator so the client can route subsequent PUT/DELETE
|
||||||
/// calls to the right code path. Global units cannot be edited.</summary>
|
/// calls to the right code path. Global units cannot be edited.</summary>
|
||||||
|
[JsonConverter(typeof(JsonStringEnumConverter<UnitKind>))]
|
||||||
public enum UnitKind { Global, Family }
|
public enum UnitKind { Global, Family }
|
||||||
|
|
||||||
public record UnitDto(
|
public record UnitDto(
|
||||||
|
|||||||
+1112
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")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DefaultSection")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
b.Property<int>("FamilyId")
|
b.Property<int>("FamilyId")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
@@ -125,6 +129,10 @@ namespace YesChef.Api.Migrations
|
|||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<string>("DefaultSection")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
@@ -289,6 +297,10 @@ namespace YesChef.Api.Migrations
|
|||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DefaultSection")
|
||||||
|
.HasMaxLength(100)
|
||||||
|
.HasColumnType("character varying(100)");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
FROM node:22-slim AS build
|
FROM node:22-slim AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json .
|
COPY package*.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
@@ -8,7 +8,7 @@ RUN npm run build
|
|||||||
FROM node:22-slim
|
FROM node:22-slim
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/build .
|
COPY --from=build /app/build .
|
||||||
COPY --from=build /app/package.json .
|
COPY --from=build /app/package.json /app/package-lock.json ./
|
||||||
RUN npm ci --omit=dev
|
RUN npm ci --omit=dev
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
notes: string | null;
|
notes: string | null;
|
||||||
isOverridden: boolean;
|
isOverridden: boolean;
|
||||||
allowedUnitCategories: number;
|
allowedUnitCategories: number;
|
||||||
|
defaultSection: string | null;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
@@ -47,7 +47,6 @@
|
|||||||
let items = $state<ListItem[]>([]);
|
let items = $state<ListItem[]>([]);
|
||||||
let sections = $state<Section[]>([]);
|
let sections = $state<Section[]>([]);
|
||||||
let newItemName = $state('');
|
let newItemName = $state('');
|
||||||
let newItemSectionId = $state<number | null>(null);
|
|
||||||
let newItemProductId = $state<number | null>(null);
|
let newItemProductId = $state<number | null>(null);
|
||||||
let newItemFamilyProductId = $state<number | null>(null);
|
let newItemFamilyProductId = $state<number | null>(null);
|
||||||
let newItemQuantity = $state<QuantityValue>({ quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null });
|
let newItemQuantity = $state<QuantityValue>({ quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null });
|
||||||
@@ -170,12 +169,14 @@
|
|||||||
async function addItem() {
|
async function addItem() {
|
||||||
if (!newItemName.trim()) return;
|
if (!newItemName.trim()) return;
|
||||||
const maxSort = items.length > 0 ? Math.max(...items.map((i) => i.sortOrder)) : 0;
|
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', {
|
await api('/api/lists/' + listId + '/items', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: newItemName,
|
name: newItemName,
|
||||||
sortOrder: maxSort + 1,
|
sortOrder: maxSort + 1,
|
||||||
sectionId: newItemSectionId,
|
|
||||||
productId: newItemProductId,
|
productId: newItemProductId,
|
||||||
familyProductId: newItemFamilyProductId,
|
familyProductId: newItemFamilyProductId,
|
||||||
quantity: newItemIsApproximate ? null : newItemQuantity.quantity,
|
quantity: newItemIsApproximate ? null : newItemQuantity.quantity,
|
||||||
@@ -208,15 +209,16 @@
|
|||||||
newItemProductId = null;
|
newItemProductId = null;
|
||||||
newItemFamilyProductId = null;
|
newItemFamilyProductId = null;
|
||||||
newItemAllowedUnitCategories = 0;
|
newItemAllowedUnitCategories = 0;
|
||||||
} else if (product.kind === 'Global') {
|
return;
|
||||||
|
}
|
||||||
|
if (product.kind === 'Global') {
|
||||||
newItemProductId = product.id;
|
newItemProductId = product.id;
|
||||||
newItemFamilyProductId = null;
|
newItemFamilyProductId = null;
|
||||||
newItemAllowedUnitCategories = product.allowedUnitCategories;
|
|
||||||
} else {
|
} else {
|
||||||
newItemProductId = null;
|
newItemProductId = null;
|
||||||
newItemFamilyProductId = product.id;
|
newItemFamilyProductId = product.id;
|
||||||
newItemAllowedUnitCategories = product.allowedUnitCategories;
|
|
||||||
}
|
}
|
||||||
|
newItemAllowedUnitCategories = product.allowedUnitCategories;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggleItem(itemId: number) {
|
async function toggleItem(itemId: number) {
|
||||||
@@ -227,10 +229,29 @@
|
|||||||
// Optimistic — the SignalR ItemSectionChanged echo will reconcile.
|
// Optimistic — the SignalR ItemSectionChanged echo will reconcile.
|
||||||
items = items.map((i) => (i.id === itemId ? { ...i, sectionId } : i));
|
items = items.map((i) => (i.id === itemId ? { ...i, sectionId } : i));
|
||||||
try {
|
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',
|
method: 'PATCH',
|
||||||
body: JSON.stringify({ sectionId })
|
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) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : 'Failed to update section');
|
toast.error(e instanceof Error ? e.message : 'Failed to update section');
|
||||||
}
|
}
|
||||||
@@ -296,7 +317,7 @@
|
|||||||
onsubmit={addItem}
|
onsubmit={addItem}
|
||||||
onProductChange={onItemProductChange}
|
onProductChange={onItemProductChange}
|
||||||
/>
|
/>
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap items-start gap-2">
|
||||||
{#if newItemIsApproximate}
|
{#if newItemIsApproximate}
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
@@ -310,18 +331,6 @@
|
|||||||
allowedUnitCategories={newItemAllowedUnitCategories}
|
allowedUnitCategories={newItemAllowedUnitCategories}
|
||||||
/>
|
/>
|
||||||
{/if}
|
{/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
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
class="btn-primary"
|
class="btn-primary"
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
import { toast } from '$lib/toast.svelte';
|
import { toast } from '$lib/toast.svelte';
|
||||||
import { UnitCategoryFlag, type ProductSuggestion } from '$lib/ProductTypeahead.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 products = $state<ProductSuggestion[]>([]);
|
||||||
let query = $state('');
|
let query = $state('');
|
||||||
@@ -17,6 +19,7 @@
|
|||||||
let formName = $state('');
|
let formName = $state('');
|
||||||
let formBrand = $state('');
|
let formBrand = $state('');
|
||||||
let formNotes = $state('');
|
let formNotes = $state('');
|
||||||
|
let formDefaultSection = $state('');
|
||||||
// Bitfield matching the backend UnitCategoryFlags. 0 = "any unit allowed".
|
// Bitfield matching the backend UnitCategoryFlags. 0 = "any unit allowed".
|
||||||
let formCategories = $state(0);
|
let formCategories = $state(0);
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
@@ -63,6 +66,7 @@
|
|||||||
formName = '';
|
formName = '';
|
||||||
formBrand = '';
|
formBrand = '';
|
||||||
formNotes = '';
|
formNotes = '';
|
||||||
|
formDefaultSection = '';
|
||||||
formCategories = 0;
|
formCategories = 0;
|
||||||
formOpen = true;
|
formOpen = true;
|
||||||
}
|
}
|
||||||
@@ -72,6 +76,7 @@
|
|||||||
formName = p.name;
|
formName = p.name;
|
||||||
formBrand = p.brand ?? '';
|
formBrand = p.brand ?? '';
|
||||||
formNotes = p.notes ?? '';
|
formNotes = p.notes ?? '';
|
||||||
|
formDefaultSection = p.defaultSection ?? '';
|
||||||
formCategories = p.allowedUnitCategories;
|
formCategories = p.allowedUnitCategories;
|
||||||
formOpen = true;
|
formOpen = true;
|
||||||
}
|
}
|
||||||
@@ -94,7 +99,8 @@
|
|||||||
name,
|
name,
|
||||||
brand: formBrand.trim() || null,
|
brand: formBrand.trim() || null,
|
||||||
notes: formNotes.trim() || null,
|
notes: formNotes.trim() || null,
|
||||||
allowedUnitCategories: formCategories
|
allowedUnitCategories: formCategories,
|
||||||
|
defaultSection: formDefaultSection.trim() || null
|
||||||
};
|
};
|
||||||
if (editingProduct === null) {
|
if (editingProduct === null) {
|
||||||
await api('/api/products', { method: 'POST', body: JSON.stringify(body) });
|
await api('/api/products', { method: 'POST', body: JSON.stringify(body) });
|
||||||
@@ -203,6 +209,9 @@
|
|||||||
{#if product.brand}
|
{#if product.brand}
|
||||||
<div class="text-xs text-gray-400">{product.brand}</div>
|
<div class="text-xs text-gray-400">{product.brand}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if product.defaultSection}
|
||||||
|
<div class="text-xs text-gray-400">Section: {product.defaultSection}</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<button onclick={() => startEdit(product)} class="text-sm text-gray-400">Edit</button>
|
<button onclick={() => startEdit(product)} class="text-sm text-gray-400">Edit</button>
|
||||||
</li>
|
</li>
|
||||||
@@ -255,6 +264,24 @@
|
|||||||
>
|
>
|
||||||
<textarea bind:value={formNotes} rows="2" class="field mt-1 w-full"></textarea>
|
<textarea bind:value={formNotes} rows="2" class="field mt-1 w-full"></textarea>
|
||||||
</label>
|
</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>
|
<fieldset>
|
||||||
<legend class="text-xs font-medium text-gray-700">Allowed units</legend>
|
<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>
|
<p class="mt-0.5 text-xs text-gray-400">Leave all unchecked to allow any unit.</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user