diff --git a/README.md b/README.md index 68e1d28..3e9be91 100644 --- a/README.md +++ b/README.md @@ -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 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`. +- 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. ## Quick Start diff --git a/backend/internal/domain/fruit.go b/backend/internal/domain/fruit.go index 979ead8..e43d96b 100644 --- a/backend/internal/domain/fruit.go +++ b/backend/internal/domain/fruit.go @@ -2,6 +2,14 @@ package domain 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 { ID int `json:"id"` Name string `json:"name"` diff --git a/backend/internal/handler/fruit_handler.go b/backend/internal/handler/fruit_handler.go index 0240fdb..fbda29b 100644 --- a/backend/internal/handler/fruit_handler.go +++ b/backend/internal/handler/fruit_handler.go @@ -21,7 +21,7 @@ var ( // FruitRepository is the consumer-defined interface the handler depends on. // The pg implementation in the repository package satisfies this structurally. 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) Create(ctx context.Context, 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 { return c.JSON(http.StatusInternalServerError, map[string]string{"error": "internal server error"}) } diff --git a/backend/internal/handler/fruit_handler_test.go b/backend/internal/handler/fruit_handler_test.go index 03cabae..8169fe2 100644 --- a/backend/internal/handler/fruit_handler_test.go +++ b/backend/internal/handler/fruit_handler_test.go @@ -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)) 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) } 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 -- func TestFruitGet_Found(t *testing.T) { diff --git a/backend/internal/repository/fruit_repo.go b/backend/internal/repository/fruit_repo.go index 037037c..d99385b 100644 --- a/backend/internal/repository/fruit_repo.go +++ b/backend/internal/repository/fruit_repo.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strings" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" @@ -33,15 +34,35 @@ func mapPgError(err error) error { 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 - 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 } rows, err := r.pool.Query(ctx, - `SELECT id, name, osdb_number, comment, fruit_type, created_at, updated_at - FROM fruits ORDER BY id LIMIT $1 OFFSET $2`, limit, offset) + `SELECT DISTINCT f.id, f.name, f.osdb_number, f.comment, f.fruit_type, f.created_at, f.updated_at + 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 { return nil, 0, err } diff --git a/backend/internal/repository/fruit_repo_integration_test.go b/backend/internal/repository/fruit_repo_integration_test.go index 858e7ca..8161209 100644 --- a/backend/internal/repository/fruit_repo_integration_test.go +++ b/backend/internal/repository/fruit_repo_integration_test.go @@ -97,7 +97,7 @@ func TestFruitRepoIntegration(t *testing.T) { } // List - fruits, total, err := repo.List(ctx, 50, 0) + fruits, total, err := repo.List(ctx, 50, 0, "", nil) if err != nil { t.Fatalf("List: %v", err) } diff --git a/frontend/src/api/fruits.test.ts b/frontend/src/api/fruits.test.ts index bdb2942..f112c0d 100644 --- a/frontend/src/api/fruits.test.ts +++ b/frontend/src/api/fruits.test.ts @@ -19,18 +19,17 @@ type MockResponse = { const fetchMock = vi.fn((url: string, init?: RequestInit): Promise => { 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', () => { diff --git a/frontend/src/api/fruits.ts b/frontend/src/api/fruits.ts index ee9f61c..0a4a6e6 100644 --- a/frontend/src/api/fruits.ts +++ b/frontend/src/api/fruits.ts @@ -77,8 +77,17 @@ async function checkOk(res: Response): Promise { return res } -export async function listFruits(limit = 50, offset = 0): Promise { - 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 { + 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() } diff --git a/frontend/src/stores/fruitStore.test.ts b/frontend/src/stores/fruitStore.test.ts index 0c61533..40cf00f 100644 --- a/frontend/src/stores/fruitStore.test.ts +++ b/frontend/src/stores/fruitStore.test.ts @@ -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')) + }) }) diff --git a/frontend/src/stores/fruitStore.ts b/frontend/src/stores/fruitStore.ts index f23acde..be48ff9 100644 --- a/frontend/src/stores/fruitStore.ts +++ b/frontend/src/stores/fruitStore.ts @@ -18,12 +18,19 @@ export const useFruitStore = defineStore('fruit', () => { const current = ref(null) const loading = ref(false) const error = ref(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 } }) diff --git a/frontend/src/views/FruitList.vue b/frontend/src/views/FruitList.vue index 3730787..4fd3e48 100644 --- a/frontend/src/views/FruitList.vue +++ b/frontend/src/views/FruitList.vue @@ -1,14 +1,46 @@