diff --git a/gerbil-manager-web/e2e/anfragen.spec.ts b/gerbil-manager-web/e2e/anfragen.spec.ts index 6e58d73..ce9a521 100644 --- a/gerbil-manager-web/e2e/anfragen.spec.ts +++ b/gerbil-manager-web/e2e/anfragen.spec.ts @@ -3,7 +3,7 @@ * AiKeyMissing-Hinweis), Senden (inkl. MailNotConfigured-Hinweis). * Mock-gebunden (Seed-Anfragen + Fehlerpfad-Flags) → skipUnlessMock. */ -import { acceptNextDialog, de, expect, gotoSection, skipUnlessMock, test } from './fixtures' +import { acceptNextDialog, de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures' const ta = de.pages.anfragen const td = ta.detail @@ -23,6 +23,8 @@ test.describe('Anfragen', () => { // Status-Badge auf der Karte await expect(cards.nth(0)).toContainText(ta.statusLabels.New) + // UX-MOBILE-1: Status-Select liegt im Filter-Drawer — auf Mobil erst öffnen. + await openFilterPanel(page) // Filter: nur Beantwortet await page.getByLabel(td.statusLabel).selectOption('Answered') await expect(cards).toHaveCount(1) diff --git a/gerbil-manager-web/e2e/bestand.spec.ts b/gerbil-manager-web/e2e/bestand.spec.ts index 2718a70..dc7dc59 100644 --- a/gerbil-manager-web/e2e/bestand.spec.ts +++ b/gerbil-manager-web/e2e/bestand.spec.ts @@ -1,5 +1,5 @@ /** BESTAND-FILTER: die Tiere-Liste zeigt standardmäßig nur den eigenen Bestand. */ -import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures' +import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test } from './fixtures' const t = de.pages.gerbils @@ -14,11 +14,13 @@ test('Tiere-Liste blendet externe Ahnen standardmäßig aus', async ({ page }) = await expect(page.locator('.gerbil-row', { hasText: 'Max' })).toHaveCount(0) }) -test('Toggle „Externe Ahnen einblenden“ zeigt externe Tiere mit Extern-Markierung', async ({ page }) => { +test('Toggle „Externe Ahnen einblenden” zeigt externe Tiere mit Extern-Markierung', async ({ page }) => { skipUnlessMock() await gotoSection(page, de.nav.gerbils) await expect(page.locator('.gerbil-row', { hasText: 'Krümel' })).toBeVisible() + // UX-MOBILE-1: Checkbox liegt im Filter-Drawer — auf Mobil erst öffnen. + await openFilterPanel(page) await page.getByRole('checkbox', { name: t.filters.showExternal }).check() const maxRow = page.locator('.gerbil-row', { hasText: 'Max' }) diff --git a/gerbil-manager-web/e2e/filter-panel.spec.ts b/gerbil-manager-web/e2e/filter-panel.spec.ts new file mode 100644 index 0000000..7ba4976 --- /dev/null +++ b/gerbil-manager-web/e2e/filter-panel.spec.ts @@ -0,0 +1,85 @@ +/** + * UX-MOBILE-1: FilterPanel — einklappbare Filter auf Smartphone, inline auf Desktop. + * + * Telefon (390px): Filter-Button sichtbar, Drawer eingeklappt; Tippen öffnet/schliesst. + * Desktop (1280px): Alle Controls direkt sichtbar, kein Toggle-Button. + */ +import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures' + +const t = de.pages.gerbils + +test.describe('FilterPanel – Rennmäuse-Liste', () => { + test('Phone: Filter-Drawer standardmäßig eingeklappt, Toggle-Button sichtbar', async ({ + page, + }, testInfo) => { + skipUnlessMock() + if (testInfo.project.name !== 'phone') return + + await gotoSection(page, de.nav.gerbils) + await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible() + + // Toggle-Button sichtbar. + const toggle = page.locator('.filter-panel__toggle') + await expect(toggle).toBeVisible() + + // Status-Beschriftung im Drawer ist noch verborgen. + await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible() + }) + + test('Phone: Toggle öffnet und schließt den Filter-Drawer', async ({ + page, + }, testInfo) => { + skipUnlessMock() + if (testInfo.project.name !== 'phone') return + + await gotoSection(page, de.nav.gerbils) + const toggle = page.locator('.filter-panel__toggle') + + // Öffnen → Status-Feld wird sichtbar. + await toggle.click() + await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible() + + // Schließen → wieder verborgen. + await toggle.click() + await expect(page.getByText(t.filters.status, { exact: true }).first()).not.toBeVisible() + }) + + test('Phone: Badge zählt aktive Filter korrekt', async ({ + page, + }, testInfo) => { + skipUnlessMock() + if (testInfo.project.name !== 'phone') return + + await gotoSection(page, de.nav.gerbils) + const toggle = page.locator('.filter-panel__toggle') + + // Initial: Status='Active' ist Default → Badge zeigt kein „(N)". + await expect(toggle).toHaveText('Filter') + + // Filter-Drawer öffnen und Geschlecht setzen → 1 aktiver Filter. + await toggle.click() + await page.locator('.filter-panel__drawer select').nth(1).selectOption('male') + await expect(toggle).toHaveText('Filter (1)') + + // Zurücksetzen → Badge weg. + await page.getByRole('button', { name: de.filterPanel.resetButton }).click() + await expect(toggle).toHaveText('Filter') + }) + + test('Desktop: Toggle-Button nicht sichtbar, alle Controls inline', async ({ + page, + }, testInfo) => { + skipUnlessMock() + if (testInfo.project.name !== 'desktop') return + + await gotoSection(page, de.nav.gerbils) + await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible() + + // Kein Toggle-Button auf Desktop (display:none via Media Query). + const toggle = page.locator('.filter-panel__toggle') + await expect(toggle).toBeHidden() + + // Status-Beschriftung direkt sichtbar. + await expect(page.getByText(t.filters.status, { exact: true }).first()).toBeVisible() + }) +}) diff --git a/gerbil-manager-web/e2e/fixtures.ts b/gerbil-manager-web/e2e/fixtures.ts index 358f507..6898651 100644 --- a/gerbil-manager-web/e2e/fixtures.ts +++ b/gerbil-manager-web/e2e/fixtures.ts @@ -63,6 +63,18 @@ export function acceptNextDialog(page: Page) { page.once('dialog', (d) => void d.accept()) } +/** + * UX-MOBILE-1: Filter-Drawer öffnen, falls der Toggle-Button sichtbar ist + * (= Smartphone-Ansicht). Auf Desktop-Ansicht ist er per CSS versteckt, dann + * kein Klick nötig — Controls sind direkt sichtbar. + */ +export async function openFilterPanel(page: Page) { + const toggle = page.locator('.filter-panel__toggle') + if (await toggle.isVisible()) { + await toggle.click() + } +} + /** Eindeutiger Name für LIVE-taugliche Create-Flows. */ export const uniqueName = (prefix: string) => `${prefix} E2E ${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}` diff --git a/gerbil-manager-web/e2e/tiere.spec.ts b/gerbil-manager-web/e2e/tiere.spec.ts index 10eb210..7fb571a 100644 --- a/gerbil-manager-web/e2e/tiere.spec.ts +++ b/gerbil-manager-web/e2e/tiere.spec.ts @@ -1,5 +1,5 @@ /** QA-1: Tiere (Rennmäuse) — Liste/Suche, CRUD, Detail-Tabs (FEAT-1 + FEAT-6). */ -import { de, expect, gotoSection, skipUnlessMock, test, uniqueName } from './fixtures' +import { de, expect, gotoSection, openFilterPanel, skipUnlessMock, test, uniqueName } from './fixtures' const t = de.pages.gerbils const tabs = de.pages.tierTabs @@ -23,6 +23,8 @@ test('Herkunft-Filter (originBreeder) zeigt nur Tiere der gewählten Zucht (SEAR await expect(page.getByRole('link', { name: /Krümel/ })).toBeVisible() await expect(page.getByRole('link', { name: /Fridolin/ })).toBeVisible() + // UX-MOBILE-1: Herkunft-Select liegt im Filter-Drawer — auf Mobil erst öffnen. + await openFilterPanel(page) // 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}")`) }) diff --git a/gerbil-manager-web/src/components/FilterPanel.tsx b/gerbil-manager-web/src/components/FilterPanel.tsx new file mode 100644 index 0000000..67d6766 --- /dev/null +++ b/gerbil-manager-web/src/components/FilterPanel.tsx @@ -0,0 +1,52 @@ +import { useState, type ReactNode } from 'react' +import { de } from '../strings/de' +import './filterPanel.css' + +interface FilterPanelProps { + /** Always visible on mobile (typically the search text input). Optional. */ + searchField?: ReactNode + /** Collapsible filters (hidden behind toggle on mobile; inline on desktop). */ + children: ReactNode + /** Number of currently active (non-default) filter values. Shown as badge. */ + activeCount: number + /** Called when the reset button is clicked. */ + onReset: () => void +} + +/** + * UX-MOBILE-1: wraps a set of filter controls so they collapse on mobile. + * Render inside the existing `.filters` div (or replace it entirely). + * + * Desktop (>=768px): renders all children inline, identical to today. + * Mobile (<768px): shows searchField + a "Filter (N)" toggle; tapping reveals + * the rest of the controls in a column drawer + a reset button. + */ +export function FilterPanel({ searchField, children, activeCount, onReset }: FilterPanelProps) { + const [open, setOpen] = useState(false) + const t = de.filterPanel + + const label = activeCount > 0 ? `${t.toggleButton} (${activeCount})` : t.toggleButton + + return ( +
{de.common.loading}
} {requests.error && ( diff --git a/gerbil-manager-web/src/pages/GerbilsPage.tsx b/gerbil-manager-web/src/pages/GerbilsPage.tsx index 9d90657..0f14964 100644 --- a/gerbil-manager-web/src/pages/GerbilsPage.tsx +++ b/gerbil-manager-web/src/pages/GerbilsPage.tsx @@ -7,6 +7,7 @@ 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' import { formatDate, genderLabel, statusLabel } from '../format/labels' +import { FilterPanel } from '../components/FilterPanel' import './gerbils.css' const PAGE_SIZE = 20 @@ -82,6 +83,14 @@ export default function GerbilsPage() { const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)) const items = gerbils.data?.items ?? [] + // UX-MOBILE-1: count non-default filter values for the badge. + const activeFilterCount = + (status !== 'Active' ? 1 : 0) + + (gender !== '' ? 1 : 0) + + (colorVarietyId !== '' ? 1 : 0) + + (originBreeder !== '' ? 1 : 0) + + (showExternal ? 1 : 0) + // Multi-select bulk "Zur Abgabe stellen". const [selected, setSelected] = useState{de.common.loading}
} {gerbils.error && ( diff --git a/gerbil-manager-web/src/pages/WuerfeListPage.tsx b/gerbil-manager-web/src/pages/WuerfeListPage.tsx index d0bdda7..8e64500 100644 --- a/gerbil-manager-web/src/pages/WuerfeListPage.tsx +++ b/gerbil-manager-web/src/pages/WuerfeListPage.tsx @@ -7,6 +7,7 @@ import { andFilter, condition, type GridifyQuery } from '../api/gridify' import type { Litter } from '../api/types' import { useApi } from '../hooks/useApi' import { formatDate } from '../format/labels' +import { FilterPanel } from '../components/FilterPanel' const PAGE_SIZE = 20 @@ -111,7 +112,10 @@ export default function WuerfeListPage() { {tab === 'litters' && ( <> -{de.common.loading}
} {litters.error && ( diff --git a/gerbil-manager-web/src/strings/de.ts b/gerbil-manager-web/src/strings/de.ts index 37f724c..2fdb0a0 100644 --- a/gerbil-manager-web/src/strings/de.ts +++ b/gerbil-manager-web/src/strings/de.ts @@ -807,6 +807,12 @@ export const de = { { key: 'schreckhaft', label: 'schreckhaft' }, ], }, + // ── UX-MOBILE-1 (Kevin): FilterPanel — einklappbare Filter auf Mobil ── + filterPanel: { + toggleButton: 'Filter', + resetButton: 'Filter zurücksetzen', + closeButton: 'Schließen', + }, } as const export type Strings = typeof de