From 2cef8a1169e95383a2ed01e6bc105e3570a6a88e Mon Sep 17 00:00:00 2001 From: Marcel Raddatz Date: Fri, 3 Apr 2026 10:17:17 +0200 Subject: [PATCH] =?UTF-8?q?feat(recipes):=20add=20RecipeForm=20component?= =?UTF-8?q?=20=E2=80=94=20add/edit=20two-state=20form?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/lib/recipes/RecipeForm.svelte | 134 ++++++++++++++++++ frontend/src/lib/recipes/RecipeForm.test.ts | 143 ++++++++++++++++++++ 2 files changed, 277 insertions(+) create mode 100644 frontend/src/lib/recipes/RecipeForm.svelte create mode 100644 frontend/src/lib/recipes/RecipeForm.test.ts diff --git a/frontend/src/lib/recipes/RecipeForm.svelte b/frontend/src/lib/recipes/RecipeForm.svelte new file mode 100644 index 0000000..f62df04 --- /dev/null +++ b/frontend/src/lib/recipes/RecipeForm.svelte @@ -0,0 +1,134 @@ + + +
+ + + + + + + + + + + +
+ Schwierigkeitsgrad + {#each effortOptions as opt (opt.value)} + + {/each} +
+ + +
+ Kategorien + {#each categories as cat (cat.id)} + + {/each} +
+ + +
+ Zutaten + {#each ingredients as ing, i (i)} +
+ + + + +
+ {/each} + +
+ + +
+ Schritte + {#each steps as _, i (i)} +
+ + +
+ {/each} + +
+ + + + + + + Abbrechen + +
diff --git a/frontend/src/lib/recipes/RecipeForm.test.ts b/frontend/src/lib/recipes/RecipeForm.test.ts new file mode 100644 index 0000000..3ecf980 --- /dev/null +++ b/frontend/src/lib/recipes/RecipeForm.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/svelte'; +import { userEvent } from '@testing-library/user-event'; +import RecipeForm from './RecipeForm.svelte'; + +const mockCategories = [ + { id: 'c1', name: 'Pasta', tagType: 'category' }, + { id: 'c2', name: 'Fleisch', tagType: 'category' } +]; + +const emptyProps = { + recipe: null, + categories: mockCategories, + action: '?/create' +}; + +const editProps = { + recipe: { + id: 'r1', + name: 'Spaghetti Bolognese', + serves: 4, + cookTimeMin: 30, + effort: 'Medium', + heroImageUrl: undefined as string | undefined, + ingredients: [ + { name: 'Spaghetti', quantity: 200, unit: 'g' } + ], + steps: [ + { instruction: 'Wasser aufsetzen' } + ], + tagIds: ['c1'] + }, + categories: mockCategories, + action: '?/update' +}; + +describe('RecipeForm', () => { + it('renders recipe name input', () => { + render(RecipeForm, { props: emptyProps }); + expect(screen.getByLabelText(/name/i)).toBeInTheDocument(); + }); + + it('renders serves input', () => { + render(RecipeForm, { props: emptyProps }); + expect(screen.getByLabelText(/portionen/i)).toBeInTheDocument(); + }); + + it('renders cook time input', () => { + render(RecipeForm, { props: emptyProps }); + expect(screen.getByLabelText(/kochzeit/i)).toBeInTheDocument(); + }); + + it('prefills name when editing', () => { + render(RecipeForm, { props: editProps }); + expect(screen.getByLabelText(/name/i)).toHaveValue('Spaghetti Bolognese'); + }); + + it('prefills serves when editing', () => { + render(RecipeForm, { props: editProps }); + expect(screen.getByLabelText(/portionen/i)).toHaveValue(4); + }); + + it('renders effort chips', () => { + render(RecipeForm, { props: emptyProps }); + expect(screen.getByRole('radio', { name: /leicht/i })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /mittel/i })).toBeInTheDocument(); + expect(screen.getByRole('radio', { name: /schwer/i })).toBeInTheDocument(); + }); + + it('prefills effort when editing', () => { + render(RecipeForm, { props: editProps }); + expect(screen.getByRole('radio', { name: /mittel/i })).toBeChecked(); + }); + + it('renders category chips', () => { + render(RecipeForm, { props: emptyProps }); + expect(screen.getByRole('checkbox', { name: 'Pasta' })).toBeInTheDocument(); + expect(screen.getByRole('checkbox', { name: 'Fleisch' })).toBeInTheDocument(); + }); + + it('prefills selected categories when editing', () => { + render(RecipeForm, { props: editProps }); + expect(screen.getByRole('checkbox', { name: 'Pasta' })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: 'Fleisch' })).not.toBeChecked(); + }); + + it('renders at least one ingredient row initially for empty form', () => { + render(RecipeForm, { props: emptyProps }); + expect(screen.getByPlaceholderText(/zutat/i)).toBeInTheDocument(); + }); + + it('prefills ingredient rows when editing', () => { + render(RecipeForm, { props: editProps }); + expect(screen.getByDisplayValue('Spaghetti')).toBeInTheDocument(); + }); + + it('adds ingredient row when "Zutat hinzufügen" is clicked', async () => { + const user = userEvent.setup(); + render(RecipeForm, { props: emptyProps }); + const before = screen.getAllByPlaceholderText(/zutat/i).length; + await user.click(screen.getByRole('button', { name: /zutat hinzufügen/i })); + expect(screen.getAllByPlaceholderText(/zutat/i)).toHaveLength(before + 1); + }); + + it('removes ingredient row when remove button is clicked', async () => { + const user = userEvent.setup(); + render(RecipeForm, { props: editProps }); + await user.click(screen.getByRole('button', { name: /zutat hinzufügen/i })); + const before = screen.getAllByPlaceholderText(/zutat/i).length; + const removeButtons = screen.getAllByRole('button', { name: /entfernen/i }); + await user.click(removeButtons[0]); + expect(screen.getAllByPlaceholderText(/zutat/i)).toHaveLength(before - 1); + }); + + it('renders at least one step row initially for empty form', () => { + render(RecipeForm, { props: emptyProps }); + expect(screen.getByPlaceholderText(/schritt/i)).toBeInTheDocument(); + }); + + it('prefills step rows when editing', () => { + render(RecipeForm, { props: editProps }); + expect(screen.getByDisplayValue('Wasser aufsetzen')).toBeInTheDocument(); + }); + + it('adds step row when "Schritt hinzufügen" is clicked', async () => { + const user = userEvent.setup(); + render(RecipeForm, { props: emptyProps }); + const before = screen.getAllByPlaceholderText(/schritt/i).length; + await user.click(screen.getByRole('button', { name: /schritt hinzufügen/i })); + expect(screen.getAllByPlaceholderText(/schritt/i)).toHaveLength(before + 1); + }); + + it('renders save button', () => { + render(RecipeForm, { props: emptyProps }); + expect(screen.getByRole('button', { name: /speichern/i })).toBeInTheDocument(); + }); + + it('renders cancel link back to /recipes', () => { + render(RecipeForm, { props: emptyProps }); + const cancelLink = screen.getByRole('link', { name: /abbrechen/i }); + expect(cancelLink).toHaveAttribute('href', '/recipes'); + }); +});