v0.18.1 — Pantry: add custom categories on the fly
Type a new name in the Category or Subcategory dropdown and pick "Add '...'" at the bottom. Custom categories persist (stored as strings on the item) and reappear merged into future dropdowns. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -148,7 +148,22 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
|
||||
set("name", s.name);
|
||||
}
|
||||
|
||||
const subcategoryOptions = getSubcategoryOptions(form.pantryCategory);
|
||||
// Merge static category list with any custom categories previously saved to the DB.
|
||||
const catalogCategories: string[] = catalog?.categories ?? [];
|
||||
const knownCategoryValues = new Set(CATEGORY_OPTIONS.map(o => o.value.toLowerCase()));
|
||||
const extraCategoryOptions = catalogCategories
|
||||
.filter(c => !knownCategoryValues.has(c.toLowerCase()))
|
||||
.map(c => ({ value: c, label: c }));
|
||||
const allCategoryOptions = [...CATEGORY_OPTIONS, ...extraCategoryOptions];
|
||||
|
||||
// Static subcategories for this category + any custom ones from the catalog.
|
||||
const staticSubcategoryOptions = getSubcategoryOptions(form.pantryCategory);
|
||||
const catalogSubcategories: { category: string; subcategory: string }[] = catalog?.subcategories ?? [];
|
||||
const knownSubValues = new Set(staticSubcategoryOptions.map(o => o.value.toLowerCase()));
|
||||
const extraSubOptions = catalogSubcategories
|
||||
.filter(cs => cs.category.toLowerCase() === form.pantryCategory.toLowerCase() && !knownSubValues.has(cs.subcategory.toLowerCase()))
|
||||
.map(cs => ({ value: cs.subcategory, label: cs.subcategory }));
|
||||
const subcategoryOptions = [...staticSubcategoryOptions, ...extraSubOptions];
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
@@ -262,10 +277,11 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
|
||||
<div className="space-y-1">
|
||||
<Label>Category</Label>
|
||||
<Combobox
|
||||
options={CATEGORY_OPTIONS}
|
||||
options={allCategoryOptions}
|
||||
value={form.pantryCategory}
|
||||
onChange={v => { set("pantryCategory", v); set("pantrySubcategory", ""); }}
|
||||
placeholder="Select category…"
|
||||
placeholder="Select or add category…"
|
||||
allowCustom
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -274,9 +290,10 @@ export function PantryItemDialog({ item, locations, plants, onSuccess }: Props)
|
||||
options={subcategoryOptions}
|
||||
value={form.pantrySubcategory}
|
||||
onChange={v => set("pantrySubcategory", v)}
|
||||
placeholder="Select subcategory…"
|
||||
placeholder="Select or add…"
|
||||
disabled={!form.pantryCategory}
|
||||
emptyText="No subcategories"
|
||||
emptyText="Type to add a subcategory"
|
||||
allowCustom
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import { Check, ChevronsUpDown, Search } from "lucide-react";
|
||||
import { Check, ChevronsUpDown, Plus, Search } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ComboOption = { value: string; label: string };
|
||||
@@ -14,9 +14,10 @@ type Props = {
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
emptyText?: string;
|
||||
allowCustom?: boolean; // show "Add '[query]'" when no exact match
|
||||
};
|
||||
|
||||
export function Combobox({ options, value, onChange, placeholder = "Select…", disabled, emptyText = "No results" }: Props) {
|
||||
export function Combobox({ options, value, onChange, placeholder = "Select…", disabled, emptyText = "No results", allowCustom = false }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -25,7 +26,14 @@ export function Combobox({ options, value, onChange, placeholder = "Select…",
|
||||
? options.filter(o => o.label.toLowerCase().includes(query.toLowerCase()))
|
||||
: options;
|
||||
|
||||
// Show "add" option when allowCustom and the query isn't already an exact match
|
||||
const trimmed = query.trim();
|
||||
const exactMatch = options.some(o => o.label.toLowerCase() === trimmed.toLowerCase());
|
||||
const showAdd = allowCustom && trimmed.length > 0 && !exactMatch;
|
||||
|
||||
// Display: if value matches a known option use its label, otherwise show value as-is (custom)
|
||||
const selected = options.find(o => o.value === value);
|
||||
const displayValue = selected?.label ?? value;
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
@@ -34,6 +42,11 @@ export function Combobox({ options, value, onChange, placeholder = "Select…",
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
function pick(val: string) {
|
||||
onChange(val);
|
||||
setOpen(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover.Root open={open} onOpenChange={disabled ? undefined : setOpen}>
|
||||
<Popover.Trigger asChild>
|
||||
@@ -43,10 +56,10 @@ export function Combobox({ options, value, onChange, placeholder = "Select…",
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between rounded-md border bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
!selected && "text-muted-foreground"
|
||||
!displayValue && "text-muted-foreground"
|
||||
)}
|
||||
>
|
||||
<span className="truncate">{selected?.label ?? placeholder}</span>
|
||||
<span className="truncate">{displayValue || placeholder}</span>
|
||||
<ChevronsUpDown className="h-4 w-4 text-muted-foreground shrink-0 ml-2" />
|
||||
</button>
|
||||
</Popover.Trigger>
|
||||
@@ -75,17 +88,14 @@ export function Combobox({ options, value, onChange, placeholder = "Select…",
|
||||
{value && (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 text-sm text-muted-foreground hover:bg-accent flex items-center gap-2"
|
||||
onClick={() => { onChange(""); setOpen(false); }}
|
||||
className="w-full text-left px-3 py-2 text-sm text-muted-foreground hover:bg-accent"
|
||||
onClick={() => pick("")}
|
||||
>
|
||||
— clear —
|
||||
</button>
|
||||
)}
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<p className="px-3 py-4 text-sm text-muted-foreground text-center">{emptyText}</p>
|
||||
) : (
|
||||
filtered.map(opt => (
|
||||
{filtered.map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
@@ -93,12 +103,27 @@ export function Combobox({ options, value, onChange, placeholder = "Select…",
|
||||
"w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center justify-between gap-2",
|
||||
opt.value === value && "font-medium"
|
||||
)}
|
||||
onClick={() => { onChange(opt.value); setOpen(false); }}
|
||||
onClick={() => pick(opt.value)}
|
||||
>
|
||||
<span>{opt.label}</span>
|
||||
{opt.value === value && <Check className="h-3.5 w-3.5 text-primary shrink-0" />}
|
||||
</button>
|
||||
))
|
||||
))}
|
||||
|
||||
{filtered.length === 0 && !showAdd && (
|
||||
<p className="px-3 py-4 text-sm text-muted-foreground text-center">{emptyText}</p>
|
||||
)}
|
||||
|
||||
{/* Add custom option */}
|
||||
{showAdd && (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left px-3 py-2 text-sm hover:bg-accent flex items-center gap-2 text-primary border-t mt-1"
|
||||
onClick={() => pick(trimmed)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 shrink-0" />
|
||||
Add “{trimmed}”
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</Popover.Content>
|
||||
|
||||
@@ -8,6 +8,13 @@ export type ChangelogEntry = {
|
||||
};
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.18.1",
|
||||
date: "2026-06-29",
|
||||
changes: [
|
||||
"Pantry: you can now add a brand-new category or subcategory on the fly — just type the name into the Category or Subcategory dropdown and pick \"Add [name]\" at the bottom of the list. New categories are remembered in future add-item dialogs.",
|
||||
],
|
||||
},
|
||||
{
|
||||
version: "0.18.0",
|
||||
date: "2026-06-16",
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
// Bump on every user-visible release. Changelog entries live in `src/lib/changelog.ts`.
|
||||
export const APP_VERSION = "0.18.0";
|
||||
export const APP_VERSION = "0.18.1";
|
||||
|
||||
Reference in New Issue
Block a user