feat: allow adding pantry items by text input with auto-ingredient creation
CI / backend (pytest + alembic) (push) Has been cancelled
CI / frontend (build) (push) Has been cancelled

- backend: expose POST /api/ingredients on public router so frontend can create ingredients without admin token
- frontend/api: point listIngredients and createIngredient to /api/ingredients
- frontend/pantry: replace ingredient dropdown with searchable text input + fuzzy matching + auto-create
This commit is contained in:
2026-05-17 21:31:37 -07:00
parent 986968b93d
commit 54b3e785b3
3 changed files with 89 additions and 22 deletions
+20
View File
@@ -55,6 +55,26 @@ def get_ingredient(ingredient_id: UUID, db: Session = Depends(get_db)) -> Ingred
return row return row
@public_router.post("", response_model=IngredientRead, status_code=status.HTTP_201_CREATED)
def create_ingredient_public(payload: IngredientCreate, db: Session = Depends(get_db)) -> Ingredient:
row = Ingredient(
name=payload.name,
name_lower=payload.name.lower(),
aliases=payload.aliases,
aisle=payload.aisle,
unit=payload.unit,
typical_price=payload.typical_price,
)
db.add(row)
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail="ingredient name already exists")
db.refresh(row)
return row
@admin_router.post("", response_model=IngredientRead, status_code=status.HTTP_201_CREATED) @admin_router.post("", response_model=IngredientRead, status_code=status.HTTP_201_CREATED)
def create_ingredient(payload: IngredientCreate, db: Session = Depends(get_db)) -> Ingredient: def create_ingredient(payload: IngredientCreate, db: Session = Depends(get_db)) -> Ingredient:
row = Ingredient( row = Ingredient(
+2 -2
View File
@@ -29,8 +29,8 @@ export const mealPlannerApi = {
get: (id: string) => api.get(`/recipes/${id}`), get: (id: string) => api.get(`/recipes/${id}`),
create: (data: any) => api.post('/recipes', data), create: (data: any) => api.post('/recipes', data),
delete: (id: string) => api.delete(`/recipes/${id}`), delete: (id: string) => api.delete(`/recipes/${id}`),
listIngredients: () => api.get('/recipes/ingredients'), listIngredients: () => api.get('/ingredients?limit=500'),
createIngredient: (data: any) => api.post('/recipes/ingredients', data), createIngredient: (data: any) => api.post('/ingredients', data),
}, },
meals: { meals: {
+67 -20
View File
@@ -6,7 +6,6 @@ import type { HomePantryItem, Ingredient } from '../types'
import { Button } from '../components/ui/Button' import { Button } from '../components/ui/Button'
import { Card, CardBody } from '../components/ui/Card' import { Card, CardBody } from '../components/ui/Card'
import { Input } from '../components/ui/Input' import { Input } from '../components/ui/Input'
import { Select } from '../components/ui/Select'
import { Skeleton, SkeletonText } from '../components/ui/Skeleton' import { Skeleton, SkeletonText } from '../components/ui/Skeleton'
import { EmptyState } from '../components/ui/EmptyState' import { EmptyState } from '../components/ui/EmptyState'
import { showToast } from '../lib/toast' import { showToast } from '../lib/toast'
@@ -14,10 +13,13 @@ import { showToast } from '../lib/toast'
export default function Pantry() { export default function Pantry() {
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [showAddForm, setShowAddForm] = useState(false) const [showAddForm, setShowAddForm] = useState(false)
const [selectedIngredient, setSelectedIngredient] = useState('') const [searchQuery, setSearchQuery] = useState('')
/* ingredient name typed by user */
const [ingredientName, setIngredientName] = useState('')
const [quantity, setQuantity] = useState('') const [quantity, setQuantity] = useState('')
const [unit, setUnit] = useState('') const [unit, setUnit] = useState('')
const [searchQuery, setSearchQuery] = useState('') const [aisle, setAisle] = useState('')
const { data: pantryItems, isLoading } = useQuery<HomePantryItem[]>({ const { data: pantryItems, isLoading } = useQuery<HomePantryItem[]>({
queryKey: ['pantry'], queryKey: ['pantry'],
@@ -29,15 +31,23 @@ export default function Pantry() {
queryFn: () => mealPlannerApi.recipes.listIngredients().then(r => r.data), queryFn: () => mealPlannerApi.recipes.listIngredients().then(r => r.data),
}) })
/* fuzzy match existing ingredient */
const matchedIngredient = ingredientName.trim().length > 0
? ingredients?.find(
ing => ing.name.toLowerCase() === ingredientName.trim().toLowerCase()
)
: undefined
const addMutation = useMutation({ const addMutation = useMutation({
mutationFn: (data: { ingredient_id: string; quantity?: number; unit?: string }) => mutationFn: (data: { ingredient_id: string; quantity?: number; unit?: string }) =>
mealPlannerApi.pantry.add(data), mealPlannerApi.pantry.add(data),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pantry'] }) queryClient.invalidateQueries({ queryKey: ['pantry'] })
setShowAddForm(false) setShowAddForm(false)
setSelectedIngredient('') setIngredientName('')
setQuantity('') setQuantity('')
setUnit('') setUnit('')
setAisle('')
showToast.success('Item added to pantry') showToast.success('Item added to pantry')
}, },
onError: () => { onError: () => {
@@ -56,12 +66,45 @@ export default function Pantry() {
}, },
}) })
const handleAdd = () => { async function handleAdd() {
if (!selectedIngredient) return const name = ingredientName.trim()
if (!name) {
showToast.error('Please enter an ingredient name')
return
}
let ingredientId = matchedIngredient?.id
if (!ingredientId) {
try {
const res = await mealPlannerApi.recipes.createIngredient({
name,
aisle: aisle.trim() || undefined,
unit: unit.trim() || undefined,
})
const created = res.data as any
ingredientId = created?.id
// refresh ingredient list so next add sees it
await queryClient.invalidateQueries({ queryKey: ['ingredients'] })
} catch (err: any) {
if (err?.response?.status === 409) {
showToast.error('Ingredient name already exists')
} else {
showToast.error('Failed to create ingredient')
}
return
}
}
if (!ingredientId) {
showToast.error('Could not resolve ingredient')
return
}
addMutation.mutate({ addMutation.mutate({
ingredient_id: selectedIngredient, ingredient_id: ingredientId,
quantity: quantity ? parseFloat(quantity) : undefined, quantity: quantity ? parseFloat(quantity) : undefined,
unit: unit || undefined, unit: unit.trim() || undefined,
}) })
} }
@@ -70,11 +113,6 @@ export default function Pantry() {
(item.ingredient?.aisle || '').toLowerCase().includes(searchQuery.toLowerCase()) (item.ingredient?.aisle || '').toLowerCase().includes(searchQuery.toLowerCase())
) )
const ingredientOptions = ingredients?.map(ing => ({
value: ing.id,
label: ing.name + (ing.aisle ? ` (${ing.aisle})` : ''),
})) || []
if (isLoading) { if (isLoading) {
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in">
@@ -132,14 +170,17 @@ export default function Pantry() {
<Card className="animate-slide-down"> <Card className="animate-slide-down">
<CardBody> <CardBody>
<h3 className="text-lg font-semibold text-surface-900 mb-4">Add Pantry Item</h3> <h3 className="text-lg font-semibold text-surface-900 mb-4">Add Pantry Item</h3>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<div className="md:col-span-2"> <div className="md:col-span-2">
<Select <Input
label="Ingredient" label="Ingredient name"
options={[{ value: '', label: 'Select ingredient...' }, ...ingredientOptions]} value={ingredientName}
value={selectedIngredient} onChange={(e) => setIngredientName(e.target.value)}
onChange={(e) => setSelectedIngredient(e.target.value)} placeholder="e.g., Avocado"
/> />
{matchedIngredient && (
<p className="mt-1 text-xs text-success-600">Matched existing ingredient </p>
)}
</div> </div>
<Input <Input
label="Quantity" label="Quantity"
@@ -154,11 +195,17 @@ export default function Pantry() {
onChange={(e) => setUnit(e.target.value)} onChange={(e) => setUnit(e.target.value)}
placeholder="cans, lbs, etc." placeholder="cans, lbs, etc."
/> />
<Input
label="Aisle"
value={aisle}
onChange={(e) => setAisle(e.target.value)}
placeholder="e.g., Produce"
/>
<div className="flex items-end"> <div className="flex items-end">
<Button <Button
onClick={handleAdd} onClick={handleAdd}
loading={addMutation.isPending} loading={addMutation.isPending}
disabled={!selectedIngredient || addMutation.isPending} disabled={!ingredientName.trim() || addMutation.isPending}
className="w-full" className="w-full"
> >
Add Add