feat(recipes): B3 — Add/edit recipe form with dynamic ingredients, steps, tag chips #38
61
frontend/src/routes/(app)/recipes/new/+page.server.ts
Normal file
61
frontend/src/routes/(app)/recipes/new/+page.server.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { redirect, fail } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { apiClient } from '$lib/server/api';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
const api = apiClient(fetch);
|
||||
const { data, error } = await api.GET('/v1/tags', {});
|
||||
|
||||
const allTags = error || !data ? [] : data;
|
||||
const categories = allTags
|
||||
.filter((t) => t.tagType === 'category')
|
||||
.map((t) => ({ id: t.id!, name: t.name!, tagType: t.tagType }));
|
||||
|
||||
return { recipe: null, categories };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async ({ request, fetch }) => {
|
||||
const formData = await request.formData();
|
||||
const name = formData.get('name') as string;
|
||||
const serves = formData.get('serves');
|
||||
const cookTimeMin = formData.get('cookTimeMin');
|
||||
const effort = formData.get('effort') as string;
|
||||
const ingredientsJson = formData.get('ingredientsJson') as string;
|
||||
const stepsJson = formData.get('stepsJson') as string;
|
||||
const tagIds = formData.getAll('tagIds') as string[];
|
||||
|
||||
if (!name?.trim()) return fail(422, { error: 'Name ist erforderlich' });
|
||||
if (!effort) return fail(422, { error: 'Schwierigkeitsgrad ist erforderlich' });
|
||||
if (!tagIds.length) return fail(422, { error: 'Mindestens eine Kategorie ist erforderlich' });
|
||||
|
||||
const parsedIngredients = JSON.parse(ingredientsJson || '[]');
|
||||
const parsedSteps = JSON.parse(stepsJson || '[]');
|
||||
|
||||
const api = apiClient(fetch);
|
||||
const { error: apiError } = await api.POST('/v1/recipes', {
|
||||
body: {
|
||||
name: name.trim(),
|
||||
serves: serves ? Number(serves) : undefined,
|
||||
cookTimeMin: cookTimeMin ? Number(cookTimeMin) : undefined,
|
||||
effort,
|
||||
ingredients: parsedIngredients
|
||||
.filter((ing: { name: string }) => ing.name?.trim())
|
||||
.map((ing: { name: string; quantity: string; unit: string }, i: number) => ({
|
||||
newIngredientName: ing.name.trim(),
|
||||
quantity: Number(ing.quantity) || 0,
|
||||
unit: ing.unit || '',
|
||||
sortOrder: i
|
||||
})),
|
||||
steps: parsedSteps
|
||||
.filter((s: string) => s?.trim())
|
||||
.map((s: string, i: number) => ({ stepNumber: i + 1, instruction: s.trim() })),
|
||||
tagIds
|
||||
}
|
||||
});
|
||||
|
||||
if (apiError) return fail(500, { error: 'Fehler beim Speichern' });
|
||||
|
||||
redirect(303, '/recipes');
|
||||
}
|
||||
};
|
||||
17
frontend/src/routes/(app)/recipes/new/+page.svelte
Normal file
17
frontend/src/routes/(app)/recipes/new/+page.svelte
Normal file
@@ -0,0 +1,17 @@
|
||||
<script lang="ts">
|
||||
import RecipeForm from '$lib/recipes/RecipeForm.svelte';
|
||||
import type { PageData } from './$types';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Neues Rezept — Mealplan</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="p-[24px]">
|
||||
<h1 class="font-[var(--font-display)] text-[24px] md:text-[28px] font-medium text-[var(--color-text)] mb-[24px]">
|
||||
Neues Rezept
|
||||
</h1>
|
||||
<RecipeForm recipe={data.recipe} categories={data.categories} action="?/create" />
|
||||
</div>
|
||||
53
frontend/src/routes/(app)/recipes/new/page.server.test.ts
Normal file
53
frontend/src/routes/(app)/recipes/new/page.server.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('$env/dynamic/private', () => ({
|
||||
env: { BACKEND_URL: 'http://localhost:8080' }
|
||||
}));
|
||||
|
||||
const mockGet = vi.fn();
|
||||
const mockPost = vi.fn();
|
||||
vi.mock('$lib/server/api', () => ({
|
||||
apiClient: () => ({ GET: mockGet, POST: mockPost })
|
||||
}));
|
||||
|
||||
describe('new recipe page — load', () => {
|
||||
let load: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockGet.mockReset();
|
||||
mockPost.mockReset();
|
||||
vi.resetModules();
|
||||
const mod = await import('./+page.server');
|
||||
load = mod.load;
|
||||
});
|
||||
|
||||
const mockTags = [
|
||||
{ id: 't1', name: 'Pasta', tagType: 'category' },
|
||||
{ id: 't2', name: 'Fleisch', tagType: 'category' }
|
||||
];
|
||||
|
||||
it('fetches tags from GET /v1/tags', async () => {
|
||||
mockGet.mockResolvedValue({ data: mockTags, error: undefined });
|
||||
await load({ fetch: vi.fn() } as any);
|
||||
expect(mockGet).toHaveBeenCalledWith('/v1/tags', expect.anything());
|
||||
});
|
||||
|
||||
it('returns categories filtered from tags', async () => {
|
||||
mockGet.mockResolvedValue({ data: mockTags, error: undefined });
|
||||
const result = await load({ fetch: vi.fn() } as any);
|
||||
expect(result.categories).toHaveLength(2);
|
||||
expect(result.categories[0].name).toBe('Pasta');
|
||||
});
|
||||
|
||||
it('returns empty categories when API fails', async () => {
|
||||
mockGet.mockResolvedValue({ data: undefined, error: { status: 500 } });
|
||||
const result = await load({ fetch: vi.fn() } as any);
|
||||
expect(result.categories).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns null recipe for new form', async () => {
|
||||
mockGet.mockResolvedValue({ data: mockTags, error: undefined });
|
||||
const result = await load({ fetch: vi.fn() } as any);
|
||||
expect(result.recipe).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user