Filter unit dropdown by product allowed-unit categories

Adds a UnitCategoryFlags column to Product, FamilyProduct, and
FamilyProductOverride so each product can advertise which unit categories
it is typically packaged by (e.g. flour: Weight | Volume). The product
endpoints round-trip the flag, search projects the effective value with
the override applied, and the frontend QuantityInput soft-filters its
dropdown by the selected product's flag, with a "show all units" escape
hatch for ad-hoc overrides.

No backend rejection on a unit outside the allowed set — the flag is
purely a hint. Default value is None (no filter), so existing data is
unaffected.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Josh Rogers
2026-05-13 22:17:43 -05:00
parent fb1bc2b7e1
commit fd6b0accc8
14 changed files with 1411 additions and 36 deletions
@@ -247,6 +247,115 @@ public class ProductEndpointsTests : AuthenticatedIntegrationTest
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
}
[Test]
public async Task Create_family_product_persists_allowed_unit_categories()
{
var response = await Client.PostAsJsonAsync("/api/products",
new ProductEndpoints.CreateProductRequest("House Flour", null, null,
UnitCategoryFlags.Weight | UnitCategoryFlags.Volume));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Created);
var stored = await UseDbAsync(db => db.FamilyProducts.SingleAsync());
await Assert.That(stored.AllowedUnitCategories)
.IsEqualTo(UnitCategoryFlags.Weight | UnitCategoryFlags.Volume);
}
[Test]
public async Task Update_family_product_changes_allowed_unit_categories()
{
var familyId = await GetFamilyIdAsync();
var product = await UseDbAsync(async db =>
{
var p = new FamilyProduct
{
FamilyId = familyId,
Name = "Flour",
AllowedUnitCategories = UnitCategoryFlags.Weight,
};
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,
UnitCategoryFlags.Weight | UnitCategoryFlags.Volume));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var stored = await UseDbAsync(db => db.FamilyProducts.SingleAsync());
await Assert.That(stored.AllowedUnitCategories)
.IsEqualTo(UnitCategoryFlags.Weight | UnitCategoryFlags.Volume);
}
[Test]
public async Task Update_global_product_writes_allowed_unit_categories_to_override()
{
var apples = await UseDbAsync(async db =>
{
var p = new Product
{
Name = "Milk",
AllowedUnitCategories = UnitCategoryFlags.Volume,
};
db.Products.Add(p);
await db.SaveChangesAsync();
return p;
});
var response = await Client.PutAsJsonAsync($"/api/products/global/{apples.Id}",
new ProductEndpoints.UpdateProductRequest(null, null, null,
UnitCategoryFlags.Volume | UnitCategoryFlags.Packaging));
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
var ovr = await UseDbAsync(db => db.FamilyProductOverrides.SingleAsync());
await Assert.That(ovr.AllowedUnitCategories)
.IsEqualTo(UnitCategoryFlags.Volume | UnitCategoryFlags.Packaging);
// The global row remains unchanged.
var global = await UseDbAsync(db => db.Products.SingleAsync(p => p.Id == apples.Id));
await Assert.That(global.AllowedUnitCategories).IsEqualTo(UnitCategoryFlags.Volume);
}
[Test]
public async Task Search_projects_effective_allowed_unit_categories()
{
var familyId = await GetFamilyIdAsync();
await UseDbAsync(async db =>
{
db.Products.Add(new Product { Name = "Milk", AllowedUnitCategories = UnitCategoryFlags.Volume });
db.Products.Add(new Product { Name = "Eggs", AllowedUnitCategories = UnitCategoryFlags.Count });
db.FamilyProducts.Add(new FamilyProduct
{
FamilyId = familyId,
Name = "House Flour",
AllowedUnitCategories = UnitCategoryFlags.Weight,
});
await db.SaveChangesAsync();
// Override Eggs to widen to Count | Packaging for this family.
var eggs = db.Products.Single(p => p.Name == "Eggs");
db.FamilyProductOverrides.Add(new FamilyProductOverride
{
FamilyId = familyId,
ProductId = eggs.Id,
AllowedUnitCategories = UnitCategoryFlags.Count | UnitCategoryFlags.Packaging,
});
await db.SaveChangesAsync();
});
var results = await Client.GetFromJsonAsync<List<ProductEndpoints.ProductDto>>("/api/products?q=");
var milk = results!.Single(r => r.Name == "Milk");
await Assert.That(milk.AllowedUnitCategories).IsEqualTo(UnitCategoryFlags.Volume);
var eggs = results.Single(r => r.Name == "Eggs");
await Assert.That(eggs.AllowedUnitCategories)
.IsEqualTo(UnitCategoryFlags.Count | UnitCategoryFlags.Packaging);
var flour = results.Single(r => r.Name == "House Flour");
await Assert.That(flour.AllowedUnitCategories).IsEqualTo(UnitCategoryFlags.Weight);
}
[Test]
public async Task Endpoints_require_authentication()
{
@@ -140,6 +140,7 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
e.Property(p => p.Name).HasMaxLength(200);
e.Property(p => p.Brand).HasMaxLength(200);
e.Property(p => p.Notes).HasMaxLength(1000);
e.Property(p => p.AllowedUnitCategories).HasConversion<int>();
e.HasIndex(p => p.Name).IsUnique();
});
@@ -148,6 +149,7 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
e.Property(p => p.Name).HasMaxLength(200);
e.Property(p => p.Brand).HasMaxLength(200);
e.Property(p => p.Notes).HasMaxLength(1000);
e.Property(p => p.AllowedUnitCategories).HasConversion<int>();
e.HasOne(p => p.Family).WithMany().HasForeignKey(p => p.FamilyId).OnDelete(DeleteBehavior.Cascade);
e.HasIndex(p => new { p.FamilyId, p.Name }).IsUnique();
});
@@ -158,6 +160,7 @@ public class YesChefDb(DbContextOptions<YesChefDb> options) : DbContext(options)
e.Property(o => o.Name).HasMaxLength(200);
e.Property(o => o.Brand).HasMaxLength(200);
e.Property(o => o.Notes).HasMaxLength(1000);
e.Property(o => o.AllowedUnitCategories).HasConversion<int?>();
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);
});
@@ -12,5 +12,6 @@ public class FamilyProduct
public required string Name { get; set; }
public string? Brand { get; set; }
public string? Notes { get; set; }
public UnitCategoryFlags AllowedUnitCategories { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -15,5 +15,8 @@ public class FamilyProductOverride
public string? Name { get; set; }
public string? Brand { get; set; }
public string? Notes { get; set; }
// Nullable so "inherit global" (null) is distinguishable from
// "explicitly None / any unit" (UnitCategoryFlags.None).
public UnitCategoryFlags? AllowedUnitCategories { get; set; }
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
}
@@ -11,5 +11,8 @@ public class Product
public required string Name { get; set; }
public string? Brand { get; set; }
public string? Notes { get; set; }
// None = "any unit". Non-zero narrows the unit-dropdown suggestions to the
// flagged categories. Families can replace this with FamilyProductOverride.
public UnitCategoryFlags AllowedUnitCategories { get; set; }
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
}
@@ -0,0 +1,29 @@
namespace YesChef.Api.Entities;
/// <summary>
/// Multi-select sibling of <see cref="UnitCategory"/>. A product can be
/// "typically packaged" by one or more categories (e.g. flour: Weight | Volume),
/// and the unit dropdown filters its suggestions accordingly. Stored as a
/// 32-bit integer so families can OR in additional categories over time.
/// </summary>
[System.Flags]
public enum UnitCategoryFlags
{
None = 0,
Count = 1 << 0,
Weight = 1 << 1,
Volume = 1 << 2,
Packaging = 1 << 3,
}
public static class UnitCategoryFlagsExtensions
{
public static UnitCategoryFlags ToFlag(this UnitCategory c) => c switch
{
UnitCategory.Count => UnitCategoryFlags.Count,
UnitCategory.Weight => UnitCategoryFlags.Weight,
UnitCategory.Volume => UnitCategoryFlags.Volume,
UnitCategory.Packaging => UnitCategoryFlags.Packaging,
_ => UnitCategoryFlags.None,
};
}
@@ -18,10 +18,11 @@ public static class ProductEndpoints
string Name,
string? Brand,
string? Notes,
bool IsOverridden);
bool IsOverridden,
UnitCategoryFlags AllowedUnitCategories);
public record CreateProductRequest(string Name, string? Brand, string? Notes);
public record UpdateProductRequest(string? Name, string? Brand, string? Notes);
public record CreateProductRequest(string Name, string? Brand, string? Notes, UnitCategoryFlags AllowedUnitCategories = UnitCategoryFlags.None);
public record UpdateProductRequest(string? Name, string? Brand, string? Notes, UnitCategoryFlags? AllowedUnitCategories = null);
private const int SearchResultLimit = 50;
@@ -54,9 +55,11 @@ public static class ProductEndpoints
GlobalName = p.Name,
GlobalBrand = p.Brand,
GlobalNotes = p.Notes,
GlobalAllowedUnitCategories = p.AllowedUnitCategories,
OverrideName = o != null ? o.Name : null,
OverrideBrand = o != null ? o.Brand : null,
OverrideNotes = o != null ? o.Notes : null,
OverrideAllowedUnitCategories = o != null ? o.AllowedUnitCategories : null,
HasOverride = o != null,
})
.Take(SearchResultLimit)
@@ -82,11 +85,12 @@ public static class ProductEndpoints
name,
r.OverrideBrand ?? r.GlobalBrand,
r.OverrideNotes ?? r.GlobalNotes,
r.HasOverride);
r.HasOverride,
r.OverrideAllowedUnitCategories ?? r.GlobalAllowedUnitCategories);
}).Where(d => d is not null).Cast<ProductDto>();
var familyDtos = familyRows.Select(p =>
new ProductDto(p.Id, ProductKind.Family, p.Name, p.Brand, p.Notes, IsOverridden: false));
new ProductDto(p.Id, ProductKind.Family, p.Name, p.Brand, p.Notes, IsOverridden: false, p.AllowedUnitCategories));
var results = globalDtos.Concat(familyDtos)
.OrderBy(d => d.Name, StringComparer.OrdinalIgnoreCase)
@@ -114,6 +118,7 @@ public static class ProductEndpoints
Name = name,
Brand = request.Brand,
Notes = request.Notes,
AllowedUnitCategories = request.AllowedUnitCategories,
};
db.FamilyProducts.Add(product);
await db.SaveChangesAsync();
@@ -142,6 +147,7 @@ public static class ProductEndpoints
}
product.Brand = request.Brand;
product.Notes = request.Notes;
if (request.AllowedUnitCategories is { } cats) product.AllowedUnitCategories = cats;
await db.SaveChangesAsync();
return Results.Ok(ToDto(product));
@@ -174,6 +180,10 @@ public static class ProductEndpoints
ovr.Name = trimmedName;
ovr.Brand = request.Brand;
ovr.Notes = request.Notes;
// null in the request leaves the override's value alone (which may
// itself be null, i.e. "inherit global"). Pass an explicit value to
// either narrow categories or restore "any" (UnitCategoryFlags.None).
if (request.AllowedUnitCategories is { } cats) ovr.AllowedUnitCategories = cats;
ovr.UpdatedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
@@ -183,12 +193,13 @@ public static class ProductEndpoints
ovr.Name ?? product.Name,
ovr.Brand ?? product.Brand,
ovr.Notes ?? product.Notes,
IsOverridden: true));
IsOverridden: true,
ovr.AllowedUnitCategories ?? product.AllowedUnitCategories));
});
return group;
}
private static ProductDto ToDto(FamilyProduct p) =>
new(p.Id, ProductKind.Family, p.Name, p.Brand, p.Notes, IsOverridden: false);
new(p.Id, ProductKind.Family, p.Name, p.Brand, p.Notes, IsOverridden: false, p.AllowedUnitCategories);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,50 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace YesChef.Api.Migrations
{
/// <inheritdoc />
public partial class AddProductAllowedUnitCategories : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "AllowedUnitCategories",
table: "Products",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "AllowedUnitCategories",
table: "FamilyProducts",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "AllowedUnitCategories",
table: "FamilyProductOverrides",
type: "integer",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AllowedUnitCategories",
table: "Products");
migrationBuilder.DropColumn(
name: "AllowedUnitCategories",
table: "FamilyProducts");
migrationBuilder.DropColumn(
name: "AllowedUnitCategories",
table: "FamilyProductOverrides");
}
}
}
@@ -80,6 +80,9 @@ namespace YesChef.Api.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AllowedUnitCategories")
.HasColumnType("integer");
b.Property<string>("Brand")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
@@ -115,6 +118,9 @@ namespace YesChef.Api.Migrations
b.Property<int>("ProductId")
.HasColumnType("integer");
b.Property<int?>("AllowedUnitCategories")
.HasColumnType("integer");
b.Property<string>("Brand")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
@@ -273,6 +279,9 @@ namespace YesChef.Api.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<int>("AllowedUnitCategories")
.HasColumnType("integer");
b.Property<string>("Brand")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
@@ -1,4 +1,14 @@
<script lang="ts" module>
// Bitfield matching backend UnitCategoryFlags. Stored as an int on the wire.
export const UnitCategoryFlag = {
None: 0,
Count: 1,
Weight: 2,
Volume: 4,
Packaging: 8,
} as const;
export type UnitCategoryFlag = typeof UnitCategoryFlag[keyof typeof UnitCategoryFlag];
export interface ProductSuggestion {
id: number;
kind: 'Global' | 'Family';
@@ -6,6 +16,7 @@
brand: string | null;
notes: string | null;
isOverridden: boolean;
allowedUnitCategories: number;
}
</script>
+35 -5
View File
@@ -7,17 +7,20 @@
</script>
<script lang="ts">
import { units } from '$lib/units.svelte';
import { units, type UnitCategory } from '$lib/units.svelte';
interface Props {
value: QuantityValue;
ariaLabel?: string;
// When non-zero, soft-filters the unit dropdown to units whose category
// is one of the flagged categories. Users can still expand to all units
// via the "show all" link. 0 (None) = no filter applied.
allowedUnitCategories?: number;
}
let { value = $bindable(), ariaLabel = 'Quantity' }: Props = $props();
let { value = $bindable(), ariaLabel = 'Quantity', allowedUnitCategories = 0 }: Props = $props();
let showAll = $state(false);
// The unit dropdown carries a composite id "kind:id" so we can route to
// the right FK column on the server. "" = no unit selected.
const composite = $derived.by(() => {
if (value.unitOfMeasureId !== null) return `Global:${value.unitOfMeasureId}`;
if (value.familyUnitOfMeasureId !== null) return `Family:${value.familyUnitOfMeasureId}`;
@@ -47,11 +50,32 @@
value.quantity = raw === '' ? null : Number(raw);
}
const categoryBit: Record<UnitCategory, number> = {
Count: 1, Weight: 2, Volume: 4, Packaging: 8,
};
const sorted = $derived(
[...units.all].sort((a, b) => a.sortOrder - b.sortOrder || a.singularName.localeCompare(b.singularName))
);
// If the filter mask is non-zero and showAll isn't on, keep only matching
// units — plus the currently-selected one so the dropdown can render it
// even if the product was changed to one with a tighter filter.
const visible = $derived.by(() => {
const mask = allowedUnitCategories;
if (mask === 0 || showAll) return sorted;
const selectedId = value.unitOfMeasureId ?? value.familyUnitOfMeasureId;
const selectedKind = value.unitOfMeasureId !== null ? 'Global' : 'Family';
return sorted.filter((u) => {
if ((categoryBit[u.category] & mask) !== 0) return true;
return selectedId !== null && u.id === selectedId && u.kind === selectedKind;
});
});
const filterIsActive = $derived(allowedUnitCategories !== 0 && !showAll && visible.length !== sorted.length);
</script>
<div class="flex flex-col gap-0.5">
<div class="flex gap-1">
<input
type="number"
@@ -71,8 +95,14 @@
class="w-24 rounded-lg border border-gray-300 px-1 py-2 text-sm focus:border-primary focus:outline-none"
>
<option value="">unit</option>
{#each sorted as unit}
{#each visible as unit}
<option value="{unit.kind}:{unit.id}">{unit.abbreviation}</option>
{/each}
</select>
</div>
{#if filterIsActive}
<button type="button" onclick={() => (showAll = true)} class="text-[10px] text-gray-400 hover:text-primary">
show all units
</button>
{/if}
</div>
@@ -53,6 +53,7 @@
let newItemQuantity = $state<QuantityValue>({ quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null });
let newItemIsApproximate = $state(false);
let newItemQuantityNote = $state('');
let newItemAllowedUnitCategories = $state(0);
let loading = $state(true);
let connection: HubConnection | null = null;
@@ -190,6 +191,7 @@
newItemQuantity = { quantity: null, unitOfMeasureId: null, familyUnitOfMeasureId: null };
newItemIsApproximate = false;
newItemQuantityNote = '';
newItemAllowedUnitCategories = 0;
}
function toggleApproximateNewItem() {
@@ -205,12 +207,15 @@
if (product === null) {
newItemProductId = null;
newItemFamilyProductId = null;
newItemAllowedUnitCategories = 0;
} else if (product.kind === 'Global') {
newItemProductId = product.id;
newItemFamilyProductId = null;
newItemAllowedUnitCategories = product.allowedUnitCategories;
} else {
newItemProductId = null;
newItemFamilyProductId = product.id;
newItemAllowedUnitCategories = product.allowedUnitCategories;
}
}
@@ -278,7 +283,10 @@
class="w-36 rounded-lg border border-gray-300 px-2 py-2.5 text-sm focus:border-primary focus:outline-none"
/>
{:else}
<QuantityInput bind:value={newItemQuantity} />
<QuantityInput
bind:value={newItemQuantity}
allowedUnitCategories={newItemAllowedUnitCategories}
/>
{/if}
<div class="min-w-0 flex-1">
<ProductTypeahead
@@ -11,6 +11,7 @@
quantityNote: string;
productId: number | null;
familyProductId: number | null;
allowedUnitCategories: number;
}
let title = $state('');
@@ -28,6 +29,7 @@
quantityNote: '',
productId: null,
familyProductId: null,
allowedUnitCategories: 0,
};
}
@@ -54,12 +56,15 @@
if (product === null) {
next.productId = null;
next.familyProductId = null;
next.allowedUnitCategories = 0;
} else if (product.kind === 'Global') {
next.productId = product.id;
next.familyProductId = null;
next.allowedUnitCategories = product.allowedUnitCategories;
} else {
next.productId = null;
next.familyProductId = product.id;
next.allowedUnitCategories = product.allowedUnitCategories;
}
}
@@ -143,7 +148,10 @@
class="w-44 rounded-lg border border-gray-300 px-2 py-2 text-sm focus:border-primary focus:outline-none"
/>
{:else}
<QuantityInput bind:value={ingredient.quantity} />
<QuantityInput
bind:value={ingredient.quantity}
allowedUnitCategories={ingredient.allowedUnitCategories}
/>
{/if}
<div class="flex-1">
<ProductTypeahead