diff --git a/gerbil-manager-web/e2e/mock-api.ts b/gerbil-manager-web/e2e/mock-api.ts index 6219a76..9d22412 100644 --- a/gerbil-manager-web/e2e/mock-api.ts +++ b/gerbil-manager-web/e2e/mock-api.ts @@ -135,6 +135,19 @@ export async function installMockApi(page: Page): Promise { if (method === 'PUT') return json(route, 204) } + // SEARCH-2b: distinct Herkunft (originBreeder) values, sorted — vor der + // generischen /gerbils/:id-Route abfangen. + if (path === '/gerbils/breeders' && method === 'GET') { + const values = [ + ...new Set( + db.gerbils + .map((g) => g.originBreeder) + .filter((v): v is string => typeof v === 'string' && v.length > 0), + ), + ].sort() + return json(route, 200, values) + } + // Sonderrouten zuerst (FEAT-1b/FEAT-4-Verträge) let m = path.match(/^\/gerbils\/([^/]+)\/photos$/) if (m) { diff --git a/gerbil-manager-web/e2e/mock-data.ts b/gerbil-manager-web/e2e/mock-data.ts index 3a7ebb3..5b143f1 100644 --- a/gerbil-manager-web/e2e/mock-data.ts +++ b/gerbil-manager-web/e2e/mock-data.ts @@ -58,6 +58,9 @@ function gerbil( colorVarietyId, originContactId: null, receiverContactId: null, + originBreeder: null, + // SEARCH-2b: backend stores a normalized name (lowercase, [\s._-] stripped). + nameSearch: name.toLowerCase().replace(/[\s._-]/g, ''), genotype, notes: null, } @@ -70,8 +73,9 @@ export function seedDb(): MockDb { ...gerbil('kruemel', 'Krümel', 'female', '2025-03-12', 'w-kruemel', 'cv-agouti', 'Aa CC DD EE GG Pp spsp rere'), enclosureId: 'enc-gross', originContactId: 'con-meier', + originBreeder: 'Clan-Kleine-Chaoten', }, - { ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross' }, + { ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier' }, gerbil('luna', 'Luna', 'female', '2023-08-15', 'w-luna', 'cv-gold', 'AA CC DD EE GG pp spsp rere'), gerbil('balu', 'Balu', 'male', '2021-04-20', 'w-balu', 'cv-agouti'), gerbil('maja', 'Maja', 'female', '2021-06-11', null, 'cv-schwarz-schecke', 'aa CC DD EE GG PP Spsp rere'), diff --git a/gerbil-manager-web/e2e/tiere.spec.ts b/gerbil-manager-web/e2e/tiere.spec.ts index ee7d4f0..10eb210 100644 --- a/gerbil-manager-web/e2e/tiere.spec.ts +++ b/gerbil-manager-web/e2e/tiere.spec.ts @@ -15,22 +15,33 @@ test('Liste lädt und die Namenssuche filtert', async ({ page }) => { await expect(page.getByRole('link', { name: /Fridolin/ })).toBeHidden() }) -test('Herkunft-Filter zeigt nur Tiere des gewählten Kontakts (SEARCH-2)', async ({ page }) => { +test('Herkunft-Filter (originBreeder) zeigt nur Tiere der gewählten Zucht (SEARCH-2b)', async ({ + page, +}) => { skipUnlessMock() await page.goto('/rennmaeuse') await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible() await expect(page.getByRole('link', { name: /Fridolin/ })).toBeVisible() - // Herkunft (originContactId) auf den Seed-Kontakt 'Zoohandlung Meier' (nur Krümel). + // Herkunft (originBreeder) auf die Seed-Zucht 'Clan-Kleine-Chaoten' (nur Krümel). await page .locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) }) .locator('select') - .selectOption({ label: 'Zoohandlung Meier' }) + .selectOption({ label: 'Clan-Kleine-Chaoten' }) await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible() await expect(page.getByRole('link', { name: /Fridolin/ })).toBeHidden() }) +test('Namenssuche ist trennzeichen-unempfindlich (SEARCH-2b)', async ({ page }) => { + skipUnlessMock() + await page.goto('/rennmaeuse') + // 'Krü mel' (mit Leerzeichen) wird normalisiert zu 'krümel' -> matcht Krümel. + await page.getByPlaceholder(t.searchPlaceholder).first().fill('Krü mel') + await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible() + await expect(page.getByRole('link', { name: /Fridolin/ })).toBeHidden() +}) + test('Neue Rennmaus anlegen — Validierung + Erfolg', async ({ page }) => { await gotoSection(page, de.nav.gerbils) await page.getByRole('link', { name: t.newButton }).click() diff --git a/gerbil-manager-web/src/api/gerbils.ts b/gerbil-manager-web/src/api/gerbils.ts index 184bbc4..f43ba0a 100644 --- a/gerbil-manager-web/src/api/gerbils.ts +++ b/gerbil-manager-web/src/api/gerbils.ts @@ -11,6 +11,11 @@ export function getGerbil(id: string): Promise { return api.get(`${resources.gerbils}/${id}`) } +/** SEARCH-2b: distinct non-empty originBreeder (Herkunft) values, sorted. */ +export function listOriginBreeders(): Promise { + return api.get(`${resources.gerbils}/breeders`) +} + export function createGerbil(body: CreateGerbil): Promise { return api.post(resources.gerbils, body) } diff --git a/gerbil-manager-web/src/api/types.ts b/gerbil-manager-web/src/api/types.ts index ebdedff..bcd7f6f 100644 --- a/gerbil-manager-web/src/api/types.ts +++ b/gerbil-manager-web/src/api/types.ts @@ -31,6 +31,13 @@ export interface Gerbil { colorVarietyId: string | null originContactId: string | null receiverContactId: string | null + /** SEARCH-1/2b: free-text Herkunft (Züchter) from the import; Gridify-filterable. */ + originBreeder?: string | null + /** + * SEARCH-1/2b: normalized name (lowercase, [\s._-] stripped) — filter-only, + * not part of the returned payload; present here so it can be a Gridify field. + */ + nameSearch?: string | null /** Compact GEN-1 genotype string, e.g. "Aa CC Dd EE GG Pp Spsp rere" (or "?"-wildcards). */ genotype: string | null notes: string | null @@ -56,6 +63,7 @@ export interface CreateGerbil { colorVarietyId?: string | null originContactId?: string | null receiverContactId?: string | null + originBreeder?: string | null genotype?: string | null notes?: string | null characterTraits?: string[] | null diff --git a/gerbil-manager-web/src/pages/GerbilsPage.tsx b/gerbil-manager-web/src/pages/GerbilsPage.tsx index 08691bf..5044ea2 100644 --- a/gerbil-manager-web/src/pages/GerbilsPage.tsx +++ b/gerbil-manager-web/src/pages/GerbilsPage.tsx @@ -1,8 +1,8 @@ import { useMemo, useState } from 'react' import { Link } from 'react-router-dom' import { de } from '../strings/de' -import { listGerbils, updateGerbil } from '../api/gerbils' -import { listColorVarieties, listContacts } from '../api/lookups' +import { listGerbils, listOriginBreeders, updateGerbil } from '../api/gerbils' +import { listColorVarieties } from '../api/lookups' import { andFilter, condition, type GridifyQuery } from '../api/gridify' import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types' import { useApi, useMutation } from '../hooks/useApi' @@ -11,10 +11,11 @@ import './gerbils.css' const PAGE_SIZE = 20 -// SEARCH-2: name search field. Switches to Pam's separator-insensitive -// normalized field once SEARCH-1 lands (see conversation SEARCH-1); 'name' -// works today via the contains hotfix. -const NAME_SEARCH_FIELD = 'name' +// SEARCH-2b: separator-insensitive search via Pam's normalized field. The term +// is normalized the SAME way server-side stores it (lowercase + strip [\s._-]), +// so 'clan kleine chaoten' matches 'clan-kleine-chaoten'. +const NAME_SEARCH_FIELD = 'nameSearch' +const normalizeSearch = (s: string): string => s.toLowerCase().replace(/[\s._-]/g, '') type SortKey = 'nameAsc' | 'nameDesc' | 'birthDesc' | 'birthAsc' const SORT_ORDER_BY: Record = { @@ -30,7 +31,7 @@ export default function GerbilsPage() { const [status, setStatus] = useState('Active') const [gender, setGender] = useState('') const [colorVarietyId, setColorVarietyId] = useState('') - const [originContactId, setOriginContactId] = useState('') + const [originBreeder, setOriginBreeder] = useState('') const [sort, setSort] = useState('nameAsc') const [page, setPage] = useState(1) @@ -40,14 +41,15 @@ export default function GerbilsPage() { for (const cv of colorVarieties.data ?? []) map.set(cv.id, cv.name) return map }, [colorVarieties.data]) - const contacts = useApi(() => listContacts(), []) + const breeders = useApi(() => listOriginBreeders(), []) const filter = andFilter( - search.trim() && condition({ field: NAME_SEARCH_FIELD, op: 'contains', value: search.trim() }), + search.trim() && + condition({ field: NAME_SEARCH_FIELD, op: 'contains', value: normalizeSearch(search) }), status && condition({ field: 'status', op: '==', value: status }), gender && condition({ field: 'gender', op: '==', value: gender }), colorVarietyId && condition({ field: 'colorVarietyId', op: '==', value: colorVarietyId }), - originContactId && condition({ field: 'originContactId', op: '==', value: originContactId }), + originBreeder && condition({ field: 'originBreeder', op: '==', value: originBreeder }), ) const orderBy = SORT_ORDER_BY[sort] const query: GridifyQuery = { filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE } @@ -59,7 +61,7 @@ export default function GerbilsPage() { setStatus('Active') setGender('') setColorVarietyId('') - setOriginContactId('') + setOriginBreeder('') setPage(1) setSort('nameAsc') } @@ -164,13 +166,13 @@ export default function GerbilsPage() {