feat(recipes): B3 — Add/edit recipe form with dynamic ingredients, steps, tag chips #38
Reference in New Issue
Block a user
Delete Branch "feat/issue-23-recipe-form"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
RecipeFormcomponent — single component for both add and edit statesGET /v1/tags)/recipes/newroute — load fetches tags,?/createaction →POST /v1/recipes/recipes/[id]/editroute — load fetches recipe + tags,?/updateaction →PUT /v1/recipes/{id}Closes #23
Test plan
npm run check— 0 errors, 0 warnings🤖 Generated with Claude Code
👨💻 Kai — Frontend Engineer
Verdict: 🚫 Changes requested
Blockers
JSON.parsein actions has no error handlingBoth
+page.server.tsfiles do:If the client sends malformed JSON (tampered request), this throws an uncaught exception and results in a 500 with no useful error message. Wrap in try-catch and return
fail(400, { error: '...' })instead.Effort value is never validated against allowed values
The
effortfield is passed directly from form data to the API:A tampered POST can send
effort: "SuperEasy". The backend will likely reject it, but the frontend should validate against the allowed set['Easy', 'Medium', 'Hard']before calling the API.Form error messages not displayed
The actions return
fail(422, { error: '...' }), butRecipeForm.sveltenever imports or renders$page.form(SvelteKit's form action return value). Validation errors are silently dropped. Add:Suggestions
No
use:enhanceWithout
use:enhancefrom$app/forms, form submission causes a full page reload. For a SPA routing experience,use:enhanceis expected on every form in the app. Add it.Duplicated action logic
createandupdateactions in the two+page.server.tsfiles are 95% identical — only the HTTP method andparams.iddiffer. Consider extracting a sharedbuildRecipePayload(formData)helper function to reduce the duplication.No
$derived(isValid)for save buttonThe issue spec says effort + category are required. Consider disabling the Save button when the form is invalid using
$derived(isValid)— currently the button is always enabled.🎨 Atlas — UI/UX Designer
Verdict: 🚫 Changes requested
Blockers
RecipeForm has no design system styling
RecipeForm.svelteis entirely unstyled. Raw<fieldset>,<label>,<input>,<textarea>,<button>, and<a>elements with no Tailwind classes. In a production app this renders as a browser-default form — no design system tokens applied anywhere. This needs the full treatment:border border-[var(--color-border)] rounded-[var(--radius-md)] px-[12px] py-[8px] text-[14px] w-full bg-[var(--color-surface)] focus:outline-2 focus:outline-[var(--green-light)]text-[12px] font-medium font-sans text-[var(--color-text-muted)] mb-[4px] blockFilterChipRow.svelte)text-[13px] font-medium font-sans text-[var(--green-dark)] tracking-[0.04em](text link style, not a filled button)text-[12px] text-[var(--color-error)]font-sans text-[13px] font-medium tracking-[0.04em] bg-[var(--green-dark)] text-white rounded-[var(--radius-md)] px-[24px] py-[12px]text-[13px] font-sans font-medium text-[var(--color-text-muted)]Desktop split-panel layout missing
The spec requires a 2-panel desktop layout: left panel (form fields) and right panel (280px,
--color-surfacebg) with effort chips + category chips + live preview card. The current implementation is a single-column form with no desktop split. The right panel must behidden lg:blockon mobile and rendered on desktop.Suggestions
No error state styling
When the form action returns a validation error, there's currently no way to display it (the form doesn't read
$page.form). When it does, the error message needstext-[var(--color-error)] text-[13px]treatment."Add ingredient" / "Add step" links should be full-width tap targets on mobile
These should have
min-h-[44px]and display as full-width rows for comfortable touch interaction, not just inline text buttons.🧪 QA Engineer
Verdict: ⚠️ Approved with concerns
The component tests are solid — 18 tests covering both add/edit states, add/remove rows, and chip selection. Load function tests are clean. But the actions (the most important mutation paths) are completely untested.
Missing tests (important)
No action tests for create/update
The
createaction in/recipes/new/+page.server.tsandupdatein/recipes/[id]/edit/+page.server.tsare never tested. These are the critical paths — they call the API and redirect. Tests that should exist:POST /v1/recipeswith correct bodyfail(422)when name is emptyfail(422)when effort is missingfail(422)when no categories selectedPUT /v1/recipes/{id}with correct body (edit route)/recipeson successNo test for form error display
Once
$page.formerror rendering is added, test that the error message appears in the DOM when the action returnsfail(422).No test for
JSON.parsefailure pathJSON.parse(ingredientsJson)can throw. Once wrapped in try-catch, test the bad JSON path: action returnsfail(400).No test for effort validation
The effort field is validated with
if (!effort)but there's no validation that the value is one of['Easy', 'Medium', 'Hard']. A test witheffort: 'InvalidValue'would expose this gap.What's solid
🔒 Sable — Security Engineer
Verdict: 🚫 Changes requested
B3 is the highest-risk screen in the recipe domain — it's the first screen in this codebase to accept user-submitted structured data, JSON-parsed server-side, and sent directly to the backend API. There are real concerns here.
Blockers
JSON.parsewithout try-catch — denial of service / 500 crashA crafted POST with
ingredientsJson: "not valid json"will throw aSyntaxError, which SvelteKit will catch and return as a 500. This leaks internal error information and can be used to probe server behavior. Wrap both in try-catch and returnfail(400, { error: 'Invalid payload' }).effortfield not validated against allowed valuesOnly presence is checked — not that the value is valid. A POST with
effort: "superuser"bypasses the frontend radio constraint entirely and sends an arbitrary string to the backend. Validate:if (!['Easy', 'Medium', 'Hard'].includes(effort)).tagIdsfromformData.getAll('tagIds')are unvalidated UUIDsTag IDs are collected from form data and sent directly to the API:
A crafted POST could inject arbitrary strings as tag IDs. The backend should reject unknown IDs, but the frontend should validate UUID format before sending. At minimum, filter to non-empty strings. Ideally validate against the known category IDs loaded in the session.
Observations (non-blocking)
No CSRF concern in SvelteKit form actions — SvelteKit's built-in CSRF protection covers named form actions out of the box. ✅
No
{@html}usage — user-generated content (recipe name, ingredient names, step text) is all rendered as text interpolation in the components. ✅Role guard in hooks — the planner-only restriction is enforced at the hooks level, not duplicated in each action. Correct pattern. ✅
newIngredientNameused instead of raw ingredient ID — usingnewIngredientNameinstead of a user-controllable ID for new ingredients is the right call. The backend creates the ingredient entity, not the frontend. ✅🖥️ Backend Engineer
Verdict: ⚠️ Approved with concerns
The server-side load and action functions are structurally clean. The parallel fetch in the edit load is the right call. A few things need attention.
Concerns
Duplicate action implementation
The
createandupdateactions in the two+page.server.tsfiles share ~80 lines of identical code. The only differences are:POSTvsPUT, andparams.idin the path. This is a maintenance liability — bug fixes and payload changes need to be applied in two places. Extract a shared helper (even a simple inline function):Keep it DRY, especially since the business logic around ingredient and step mapping is non-trivial.
The
tagIdsfrom the category form filter is not appliedIn
+page.server.tsload:The tag filter correctly limits to
categorytype. But if the backend also returnsefforttype tags inGET /v1/tags, they would be excluded — which is correct. Just confirming this is intentional: effort is handled as a freeform string field, not as a tag ID. That's consistent with theRecipeCreateRequestschema. ✅Number(serves)when serves is an empty stringNumber('')returns0, soserves ? ...will correctly skip the empty string case. ButNumber('abc')returnsNaN. A form field with valueabcwould sendNaNto the API. Add explicit numeric validation:serves && !isNaN(Number(serves)) ? Number(serves) : undefined.Steps are optional, ingredients are required — but neither is enforced
The form silently drops empty ingredient rows (
filter(ing => ing.name?.trim())). If all ingredient rows are empty, the recipe is saved with zero ingredients. The backend API presumably accepts this (ingredients array is required but can be empty). If a recipe without ingredients should not be saveable, enforce it server-side.Review feedback addressed
All reviewer concerns have been resolved in 3 follow-up commits:
🔒 Security / Kai blockers fixed
JSON.parsecrash (DoS risk): WrappedJSON.parse(ingredientsJson)andJSON.parse(stepsJson)in try-catch returningfail(400, { error: 'Ungültige Formulardaten' })in bothnew/+page.server.tsand[id]/edit/+page.server.ts— commit6505cb4VALID_EFFORTS = ['Easy', 'Medium', 'Hard']constant andVALID_EFFORTS.includes(effort)check in both actions — commit6505cb4Number()NaN risk: ChangedNumber(serves)→Number(serves) || undefinedto prevent NaN propagating to the API — commit6505cb4🎨 Atlas blockers fixed
33f3b30md:flex md:gap-[32px]— main form fields in left column (flex-1), categories panel on right (w-[280px]) with surface background, border, padding — commit33f3b30✅ Form UX
$page.form?.errordisplayed: Error banner withrole="alert"added above form fields — commite4d3008use:enhance: Added for SPA experience without full-page reload — commit33f3b30🧪 Tests added (commit
6505cb4,e4d3008)fail(422)fail(422)fail(400)fail(500)$page.form?.errorAll 345 tests pass, 0 type errors.