feat(recipes): implement recipe library page with search and effort filtering
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1 +1,54 @@
|
|||||||
<h1 class="text-2xl font-medium p-6">Rezepte</h1>
|
<script lang="ts">
|
||||||
|
import FilterChipRow from '$lib/recipes/FilterChipRow.svelte';
|
||||||
|
import RecipeGrid from '$lib/recipes/RecipeGrid.svelte';
|
||||||
|
|
||||||
|
type RecipeSummary = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
cookTimeMin?: number;
|
||||||
|
effort?: string;
|
||||||
|
heroImageUrl?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { data }: { data: { recipes: RecipeSummary[] } } = $props();
|
||||||
|
|
||||||
|
let searchQuery = $state('');
|
||||||
|
let activeFilter = $state('Alle');
|
||||||
|
|
||||||
|
const effortMap: Record<string, string> = {
|
||||||
|
Leicht: 'Easy',
|
||||||
|
Mittel: 'Medium',
|
||||||
|
Schwer: 'Hard'
|
||||||
|
};
|
||||||
|
|
||||||
|
let filteredRecipes = $derived(
|
||||||
|
data.recipes
|
||||||
|
.filter((r) => {
|
||||||
|
if (activeFilter === 'Alle') return true;
|
||||||
|
return r.effort === effortMap[activeFilter];
|
||||||
|
})
|
||||||
|
.filter((r) => r.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Rezepte — Mealplan</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h1 class="text-2xl font-medium">Rezepte</h1>
|
||||||
|
<a href="/recipes/new" class="btn variant-filled-primary">Rezept hinzufügen</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="search"
|
||||||
|
placeholder="Suchen…"
|
||||||
|
class="input"
|
||||||
|
bind:value={searchQuery}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilterChipRow {activeFilter} onFilter={(f) => (activeFilter = f)} />
|
||||||
|
|
||||||
|
<RecipeGrid recipes={filteredRecipes} />
|
||||||
|
</div>
|
||||||
|
|||||||
74
frontend/src/routes/(app)/recipes/page.test.ts
Normal file
74
frontend/src/routes/(app)/recipes/page.test.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import { userEvent } from '@testing-library/user-event';
|
||||||
|
import Page from './+page.svelte';
|
||||||
|
|
||||||
|
const mockData = {
|
||||||
|
recipes: [
|
||||||
|
{ id: 'r1', name: 'Spaghetti Bolognese', cookTimeMin: 30, effort: 'Easy' },
|
||||||
|
{ id: 'r2', name: 'Chicken Curry', cookTimeMin: 45, effort: 'Medium' },
|
||||||
|
{ id: 'r3', name: 'Gemüsesuppe', cookTimeMin: 20, effort: 'Easy' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('recipe library page', () => {
|
||||||
|
it('renders all recipe cards initially', () => {
|
||||||
|
render(Page, { props: { data: mockData } });
|
||||||
|
expect(screen.getByText('Spaghetti Bolognese')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Chicken Curry')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Gemüsesuppe')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the page title', () => {
|
||||||
|
render(Page, { props: { data: mockData } });
|
||||||
|
expect(document.title).toBe('Rezepte — Mealplan');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a link to add a new recipe', () => {
|
||||||
|
render(Page, { props: { data: mockData } });
|
||||||
|
const addLink = screen.getByRole('link', { name: /rezept hinzufügen/i });
|
||||||
|
expect(addLink).toHaveAttribute('href', '/recipes/new');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters recipes by search term', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(Page, { props: { data: mockData } });
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText(/suchen/i);
|
||||||
|
await user.type(searchInput, 'Curry');
|
||||||
|
|
||||||
|
expect(screen.getByText('Chicken Curry')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Spaghetti Bolognese')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters recipes by effort chip', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(Page, { props: { data: mockData } });
|
||||||
|
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Mittel' }));
|
||||||
|
|
||||||
|
expect(screen.getByText('Chicken Curry')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Spaghetti Bolognese')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when no recipes match search', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(Page, { props: { data: mockData } });
|
||||||
|
|
||||||
|
const searchInput = screen.getByPlaceholderText(/suchen/i);
|
||||||
|
await user.type(searchInput, 'xyznotexist');
|
||||||
|
|
||||||
|
expect(screen.getByText(/keine rezepte/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders filter chips', () => {
|
||||||
|
render(Page, { props: { data: mockData } });
|
||||||
|
expect(screen.getByRole('button', { name: 'Alle' })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: 'Leicht' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders empty state page when no recipes at all', () => {
|
||||||
|
render(Page, { props: { data: { recipes: [] } } });
|
||||||
|
expect(screen.getByText(/keine rezepte/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user