Merge feature/search-2b: Herkunft filter -> originBreeder + /gerbils/breeders; name search -> nameSearch (separator-insensitive) [god-QA: 47/build/e2e 62]
This commit is contained in:
@@ -135,6 +135,19 @@ export async function installMockApi(page: Page): Promise<MockDb> {
|
|||||||
if (method === 'PUT') return json(route, 204)
|
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)
|
// Sonderrouten zuerst (FEAT-1b/FEAT-4-Verträge)
|
||||||
let m = path.match(/^\/gerbils\/([^/]+)\/photos$/)
|
let m = path.match(/^\/gerbils\/([^/]+)\/photos$/)
|
||||||
if (m) {
|
if (m) {
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ function gerbil(
|
|||||||
colorVarietyId,
|
colorVarietyId,
|
||||||
originContactId: null,
|
originContactId: null,
|
||||||
receiverContactId: null,
|
receiverContactId: null,
|
||||||
|
originBreeder: null,
|
||||||
|
// SEARCH-2b: backend stores a normalized name (lowercase, [\s._-] stripped).
|
||||||
|
nameSearch: name.toLowerCase().replace(/[\s._-]/g, ''),
|
||||||
genotype,
|
genotype,
|
||||||
notes: null,
|
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'),
|
...gerbil('kruemel', 'Krümel', 'female', '2025-03-12', 'w-kruemel', 'cv-agouti', 'Aa CC DD EE GG Pp spsp rere'),
|
||||||
enclosureId: 'enc-gross',
|
enclosureId: 'enc-gross',
|
||||||
originContactId: 'con-meier',
|
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('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('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'),
|
gerbil('maja', 'Maja', 'female', '2021-06-11', null, 'cv-schwarz-schecke', 'aa CC DD EE GG PP Spsp rere'),
|
||||||
|
|||||||
@@ -15,22 +15,33 @@ test('Liste lädt und die Namenssuche filtert', async ({ page }) => {
|
|||||||
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeHidden()
|
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()
|
skipUnlessMock()
|
||||||
await page.goto('/rennmaeuse')
|
await page.goto('/rennmaeuse')
|
||||||
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
|
await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible()
|
||||||
await expect(page.getByRole('link', { name: /Fridolin/ })).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
|
await page
|
||||||
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) })
|
.locator('label.field', { has: page.locator(`span:text-is("${t.fields.origin}")`) })
|
||||||
.locator('select')
|
.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: /Krümel/ })).toBeVisible()
|
||||||
await expect(page.getByRole('link', { name: /Fridolin/ })).toBeHidden()
|
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 }) => {
|
test('Neue Rennmaus anlegen — Validierung + Erfolg', async ({ page }) => {
|
||||||
await gotoSection(page, de.nav.gerbils)
|
await gotoSection(page, de.nav.gerbils)
|
||||||
await page.getByRole('link', { name: t.newButton }).click()
|
await page.getByRole('link', { name: t.newButton }).click()
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ export function getGerbil(id: string): Promise<Gerbil> {
|
|||||||
return api.get<Gerbil>(`${resources.gerbils}/${id}`)
|
return api.get<Gerbil>(`${resources.gerbils}/${id}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** SEARCH-2b: distinct non-empty originBreeder (Herkunft) values, sorted. */
|
||||||
|
export function listOriginBreeders(): Promise<string[]> {
|
||||||
|
return api.get<string[]>(`${resources.gerbils}/breeders`)
|
||||||
|
}
|
||||||
|
|
||||||
export function createGerbil(body: CreateGerbil): Promise<Gerbil> {
|
export function createGerbil(body: CreateGerbil): Promise<Gerbil> {
|
||||||
return api.post<Gerbil>(resources.gerbils, body)
|
return api.post<Gerbil>(resources.gerbils, body)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ export interface Gerbil {
|
|||||||
colorVarietyId: string | null
|
colorVarietyId: string | null
|
||||||
originContactId: string | null
|
originContactId: string | null
|
||||||
receiverContactId: 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). */
|
/** Compact GEN-1 genotype string, e.g. "Aa CC Dd EE GG Pp Spsp rere" (or "?"-wildcards). */
|
||||||
genotype: string | null
|
genotype: string | null
|
||||||
notes: string | null
|
notes: string | null
|
||||||
@@ -56,6 +63,7 @@ export interface CreateGerbil {
|
|||||||
colorVarietyId?: string | null
|
colorVarietyId?: string | null
|
||||||
originContactId?: string | null
|
originContactId?: string | null
|
||||||
receiverContactId?: string | null
|
receiverContactId?: string | null
|
||||||
|
originBreeder?: string | null
|
||||||
genotype?: string | null
|
genotype?: string | null
|
||||||
notes?: string | null
|
notes?: string | null
|
||||||
characterTraits?: string[] | null
|
characterTraits?: string[] | null
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import { listGerbils, updateGerbil } from '../api/gerbils'
|
import { listGerbils, listOriginBreeders, updateGerbil } from '../api/gerbils'
|
||||||
import { listColorVarieties, listContacts } from '../api/lookups'
|
import { listColorVarieties } from '../api/lookups'
|
||||||
import { andFilter, condition, type GridifyQuery } from '../api/gridify'
|
import { andFilter, condition, type GridifyQuery } from '../api/gridify'
|
||||||
import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types'
|
import { GENDERS, GERBIL_STATUSES, type Gender, type GerbilStatus } from '../api/types'
|
||||||
import { useApi, useMutation } from '../hooks/useApi'
|
import { useApi, useMutation } from '../hooks/useApi'
|
||||||
@@ -11,10 +11,11 @@ import './gerbils.css'
|
|||||||
|
|
||||||
const PAGE_SIZE = 20
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
// SEARCH-2: name search field. Switches to Pam's separator-insensitive
|
// SEARCH-2b: separator-insensitive search via Pam's normalized field. The term
|
||||||
// normalized field once SEARCH-1 lands (see conversation SEARCH-1); 'name'
|
// is normalized the SAME way server-side stores it (lowercase + strip [\s._-]),
|
||||||
// works today via the contains hotfix.
|
// so 'clan kleine chaoten' matches 'clan-kleine-chaoten'.
|
||||||
const NAME_SEARCH_FIELD = 'name'
|
const NAME_SEARCH_FIELD = 'nameSearch'
|
||||||
|
const normalizeSearch = (s: string): string => s.toLowerCase().replace(/[\s._-]/g, '')
|
||||||
|
|
||||||
type SortKey = 'nameAsc' | 'nameDesc' | 'birthDesc' | 'birthAsc'
|
type SortKey = 'nameAsc' | 'nameDesc' | 'birthDesc' | 'birthAsc'
|
||||||
const SORT_ORDER_BY: Record<SortKey, string> = {
|
const SORT_ORDER_BY: Record<SortKey, string> = {
|
||||||
@@ -30,7 +31,7 @@ export default function GerbilsPage() {
|
|||||||
const [status, setStatus] = useState<GerbilStatus | ''>('Active')
|
const [status, setStatus] = useState<GerbilStatus | ''>('Active')
|
||||||
const [gender, setGender] = useState<Gender | ''>('')
|
const [gender, setGender] = useState<Gender | ''>('')
|
||||||
const [colorVarietyId, setColorVarietyId] = useState('')
|
const [colorVarietyId, setColorVarietyId] = useState('')
|
||||||
const [originContactId, setOriginContactId] = useState('')
|
const [originBreeder, setOriginBreeder] = useState('')
|
||||||
const [sort, setSort] = useState<SortKey>('nameAsc')
|
const [sort, setSort] = useState<SortKey>('nameAsc')
|
||||||
const [page, setPage] = useState(1)
|
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)
|
for (const cv of colorVarieties.data ?? []) map.set(cv.id, cv.name)
|
||||||
return map
|
return map
|
||||||
}, [colorVarieties.data])
|
}, [colorVarieties.data])
|
||||||
const contacts = useApi(() => listContacts(), [])
|
const breeders = useApi(() => listOriginBreeders(), [])
|
||||||
|
|
||||||
const filter = andFilter(
|
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 }),
|
status && condition({ field: 'status', op: '==', value: status }),
|
||||||
gender && condition({ field: 'gender', op: '==', value: gender }),
|
gender && condition({ field: 'gender', op: '==', value: gender }),
|
||||||
colorVarietyId && condition({ field: 'colorVarietyId', op: '==', value: colorVarietyId }),
|
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 orderBy = SORT_ORDER_BY[sort]
|
||||||
const query: GridifyQuery = { filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }
|
const query: GridifyQuery = { filter: filter || undefined, orderBy, page, pageSize: PAGE_SIZE }
|
||||||
@@ -59,7 +61,7 @@ export default function GerbilsPage() {
|
|||||||
setStatus('Active')
|
setStatus('Active')
|
||||||
setGender('')
|
setGender('')
|
||||||
setColorVarietyId('')
|
setColorVarietyId('')
|
||||||
setOriginContactId('')
|
setOriginBreeder('')
|
||||||
setPage(1)
|
setPage(1)
|
||||||
setSort('nameAsc')
|
setSort('nameAsc')
|
||||||
}
|
}
|
||||||
@@ -164,13 +166,13 @@ export default function GerbilsPage() {
|
|||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t.fields.origin}</span>
|
<span>{t.fields.origin}</span>
|
||||||
<select
|
<select
|
||||||
value={originContactId}
|
value={originBreeder}
|
||||||
onChange={(e) => onFilterChange(setOriginContactId)(e.target.value)}
|
onChange={(e) => onFilterChange(setOriginBreeder)(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="">{t.filters.all}</option>
|
<option value="">{t.filters.all}</option>
|
||||||
{(contacts.data ?? []).map((c) => (
|
{(breeders.data ?? []).map((b) => (
|
||||||
<option key={c.id} value={c.id}>
|
<option key={b} value={b}>
|
||||||
{c.name}
|
{b}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
Reference in New Issue
Block a user