feat(recipes): add C6 day-picker flow — week plan load + slot actions + DayPicker sheet
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -2,13 +2,13 @@
|
|||||||
import RecipeCard from './RecipeCard.svelte';
|
import RecipeCard from './RecipeCard.svelte';
|
||||||
import type { RecipeSummary } from './types';
|
import type { RecipeSummary } from './types';
|
||||||
|
|
||||||
let { recipes }: { recipes: RecipeSummary[] } = $props();
|
let { recipes, onplan }: { recipes: RecipeSummary[]; onplan?: (recipeId: string, recipeName: string) => void } = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if recipes.length > 0}
|
{#if recipes.length > 0}
|
||||||
<div data-testid="recipe-grid" class="grid grid-cols-2 lg:grid-cols-4 gap-[8px] lg:gap-[12px] p-[16px]">
|
<div data-testid="recipe-grid" class="grid grid-cols-2 lg:grid-cols-4 gap-[8px] lg:gap-[12px] p-[16px]">
|
||||||
{#each recipes as recipe (recipe.id)}
|
{#each recipes as recipe (recipe.id)}
|
||||||
<RecipeCard {recipe} compact={true} />
|
<RecipeCard {recipe} compact={true} {onplan} />
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,21 +1,77 @@
|
|||||||
import type { PageServerLoad } from './$types';
|
import type { PageServerLoad, Actions } from './$types';
|
||||||
import { apiClient } from '$lib/server/api';
|
import { apiClient } from '$lib/server/api';
|
||||||
|
import { getWeekStart } from '$lib/planner/week';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ fetch }) => {
|
export const load: PageServerLoad = async ({ fetch }) => {
|
||||||
const api = apiClient(fetch);
|
const api = apiClient(fetch);
|
||||||
const { data, error } = await api.GET('/v1/recipes', {});
|
const weekStart = getWeekStart(new Date());
|
||||||
|
|
||||||
if (error || !data?.data) {
|
const [recipesResult, weekPlanResult] = await Promise.all([
|
||||||
return { recipes: [] };
|
api.GET('/v1/recipes', {}),
|
||||||
}
|
api.GET('/v1/week-plans', { params: { query: { weekStart } } })
|
||||||
|
]);
|
||||||
|
|
||||||
return {
|
const recipes =
|
||||||
recipes: data.data.map((r) => ({
|
recipesResult.error || !recipesResult.data?.data
|
||||||
id: r.id!,
|
? []
|
||||||
name: r.name!,
|
: recipesResult.data.data.map((r) => ({
|
||||||
cookTimeMin: r.cookTimeMin,
|
id: r.id!,
|
||||||
effort: r.effort,
|
name: r.name!,
|
||||||
heroImageUrl: r.heroImageUrl
|
cookTimeMin: r.cookTimeMin,
|
||||||
}))
|
effort: r.effort,
|
||||||
};
|
heroImageUrl: r.heroImageUrl
|
||||||
|
}));
|
||||||
|
|
||||||
|
const activePlan =
|
||||||
|
weekPlanResult.error || !weekPlanResult.data?.id ? null : weekPlanResult.data;
|
||||||
|
|
||||||
|
return { recipes, activePlan };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
addSlot: async ({ fetch, request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const planId = formData.get('planId') as string;
|
||||||
|
const slotDate = formData.get('slotDate') as string;
|
||||||
|
const recipeId = formData.get('recipeId') as string;
|
||||||
|
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const { data, error } = await api.POST('/v1/week-plans/{id}/slots', {
|
||||||
|
params: { path: { id: planId } },
|
||||||
|
body: { slotDate, recipeId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error || !data) return { success: false };
|
||||||
|
return { success: true, slot: data };
|
||||||
|
},
|
||||||
|
|
||||||
|
updateSlot: async ({ fetch, request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const planId = formData.get('planId') as string;
|
||||||
|
const slotId = formData.get('slotId') as string;
|
||||||
|
const recipeId = formData.get('recipeId') as string;
|
||||||
|
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const { data, error } = await api.PATCH('/v1/week-plans/{planId}/slots/{slotId}', {
|
||||||
|
params: { path: { planId, slotId } },
|
||||||
|
body: { recipeId }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error || !data) return { success: false };
|
||||||
|
return { success: true, slot: data };
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteSlot: async ({ fetch, request }) => {
|
||||||
|
const formData = await request.formData();
|
||||||
|
const planId = formData.get('planId') as string;
|
||||||
|
const slotId = formData.get('slotId') as string;
|
||||||
|
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const { error } = await api.DELETE('/v1/week-plans/{planId}/slots/{slotId}', {
|
||||||
|
params: { path: { planId, slotId } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) return { success: false };
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { invalidateAll } from '$app/navigation';
|
||||||
|
import { tick } from 'svelte';
|
||||||
import FilterChipRow from '$lib/recipes/FilterChipRow.svelte';
|
import FilterChipRow from '$lib/recipes/FilterChipRow.svelte';
|
||||||
import RecipeGrid from '$lib/recipes/RecipeGrid.svelte';
|
import RecipeGrid from '$lib/recipes/RecipeGrid.svelte';
|
||||||
import type { RecipeSummary } from '$lib/recipes/types';
|
import type { RecipeSummary } from '$lib/recipes/types';
|
||||||
|
import DayPicker from '$lib/planner/DayPicker.svelte';
|
||||||
|
import BottomSheet from '$lib/components/BottomSheet.svelte';
|
||||||
|
import UndoBar from '$lib/planner/UndoBar.svelte';
|
||||||
|
|
||||||
let { data }: { data: { recipes: RecipeSummary[] } } = $props();
|
let { data, form = null }: { data: { recipes: RecipeSummary[]; activePlan: any }; form?: any } =
|
||||||
|
$props();
|
||||||
|
|
||||||
|
// ── Search / filter ──────────────────────────────────────────────────────
|
||||||
let searchQuery = $state('');
|
let searchQuery = $state('');
|
||||||
let activeFilter = $state('Alle');
|
let activeFilter = $state('Alle');
|
||||||
|
|
||||||
@@ -22,6 +30,77 @@
|
|||||||
})
|
})
|
||||||
.filter((r) => r.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
.filter((r) => r.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ── Today (computed once at module level) ─────────────────────────────────
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
// ── DayPicker / BottomSheet state ─────────────────────────────────────────
|
||||||
|
let pickerOpen = $state(false);
|
||||||
|
let pickerRecipeId = $state('');
|
||||||
|
let pickerRecipeName = $state('');
|
||||||
|
let pickerPlan = $state<any>(null);
|
||||||
|
let pickerWeekStart = $state('');
|
||||||
|
|
||||||
|
// ── Undo bar state ────────────────────────────────────────────────────────
|
||||||
|
let undoVisible = $state(false);
|
||||||
|
let undoMessage = $state('');
|
||||||
|
let undoPlanId = $state('');
|
||||||
|
let undoSlotId = $state('');
|
||||||
|
|
||||||
|
// ── Hidden form field state ───────────────────────────────────────────────
|
||||||
|
let addPlanId = $state('');
|
||||||
|
let addSlotDate = $state('');
|
||||||
|
let addRecipeId = $state('');
|
||||||
|
let updPlanId = $state('');
|
||||||
|
let updSlotId = $state('');
|
||||||
|
let updRecipeId = $state('');
|
||||||
|
|
||||||
|
// ── Form element refs ─────────────────────────────────────────────────────
|
||||||
|
let addSlotFormEl: HTMLFormElement;
|
||||||
|
let updateSlotFormEl: HTMLFormElement;
|
||||||
|
let deleteSlotFormEl: HTMLFormElement;
|
||||||
|
|
||||||
|
// ── Handlers ──────────────────────────────────────────────────────────────
|
||||||
|
function openDayPicker(recipeId: string, recipeName: string) {
|
||||||
|
if (!data.activePlan) return;
|
||||||
|
pickerRecipeId = recipeId;
|
||||||
|
pickerRecipeName = recipeName;
|
||||||
|
pickerPlan = data.activePlan;
|
||||||
|
pickerWeekStart = data.activePlan.weekStart;
|
||||||
|
pickerOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleWeekChange(newWeekStart: string) {
|
||||||
|
const res = await fetch(`/recipes?week=${newWeekStart}`);
|
||||||
|
const { plan } = await res.json();
|
||||||
|
pickerPlan = plan;
|
||||||
|
pickerWeekStart = newWeekStart;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDayPickerConfirm({ date, slotId }: { date: string; slotId: string | null }) {
|
||||||
|
pickerOpen = false;
|
||||||
|
|
||||||
|
if (slotId) {
|
||||||
|
// Replace existing slot
|
||||||
|
updPlanId = pickerPlan?.id ?? '';
|
||||||
|
updSlotId = slotId;
|
||||||
|
updRecipeId = pickerRecipeId;
|
||||||
|
await tick();
|
||||||
|
updateSlotFormEl.requestSubmit();
|
||||||
|
} else {
|
||||||
|
// Add to empty slot
|
||||||
|
addPlanId = pickerPlan?.id ?? '';
|
||||||
|
addSlotDate = date;
|
||||||
|
addRecipeId = pickerRecipeId;
|
||||||
|
await tick();
|
||||||
|
addSlotFormEl.requestSubmit();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleUndo() {
|
||||||
|
undoVisible = false;
|
||||||
|
deleteSlotFormEl.requestSubmit();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -30,18 +109,116 @@
|
|||||||
|
|
||||||
<div class="p-6 space-y-4">
|
<div class="p-6 space-y-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="font-[var(--font-display)] text-[28px] font-medium text-[var(--color-text)]">Rezepte</h1>
|
<h1 class="font-[var(--font-display)] text-[28px] font-medium text-[var(--color-text)]">
|
||||||
<a href="/recipes/new" class="font-sans text-[13px] font-medium tracking-[0.04em] rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-white">Rezept hinzufügen</a>
|
Rezepte
|
||||||
|
</h1>
|
||||||
|
<a
|
||||||
|
href="/recipes/new"
|
||||||
|
class="font-sans text-[13px] font-medium tracking-[0.04em] rounded-[var(--radius-md)] bg-[var(--green-dark)] px-[24px] py-[12px] text-white"
|
||||||
|
>
|
||||||
|
Rezept hinzufügen
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input type="search" placeholder="Suchen…" class="input" bind:value={searchQuery} />
|
||||||
type="search"
|
|
||||||
placeholder="Suchen…"
|
|
||||||
class="input"
|
|
||||||
bind:value={searchQuery}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterChipRow {activeFilter} onFilter={(f) => (activeFilter = f)} />
|
<FilterChipRow {activeFilter} onFilter={(f) => (activeFilter = f)} />
|
||||||
|
|
||||||
<RecipeGrid recipes={filteredRecipes} />
|
<RecipeGrid
|
||||||
|
recipes={filteredRecipes}
|
||||||
|
onplan={data.activePlan ? openDayPicker : undefined}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<BottomSheet open={pickerOpen} onclose={() => (pickerOpen = false)} height="55vh">
|
||||||
|
{#if pickerPlan}
|
||||||
|
<DayPicker
|
||||||
|
recipeName={pickerRecipeName}
|
||||||
|
recipeId={pickerRecipeId}
|
||||||
|
planId={pickerPlan?.id ?? ''}
|
||||||
|
weekStart={pickerWeekStart}
|
||||||
|
{today}
|
||||||
|
slots={pickerPlan?.slots ?? []}
|
||||||
|
onconfirm={handleDayPickerConfirm}
|
||||||
|
onweekchange={handleWeekChange}
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
</BottomSheet>
|
||||||
|
|
||||||
|
<UndoBar
|
||||||
|
visible={undoVisible}
|
||||||
|
message={undoMessage}
|
||||||
|
onundo={handleUndo}
|
||||||
|
ondismiss={() => (undoVisible = false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Hidden forms for slot mutations -->
|
||||||
|
<form
|
||||||
|
bind:this={addSlotFormEl}
|
||||||
|
method="POST"
|
||||||
|
action="?/addSlot"
|
||||||
|
class="hidden"
|
||||||
|
use:enhance={({ formData }) => {
|
||||||
|
formData.set('planId', addPlanId);
|
||||||
|
formData.set('slotDate', addSlotDate);
|
||||||
|
formData.set('recipeId', addRecipeId);
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'success') {
|
||||||
|
undoPlanId = addPlanId;
|
||||||
|
undoSlotId = (result.data as any)?.slot?.id ?? '';
|
||||||
|
undoMessage = `${pickerRecipeName} hinzugefügt`;
|
||||||
|
undoVisible = true;
|
||||||
|
await invalidateAll();
|
||||||
|
}
|
||||||
|
await update({ reset: false });
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="planId" value={addPlanId} />
|
||||||
|
<input type="hidden" name="slotDate" value={addSlotDate} />
|
||||||
|
<input type="hidden" name="recipeId" value={addRecipeId} />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form
|
||||||
|
bind:this={updateSlotFormEl}
|
||||||
|
method="POST"
|
||||||
|
action="?/updateSlot"
|
||||||
|
class="hidden"
|
||||||
|
use:enhance={({ formData }) => {
|
||||||
|
formData.set('planId', updPlanId);
|
||||||
|
formData.set('slotId', updSlotId);
|
||||||
|
formData.set('recipeId', updRecipeId);
|
||||||
|
return async ({ result, update }) => {
|
||||||
|
if (result.type === 'success') {
|
||||||
|
undoPlanId = updPlanId;
|
||||||
|
undoSlotId = (result.data as any)?.slot?.id ?? '';
|
||||||
|
undoMessage = `${pickerRecipeName} hinzugefügt`;
|
||||||
|
undoVisible = true;
|
||||||
|
await invalidateAll();
|
||||||
|
}
|
||||||
|
await update({ reset: false });
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="planId" value={updPlanId} />
|
||||||
|
<input type="hidden" name="slotId" value={updSlotId} />
|
||||||
|
<input type="hidden" name="recipeId" value={updRecipeId} />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form
|
||||||
|
bind:this={deleteSlotFormEl}
|
||||||
|
method="POST"
|
||||||
|
action="?/deleteSlot"
|
||||||
|
class="hidden"
|
||||||
|
use:enhance={({ formData }) => {
|
||||||
|
formData.set('planId', undoPlanId);
|
||||||
|
formData.set('slotId', undoSlotId);
|
||||||
|
return async ({ update }) => {
|
||||||
|
await invalidateAll();
|
||||||
|
await update({ reset: false });
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="planId" value={undoPlanId} />
|
||||||
|
<input type="hidden" name="slotId" value={undoSlotId} />
|
||||||
|
</form>
|
||||||
|
|||||||
17
frontend/src/routes/(app)/recipes/+server.ts
Normal file
17
frontend/src/routes/(app)/recipes/+server.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { json } from '@sveltejs/kit';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
import { apiClient } from '$lib/server/api';
|
||||||
|
|
||||||
|
// GET /recipes?week=YYYY-MM-DD — returns week plan for DayPicker week navigation
|
||||||
|
export const GET: RequestHandler = async ({ fetch, url }) => {
|
||||||
|
const weekStart = url.searchParams.get('week');
|
||||||
|
if (!weekStart) return json({ plan: null });
|
||||||
|
|
||||||
|
const api = apiClient(fetch);
|
||||||
|
const { data, error } = await api.GET('/v1/week-plans', {
|
||||||
|
params: { query: { weekStart } }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error || !data?.id) return json({ plan: null });
|
||||||
|
return json({ plan: data });
|
||||||
|
};
|
||||||
@@ -5,8 +5,15 @@ vi.mock('$env/dynamic/private', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
const mockGet = vi.fn();
|
const mockGet = vi.fn();
|
||||||
|
const mockPost = vi.fn();
|
||||||
|
const mockPatch = vi.fn();
|
||||||
|
const mockDelete = vi.fn();
|
||||||
vi.mock('$lib/server/api', () => ({
|
vi.mock('$lib/server/api', () => ({
|
||||||
apiClient: () => ({ GET: mockGet })
|
apiClient: () => ({ GET: mockGet, POST: mockPost, PATCH: mockPatch, DELETE: mockDelete })
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('$lib/planner/week', () => ({
|
||||||
|
getWeekStart: () => '2026-04-07'
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('recipe library page — load', () => {
|
describe('recipe library page — load', () => {
|
||||||
@@ -24,22 +31,144 @@ describe('recipe library page — load', () => {
|
|||||||
{ id: 'r2', name: 'Curry', cookTimeMin: 45, effort: 'Medium' }
|
{ id: 'r2', name: 'Curry', cookTimeMin: 45, effort: 'Medium' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const mockWeekPlan = {
|
||||||
|
id: 'plan-1',
|
||||||
|
weekStart: '2026-04-07',
|
||||||
|
slots: [{ id: 's1', slotDate: '2026-04-07', recipe: { id: 'r1', name: 'Pasta', effort: 'easy' } }]
|
||||||
|
};
|
||||||
|
|
||||||
it('fetches recipes from GET /v1/recipes', async () => {
|
it('fetches recipes from GET /v1/recipes', async () => {
|
||||||
mockGet.mockResolvedValue({ data: { data: mockRecipes }, error: undefined });
|
mockGet
|
||||||
|
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
|
||||||
|
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||||
await load({ fetch: vi.fn() } as any);
|
await load({ fetch: vi.fn() } as any);
|
||||||
expect(mockGet).toHaveBeenCalledWith('/v1/recipes', expect.any(Object));
|
expect(mockGet).toHaveBeenCalledWith('/v1/recipes', expect.any(Object));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns recipes in data', async () => {
|
it('returns recipes in data', async () => {
|
||||||
mockGet.mockResolvedValue({ data: { data: mockRecipes }, error: undefined });
|
mockGet
|
||||||
|
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
|
||||||
|
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||||
const result = await load({ fetch: vi.fn() } as any);
|
const result = await load({ fetch: vi.fn() } as any);
|
||||||
expect(result.recipes).toHaveLength(2);
|
expect(result.recipes).toHaveLength(2);
|
||||||
expect(result.recipes[0].name).toBe('Spaghetti');
|
expect(result.recipes[0].name).toBe('Spaghetti');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty array when API fails', async () => {
|
it('returns empty array when recipes API fails', async () => {
|
||||||
mockGet.mockResolvedValue({ data: undefined, error: { status: 500 } });
|
mockGet
|
||||||
|
.mockResolvedValueOnce({ data: undefined, error: { status: 500 } })
|
||||||
|
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||||
const result = await load({ fetch: vi.fn() } as any);
|
const result = await load({ fetch: vi.fn() } as any);
|
||||||
expect(result.recipes).toEqual([]);
|
expect(result.recipes).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fetches active week plan from GET /v1/week-plans', async () => {
|
||||||
|
mockGet
|
||||||
|
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
|
||||||
|
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||||
|
await load({ fetch: vi.fn() } as any);
|
||||||
|
expect(mockGet).toHaveBeenCalledWith('/v1/week-plans', expect.any(Object));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns activePlan with id and slots in data', async () => {
|
||||||
|
mockGet
|
||||||
|
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
|
||||||
|
.mockResolvedValueOnce({ data: mockWeekPlan, error: undefined });
|
||||||
|
const result = await load({ fetch: vi.fn() } as any);
|
||||||
|
expect(result.activePlan.id).toBe('plan-1');
|
||||||
|
expect(result.activePlan.slots).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null activePlan when week plan API fails', async () => {
|
||||||
|
mockGet
|
||||||
|
.mockResolvedValueOnce({ data: { data: mockRecipes }, error: undefined })
|
||||||
|
.mockResolvedValueOnce({ data: undefined, error: { status: 404 } });
|
||||||
|
const result = await load({ fetch: vi.fn() } as any);
|
||||||
|
expect(result.activePlan).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('recipe library page — actions', () => {
|
||||||
|
let actions: any;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockGet.mockReset();
|
||||||
|
mockPost.mockReset();
|
||||||
|
mockPatch.mockReset();
|
||||||
|
mockDelete.mockReset();
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import('./+page.server');
|
||||||
|
actions = mod.actions;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addSlot calls POST /v1/week-plans/{id}/slots and returns success', async () => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set('planId', 'plan-1');
|
||||||
|
formData.set('slotDate', '2026-04-01');
|
||||||
|
formData.set('recipeId', 'r1');
|
||||||
|
mockPost.mockResolvedValue({ data: { id: 's1' }, error: undefined });
|
||||||
|
const result = await actions.addSlot({
|
||||||
|
fetch: vi.fn(),
|
||||||
|
request: { formData: async () => formData }
|
||||||
|
} as any);
|
||||||
|
expect(mockPost).toHaveBeenCalledWith(
|
||||||
|
'/v1/week-plans/{id}/slots',
|
||||||
|
expect.objectContaining({
|
||||||
|
params: { path: { id: 'plan-1' } },
|
||||||
|
body: { slotDate: '2026-04-01', recipeId: 'r1' }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('addSlot returns error when API fails', async () => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set('planId', 'plan-1');
|
||||||
|
formData.set('slotDate', '2026-04-01');
|
||||||
|
formData.set('recipeId', 'r1');
|
||||||
|
mockPost.mockResolvedValue({ data: undefined, error: { status: 500 } });
|
||||||
|
const result = await actions.addSlot({
|
||||||
|
fetch: vi.fn(),
|
||||||
|
request: { formData: async () => formData }
|
||||||
|
} as any);
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updateSlot calls PATCH /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set('planId', 'plan-1');
|
||||||
|
formData.set('slotId', 's1');
|
||||||
|
formData.set('recipeId', 'r1');
|
||||||
|
mockPatch.mockResolvedValue({ data: { id: 's1' }, error: undefined });
|
||||||
|
const result = await actions.updateSlot({
|
||||||
|
fetch: vi.fn(),
|
||||||
|
request: { formData: async () => formData }
|
||||||
|
} as any);
|
||||||
|
expect(mockPatch).toHaveBeenCalledWith(
|
||||||
|
'/v1/week-plans/{planId}/slots/{slotId}',
|
||||||
|
expect.objectContaining({
|
||||||
|
params: { path: { planId: 'plan-1', slotId: 's1' } },
|
||||||
|
body: { recipeId: 'r1' }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deleteSlot calls DELETE /v1/week-plans/{planId}/slots/{slotId} and returns success', async () => {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.set('planId', 'plan-1');
|
||||||
|
formData.set('slotId', 's1');
|
||||||
|
mockDelete.mockResolvedValue({ error: undefined });
|
||||||
|
const result = await actions.deleteSlot({
|
||||||
|
fetch: vi.fn(),
|
||||||
|
request: { formData: async () => formData }
|
||||||
|
} as any);
|
||||||
|
expect(mockDelete).toHaveBeenCalledWith(
|
||||||
|
'/v1/week-plans/{planId}/slots/{slotId}',
|
||||||
|
expect.objectContaining({
|
||||||
|
params: { path: { planId: 'plan-1', slotId: 's1' } }
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ const mockData = {
|
|||||||
{ id: 'r1', name: 'Spaghetti Bolognese', cookTimeMin: 30, effort: 'Easy' },
|
{ id: 'r1', name: 'Spaghetti Bolognese', cookTimeMin: 30, effort: 'Easy' },
|
||||||
{ id: 'r2', name: 'Chicken Curry', cookTimeMin: 45, effort: 'Medium' },
|
{ id: 'r2', name: 'Chicken Curry', cookTimeMin: 45, effort: 'Medium' },
|
||||||
{ id: 'r3', name: 'Gemüsesuppe', cookTimeMin: 20, effort: 'Easy' }
|
{ id: 'r3', name: 'Gemüsesuppe', cookTimeMin: 20, effort: 'Easy' }
|
||||||
]
|
],
|
||||||
|
activePlan: null
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('recipe library page', () => {
|
describe('recipe library page', () => {
|
||||||
@@ -68,7 +69,7 @@ describe('recipe library page', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders empty state page when no recipes at all', () => {
|
it('renders empty state page when no recipes at all', () => {
|
||||||
render(Page, { props: { data: { recipes: [] } } });
|
render(Page, { props: { data: { recipes: [], activePlan: null } } });
|
||||||
expect(screen.getByText(/keine rezepte/i)).toBeInTheDocument();
|
expect(screen.getByText(/keine rezepte/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user