Merge pull request 'feat: fruit search by name/synonym and type filter (story #06)' (#6) from feature/06-fruit-search into main
Reviewed-on: #6
This commit was merged in pull request #6.
This commit is contained in:
@@ -7,6 +7,7 @@ A full-stack fruit-variety database (Go + Vue 3).
|
|||||||
- Users can view a "Hello, OSDB!" landing page that confirms the backend is reachable via the Vite proxy.
|
- Users can view a "Hello, OSDB!" landing page that confirms the backend is reachable via the Vite proxy.
|
||||||
- Users can create, view, edit, and delete fruit varieties, including managing synonyms and uploading images.
|
- Users can create, view, edit, and delete fruit varieties, including managing synonyms and uploading images.
|
||||||
- Administrators can bulk-import the legacy fruit database from XML using `scripts/import_fruits.py`, and then import all publications (cover images, linked fruits, PDFs, fruit images) using `scripts/import_publications.py`.
|
- Administrators can bulk-import the legacy fruit database from XML using `scripts/import_fruits.py`, and then import all publications (cover images, linked fruits, PDFs, fruit images) using `scripts/import_publications.py`.
|
||||||
|
- Users can search fruits by name or synonym (case-insensitive, debounced) and filter by type or combined-type alias (e.g. "Birnen- und Quittensorten") from the fruit list.
|
||||||
- Users can manage publications (books, catalogues) with cover images, linked fruits, per-fruit PDF descriptions, and fruit images; fruit detail pages show publication descriptions and images.
|
- Users can manage publications (books, catalogues) with cover images, linked fruits, per-fruit PDF descriptions, and fruit images; fruit detail pages show publication descriptions and images.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ package domain
|
|||||||
|
|
||||||
import "time"
|
import "time"
|
||||||
|
|
||||||
|
// FruitTypeAliases maps combined-type alias labels to the enum values they expand to.
|
||||||
|
var FruitTypeAliases = map[string][]string{
|
||||||
|
"Birnen- und Quittensorten": {"Birnensorten", "Quittensorten"},
|
||||||
|
"Aprikosen und Pfirsiche": {"Aprikosen", "Pfirsiche"},
|
||||||
|
"Mirabellen und Reineclauden": {"Mirabellen", "Renekloden"},
|
||||||
|
"Pflaumen und Zwetschen": {"Pflaumen", "Zwetschen"},
|
||||||
|
}
|
||||||
|
|
||||||
type Fruit struct {
|
type Fruit struct {
|
||||||
ID int `json:"id"`
|
ID int `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ var (
|
|||||||
// FruitRepository is the consumer-defined interface the handler depends on.
|
// FruitRepository is the consumer-defined interface the handler depends on.
|
||||||
// The pg implementation in the repository package satisfies this structurally.
|
// The pg implementation in the repository package satisfies this structurally.
|
||||||
type FruitRepository interface {
|
type FruitRepository interface {
|
||||||
List(ctx context.Context, limit, offset int) ([]domain.Fruit, int, error)
|
List(ctx context.Context, limit, offset int, name string, types []string) ([]domain.Fruit, int, error)
|
||||||
Get(ctx context.Context, id int) (domain.Fruit, error)
|
Get(ctx context.Context, id int) (domain.Fruit, error)
|
||||||
Create(ctx context.Context, dto domain.FruitWriteDTO) (domain.Fruit, error)
|
Create(ctx context.Context, dto domain.FruitWriteDTO) (domain.Fruit, error)
|
||||||
Update(ctx context.Context, id int, dto domain.FruitWriteDTO) (domain.Fruit, error)
|
Update(ctx context.Context, id int, dto domain.FruitWriteDTO) (domain.Fruit, error)
|
||||||
@@ -107,7 +107,18 @@ func (h *FruitHandler) List(c echo.Context) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fruits, total, err := h.repo.List(c.Request().Context(), limit, offset)
|
name := c.QueryParam("name")
|
||||||
|
var types []string
|
||||||
|
if typeParam := c.QueryParam("type"); typeParam != "" {
|
||||||
|
if expanded, ok := domain.FruitTypeAliases[typeParam]; ok {
|
||||||
|
types = expanded
|
||||||
|
} else if _, ok := validFruitTypes[typeParam]; ok {
|
||||||
|
types = []string{typeParam}
|
||||||
|
}
|
||||||
|
// unknown typeParam → types stays nil → no filter applied
|
||||||
|
}
|
||||||
|
|
||||||
|
fruits, total, err := h.repo.List(c.Request().Context(), limit, offset, name, types)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,9 +52,32 @@ func newFakeRepo() *fakeRepo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *fakeRepo) List(_ context.Context, limit, offset int) ([]domain.Fruit, int, error) {
|
func (r *fakeRepo) List(_ context.Context, limit, offset int, name string, types []string) ([]domain.Fruit, int, error) {
|
||||||
|
nameLower := strings.ToLower(name)
|
||||||
|
typeSet := make(map[string]struct{}, len(types))
|
||||||
|
for _, t := range types {
|
||||||
|
typeSet[t] = struct{}{}
|
||||||
|
}
|
||||||
all := make([]domain.Fruit, 0, len(r.fruits))
|
all := make([]domain.Fruit, 0, len(r.fruits))
|
||||||
for _, f := range r.fruits {
|
for _, f := range r.fruits {
|
||||||
|
if name != "" {
|
||||||
|
nameMatch := strings.Contains(strings.ToLower(f.Name), nameLower)
|
||||||
|
synMatch := false
|
||||||
|
for _, s := range f.Synonyms {
|
||||||
|
if strings.Contains(strings.ToLower(s), nameLower) {
|
||||||
|
synMatch = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !nameMatch && !synMatch {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(types) > 0 {
|
||||||
|
if _, ok := typeSet[f.FruitType]; !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
all = append(all, f)
|
all = append(all, f)
|
||||||
}
|
}
|
||||||
total := len(all)
|
total := len(all)
|
||||||
@@ -241,6 +264,79 @@ func TestFruitList_WithItems(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFruitList_FilterByName(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Boskop", OSDBNumber: "A001", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
|
||||||
|
repo.fruits[2] = domain.Fruit{ID: 2, Name: "Cox Orange", OSDBNumber: "A002", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
|
||||||
|
h := handler.NewFruitHandler(repo)
|
||||||
|
e := newEcho()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits?name=Boskop", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
if err := h.List(c); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("want 200 got %d", rec.Code)
|
||||||
|
}
|
||||||
|
var resp domain.FruitListResponse
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
if resp.Total != 1 {
|
||||||
|
t.Fatalf("want 1 result got %d", resp.Total)
|
||||||
|
}
|
||||||
|
if resp.Items[0].Name != "Boskop" {
|
||||||
|
t.Fatalf("want Boskop got %s", resp.Items[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFruitList_FilterByType(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Boskop", OSDBNumber: "A001", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
|
||||||
|
repo.fruits[2] = domain.Fruit{ID: 2, Name: "Williams", OSDBNumber: "B001", FruitType: "Birnensorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
|
||||||
|
h := handler.NewFruitHandler(repo)
|
||||||
|
e := newEcho()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits?type=Apfelsorten", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
if err := h.List(c); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("want 200 got %d", rec.Code)
|
||||||
|
}
|
||||||
|
var resp domain.FruitListResponse
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
if resp.Total != 1 {
|
||||||
|
t.Fatalf("want 1 result got %d", resp.Total)
|
||||||
|
}
|
||||||
|
if resp.Items[0].FruitType != "Apfelsorten" {
|
||||||
|
t.Fatalf("want Apfelsorten got %s", resp.Items[0].FruitType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFruitList_AliasExpandsToMultipleTypes(t *testing.T) {
|
||||||
|
repo := newFakeRepo()
|
||||||
|
repo.fruits[1] = domain.Fruit{ID: 1, Name: "Williams", OSDBNumber: "B001", FruitType: "Birnensorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
|
||||||
|
repo.fruits[2] = domain.Fruit{ID: 2, Name: "Quitte", OSDBNumber: "Q001", FruitType: "Quittensorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
|
||||||
|
repo.fruits[3] = domain.Fruit{ID: 3, Name: "Boskop", OSDBNumber: "A001", FruitType: "Apfelsorten", Synonyms: []string{}, Images: []domain.FruitImage{}}
|
||||||
|
h := handler.NewFruitHandler(repo)
|
||||||
|
e := newEcho()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/fruits?type=Birnen-+und+Quittensorten", nil)
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
c := e.NewContext(req, rec)
|
||||||
|
if err := h.List(c); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("want 200 got %d", rec.Code)
|
||||||
|
}
|
||||||
|
var resp domain.FruitListResponse
|
||||||
|
json.Unmarshal(rec.Body.Bytes(), &resp)
|
||||||
|
if resp.Total != 2 {
|
||||||
|
t.Fatalf("want 2 results (Birnensorten + Quittensorten) got %d", resp.Total)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// -- Get --
|
// -- Get --
|
||||||
|
|
||||||
func TestFruitGet_Found(t *testing.T) {
|
func TestFruitGet_Found(t *testing.T) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgconn"
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
@@ -33,15 +34,35 @@ func mapPgError(err error) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *FruitRepo) List(ctx context.Context, limit, offset int) ([]domain.Fruit, int, error) {
|
func escapeLike(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||||
|
s = strings.ReplaceAll(s, `%`, `\%`)
|
||||||
|
s = strings.ReplaceAll(s, `_`, `\_`)
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *FruitRepo) List(ctx context.Context, limit, offset int, name string, types []string) ([]domain.Fruit, int, error) {
|
||||||
|
escaped := escapeLike(name)
|
||||||
|
countRow := r.pool.QueryRow(ctx,
|
||||||
|
`SELECT COUNT(DISTINCT f.id)
|
||||||
|
FROM fruits f
|
||||||
|
LEFT JOIN fruit_synonyms fs ON fs.fruit_id = f.id
|
||||||
|
WHERE ($1 = '' OR f.name ILIKE '%' || $1 || '%' ESCAPE '\' OR fs.synonym ILIKE '%' || $1 || '%' ESCAPE '\')
|
||||||
|
AND ($2::fruit_type[] IS NULL OR f.fruit_type = ANY($2::fruit_type[]))`,
|
||||||
|
escaped, types)
|
||||||
var total int
|
var total int
|
||||||
if err := r.pool.QueryRow(ctx, "SELECT COUNT(*) FROM fruits").Scan(&total); err != nil {
|
if err := countRow.Scan(&total); err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, err := r.pool.Query(ctx,
|
rows, err := r.pool.Query(ctx,
|
||||||
`SELECT id, name, osdb_number, comment, fruit_type, created_at, updated_at
|
`SELECT DISTINCT f.id, f.name, f.osdb_number, f.comment, f.fruit_type, f.created_at, f.updated_at
|
||||||
FROM fruits ORDER BY id LIMIT $1 OFFSET $2`, limit, offset)
|
FROM fruits f
|
||||||
|
LEFT JOIN fruit_synonyms fs ON fs.fruit_id = f.id
|
||||||
|
WHERE ($1 = '' OR f.name ILIKE '%' || $1 || '%' ESCAPE '\' OR fs.synonym ILIKE '%' || $1 || '%' ESCAPE '\')
|
||||||
|
AND ($2::fruit_type[] IS NULL OR f.fruit_type = ANY($2::fruit_type[]))
|
||||||
|
ORDER BY f.name, f.id LIMIT $3 OFFSET $4`,
|
||||||
|
escaped, types, limit, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ func TestFruitRepoIntegration(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// List
|
// List
|
||||||
fruits, total, err := repo.List(ctx, 50, 0)
|
fruits, total, err := repo.List(ctx, 50, 0, "", nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("List: %v", err)
|
t.Fatalf("List: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,18 +19,17 @@ type MockResponse = {
|
|||||||
const fetchMock = vi.fn((url: string, init?: RequestInit): Promise<MockResponse> => {
|
const fetchMock = vi.fn((url: string, init?: RequestInit): Promise<MockResponse> => {
|
||||||
const method = init?.method ?? 'GET'
|
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({
|
return Promise.resolve({
|
||||||
ok: true,
|
ok: true,
|
||||||
status: 200,
|
status: 200,
|
||||||
json: async () => ({ items: [], total: 0, limit: 50, offset: 0 }),
|
json: async () => ({
|
||||||
})
|
items: [],
|
||||||
}
|
total: 0,
|
||||||
if (url === '/api/v1/fruits?limit=10&offset=20' && method === 'GET') {
|
limit: Number(params.get('limit') ?? 50),
|
||||||
return Promise.resolve({
|
offset: Number(params.get('offset') ?? 0),
|
||||||
ok: true,
|
}),
|
||||||
status: 200,
|
|
||||||
json: async () => ({ items: [], total: 0, limit: 10, offset: 20 }),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (url === '/api/v1/fruits/1' && method === 'GET') {
|
if (url === '/api/v1/fruits/1' && method === 'GET') {
|
||||||
@@ -99,9 +98,19 @@ describe('listFruits', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('passes custom limit and offset', async () => {
|
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)
|
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', () => {
|
describe('getFruit', () => {
|
||||||
|
|||||||
@@ -77,8 +77,17 @@ async function checkOk(res: Response): Promise<Response> {
|
|||||||
return res
|
return res
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listFruits(limit = 50, offset = 0): Promise<FruitListResponse> {
|
export async function listFruits(params?: {
|
||||||
const res = await fetch(`/api/v1/fruits?limit=${limit}&offset=${offset}`)
|
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()
|
return (await checkOk(res)).json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,4 +115,15 @@ describe('fruitStore', () => {
|
|||||||
expect(store.fruits.find((f) => f.id === 1)).toBeUndefined()
|
expect(store.fruits.find((f) => f.id === 1)).toBeUndefined()
|
||||||
expect(store.total).toBe(1)
|
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 current = ref<Fruit | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
|
const searchName = ref('')
|
||||||
|
const searchType = ref('')
|
||||||
|
|
||||||
async function fetchFruits(lim = limit.value, off = offset.value) {
|
async function fetchFruits(lim = limit.value, off = offset.value) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
try {
|
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
|
fruits.value = resp.items
|
||||||
total.value = resp.total
|
total.value = resp.total
|
||||||
limit.value = resp.limit
|
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) {
|
async function fetchFruit(id: number) {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
@@ -71,5 +85,5 @@ export const useFruitStore = defineStore('fruit', () => {
|
|||||||
if (current.value?.id === id) current.value = null
|
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">
|
<script setup lang="ts">
|
||||||
import { onMounted, computed } from 'vue'
|
import { onMounted, onUnmounted, computed, ref } from 'vue'
|
||||||
import { RouterLink } from 'vue-router'
|
import { RouterLink } from 'vue-router'
|
||||||
import { useFruitStore } from '../stores/fruitStore'
|
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 store = useFruitStore()
|
||||||
|
const nameInput = ref(store.searchName)
|
||||||
|
const typeSelect = ref(store.searchType)
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await store.fetchFruits()
|
await store.fetchFruits()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer)
|
||||||
|
})
|
||||||
|
|
||||||
const hasPrev = computed(() => store.offset > 0)
|
const hasPrev = computed(() => store.offset > 0)
|
||||||
const hasNext = computed(() => store.offset + store.limit < store.total)
|
const hasNext = computed(() => store.offset + store.limit < store.total)
|
||||||
|
|
||||||
@@ -19,6 +51,23 @@ async function prev() {
|
|||||||
async function next() {
|
async function next() {
|
||||||
await store.fetchFruits(store.limit, store.offset + store.limit)
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -33,14 +82,24 @@ async function next() {
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Search placeholder (story #06) -->
|
<div class="mb-4 flex gap-3">
|
||||||
<div class="mb-4">
|
|
||||||
<input
|
<input
|
||||||
|
v-model="nameInput"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Suche... (kommt in Story #06)"
|
placeholder="Name oder Synonym suchen..."
|
||||||
disabled
|
class="flex-1 border border-gray-300 rounded px-3 py-2 focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||||
class="w-full border border-gray-300 rounded px-3 py-2 bg-gray-100 text-gray-400 cursor-not-allowed"
|
@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>
|
||||||
|
|
||||||
<div v-if="store.loading" class="text-gray-500">Wird geladen...</div>
|
<div v-if="store.loading" class="text-gray-500">Wird geladen...</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user