feat(staples): add StaplesManager with optimistic toggle and debounced PATCH
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
80
frontend/src/lib/onboarding/StaplesManager.svelte
Normal file
80
frontend/src/lib/onboarding/StaplesManager.svelte
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import CategorySection from './CategorySection.svelte';
|
||||||
|
|
||||||
|
type Ingredient = { id: string; name: string; isStaple: boolean };
|
||||||
|
type Category = { id: string; name: string; ingredients: Ingredient[] };
|
||||||
|
|
||||||
|
let { categories, context }: {
|
||||||
|
categories: Category[];
|
||||||
|
context: 'onboarding' | 'settings';
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let stapleState = $state<Record<string, boolean>>({});
|
||||||
|
let errorMessage = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const initial: Record<string, boolean> = {};
|
||||||
|
for (const cat of categories) {
|
||||||
|
for (const ing of cat.ingredients) {
|
||||||
|
initial[ing.id] = ing.isStaple;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stapleState = initial;
|
||||||
|
});
|
||||||
|
|
||||||
|
function debounce<T extends (...args: any[]) => void>(fn: T, ms: number): T {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
return ((...args: any[]) => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => fn(...args), ms);
|
||||||
|
}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
const debouncedPatchers: Record<string, (id: string, value: boolean) => void> = {};
|
||||||
|
|
||||||
|
function getPatcher(id: string) {
|
||||||
|
if (!debouncedPatchers[id]) {
|
||||||
|
debouncedPatchers[id] = debounce(async (ingredientId: string, value: boolean) => {
|
||||||
|
const previous = !value;
|
||||||
|
const res = await fetch(`/household/staples`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: ingredientId, isStaple: value })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
stapleState[ingredientId] = previous;
|
||||||
|
errorMessage = 'Vorrat konnte nicht gespeichert werden.';
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
return debouncedPatchers[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleToggle(ingredientId: string, newValue: boolean) {
|
||||||
|
errorMessage = '';
|
||||||
|
stapleState[ingredientId] = newValue;
|
||||||
|
getPatcher(ingredientId)(ingredientId, newValue);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{#if errorMessage}
|
||||||
|
<p class="mb-[12px] text-[12px] text-[var(--color-error)]">{errorMessage}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div
|
||||||
|
data-testid="category-grid"
|
||||||
|
class="grid grid-cols-1 gap-[24px_32px] {context === 'settings' ? 'md:grid-cols-3' : 'md:grid-cols-2'}"
|
||||||
|
>
|
||||||
|
{#each categories as category (category.id)}
|
||||||
|
<CategorySection
|
||||||
|
name={category.name}
|
||||||
|
ingredients={category.ingredients.map(ing => ({
|
||||||
|
...ing,
|
||||||
|
isStaple: stapleState[ing.id] ?? ing.isStaple
|
||||||
|
}))}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
102
frontend/src/lib/onboarding/StaplesManager.test.ts
Normal file
102
frontend/src/lib/onboarding/StaplesManager.test.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/svelte';
|
||||||
|
import { userEvent } from '@testing-library/user-event';
|
||||||
|
import StaplesManager from './StaplesManager.svelte';
|
||||||
|
|
||||||
|
const mockCategories = [
|
||||||
|
{
|
||||||
|
id: 'cat-1',
|
||||||
|
name: 'Öle & Fette',
|
||||||
|
ingredients: [
|
||||||
|
{ id: 'ing-1', name: 'Olivenöl', isStaple: true },
|
||||||
|
{ id: 'ing-2', name: 'Butter', isStaple: false }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cat-2',
|
||||||
|
name: 'Gewürze',
|
||||||
|
ingredients: [
|
||||||
|
{ id: 'ing-3', name: 'Salz', isStaple: true },
|
||||||
|
{ id: 'ing-4', name: 'Pfeffer', isStaple: true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('StaplesManager', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all categories', () => {
|
||||||
|
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
|
||||||
|
expect(screen.getByText('Öle & Fette')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Gewürze')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders all chips with correct initial aria-pressed state', () => {
|
||||||
|
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
|
||||||
|
expect(screen.getByRole('button', { name: 'Olivenöl' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
expect(screen.getByRole('button', { name: 'Butter' })).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
expect(screen.getByRole('button', { name: 'Salz' })).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clicking a chip immediately updates aria-pressed (optimistic)', async () => {
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
|
||||||
|
|
||||||
|
const butter = screen.getByRole('button', { name: 'Butter' });
|
||||||
|
expect(butter).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
await user.click(butter);
|
||||||
|
expect(butter).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rapid clicks on same chip result in exactly one fetch call after debounce', async () => {
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
|
||||||
|
|
||||||
|
const butter = screen.getByRole('button', { name: 'Butter' });
|
||||||
|
await user.click(butter);
|
||||||
|
await user.click(butter);
|
||||||
|
await user.click(butter);
|
||||||
|
|
||||||
|
expect(fetch).not.toHaveBeenCalled();
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(fetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reverts chip and shows error when PATCH fails', async () => {
|
||||||
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false }));
|
||||||
|
const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
|
||||||
|
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
|
||||||
|
|
||||||
|
const butter = screen.getByRole('button', { name: 'Butter' });
|
||||||
|
await user.click(butter);
|
||||||
|
expect(butter).toHaveAttribute('aria-pressed', 'true');
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(300);
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(butter).toHaveAttribute('aria-pressed', 'false');
|
||||||
|
expect(screen.getByText(/konnte nicht gespeichert werden/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses 2-column grid class in onboarding context', () => {
|
||||||
|
render(StaplesManager, { props: { categories: mockCategories, context: 'onboarding' } });
|
||||||
|
const grid = screen.getByTestId('category-grid');
|
||||||
|
expect(grid.className).toContain('md:grid-cols-2');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses 3-column grid class in settings context', () => {
|
||||||
|
render(StaplesManager, { props: { categories: mockCategories, context: 'settings' } });
|
||||||
|
const grid = screen.getByTestId('category-grid');
|
||||||
|
expect(grid.className).toContain('md:grid-cols-3');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user