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:
@@ -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', () => {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 }
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user