feat: fruit search by name/synonym and type filter (story #06)

Backend: List repo query gains name (ILIKE with wildcard escaping on
name + synonym LEFT JOIN, SELECT DISTINCT) and types (ANY cast) params;
handler parses ?name= and ?type=, resolves combined-type aliases from
domain.FruitTypeAliases, validates plain types against validFruitTypes
to prevent Postgres enum cast errors. ORDER BY f.name, f.id for stable
pagination.

Frontend: listFruits gains optional {name, type} params; fruitStore adds
searchName/searchType state and setSearch action (resets offset, refetches);
FruitList.vue wires text input (debounced 300 ms + Enter) and flat type
dropdown (17 enum values + 4 aliases per spec §5 order); debounce timer
cleared on unmount.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-18 12:46:38 +02:00
co-authored by Claude Sonnet 4.6
parent 50bfa30b06
commit 78c23557da
11 changed files with 267 additions and 28 deletions
+19 -10
View File
@@ -19,18 +19,17 @@ type MockResponse = {
const fetchMock = vi.fn((url: string, init?: RequestInit): Promise<MockResponse> => {
const method = init?.method ?? 'GET'
if (url === '/api/v1/fruits?limit=50&offset=0' && method === 'GET') {
if (url.startsWith('/api/v1/fruits?') && method === 'GET') {
const params = new URLSearchParams(url.split('?')[1])
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ items: [], total: 0, limit: 50, offset: 0 }),
})
}
if (url === '/api/v1/fruits?limit=10&offset=20' && method === 'GET') {
return Promise.resolve({
ok: true,
status: 200,
json: async () => ({ items: [], total: 0, limit: 10, offset: 20 }),
json: async () => ({
items: [],
total: 0,
limit: Number(params.get('limit') ?? 50),
offset: Number(params.get('offset') ?? 0),
}),
})
}
if (url === '/api/v1/fruits/1' && method === 'GET') {
@@ -99,9 +98,19 @@ describe('listFruits', () => {
})
it('passes custom limit and offset', async () => {
const result = await listFruits(10, 20)
const result = await listFruits({ limit: 10, offset: 20 })
expect(result.offset).toBe(20)
})
it('appends name param when provided', async () => {
await listFruits({ name: 'Boskop' })
expect(fetchMock).toHaveBeenLastCalledWith(expect.stringContaining('name=Boskop'))
})
it('appends type param when provided', async () => {
await listFruits({ type: 'Apfelsorten' })
expect(fetchMock).toHaveBeenLastCalledWith(expect.stringContaining('type=Apfelsorten'))
})
})
describe('getFruit', () => {
+11 -2
View File
@@ -77,8 +77,17 @@ async function checkOk(res: Response): Promise<Response> {
return res
}
export async function listFruits(limit = 50, offset = 0): Promise<FruitListResponse> {
const res = await fetch(`/api/v1/fruits?limit=${limit}&offset=${offset}`)
export async function listFruits(params?: {
limit?: number
offset?: number
name?: string
type?: string
}): Promise<FruitListResponse> {
const { limit = 50, offset = 0, name, type } = params ?? {}
let url = `/api/v1/fruits?limit=${limit}&offset=${offset}`
if (name) url += `&name=${encodeURIComponent(name)}`
if (type) url += `&type=${encodeURIComponent(type)}`
const res = await fetch(url)
return (await checkOk(res)).json()
}
+11
View File
@@ -115,4 +115,15 @@ describe('fruitStore', () => {
expect(store.fruits.find((f) => f.id === 1)).toBeUndefined()
expect(store.total).toBe(1)
})
it('setSearch updates search state and resets offset', async () => {
const store = useFruitStore()
await store.fetchFruits()
store.offset = 50
await store.setSearch('Boskop', 'Apfelsorten')
expect(store.searchName).toBe('Boskop')
expect(store.searchType).toBe('Apfelsorten')
expect(store.offset).toBe(0)
expect(fetchMock).toHaveBeenLastCalledWith(expect.stringContaining('name=Boskop'))
})
})
+16 -2
View File
@@ -18,12 +18,19 @@ export const useFruitStore = defineStore('fruit', () => {
const current = ref<Fruit | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const searchName = ref('')
const searchType = ref('')
async function fetchFruits(lim = limit.value, off = offset.value) {
loading.value = true
error.value = null
try {
const resp = await listFruits(lim, off)
const resp = await listFruits({
limit: lim,
offset: off,
name: searchName.value || undefined,
type: searchType.value || undefined,
})
fruits.value = resp.items
total.value = resp.total
limit.value = resp.limit
@@ -35,6 +42,13 @@ export const useFruitStore = defineStore('fruit', () => {
}
}
async function setSearch(name: string, type: string) {
searchName.value = name
searchType.value = type
offset.value = 0
await fetchFruits(limit.value, 0)
}
async function fetchFruit(id: number) {
loading.value = true
error.value = null
@@ -71,5 +85,5 @@ export const useFruitStore = defineStore('fruit', () => {
if (current.value?.id === id) current.value = null
}
return { fruits, total, limit, offset, current, loading, error, fetchFruits, fetchFruit, create, update, remove }
return { fruits, total, limit, offset, current, loading, error, searchName, searchType, fetchFruits, fetchFruit, create, update, remove, setSearch }
})
+65 -6
View File
@@ -1,14 +1,46 @@
<script setup lang="ts">
import { onMounted, computed } from 'vue'
import { onMounted, onUnmounted, computed, ref } from 'vue'
import { RouterLink } from 'vue-router'
import { useFruitStore } from '../stores/fruitStore'
const SEARCH_TYPES = [
{ label: '(Alle)', value: '' },
{ label: 'Apfelsorten', value: 'Apfelsorten' },
{ label: 'Birnensorten', value: 'Birnensorten' },
{ label: 'Quittensorten', value: 'Quittensorten' },
{ label: 'Birnen- und Quittensorten', value: 'Birnen- und Quittensorten' },
{ label: 'Aprikosen', value: 'Aprikosen' },
{ label: 'Pfirsiche', value: 'Pfirsiche' },
{ label: 'Aprikosen und Pfirsiche', value: 'Aprikosen und Pfirsiche' },
{ label: 'Mirabellen', value: 'Mirabellen' },
{ label: 'Renekloden', value: 'Renekloden' },
{ label: 'Mirabellen und Reineclauden', value: 'Mirabellen und Reineclauden' },
{ label: 'Pflaumen', value: 'Pflaumen' },
{ label: 'Zwetschen', value: 'Zwetschen' },
{ label: 'Pflaumen und Zwetschen', value: 'Pflaumen und Zwetschen' },
{ label: 'Sauerkirschen', value: 'Sauerkirschen' },
{ label: 'Süßkirschen', value: 'Süßkirschen' },
{ label: 'Brombeeren', value: 'Brombeeren' },
{ label: 'Erdbeeren', value: 'Erdbeeren' },
{ label: 'Himbeeren', value: 'Himbeeren' },
{ label: 'Johannisbeeren', value: 'Johannisbeeren' },
{ label: 'Stachelbeeren', value: 'Stachelbeeren' },
{ label: 'Wein', value: 'Wein' },
]
const store = useFruitStore()
const nameInput = ref(store.searchName)
const typeSelect = ref(store.searchType)
let debounceTimer: ReturnType<typeof setTimeout> | null = null
onMounted(async () => {
await store.fetchFruits()
})
onUnmounted(() => {
if (debounceTimer) clearTimeout(debounceTimer)
})
const hasPrev = computed(() => store.offset > 0)
const hasNext = computed(() => store.offset + store.limit < store.total)
@@ -19,6 +51,23 @@ async function prev() {
async function next() {
await store.fetchFruits(store.limit, store.offset + store.limit)
}
function onNameInput() {
if (debounceTimer) clearTimeout(debounceTimer)
debounceTimer = setTimeout(() => {
store.setSearch(nameInput.value, typeSelect.value)
}, 300)
}
function onNameEnter() {
if (debounceTimer) clearTimeout(debounceTimer)
store.setSearch(nameInput.value, typeSelect.value)
}
function onTypeChange() {
if (debounceTimer) clearTimeout(debounceTimer)
store.setSearch(nameInput.value, typeSelect.value)
}
</script>
<template>
@@ -33,14 +82,24 @@ async function next() {
</RouterLink>
</div>
<!-- Search placeholder (story #06) -->
<div class="mb-4">
<div class="mb-4 flex gap-3">
<input
v-model="nameInput"
type="text"
placeholder="Suche... (kommt in Story #06)"
disabled
class="w-full border border-gray-300 rounded px-3 py-2 bg-gray-100 text-gray-400 cursor-not-allowed"
placeholder="Name oder Synonym suchen..."
class="flex-1 border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500"
@input="onNameInput"
@keydown.enter="onNameEnter"
/>
<select
v-model="typeSelect"
class="border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500"
@change="onTypeChange"
>
<option v-for="opt in SEARCH_TYPES" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
</div>
<div v-if="store.loading" class="text-gray-500">Wird geladen...</div>