From 8873727585a8df2e7cf04483d469acc15d55b730 Mon Sep 17 00:00:00 2001 From: Hayden Hargreaves Date: Wed, 17 Dec 2025 23:19:53 -0700 Subject: [PATCH] (FIX): Extracted a little bit so the Create page is cleaner. --- .../inputs/RecipeCreateFormTextArea.tsx | 4 +- .../inputs/RecipeCreateFormWrapper.tsx | 2 +- web/src/hooks/useIngredients.ts | 84 +++++++++++ web/src/hooks/validation.ts | 48 +++++++ web/src/pages/Create.tsx | 135 +++++------------- 5 files changed, 168 insertions(+), 105 deletions(-) create mode 100644 web/src/hooks/useIngredients.ts create mode 100644 web/src/hooks/validation.ts diff --git a/web/src/components/inputs/RecipeCreateFormTextArea.tsx b/web/src/components/inputs/RecipeCreateFormTextArea.tsx index d31a5df..2cfb36f 100644 --- a/web/src/components/inputs/RecipeCreateFormTextArea.tsx +++ b/web/src/components/inputs/RecipeCreateFormTextArea.tsx @@ -1,4 +1,4 @@ -import { useEffect, type ChangeEvent, type Dispatch, type InputHTMLAttributes, type SetStateAction, type TextareaHTMLAttributes } from "react"; +import { useEffect, type ChangeEvent, type Dispatch, type SetStateAction, type TextareaHTMLAttributes } from "react"; import type { CreateRecipeFormEntries } from "../../pages/Create"; interface RecipeCreateFormInputProps @@ -22,7 +22,7 @@ interface RecipeCreateFormInputProps export default function RecipeCreateFormTextArea({ label, name, desc, placeholder, required = false, valid, value, setDirty, setValue, error, parentClasses = "", classes, ...inputProps }: RecipeCreateFormInputProps) { const handleChange = (e: ChangeEvent) => { - setDirty(prev => ({...prev, [name]: true})); + setDirty(prev => ({ ...prev, [name]: true })); setValue(e.target.value); } diff --git a/web/src/components/inputs/RecipeCreateFormWrapper.tsx b/web/src/components/inputs/RecipeCreateFormWrapper.tsx index 63d19ad..b7ac0b8 100644 --- a/web/src/components/inputs/RecipeCreateFormWrapper.tsx +++ b/web/src/components/inputs/RecipeCreateFormWrapper.tsx @@ -9,7 +9,7 @@ interface RecipeCreateFormWrapperProps { children: ReactNode; } -export default function RecipeCreateFormWrapper({label, name, desc, required = false, parentClasses, children }: RecipeCreateFormWrapperProps) { +export default function RecipeCreateFormWrapper({ label, name, desc, required = false, parentClasses, children }: RecipeCreateFormWrapperProps) { const normalized_name = name.toLowerCase().replaceAll(" ", "-"); return ( diff --git a/web/src/hooks/useIngredients.ts b/web/src/hooks/useIngredients.ts new file mode 100644 index 0000000..15f21b4 --- /dev/null +++ b/web/src/hooks/useIngredients.ts @@ -0,0 +1,84 @@ +import { useState } from "react"; +import type { RecipeIngredient, RecipeIngredientSection, RecipeIngredientUnit } from "../types/recipe"; + +export function useIngredients() { + // State values + const [sections, setSections] = useState([ + { Id: "initial-section", Name: "Unnamed group" }, + ]); + + const [ingredients, setIngredients] = useState([ + { Id: crypto.randomUUID(), SectionId: "initial-section", Name: "", Amount: 0, Unit: "" as RecipeIngredientUnit }, + ]); + + // Section handlers + const sectionChange = (id: string, name: string) => { + setSections(prev => prev.map(s => (s.Id === id ? { ...s, Name: name } : s))); + }; + + const setSectionIngredients = (sectionId: string, list: RecipeIngredient[]) => { + const sorted = [ + ...list.filter(x => x.SectionId === sectionId), + ...ingredients.filter(x => x.SectionId !== sectionId), + ].sort((a, b) => a.SectionId.localeCompare(b.SectionId)); + setIngredients(sorted); + }; + + const addSection = (index: number) => { + const id = crypto.randomUUID(); + setSections(prev => [ + ...prev.slice(0, index + 1), + { Id: id, Name: "Unnamed group" }, + ...prev.slice(index + 1), + ]); + setIngredients(prev => [ + ...prev, + { Id: crypto.randomUUID(), SectionId: id, Amount: 0, Name: "", Unit: "" as RecipeIngredientUnit }, + ]); + }; + + const removeSection = (id: string) => { + setSections(prev => prev.filter(sec => sec.Id !== id)); + setIngredients(prev => prev.filter(ing => ing.SectionId !== id)); + }; + + // Ingredient handlers + const ingredientChange = (id: string, name: "Amount" | "Unit" | "Name", value: string) => { + setIngredients(prev => + prev.map(ing => + ing.Id === id + ? { ...ing, [name]: name === "Amount" ? Number(value) : value } + : ing + ) + ); + }; + + const addIngredient = (sectionId: string) => { + setIngredients(prev => + [...prev, { + Id: crypto.randomUUID(), + SectionId: sectionId, + Amount: 0, + Name: "", + Unit: "" as RecipeIngredientUnit, + }].sort((a, b) => a.SectionId.localeCompare(b.SectionId)) + ); + }; + + const removeIngredient = (id: string) => { + setIngredients(prev => prev.filter(ing => ing.Id !== id)); + }; + + return { + sections, + ingredients, + setSections, + sectionChange, + ingredientChange, + setSectionIngredients, + addIngredient, + removeIngredient, + addSection, + removeSection, + }; +} diff --git a/web/src/hooks/validation.ts b/web/src/hooks/validation.ts new file mode 100644 index 0000000..4e9b44b --- /dev/null +++ b/web/src/hooks/validation.ts @@ -0,0 +1,48 @@ +import type { CreateRecipeFormEntries } from "../pages/Create"; +import { isRecipeMeal } from "../types/recipe"; + +export interface CreateRecipeFormValues { + title: string; + description: string; + prepTime: string; + cookTime: string; + servingSize: string; + category: string; + difficulty: string; +} + +export function validateCreateRecipeForm(values: CreateRecipeFormValues, dirty: CreateRecipeFormEntries): CreateRecipeFormEntries { + return { + title: dirty.title + ? values.title.length >= 1 && values.title.length <= 128 + : true, + description: dirty.description + ? values.description.length >= 1 && values.description.length <= 1000 + : true, + prepTime: dirty.prepTime + ? values.prepTime !== "" && + Number(values.prepTime) >= 0 && + Number(values.prepTime) <= 300 + : true, + cookTime: dirty.cookTime + ? values.cookTime !== "" && + Number(values.cookTime) >= 0 && + Number(values.cookTime) <= 300 + : true, + servingSize: dirty.servingSize + ? values.servingSize !== "" && + Number(values.servingSize) >= 1 && + Number(values.servingSize) <= 16 + : true, + category: dirty.category + ? values.category !== "" && isRecipeMeal(values.category) + : true, + difficulty: dirty.difficulty + ? values.difficulty !== "" && + Number(values.difficulty) >= 1 && + Number(values.difficulty) <= 5 + : true, + ingredients: true, + instructions: true, + } +} diff --git a/web/src/pages/Create.tsx b/web/src/pages/Create.tsx index 71691de..5046485 100644 --- a/web/src/pages/Create.tsx +++ b/web/src/pages/Create.tsx @@ -1,6 +1,6 @@ -import { useEffect, useState, type FormEvent } from "react"; +import { useEffect, useState } from "react"; import Banner from "../components/Banner"; -import { isRecipeMeal, type RecipeIngredient, type RecipeIngredientSection, type RecipeIngredientUnit, type RecipeInstruction } from "../types/recipe"; +import { type RecipeInstruction } from "../types/recipe"; import InstructionList from "../components/forms/InstructionList"; import ValidationErrorList from "../components/forms/ValidationErrorList"; import IngredientSection from "../components/forms/IngredientSection"; @@ -11,6 +11,8 @@ import RecipeCreateDropdownInput, { type RecipeCreateDropdownOption } from "../c import RecipeCreateFormTextArea from "../components/inputs/RecipeCreateFormTextArea"; import RecipeCreateFormWrapper from "../components/inputs/RecipeCreateFormWrapper"; import RecipeCreateFormTagsInputs from "../components/inputs/RecipeCreateFormTagsInput"; +import { useIngredients } from "../hooks/useIngredients"; +import { validateCreateRecipeForm } from "../hooks/validation"; // TODO: Move this export interface CreateRecipeFormEntries { @@ -54,7 +56,6 @@ const DIFFICULTY_OPTIONS: RecipeCreateDropdownOption[] = [ { value: "2", name: "Easy" }, { value: "3", name: "Intermediate" }, { value: "4", name: "Challenging" }, - { value: "5", name: "Extreme" }, ]; export default function Create() { @@ -67,15 +68,10 @@ export default function Create() { const [servingSize, setServingSize] = useState(""); const [category, setCategory] = useState(""); const [difficulty, setDifficulty] = useState(""); + const [instructions, setInstructions] = useState([ { Id: crypto.randomUUID(), Content: "" } ]); - const [sections, setSections] = useState([ - { Id: "initial-section", Name: "Unnamed group" }, - ]); - const [ingredients, setIngredients] = useState([ - { Id: crypto.randomUUID(), SectionId: "initial-section", Name: "", Amount: 0, Unit: "" }, - ]); // Validation State const [validation, setValidation] = useState({ @@ -100,104 +96,39 @@ export default function Create() { servingSize: false, category: false, difficulty: false, - ingredients: true, // This can be ignored since they're self contained - instructions: true, // This we can ignore since they're self contained + ingredients: true, // Can this be ignored since they're self contained? + instructions: true, // Can this be ignored since they're self contained? }); - // Validate the dirty inputs, one at a time - const validate = () => { - const state = { ...validation }; - state.title = dirty.title ? (title.length >= 1 && title.length <= 128) : true; - state.description = dirty.description ? (description.length >= 1 && description.length <= 1000) : true; - - state.prepTime = dirty.prepTime ? (prepTime !== "" && Number(prepTime) >= 0 && Number(prepTime) <= 300) : true; - state.cookTime = dirty.cookTime ? (cookTime !== "" && Number(cookTime) >= 0 && Number(cookTime) <= 300) : true; - state.servingSize = dirty.servingSize ? (servingSize !== "" && Number(servingSize) >= 1 && Number(servingSize) <= 16) : true; - - state.category = dirty.category ? (category !== "" && isRecipeMeal(category)) : true; - state.difficulty = dirty.difficulty ? (difficulty !== "" && Number(difficulty) >= 1 && Number(difficulty) <= 5) : true; - - // TODO: How do I validate the instructions and ingredients - - setValidation(state); - } + // Import ingredients + const { + sections, + ingredients, + setSections, + sectionChange, + ingredientChange, + setSectionIngredients, + addIngredient, + removeIngredient, + addSection, + removeSection, + } = useIngredients(); // Instruction handlers const addInstructionHandler = () => { setInstructions([...instructions, { Id: crypto.randomUUID(), Content: "" }]); } - // Ingredient Handlers - const sectionChangeHandler = (id: string, name: string) => { - setSections(prev => - prev.map(section => - section.Id === id ? { ...section, Name: name } : section - ) - ); - } - - const ingredientChangeHandler = (id: string, name: "Amount" | "Unit" | "Name", value: string) => { - setIngredients(prev => - prev.map(ing => - ing.Id === id ? { ...ing, [name]: name === "Amount" ? Number(value) : value } : ing - ) - ); - } - - const setSectionIngredients = (sectionId: string, new_ingredients: RecipeIngredient[]) => { - const sorted = [ - ...new_ingredients.filter(x => x.SectionId === sectionId), - ...ingredients.filter(x => x.SectionId !== sectionId) - ].sort((a, b) => a.SectionId.localeCompare(b.SectionId)); - - setIngredients(sorted); - } - - const addIngredientHandler = (sectionId: string) => { - const new_ingredients = [...ingredients, { - Id: crypto.randomUUID(), - SectionId: sectionId, - Amount: 0, - Name: "", - Unit: "" as RecipeIngredientUnit - }]; - - // TODO: Should I be using spread? - setIngredients([...new_ingredients.sort((a, b) => a.SectionId.localeCompare(b.SectionId))]); - } - - const removeIngredientHandler = (id: string) => { - setIngredients(prev => prev.filter(ing => ing.Id !== id)); - } - - const addIngredientSectionHandler = (i: number) => { - const id = crypto.randomUUID(); - - setSections(prev => [ - ...prev.slice(0, i + 1), - { Id: id, Name: "Unnamed group" }, - ...prev.slice(i + 1), - ]); - - setIngredients([...ingredients, { - Id: crypto.randomUUID(), - SectionId: id, - Amount: 0, - Name: "", - Unit: "" as RecipeIngredientUnit, - }]); - } - - const removeIngredientSectionHandler = (id: string) => { - setSections(prev => prev.filter(sec => sec.Id !== id)); - setIngredients(prev => prev.filter(ing => ing.SectionId !== id)); - } - // EFFECTS useEffect(() => { // Execute validation every time inputs change - validate(); - }, [title, description, prepTime, cookTime, servingSize, category, difficulty, instructions, ingredients]); + setValidation( + validateCreateRecipeForm( + { title, description, prepTime, cookTime, servingSize, category, difficulty }, + dirty + ) + ); + }, [title, description, prepTime, cookTime, servingSize, category, difficulty, instructions, ingredients, dirty]); useEffect(() => { // The form is only valid when every item has been touched, and every item is valid! @@ -378,8 +309,8 @@ export default function Create() { 1} >