feat(recipes): add /recipes/[id]/edit route with update action
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
89
frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts
Normal file
89
frontend/src/routes/(app)/recipes/[id]/edit/+page.server.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { error, redirect, fail } from '@sveltejs/kit';
|
||||||
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
|
import { apiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ fetch, params }) => {
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const [recipeResult, tagsResult] = await Promise.all([
|
||||||
|
api.GET('/v1/recipes/{id}', { params: { path: { id: params.id } } }),
|
||||||
|
api.GET('/v1/tags', {})
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (recipeResult.error || !recipeResult.data) {
|
||||||
|
error(404, 'Recipe not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipe = recipeResult.data;
|
||||||
|
const allTags = tagsResult.data ?? [];
|
||||||
|
const categories = allTags
|
||||||
|
.filter((t) => t.tagType === 'category')
|
||||||
|
.map((t) => ({ id: t.id!, name: t.name!, tagType: t.tagType }));
|
||||||
|
|
||||||
|
return {
|
||||||
|
recipe: {
|
||||||
|
id: recipe.id!,
|
||||||
|
name: recipe.name!,
|
||||||
|
serves: recipe.serves,
|
||||||
|
cookTimeMin: recipe.cookTimeMin,
|
||||||
|
effort: recipe.effort,
|
||||||
|
heroImageUrl: recipe.heroImageUrl,
|
||||||
|
ingredients: (recipe.ingredients ?? []).map((ing) => ({
|
||||||
|
name: ing.name ?? '',
|
||||||
|
quantity: ing.quantity ?? 0,
|
||||||
|
unit: ing.unit ?? ''
|
||||||
|
})),
|
||||||
|
steps: (recipe.steps ?? [])
|
||||||
|
.sort((a, b) => (a.stepNumber ?? 0) - (b.stepNumber ?? 0))
|
||||||
|
.map((s) => ({ instruction: s.instruction ?? '' })),
|
||||||
|
tagIds: (recipe.tags ?? []).map((t) => t.id!)
|
||||||
|
},
|
||||||
|
categories
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
update: async ({ request, fetch, params }) => {
|
||||||
|
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.PUT('/v1/recipes/{id}', {
|
||||||
|
params: { path: { id: params.id } },
|
||||||
|
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/[id]/edit/+page.svelte
Normal file
17
frontend/src/routes/(app)/recipes/[id]/edit/+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>{data.recipe?.name ?? 'Rezept bearbeiten'} — 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]">
|
||||||
|
Rezept bearbeiten
|
||||||
|
</h1>
|
||||||
|
<RecipeForm recipe={data.recipe} categories={data.categories} action="?/update" />
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('$env/dynamic/private', () => ({
|
||||||
|
env: { BACKEND_URL: 'http://localhost:8080' }
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockGet = vi.fn();
|
||||||
|
const mockPut = vi.fn();
|
||||||
|
vi.mock('$lib/server/api', () => ({
|
||||||
|
apiClient: () => ({ GET: mockGet, PUT: mockPut })
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('edit recipe page — load', () => {
|
||||||
|
let load: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockGet.mockReset();
|
||||||
|
mockPut.mockReset();
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import('./+page.server');
|
||||||
|
load = mod.load;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockRecipe = {
|
||||||
|
id: 'r1',
|
||||||
|
name: 'Spaghetti Bolognese',
|
||||||
|
serves: 4,
|
||||||
|
cookTimeMin: 30,
|
||||||
|
effort: 'Easy',
|
||||||
|
ingredients: [{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g' }],
|
||||||
|
steps: [{ stepNumber: 1, instruction: 'Kochen' }],
|
||||||
|
tags: [{ id: 't1', name: 'Pasta', tagType: 'category' }]
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTags = [
|
||||||
|
{ id: 't1', name: 'Pasta', tagType: 'category' },
|
||||||
|
{ id: 't2', name: 'Fleisch', tagType: 'category' }
|
||||||
|
];
|
||||||
|
|
||||||
|
it('fetches recipe and tags in parallel', async () => {
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url === '/v1/recipes/{id}') return Promise.resolve({ data: mockRecipe, error: undefined });
|
||||||
|
if (url === '/v1/tags') return Promise.resolve({ data: mockTags, error: undefined });
|
||||||
|
});
|
||||||
|
await load({ fetch: vi.fn(), params: { id: 'r1' } } as any);
|
||||||
|
expect(mockGet).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns recipe data mapped for form', async () => {
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url === '/v1/recipes/{id}') return Promise.resolve({ data: mockRecipe, error: undefined });
|
||||||
|
if (url === '/v1/tags') return Promise.resolve({ data: mockTags, error: undefined });
|
||||||
|
});
|
||||||
|
const result = await load({ fetch: vi.fn(), params: { id: 'r1' } } as any);
|
||||||
|
expect(result.recipe.name).toBe('Spaghetti Bolognese');
|
||||||
|
expect(result.recipe.effort).toBe('Easy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns categories from tags', async () => {
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url === '/v1/recipes/{id}') return Promise.resolve({ data: mockRecipe, error: undefined });
|
||||||
|
if (url === '/v1/tags') return Promise.resolve({ data: mockTags, error: undefined });
|
||||||
|
});
|
||||||
|
const result = await load({ fetch: vi.fn(), params: { id: 'r1' } } as any);
|
||||||
|
expect(result.categories).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws 404 when recipe not found', async () => {
|
||||||
|
mockGet.mockImplementation((url: string) => {
|
||||||
|
if (url === '/v1/recipes/{id}') return Promise.resolve({ data: undefined, error: { status: 404 } });
|
||||||
|
if (url === '/v1/tags') return Promise.resolve({ data: mockTags, error: undefined });
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
load({ fetch: vi.fn(), params: { id: 'nonexistent' } } as any)
|
||||||
|
).rejects.toMatchObject({ status: 404 });
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user