Merge feature/polish-1: canonical Gridify '=', InvalidParentGender German message, Abgabe-abschliessen->wizard wiring, de.ts sweep, e2e mock accepts both operators [god-QA: 55/47/58]
This commit is contained in:
2
gerbil-manager-web/.gitignore
vendored
2
gerbil-manager-web/.gitignore
vendored
@@ -26,3 +26,5 @@ dist-ssr
|
|||||||
# QA-1: Playwright-Artefakte
|
# QA-1: Playwright-Artefakte
|
||||||
test-results/
|
test-results/
|
||||||
playwright-report/
|
playwright-report/
|
||||||
|
blob-report/
|
||||||
|
playwright/.cache/
|
||||||
|
|||||||
@@ -20,7 +20,9 @@ const newId = (prefix: string) => `${prefix}-e2e-${++seq}`
|
|||||||
/** Gridify-Escapes entfernen (\, vor Sonderzeichen). */
|
/** Gridify-Escapes entfernen (\, vor Sonderzeichen). */
|
||||||
const unescapeGridify = (s: string) => s.replace(/\\(.)/g, '$1')
|
const unescapeGridify = (s: string) => s.replace(/\\(.)/g, '$1')
|
||||||
|
|
||||||
/** Mini-Gridify: genau die Ausdrücke, die die App baut (==, contains, ',', '|'). */
|
/** Mini-Gridify: genau die Ausdrücke, die die App baut (=, contains, ',', '|').
|
||||||
|
* Gleichheit akzeptiert '=' (kanonisch) UND '==' (alt) — POLISH-1 stellte den
|
||||||
|
* Client auf Gridifys echtes '=' um; der Mock bleibt für beide robust. */
|
||||||
function matchesFilter(row: Row, filter: string | null): boolean {
|
function matchesFilter(row: Row, filter: string | null): boolean {
|
||||||
if (!filter) return true
|
if (!filter) return true
|
||||||
return filter.split(',').every((andPart) =>
|
return filter.split(',').every((andPart) =>
|
||||||
@@ -31,7 +33,7 @@ function matchesFilter(row: Row, filter: string | null): boolean {
|
|||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.includes(unescapeGridify(m[2]).toLowerCase())
|
.includes(unescapeGridify(m[2]).toLowerCase())
|
||||||
}
|
}
|
||||||
m = cond.match(/^(\w+)==(.*)$/)
|
m = cond.match(/^(\w+)==?(.*)$/)
|
||||||
if (m) return String(row[m[1]] ?? '') === unescapeGridify(m[2])
|
if (m) return String(row[m[1]] ?? '') === unescapeGridify(m[2])
|
||||||
return true
|
return true
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -13,15 +13,27 @@ export class ApiError extends Error {
|
|||||||
readonly status: number | null
|
readonly status: number | null
|
||||||
/** The request path that failed (for diagnostics/logging). */
|
/** The request path that failed (for diagnostics/logging). */
|
||||||
readonly path?: string
|
readonly path?: string
|
||||||
|
/** Parsed error response body, if any (e.g. { code, ... } for 400s). */
|
||||||
|
readonly body?: unknown
|
||||||
|
|
||||||
constructor(message: string, status: number | null, path?: string) {
|
constructor(message: string, status: number | null, path?: string, body?: unknown) {
|
||||||
super(message)
|
super(message)
|
||||||
this.name = 'ApiError'
|
this.name = 'ApiError'
|
||||||
this.status = status
|
this.status = status
|
||||||
this.path = path
|
this.path = path
|
||||||
|
this.body = body
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read a `code` field off a parsed ApiError body, if present. */
|
||||||
|
export function errorCode(err: unknown): string | null {
|
||||||
|
if (err instanceof ApiError && err.body && typeof err.body === 'object' && 'code' in err.body) {
|
||||||
|
const code = (err.body as { code: unknown }).code
|
||||||
|
return typeof code === 'string' ? code : null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
function errorMessageFor(status: number): string {
|
function errorMessageFor(status: number): string {
|
||||||
if (status === 404) return de.api.errors.notFound
|
if (status === 404) return de.api.errors.notFound
|
||||||
if (status >= 500) return de.api.errors.server
|
if (status >= 500) return de.api.errors.server
|
||||||
@@ -40,7 +52,14 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new ApiError(errorMessageFor(response.status), response.status, path)
|
// Best-effort parse of a JSON error body (e.g. { code: "InvalidParentGender" }).
|
||||||
|
let body: unknown
|
||||||
|
try {
|
||||||
|
body = await response.clone().json()
|
||||||
|
} catch {
|
||||||
|
body = undefined
|
||||||
|
}
|
||||||
|
throw new ApiError(errorMessageFor(response.status), response.status, path, body)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
|
|||||||
@@ -47,8 +47,10 @@ export function condition(c: FilterCondition): string {
|
|||||||
const suffix = c.caseInsensitive === false ? '' : '/i'
|
const suffix = c.caseInsensitive === false ? '' : '/i'
|
||||||
return `${c.field}=*${escaped}*${suffix}`
|
return `${c.field}=*${escaped}*${suffix}`
|
||||||
}
|
}
|
||||||
|
// Gridify's equals operator is a single '='; callers use '==' semantically.
|
||||||
|
const op = c.op === '==' ? '=' : c.op
|
||||||
const suffix = c.caseInsensitive ? '/i' : ''
|
const suffix = c.caseInsensitive ? '/i' : ''
|
||||||
return `${c.field}${c.op}${escaped}${suffix}`
|
return `${c.field}${op}${escaped}${suffix}`
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Join conditions with AND (comma). Falsy/empty conditions are dropped. */
|
/** Join conditions with AND (comma). Falsy/empty conditions are dropped. */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
import JSZip from 'jszip'
|
import JSZip from 'jszip'
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
import type { Gerbil } from '../api/types'
|
import type { Gerbil } from '../api/types'
|
||||||
@@ -29,6 +30,7 @@ function groupGender(animals: Gerbil[]): string {
|
|||||||
|
|
||||||
export default function GroupComposer({ groupNumber, animals, farbschlagOf }: GroupComposerProps) {
|
export default function GroupComposer({ groupNumber, animals, farbschlagOf }: GroupComposerProps) {
|
||||||
const t = de.pages.abgabe
|
const t = de.pages.abgabe
|
||||||
|
const navigate = useNavigate()
|
||||||
const [status, setStatus] = useState<SaleStatus>('free')
|
const [status, setStatus] = useState<SaleStatus>('free')
|
||||||
const [reservedName, setReservedName] = useState('')
|
const [reservedName, setReservedName] = useState('')
|
||||||
const [tagline, setTagline] = useState('')
|
const [tagline, setTagline] = useState('')
|
||||||
@@ -244,7 +246,13 @@ export default function GroupComposer({ groupNumber, animals, farbschlagOf }: Gr
|
|||||||
<button type="button" className="btn" disabled={ai.pending} onClick={improveWithAi}>
|
<button type="button" className="btn" disabled={ai.pending} onClick={improveWithAi}>
|
||||||
{ai.pending ? t.ai.generating : t.ai.improve}
|
{ai.pending ? t.ai.generating : t.ai.improve}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn" title={t.export.finishHint} disabled>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/vertraege/neu?tiere=${animals.map((a) => a.id).join(',')}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
{t.export.finish}
|
{t.export.finish}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -181,7 +181,7 @@ export default function GerbilDetailPage() {
|
|||||||
<p className="muted">{t.detail.genotypeNotSet}</p>
|
<p className="muted">{t.detail.genotypeNotSet}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<h3 className="visually-hidden">Weitere Daten</h3>
|
<h3 className="visually-hidden">{t.detail.moreData}</h3>
|
||||||
<div className="tabs" role="tablist">
|
<div className="tabs" role="tablist">
|
||||||
{(['photos', 'health', 'weight'] as const).map((key) => (
|
{(['photos', 'health', 'weight'] as const).map((key) => (
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -225,7 +225,7 @@ export default function GerbilsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<nav className="pager" aria-label="Seitennavigation">
|
<nav className="pager" aria-label={de.common.pageNav}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="btn"
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ export default function WuerfeListPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<nav className="pager" aria-label="Seitennavigation">
|
<nav className="pager" aria-label={de.common.pageNav}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="btn"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useMemo, useState, type FormEvent } from 'react'
|
import { useMemo, useState, type FormEvent } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||||
import { de } from '../strings/de'
|
import { de } from '../strings/de'
|
||||||
|
import { errorCode } from '../api/client'
|
||||||
import { createLitter, getLitter, updateLitter } from '../api/litters'
|
import { createLitter, getLitter, updateLitter } from '../api/litters'
|
||||||
import { listGerbils } from '../api/gerbils'
|
import { listGerbils } from '../api/gerbils'
|
||||||
import type { CreateLitter } from '../api/types'
|
import type { CreateLitter } from '../api/types'
|
||||||
@@ -49,6 +50,7 @@ export default function WurfFormPage() {
|
|||||||
|
|
||||||
const [form, setForm] = useState<FormState>(EMPTY)
|
const [form, setForm] = useState<FormState>(EMPTY)
|
||||||
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({})
|
const [errors, setErrors] = useState<Partial<Record<keyof FormState, string>>>({})
|
||||||
|
const [submitError, setSubmitError] = useState<string | null>(null)
|
||||||
const [goHomeTouched, setGoHomeTouched] = useState(false)
|
const [goHomeTouched, setGoHomeTouched] = useState(false)
|
||||||
const [initializedFor, setInitializedFor] = useState<string | null>(null)
|
const [initializedFor, setInitializedFor] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -116,9 +118,18 @@ export default function WurfFormPage() {
|
|||||||
expectedGoHomeDate: nn(form.expectedGoHomeDate),
|
expectedGoHomeDate: nn(form.expectedGoHomeDate),
|
||||||
notes: nn(form.notes),
|
notes: nn(form.notes),
|
||||||
}
|
}
|
||||||
|
setSubmitError(null)
|
||||||
const result = await mutation.run(body)
|
const result = await mutation.run(body)
|
||||||
if (result.ok) navigate(`/wuerfe/${result.value.id}`)
|
if (result.ok) {
|
||||||
// On failure mutation.error drives the inline alert; run() never throws.
|
navigate(`/wuerfe/${result.value.id}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Localize the backend's parent-gender 400 by its code; else generic message.
|
||||||
|
setSubmitError(
|
||||||
|
errorCode(result.cause) === 'InvalidParentGender'
|
||||||
|
? t.validation.invalidParentGender
|
||||||
|
: result.error,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
|
if (isEdit && existing.loading) return <p className="muted">{de.common.loading}</p>
|
||||||
@@ -223,7 +234,7 @@ export default function WurfFormPage() {
|
|||||||
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
<textarea value={form.notes} onChange={(e) => set('notes', e.target.value)} />
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
{mutation.error && <div className="alert alert--error">{mutation.error}</div>}
|
{submitError && <div className="alert alert--error">{submitError}</div>}
|
||||||
|
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button type="submit" className="btn btn--primary" disabled={mutation.pending}>
|
<button type="submit" className="btn btn--primary" disabled={mutation.pending}>
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export const de = {
|
|||||||
},
|
},
|
||||||
tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.',
|
tabPlaceholder: 'Dieser Bereich entsteht in einem späteren Schritt.',
|
||||||
notFound: 'Diese Rennmaus wurde nicht gefunden.',
|
notFound: 'Diese Rennmaus wurde nicht gefunden.',
|
||||||
|
moreData: 'Weitere Daten',
|
||||||
},
|
},
|
||||||
// Formular (anlegen/bearbeiten)
|
// Formular (anlegen/bearbeiten)
|
||||||
form: {
|
form: {
|
||||||
@@ -184,6 +185,7 @@ export const de = {
|
|||||||
validation: {
|
validation: {
|
||||||
nameRequired: 'Bitte eine Bezeichnung eingeben.',
|
nameRequired: 'Bitte eine Bezeichnung eingeben.',
|
||||||
dateRequired: 'Bitte ein Wurfdatum angeben.',
|
dateRequired: 'Bitte ein Wurfdatum angeben.',
|
||||||
|
invalidParentGender: 'Der Vater muss männlich und die Mutter weiblich sein.',
|
||||||
},
|
},
|
||||||
// Zuchtpaar-Übersicht
|
// Zuchtpaar-Übersicht
|
||||||
pairs: {
|
pairs: {
|
||||||
@@ -610,6 +612,7 @@ export const de = {
|
|||||||
no: 'Nein',
|
no: 'Nein',
|
||||||
delete: 'Löschen',
|
delete: 'Löschen',
|
||||||
confirmDelete: 'Wirklich löschen?',
|
confirmDelete: 'Wirklich löschen?',
|
||||||
|
pageNav: 'Seitennavigation',
|
||||||
},
|
},
|
||||||
// Genetik-Warnungen: der Engine (src/genetics) liefert nur CODES,
|
// Genetik-Warnungen: der Engine (src/genetics) liefert nur CODES,
|
||||||
// die deutschen Texte stehen hier. Schlüssel = GeneticsWarningCode-Werte.
|
// die deutschen Texte stehen hier. Schlüssel = GeneticsWarningCode-Werte.
|
||||||
|
|||||||
Reference in New Issue
Block a user