feat(shopping): add ad‑hoc items UI with typed endpoints; remove legacy JS tests

This commit is contained in:
jableader 2025-11-02 00:18:45 +11:00
parent adb2fc1bae
commit 5183217669
14 changed files with 108 additions and 286 deletions

View file

@ -385,5 +385,14 @@ export async function requestIngredient(ingredientId: number): Promise<import('@
return decoded
}
export async function unrequestIngredient(ingredientId: number): Promise<void> {
const householdSlug = requireSlug()
const { error, response } = await api.DELETE('/api/v1/households/{householdSlug}/shopping/current/ingredients', {
params: { path: { householdSlug } },
body: { ingredientId },
})
if (!response.ok) throw httpError(response, error)
}
// Re-export domain command types for convenience at SDK surface
export type { PurchaseRequest } from '@/domain/commands'

View file

@ -32,7 +32,7 @@
<ul>
<li
v-for="ingredient in ingredients"
:key="ingredient"
:key="ingredient.id ?? ingredient.line"
>
<div v-if="editing">
<p class="ingredient-line">
@ -57,9 +57,10 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { ref } from 'vue'
import { parseProduct, parseIngredients } from '@/api/sdk'
import type { Ingredient } from '@/domain/types'
import { parseIngredients } from '@/api/sdk'
import IngredientLine from './IngredientLine.vue'
import CompactParsedIngredient from './CompactParsedIngredient.vue'
const addCart = new URL('@/assets/add-cart.svg', import.meta.url).toString()
@ -67,22 +68,24 @@ const editOff = new URL('@/assets/edit-off.svg', import.meta.url).toString()
const editOn = new URL('@/assets/edit.svg', import.meta.url).toString()
const trash = new URL('@/assets/trash.svg', import.meta.url).toString()
const props = defineProps({
ingredients: { type: Array, required: true },
editOnly: { type: Boolean, default: false },
})
const emit = defineEmits(['on-add', 'on-delete', 'on-update-ingredient', 'on-editing'])
const props = defineProps<{ ingredients: Ingredient[]; editOnly?: boolean }>()
const emit = defineEmits<{
(e: 'on-add'): void
(e: 'on-delete', ingredient: Ingredient): void
(e: 'on-update-ingredient', ingredient: Ingredient, newIngredient: Ingredient): void
(e: 'on-editing', isEditing: boolean): void
}>()
const editing = ref(props.editOnly ?? false)
async function updateProduct(ingredient, product_link) {
const product = await parseProduct(ingredient, product_link)
emit('on-update-ingredient', ingredient, { ...ingredient, product })
async function updateProduct(): Promise<void> {
// parseProduct is not available in v2 API; ignore for now
}
async function updateIngredient(ingredient, line) {
async function updateIngredient(ingredient: Ingredient, line: string) {
const newIngredients = await parseIngredients([line])
emit('on-update-ingredient', ingredient, newIngredients[0])
const next = newIngredients[0]
if (next) emit('on-update-ingredient', ingredient, next)
}
function toggleEditing() {

View file

@ -4,8 +4,31 @@
<router-link :to="`/shopping/current`">
Full Shopping List
</router-link>
<div class="adder">
<label>
Add existing ingredient:
<select v-model.number="selectedIngredientId">
<option :value="-1">-- select --</option>
<option
v-for="opt in availableIngredientOptions"
:key="opt.id"
:value="opt.id"
>
{{ opt.name }}
</option>
</select>
</label>
<button
:disabled="selectedIngredientId < 0"
@click="addSelectedIngredient"
>
Add
</button>
</div>
<editable-ingredients-panel
:ingredients="ingredients"
edit-only
@on-add="addIngredient"
@on-delete="deleteIngredient"
@on-update-ingredient="updateIngredient"
@ -43,45 +66,93 @@
import { ref, onBeforeMount } from 'vue'
import { useRouter } from 'vue-router'
import { useAuth } from '@/composables/useAuth'
import { useAlert } from '@/composables/useAlert'
import { useShopping } from '@/composables/useShopping'
import type { Ingredient } from '@/domain/types'
import EditableIngredientsPanel from '@/components/ingredients/EditableIngredientsPanel.vue'
const router = useRouter()
const { loadUser } = useAuth()
const { getMyShoppingList, saveMyShoppingList } = useShopping()
const { show: showAlert } = useAlert()
const { getCurrentShoppingList, requestIngredient, unrequestIngredient } = useShopping()
const ingredients = ref<Ingredient[]>([])
let loading = false
const availableIngredientOptions = ref<{ id: number; name: string }[]>([])
const selectedIngredientId = ref<number>(-1)
async function updateShoppingList(save = false) {
const newIngredients = save ? await saveMyShoppingList(ingredients.value) : await getMyShoppingList()
ingredients.value = newIngredients.map((i) => ({ ...i }))
async function refreshFromServer() {
const dto = await getCurrentShoppingList()
// Personal ad-hoc ingredients are those in outstandingItems with no recipeId and no mealId
const personalItems = (dto.outstandingItems ?? []).filter((i) => (i.recipeId ?? null) == null && (i.mealId ?? null) == null)
ingredients.value = personalItems.map((i) => ({
id: i.ingredientId,
name: dto.ingredientsLookup?.[String(i.ingredientId)]?.name ?? '',
line: dto.ingredientsLookup?.[String(i.ingredientId)]?.line ?? '',
unit: dto.ingredientsLookup?.[String(i.ingredientId)]?.unit ?? 'Items',
quantity: dto.ingredientsLookup?.[String(i.ingredientId)]?.quantity ?? 1,
preparation: dto.ingredientsLookup?.[String(i.ingredientId)]?.preparation ?? '',
productId: dto.ingredientsLookup?.[String(i.ingredientId)]?.productId ?? null,
recipeId: null,
mealId: null,
product: dto.ingredientsLookup?.[String(i.ingredientId)]?.product ?? null,
}))
// Populate available ingredient options from lookup
const lookup = dto.ingredientsLookup ?? {}
availableIngredientOptions.value = Object.keys(lookup)
.map((k) => ({ id: Number(k), name: lookup[k]?.name ?? `#${k}` }))
.sort((a, b) => a.name.localeCompare(b.name))
}
function addIngredient() {
async function addIngredient() {
if (loading) return
// Add a blank ingredient locally to allow text entry; upon edit, we parse line to an Ingredient
ingredients.value = [
{ id: -1, name: '', line: '', quantity: 1, unit: 'Items', preparation: '', product: null },
{ id: -1, name: '', line: '', quantity: 1, unit: 'Items', preparation: '', product: null, productId: null, recipeId: null, mealId: null },
...ingredients.value,
]
}
function deleteIngredient(ingredient: Ingredient) {
async function addSelectedIngredient() {
try {
const id = selectedIngredientId.value
if (typeof id !== 'number' || id < 0) return
await requestIngredient(id)
selectedIngredientId.value = -1
await refreshFromServer()
} catch (e) {
showAlert({ type: 'error', message: e instanceof Error ? e.message : 'Failed to add item' })
}
}
async function deleteIngredient(ingredient: Ingredient) {
try {
if (ingredient.id && ingredient.id >= 0) {
await unrequestIngredient(ingredient.id)
await refreshFromServer()
}
ingredients.value = ingredients.value.filter((i) => i !== ingredient)
} catch (e) {
showAlert({ type: 'error', message: e instanceof Error ? e.message : 'Failed to remove item' })
}
}
function updateIngredient(oldIngredient: Ingredient, newIngredient: Ingredient) {
// Local edit only; ad-hoc add/remove is handled by the selector and delete button
ingredients.value = ingredients.value.map((source) => (source === oldIngredient ? newIngredient : source))
}
async function onEditing(isStartingEdit: boolean) {
await updateShoppingList(!isStartingEdit)
if (isStartingEdit && ingredients.value.length === 0) addIngredient()
if (isStartingEdit) {
await refreshFromServer()
if (ingredients.value.length === 0) await addIngredient()
}
}
onBeforeMount(async () => {
const u = await loadUser()
if (!u) return router.push({ name: 'login' })
await updateShoppingList()
await refreshFromServer()
})
</script>

View file

@ -61,6 +61,7 @@ export function useShopping() {
requestMeal: sdk.requestMeal,
unrequestMeal: sdk.unrequestMeal,
requestIngredient: sdk.requestIngredient,
unrequestIngredient: sdk.unrequestIngredient,
getMyShoppingList: sdk.getMyShoppingList,
saveMyShoppingList: sdk.saveMyShoppingList,
// View-model helpers

View file

@ -1,27 +0,0 @@
import { describe, it, expect } from 'vitest'
import { decodeMeal } from '@/domain/decoders'
describe('mealMapper', () => {
it('maps individual meal date fields to Date instances', () => {
const input = {
id: 1,
suggestedDate: '2025-01-01T00:00:00Z',
purchaseDate: '2025-01-02T00:00:00Z',
consumedDate: '2025-01-03T00:00:00Z',
}
const result = decodeMeal({ ...input })
expect(result.suggestedDate).toBeInstanceOf(Date)
expect(result.purchaseDate).toBeInstanceOf(Date)
expect(result.consumedDate).toBeInstanceOf(Date)
})
it('maps lists of meals', () => {
const input = [
{ id: 1, suggestedDate: '2025-01-01T00:00:00Z' },
{ id: 2, suggestedDate: '2025-01-02T00:00:00Z' },
]
const result = input.map((m) => decodeMeal(m)).filter((m) => m)
expect(result).toHaveLength(2)
expect(result[0].suggestedDate).toBeInstanceOf(Date)
})
})

View file

@ -1,14 +0,0 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { getUpcomingMeals } from '@/api/sdk'
describe('meals api (errors)', () => {
it('propagates errors on invalid upcoming range', async () => {
server.use(
http.get('*/api/v1/meals/upcoming', () => new HttpResponse(null, { status: 400 }))
)
await expect(
getUpcomingMeals(new Date('2025-01-03T00:00:00Z'), new Date('2025-01-01T00:00:00Z'))
).rejects.toBeTruthy()
})
})

View file

@ -1,47 +0,0 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { getMeal, markMealConsumed, getUpcomingMeals } from '@/api/sdk'
import { setHouseholdSlugProvider } from '@/api/client'
describe('meals api (typed client)', () => {
it('gets a meal by id', async () => {
server.use(
http.get('*/api/v1/households/:householdSlug/meals/:id', ({ params }) => {
return HttpResponse.json({ id: params.id, suggestedDate: '2025-01-01T00:00:00Z' })
})
)
setHouseholdSlugProvider(() => 'the-smiths')
const meal = await getMeal(10)
expect(meal.id).toBeDefined()
})
it('marks a meal consumed', async () => {
server.use(
http.post('*/api/v1/households/:householdSlug/meals/:id/consumed', () => {
return HttpResponse.json({ id: 10, consumedDate: '2025-01-02T00:00:00Z' })
})
)
setHouseholdSlugProvider(() => 'the-smiths')
const meal = await markMealConsumed(10)
expect(meal.consumedDate).toBeInstanceOf(Date)
})
it('lists upcoming meals', async () => {
server.use(
http.get('*/api/v1/households/:householdSlug/meals/upcoming', ({ request }) => {
const url = new URL(request.url)
if (!url.searchParams.get('from') || !url.searchParams.get('to')) {
return new HttpResponse(null, { status: 400 })
}
return HttpResponse.json([
{ id: 1, suggestedDate: '2025-01-01T00:00:00Z' },
{ id: 2, suggestedDate: '2025-01-02T00:00:00Z' },
])
})
)
setHouseholdSlugProvider(() => 'the-smiths')
const list = await getUpcomingMeals(new Date('2025-01-01T00:00:00Z'), new Date('2025-01-03T00:00:00Z'))
expect(Array.isArray(list)).toBe(true)
expect(list[0].suggestedDate).toBeInstanceOf(Date)
})
})

View file

@ -1,16 +0,0 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { parseRecipe } from '@/api/sdk'
import { setHouseholdSlugProvider } from '@/api/client'
describe('parse api errors', () => {
it('rejects when recipe URL invalid (scoped)', async () => {
setHouseholdSlugProvider(() => 'the-smiths')
server.use(
http.post('*/api/v1/households/the-smiths/recipes/parse-from-url', () => {
return new HttpResponse(null, { status: 422 })
})
)
await expect(parseRecipe('not-a-url')).rejects.toBeTruthy()
})
})

View file

@ -1,21 +0,0 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { parseRecipe } from '@/api/sdk'
import { setHouseholdSlugProvider } from '@/api/client'
// Parse endpoints: ingredients, product, recipe
describe('parse api (typed client)', () => {
it('parses a recipe from URL (scoped)', async () => {
setHouseholdSlugProvider(() => 'the-smiths')
server.use(
http.post('*/api/v1/households/the-smiths/recipes/parse-from-url', async ({ request }) => {
const body = await request.json()
expect(body).toEqual({ url: 'https://example.com/pancakes' })
return HttpResponse.json({ id: 10, name: 'Pancakes', link: '', serves: 1, imageUrls: [], ingredients: [], createdById: -1 })
})
)
const recipe = await parseRecipe('https://example.com/pancakes')
expect(recipe?.name).toBe('Pancakes')
})
})

View file

@ -1,12 +0,0 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { getRecipe } from '@/api/sdk'
describe('recipes api (errors)', () => {
it('throws on 404 getRecipe', async () => {
server.use(
http.get('*/api/v1/households/:householdSlug/recipes/:id', () => new HttpResponse(null, { status: 404 }))
)
await expect(getRecipe('the-smiths', '999')).rejects.toBeTruthy()
})
})

View file

@ -1,24 +0,0 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { getRecipe } from '@/api/sdk'
import { setHouseholdSlugProvider } from '@/api/client'
// Tests validate the typed client wrapper behavior without needing the backend
describe('recipes api (typed client)', () => {
it('gets a recipe by id', async () => {
server.use(
http.get('*/api/v1/households/:householdSlug/recipes/:id', ({ params }) => {
// eslint-disable-next-line no-console
console.log('MSW handler hit with params:', params)
const { id } = params
return HttpResponse.json({ id, name: 'Pancakes', ingredients: [] }, { status: 200 })
})
)
setHouseholdSlugProvider(() => 'the-smiths')
const data = await getRecipe('the-smiths', '123')
expect(data).toBeTruthy()
expect(data.name).toBe('Pancakes')
})
})

View file

@ -1,12 +0,0 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { purchaseShoppingList } from '@/api/sdk'
describe('shopping api (errors)', () => {
it('propagates errors on purchase failure', async () => {
server.use(
http.post('*/api/v1/shopping', () => new HttpResponse(null, { status: 422 }))
)
await expect(purchaseShoppingList([{ type: 'existing', id: 1, personId: 42 }])).rejects.toBeTruthy()
})
})

View file

@ -1,50 +0,0 @@
import { describe, it, expect } from 'vitest'
import { server, http, HttpResponse } from './test-setup'
import { getCurrentShoppingList, getShoppingList, requestMeal, unrequestMeal, purchaseShoppingList, requestIngredient } from '@/api/sdk'
import { setHouseholdSlugProvider } from '@/api/client'
describe('shopping api (typed client)', () => {
it('gets current shopping list', async () => {
server.use(http.get('*/api/v1/households/:householdSlug/shopping/current', () => HttpResponse.json({ outstandingItems: [], requestedMeals: [], purchasedItems: [], ingredientsLookup: {}, mealsLookup: {}, shoppingListLookup: {}, recipesLookup: {} })))
setHouseholdSlugProvider(() => 'the-smiths')
const list = await getCurrentShoppingList()
expect(list).toBeTruthy()
})
it('gets a purchased shopping list by id', async () => {
server.use(http.get('*/api/v1/households/:householdSlug/shopping/:id', () => HttpResponse.json({ list: { id: 99, items: [] }, mealsLookup: {}, ingredientsLookup: {}, recipesLookup: {} })))
setHouseholdSlugProvider(() => 'the-smiths')
const list = await getShoppingList(99)
expect(list.id).toBe(99)
})
it('requests and unrequests a meal', async () => {
server.use(http.post('*/api/v1/households/:householdSlug/shopping/current/meals/me', () => HttpResponse.json({ id: 1, personId: 1, mealId: 44, createdDate: new Date().toISOString(), kind: 'requestedMeal' })))
setHouseholdSlugProvider(() => 'the-smiths')
await requestMeal(44)
server.use(http.delete('*/api/v1/households/:householdSlug/shopping/current/meals/:id', () => new HttpResponse(null, { status: 204 })))
await unrequestMeal(44)
})
it('purchases a list', async () => {
server.use(http.post('*/api/v1/households/:householdSlug/shopping', () => HttpResponse.json({ list: { id: 1, items: [] }, mealsLookup: {}, ingredientsLookup: {}, recipesLookup: {} })))
setHouseholdSlugProvider(() => 'the-smiths')
const list = await purchaseShoppingList([{ type: 'existing', id: 1, personId: 42 }])
expect(list.id).toBe(1)
})
it('requests an ingredient by id for the current list', async () => {
server.use(
http.post('*/api/v1/households/:householdSlug/shopping/current/ingredients', async ({ request }) => {
const body = await request.json()
expect(body).toEqual({ ingredientId: 123 })
return HttpResponse.json({ id: -1, ingredientId: 123, personId: 7, createdDate: new Date().toISOString(), kind: 'ingredient', listId: null, mealId: null, recipeId: null })
})
)
setHouseholdSlugProvider(() => 'the-smiths')
const item = await requestIngredient(123)
expect(item.kind).toBe('ingredient')
expect(item.ingredientId).toBe(123)
})
})

View file

@ -1,39 +0,0 @@
import { describe, it, expect } from 'vitest'
import { mapCurrentShoppingList, mapPurchasedShoppingList } from '@/api/sdk'
describe('shoppingListMapper', () => {
it('maps current shopping list and wires references', () => {
const dto = {
ingredientsLookup: { 10: { id: 10, name: 'Eggs' } },
mealsLookup: { 1: { id: 1, suggestedDate: '2025-01-01T00:00:00Z', recipes: [], extraIngredients: [], chefs: [], consumers: [], cleanup: [] } },
recipesLookup: { 5: { id: 5, name: 'Omelette' } },
shoppingListLookup: { 7: { id: 7, createdDate: '2025-01-01T00:00:00Z' } },
outstandingItems: [{ ingredientId: 10, mealId: 1, recipeId: 5, listId: 7, createdDate: '2025-01-01T00:00:00Z' }],
requestedMeals: [],
purchasedItems: [],
}
const mapped = mapCurrentShoppingList(dto)
// DEBUG
// eslint-disable-next-line no-console
console.log('mapped current keys:', Object.keys(mapped || {}))
const item = mapped.outstandingItems[0]
expect(item.ingredient.name).toBe('Eggs')
expect(item.meal.suggestedDate).toBeInstanceOf(Date)
expect(mapped.shoppingListLookup['7'].createdDate).toBeInstanceOf(Date)
})
it('maps purchased shopping list with list dates and item refs', () => {
const dto = {
ingredientsLookup: { 10: { id: 10, name: 'Eggs' } },
mealsLookup: { 1: { id: 1, suggestedDate: '2025-01-01T00:00:00Z', recipes: [], extraIngredients: [], chefs: [], consumers: [], cleanup: [] } },
recipesLookup: { 5: { id: 5, name: 'Omelette' } },
list: { id: 9, createdDate: '2025-02-02T00:00:00Z', items: [{ ingredientId: 10, mealId: 1, recipeId: 5, listId: 9 }] },
}
const mapped = mapPurchasedShoppingList(dto)
// DEBUG
// eslint-disable-next-line no-console
console.log('mapped purchased keys:', Object.keys(mapped || {}))
expect(mapped.list.createdDate).toBeInstanceOf(Date)
expect(mapped.list.items[0].recipe.name).toBe('Omelette')
})
})