feat(recipes): add RecipeForm component — add/edit two-state form
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
134
frontend/src/lib/recipes/RecipeForm.svelte
Normal file
134
frontend/src/lib/recipes/RecipeForm.svelte
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
type Category = { id: string; name: string; tagType?: string };
|
||||||
|
|
||||||
|
type EditRecipe = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
serves?: number;
|
||||||
|
cookTimeMin?: number;
|
||||||
|
effort?: string;
|
||||||
|
heroImageUrl?: string;
|
||||||
|
ingredients: { name: string; quantity: number; unit: string }[];
|
||||||
|
steps: { instruction: string }[];
|
||||||
|
tagIds: string[];
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
const { recipe, categories, action }: {
|
||||||
|
recipe: EditRecipe;
|
||||||
|
categories: Category[];
|
||||||
|
action: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const effortOptions = [
|
||||||
|
{ label: 'Leicht', value: 'Easy' },
|
||||||
|
{ label: 'Mittel', value: 'Medium' },
|
||||||
|
{ label: 'Schwer', value: 'Hard' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Take a plain snapshot of the initial prop value so local state is
|
||||||
|
// initialised once and not tied to ongoing prop reactivity.
|
||||||
|
const initial = (() => $state.snapshot(recipe))();
|
||||||
|
|
||||||
|
let name = $state(initial?.name ?? '');
|
||||||
|
let serves = $state<number | ''>(initial?.serves ?? '');
|
||||||
|
let cookTimeMin = $state<number | ''>(initial?.cookTimeMin ?? '');
|
||||||
|
let effort = $state(initial?.effort ?? '');
|
||||||
|
let selectedTagIds = $state<string[]>(initial?.tagIds ? [...initial.tagIds] : []);
|
||||||
|
let ingredients = $state(
|
||||||
|
initial?.ingredients.map((ing) => ({
|
||||||
|
name: ing.name,
|
||||||
|
quantity: ing.quantity as number | '',
|
||||||
|
unit: ing.unit
|
||||||
|
})) ?? [{ name: '', quantity: '' as number | '', unit: '' }]
|
||||||
|
);
|
||||||
|
let steps = $state(initial?.steps.map((s) => s.instruction) ?? ['']);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<form method="POST" {action}>
|
||||||
|
<!-- Basic info -->
|
||||||
|
<label for="name">Name</label>
|
||||||
|
<input id="name" name="name" type="text" bind:value={name} required />
|
||||||
|
|
||||||
|
<label for="serves">Portionen</label>
|
||||||
|
<input id="serves" name="serves" type="number" bind:value={serves} />
|
||||||
|
|
||||||
|
<label for="cookTimeMin">Kochzeit</label>
|
||||||
|
<input id="cookTimeMin" name="cookTimeMin" type="number" bind:value={cookTimeMin} />
|
||||||
|
|
||||||
|
<!-- Effort chips -->
|
||||||
|
<fieldset>
|
||||||
|
<legend>Schwierigkeitsgrad</legend>
|
||||||
|
{#each effortOptions as opt (opt.value)}
|
||||||
|
<label>
|
||||||
|
<input type="radio" name="effort" value={opt.value} bind:group={effort} />
|
||||||
|
{opt.label}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- Category chips -->
|
||||||
|
<fieldset>
|
||||||
|
<legend>Kategorien</legend>
|
||||||
|
{#each categories as cat (cat.id)}
|
||||||
|
<label>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="tagIds"
|
||||||
|
value={cat.id}
|
||||||
|
checked={selectedTagIds.includes(cat.id)}
|
||||||
|
onchange={(e) => {
|
||||||
|
if (e.currentTarget.checked) {
|
||||||
|
selectedTagIds = [...selectedTagIds, cat.id];
|
||||||
|
} else {
|
||||||
|
selectedTagIds = selectedTagIds.filter((id) => id !== cat.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{cat.name}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- Ingredients -->
|
||||||
|
<fieldset>
|
||||||
|
<legend>Zutaten</legend>
|
||||||
|
{#each ingredients as ing, i (i)}
|
||||||
|
<div>
|
||||||
|
<input type="number" bind:value={ing.quantity} placeholder="Menge" />
|
||||||
|
<input type="text" bind:value={ing.unit} placeholder="Einheit" />
|
||||||
|
<input type="text" bind:value={ing.name} placeholder="Zutat" />
|
||||||
|
<button type="button" onclick={() => (ingredients = ingredients.filter((_, j) => j !== i))}>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (ingredients = [...ingredients, { name: '', quantity: '', unit: '' }])}
|
||||||
|
>
|
||||||
|
Zutat hinzufügen
|
||||||
|
</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- Steps -->
|
||||||
|
<fieldset>
|
||||||
|
<legend>Schritte</legend>
|
||||||
|
{#each steps as _, i (i)}
|
||||||
|
<div>
|
||||||
|
<textarea bind:value={steps[i]} placeholder="Schritt beschreiben…"></textarea>
|
||||||
|
<button type="button" onclick={() => (steps = steps.filter((_, j) => j !== i))}>
|
||||||
|
Entfernen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<button type="button" onclick={() => (steps = [...steps, ''])}>Schritt hinzufügen</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- Hidden inputs for form submission -->
|
||||||
|
<input type="hidden" name="ingredientsJson" value={JSON.stringify(ingredients)} />
|
||||||
|
<input type="hidden" name="stepsJson" value={JSON.stringify(steps)} />
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<a href="/recipes">Abbrechen</a>
|
||||||
|
<button type="submit">Speichern</button>
|
||||||
|
</form>
|
||||||
143
frontend/src/lib/recipes/RecipeForm.test.ts
Normal file
143
frontend/src/lib/recipes/RecipeForm.test.ts
Normal file
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user