fix(recipes): address B2 review — tags, sort, edit link, types, a11y, tests
- RecipeHero: render tag pills, min-h-[200px/240px], fix back link styling, remove font-[400] - IngredientList: sort by sortOrder ascending - StepList: aria-hidden on step circles - types.ts: add Tag, Ingredient, Step, RecipeDetail shared types - +page.svelte: add Edit link → /recipes/[id]/edit (desktop topbar) - Tests: tag pills, sortOrder sort, edit link, image variant, 403-as-404 documented Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,13 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
type Ingredient = {
|
import type { Ingredient } from './types';
|
||||||
ingredientId?: string;
|
|
||||||
name?: string;
|
|
||||||
quantity?: number;
|
|
||||||
unit?: string;
|
|
||||||
sortOrder?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
let { ingredients }: { ingredients: Ingredient[] } = $props();
|
let { ingredients }: { ingredients: Ingredient[] } = $props();
|
||||||
|
|
||||||
|
const sortedIngredients = $derived(
|
||||||
|
[...ingredients].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
@@ -18,7 +16,7 @@
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
{#each ingredients as ingredient (ingredient.ingredientId ?? ingredient.name)}
|
{#each sortedIngredients as ingredient (ingredient.ingredientId ?? ingredient.name)}
|
||||||
<li class="flex items-baseline gap-[12px] py-[8px] border-b border-[var(--color-border)] last:border-b-0">
|
<li class="flex items-baseline gap-[12px] py-[8px] border-b border-[var(--color-border)] last:border-b-0">
|
||||||
{#if ingredient.quantity != null}
|
{#if ingredient.quantity != null}
|
||||||
<span class="text-[13px] font-medium text-[var(--color-text)] w-[80px] shrink-0">
|
<span class="text-[13px] font-medium text-[var(--color-text)] w-[80px] shrink-0">
|
||||||
|
|||||||
@@ -41,4 +41,17 @@ describe('IngredientList', () => {
|
|||||||
render(IngredientList, { props: { ingredients: [] } });
|
render(IngredientList, { props: { ingredients: [] } });
|
||||||
expect(screen.getByText(/zutaten/i)).toBeInTheDocument();
|
expect(screen.getByText(/zutaten/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders ingredients sorted by sortOrder', () => {
|
||||||
|
const unsorted = [
|
||||||
|
{ ingredientId: 'i3', name: 'Oregano', quantity: 1, unit: 'TL', sortOrder: 3 },
|
||||||
|
{ ingredientId: 'i1', name: 'Spaghetti', quantity: 200, unit: 'g', sortOrder: 1 },
|
||||||
|
{ ingredientId: 'i2', name: 'Hackfleisch', quantity: 400, unit: 'g', sortOrder: 2 }
|
||||||
|
];
|
||||||
|
render(IngredientList, { props: { ingredients: unsorted } });
|
||||||
|
const spans = document.querySelectorAll('li span:last-child');
|
||||||
|
expect(spans[0].textContent).toBe('Spaghetti');
|
||||||
|
expect(spans[1].textContent).toBe('Hackfleisch');
|
||||||
|
expect(spans[2].textContent).toBe('Oregano');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
type RecipeHeroProps = {
|
import type { Tag } from './types';
|
||||||
recipe: {
|
|
||||||
id: string;
|
type RecipeHeroData = {
|
||||||
name: string;
|
id: string;
|
||||||
serves?: number;
|
name: string;
|
||||||
cookTimeMin?: number;
|
serves?: number;
|
||||||
effort?: string;
|
cookTimeMin?: number;
|
||||||
heroImageUrl?: string;
|
effort?: string;
|
||||||
tags: { id: string; name: string; tagType?: string }[];
|
heroImageUrl?: string;
|
||||||
};
|
tags: Tag[];
|
||||||
};
|
};
|
||||||
|
|
||||||
let { recipe }: RecipeHeroProps = $props();
|
let { recipe }: { recipe: RecipeHeroData } = $props();
|
||||||
|
|
||||||
let hasImage = $derived(!!recipe.heroImageUrl);
|
let hasImage = $derived(!!recipe.heroImageUrl);
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
data-testid="recipe-hero"
|
data-testid="recipe-hero"
|
||||||
class="{hasImage
|
class="min-h-[200px] md:min-h-[240px] {hasImage
|
||||||
? 'relative text-white'
|
? 'relative text-white'
|
||||||
: 'bg-[var(--green-tint)] text-[var(--color-text)]'} p-[24px] md:p-[32px]"
|
: 'bg-[var(--green-tint)] text-[var(--color-text)]'} p-[24px] md:p-[32px]"
|
||||||
>
|
>
|
||||||
@@ -44,9 +44,9 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<a href="/recipes" class="text-sm">← Zurück</a>
|
<a href="/recipes" class="text-[13px] font-sans font-medium text-[var(--color-text-muted)]">← Zurück</a>
|
||||||
|
|
||||||
<h1 class="font-[var(--font-display)] text-[24px] md:text-[28px] font-[400] mt-[8px]">
|
<h1 class="font-[var(--font-display)] text-[24px] md:text-[28px] mt-[8px]">
|
||||||
{recipe.name}
|
{recipe.name}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@@ -60,6 +60,9 @@
|
|||||||
{#if recipe.serves != null}
|
{#if recipe.serves != null}
|
||||||
<span class={pillBase}>{recipe.serves} Port.</span>
|
<span class={pillBase}>{recipe.serves} Port.</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#each recipe.tags as tag (tag.id)}
|
||||||
|
<span class={pillBase}>{tag.name}</span>
|
||||||
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<a href="/cook/{recipe.id}" class={cookBtnClass}>Jetzt kochen</a>
|
<a href="/cook/{recipe.id}" class={cookBtnClass}>Jetzt kochen</a>
|
||||||
|
|||||||
@@ -63,4 +63,25 @@ describe('RecipeHero', () => {
|
|||||||
render(RecipeHero, { props: { recipe: baseRecipe } });
|
render(RecipeHero, { props: { recipe: baseRecipe } });
|
||||||
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
expect(screen.queryByRole('img')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders tag pills', () => {
|
||||||
|
render(RecipeHero, {
|
||||||
|
props: {
|
||||||
|
recipe: {
|
||||||
|
...baseRecipe,
|
||||||
|
tags: [
|
||||||
|
{ id: 't1', name: 'Pasta' },
|
||||||
|
{ id: 't2', name: 'Italienisch' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Pasta')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Italienisch')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders no tag pills when tags array is empty', () => {
|
||||||
|
render(RecipeHero, { props: { recipe: { ...baseRecipe, tags: [] } } });
|
||||||
|
expect(screen.queryByText('Pasta')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
type Step = { stepNumber?: number; instruction?: string };
|
import type { Step } from './types';
|
||||||
|
|
||||||
let { steps }: { steps: Step[] } = $props();
|
let { steps }: { steps: Step[] } = $props();
|
||||||
|
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
<li class="flex gap-[16px] items-start mb-[20px]">
|
<li class="flex gap-[16px] items-start mb-[20px]">
|
||||||
<div
|
<div
|
||||||
data-testid="step-circle"
|
data-testid="step-circle"
|
||||||
|
aria-hidden="true"
|
||||||
class="w-[28px] h-[28px] rounded-full bg-[var(--green-dark)] text-white flex items-center justify-center shrink-0 font-sans text-[13px] font-medium"
|
class="w-[28px] h-[28px] rounded-full bg-[var(--green-dark)] text-white flex items-center justify-center shrink-0 font-sans text-[13px] font-medium"
|
||||||
>
|
>
|
||||||
{step.stepNumber}
|
{step.stepNumber}
|
||||||
|
|||||||
@@ -5,3 +5,34 @@ export type RecipeSummary = {
|
|||||||
effort?: string;
|
effort?: string;
|
||||||
heroImageUrl?: string;
|
heroImageUrl?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Tag = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
tagType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Ingredient = {
|
||||||
|
ingredientId?: string;
|
||||||
|
name?: string;
|
||||||
|
quantity?: number;
|
||||||
|
unit?: string;
|
||||||
|
sortOrder?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Step = {
|
||||||
|
stepNumber?: number;
|
||||||
|
instruction?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RecipeDetail = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
serves?: number;
|
||||||
|
cookTimeMin?: number;
|
||||||
|
effort?: string;
|
||||||
|
heroImageUrl?: string;
|
||||||
|
ingredients: Ingredient[];
|
||||||
|
steps: Step[];
|
||||||
|
tags: Tag[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -2,22 +2,9 @@
|
|||||||
import RecipeHero from '$lib/recipes/RecipeHero.svelte';
|
import RecipeHero from '$lib/recipes/RecipeHero.svelte';
|
||||||
import IngredientList from '$lib/recipes/IngredientList.svelte';
|
import IngredientList from '$lib/recipes/IngredientList.svelte';
|
||||||
import StepList from '$lib/recipes/StepList.svelte';
|
import StepList from '$lib/recipes/StepList.svelte';
|
||||||
|
import type { RecipeDetail } from '$lib/recipes/types';
|
||||||
|
|
||||||
type RecipeData = {
|
let { data }: { data: { recipe: RecipeDetail } } = $props();
|
||||||
recipe: {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
serves?: number;
|
|
||||||
cookTimeMin?: number;
|
|
||||||
effort?: string;
|
|
||||||
heroImageUrl?: string;
|
|
||||||
ingredients: { ingredientId?: string; name?: string; quantity?: number; unit?: string; sortOrder?: number }[];
|
|
||||||
steps: { stepNumber?: number; instruction?: string }[];
|
|
||||||
tags: { id: string; name: string; tagType?: string }[];
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
let { data }: { data: RecipeData } = $props();
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -25,6 +12,15 @@
|
|||||||
</svelte:head>
|
</svelte:head>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
|
<div class="hidden md:flex items-center justify-end px-[24px] py-[12px] border-b border-[var(--color-border)]">
|
||||||
|
<a
|
||||||
|
href="/recipes/{data.recipe.id}/edit"
|
||||||
|
class="border border-[var(--color-border)] text-[var(--color-text)] text-[13px] font-medium font-sans tracking-[0.04em] rounded-[var(--radius-md)] px-[16px] py-[8px]"
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<RecipeHero recipe={data.recipe} />
|
<RecipeHero recipe={data.recipe} />
|
||||||
|
|
||||||
<div class="md:flex">
|
<div class="md:flex">
|
||||||
|
|||||||
@@ -54,4 +54,13 @@ describe('recipe detail page — load', () => {
|
|||||||
load({ fetch: vi.fn(), params: { id: 'nonexistent' } } as any)
|
load({ fetch: vi.fn(), params: { id: 'nonexistent' } } as any)
|
||||||
).rejects.toMatchObject({ status: 404 });
|
).rejects.toMatchObject({ status: 404 });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('throws 404 error when API returns 403 (different household — intentional)', async () => {
|
||||||
|
// Security design: we return 404 for both "not found" and "forbidden"
|
||||||
|
// to avoid revealing resource existence to unauthorized users
|
||||||
|
mockGet.mockResolvedValue({ data: undefined, error: { status: 403 } });
|
||||||
|
await expect(
|
||||||
|
load({ fetch: vi.fn(), params: { id: 'r-other-household' } } as any)
|
||||||
|
).rejects.toMatchObject({ status: 404 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -66,4 +66,27 @@ describe('recipe detail page', () => {
|
|||||||
expect(screen.getByText('Wasser aufsetzen')).toBeInTheDocument();
|
expect(screen.getByText('Wasser aufsetzen')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Sauce zubereiten')).toBeInTheDocument();
|
expect(screen.getByText('Sauce zubereiten')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders edit link to /recipes/[id]/edit', () => {
|
||||||
|
render(Page, { props: { data: mockData } });
|
||||||
|
const editLink = screen.getByRole('link', { name: /bearbeiten/i });
|
||||||
|
expect(editLink).toHaveAttribute('href', '/recipes/r1/edit');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders tag pills in hero', () => {
|
||||||
|
render(Page, { props: { data: mockData } });
|
||||||
|
expect(screen.getByText('Pasta')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders hero image when heroImageUrl is provided', () => {
|
||||||
|
render(Page, {
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
recipe: { ...mockData.recipe, heroImageUrl: '/uploads/pasta.jpg' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const img = screen.getByRole('img', { name: /spaghetti bolognese/i });
|
||||||
|
expect(img).toHaveAttribute('src', '/uploads/pasta.jpg');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user