diff --git a/web/src/components/forms/IngredientItem.tsx b/web/src/components/forms/IngredientItem.tsx index 5a83c3e..9567d63 100644 --- a/web/src/components/forms/IngredientItem.tsx +++ b/web/src/components/forms/IngredientItem.tsx @@ -1,17 +1,108 @@ -import type { RecipeIngredient } from "../../types/recipe"; +import { Reorder, useDragControls } from "motion/react"; +import { INGREDIENT_UNITS, type RecipeIngredient } from "../../types/recipe"; +import DeleteIconSmall from "../icons/DeleteIconSmall"; +import DragIconSmall from "../icons/DragIconSmall"; +import { useEffect, useState } from "react"; interface IngredientItemProps { + classes: string; ingredient: RecipeIngredient; - onChange: (id: string, name: string) => void; + onChange: (id: string, name: "Amount" | "Unit" | "Name", value: string) => void; + removeIngredientHandler: (id: string) => void; + allowDelete: boolean; } -export default function IngredientItem({ ingredient, onChange }: IngredientItemProps) { +export default function IngredientItem({ classes, ingredient, onChange, removeIngredientHandler, allowDelete }: IngredientItemProps) { + const [dirty, setDirty] = useState(false); + const [valid, setValid] = useState(false); + + const controls = useDragControls(); + + + // HANDLERS + const changeHandler = (name: "Amount" | "Unit" | "Name", value: string) => { + if (!dirty) setDirty(true); + onChange(ingredient.Id, name, value); + } + + + + useEffect(() => { + let _valid = true; + if (ingredient.Name.trim() === "") _valid = false; + if (ingredient.Unit === "") _valid = false; + if (ingredient.Amount <= 0) _valid = false; + setValid(_valid); + }, [ingredient]); + + useEffect(() => { + console.log("@dirty", dirty); + }, [dirty]); + return ( - onChange(ingredient.Id, e.target.value)} - placeholder="Ingredient name" - /> + +
+
+
+ changeHandler("Amount", e.target.value)} + className={`w-1/2 md:w-28 ${classes} ${dirty && ingredient.Amount <= 0 ? "border-red-500" : ""}`} + /> + + +
+ + changeHandler("Name", e.target.value)} + className={`flex-grow ${classes} ${dirty && ingredient.Name.trim() === "" ? "border-red-500" : ""}`} + /> +
+ +
+ +
controls.start(e)} + className="p-1 md:p-0 cursor-pointer" + > + +
+
+
+ + {(dirty && !valid) && ( +

Please fill out all fields.

+ )} +
); } diff --git a/web/src/components/forms/IngredientList.tsx b/web/src/components/forms/IngredientList.tsx index 160506b..854c206 100644 --- a/web/src/components/forms/IngredientList.tsx +++ b/web/src/components/forms/IngredientList.tsx @@ -1,59 +1,40 @@ -import type { Dispatch, SetStateAction } from "react"; -import { type IngredientId, type IngredientsById, type RecipeIngredient, type RecipeIngredientSection, type SectionsById } from "../../types/recipe"; -import IngredientSectionElement from "./IngredientSectionElement"; import { Reorder } from "motion/react"; - +import type { RecipeIngredient, RecipeIngredientSection } from "../../types/recipe"; +import IngredientItem from "./IngredientItem"; interface IngredientListProps { + classes: string; + section: RecipeIngredientSection; + ingredients: RecipeIngredient[]; + setSectionIngredients: (sectionId: string, ingredients: RecipeIngredient[]) => void; + ingredientChangeHandler: (id: string, name: "Amount" | "Unit" | "Name", value: string) => void; + removeIngredientHandler: (id: string) => void; } -export default function IngredientList({ sectionOrder, setSectionOrder, sectionsById, ingredientsById, setIngredientsById }: IngredientListProps) { - const handleIngredientChange = ( - ingredientId: IngredientId, - field: "Name" | "Amount" | "Unit", - value: string - ) => { - setIngredientsById(prev => { - const ing = prev[ingredientId]; - if (!ing) return prev; +export default function IngredientList({ classes, section, ingredients, setSectionIngredients, ingredientChangeHandler, removeIngredientHandler }: IngredientListProps) { + const sectionIngredients = ingredients.filter(x => x.SectionId === section.Id); - const updated: RecipeIngredient = { - ...ing, - [field]: - field === "Amount" - ? (value === "" ? 0 : Number(value)) - : value, - }; - - // If nothing changed, keep same reference - if (updated === ing) return prev; - - return { ...prev, [ingredientId]: updated }; - }); - }; + const reorderHandler = (ingredients: RecipeIngredient[]) => { + setSectionIngredients(section.Id, ingredients); + } return ( - <> - - {sectionOrder.map(sectionId => { - const section = sectionsById[sectionId]; - console.log("@section", section); - return ( - - - - - ) - })} - - + + {sectionIngredients.map(ing => + 1} + /> + )} + ); } diff --git a/web/src/components/forms/IngredientSection.tsx b/web/src/components/forms/IngredientSection.tsx index 522a8a4..f8ad212 100644 --- a/web/src/components/forms/IngredientSection.tsx +++ b/web/src/components/forms/IngredientSection.tsx @@ -1,36 +1,53 @@ -import type { DragControls } from "motion/react"; +import { Reorder, useDragControls } from "motion/react"; import type { RecipeIngredientSection } from "../../types/recipe"; import DeleteIconSmall from "../icons/DeleteIconSmall"; import DragIconSmall from "../icons/DragIconSmall"; +import type { ReactNode } from "react"; interface IngredientSectionProps { section: RecipeIngredientSection; onChange: (id: string, name: string) => void; - index: number; - controls: DragControls; + removeIngredientSectionHandler: (id: string) => void; + allowDelete: boolean; + children?: ReactNode; }; -export default function IngredientSection({ section, onChange, index, controls }: IngredientSectionProps) { +export default function IngredientSection({ section, onChange, removeIngredientSectionHandler, allowDelete, children }: IngredientSectionProps) { + const controls = useDragControls(); + return ( -
-

Group:

- onChange(section.Id, e.target.value)} - placeholder="Section title" - className="mx-2 px-2 py-1 border border-gray-300 flex-grow rounded-sm" - /> + +
+

Group:

+ onChange(section.Id, e.target.value)} + placeholder="Section title" + className="mx-2 px-2 py-1 border border-gray-300 flex-grow rounded-sm" + /> -
- -
controls.start(e)}> - +
+ +
controls.start(e)}> + +
-
-
+ {children} + ); } + diff --git a/web/src/pages/Create.tsx b/web/src/pages/Create.tsx index ca22c20..59061ba 100644 --- a/web/src/pages/Create.tsx +++ b/web/src/pages/Create.tsx @@ -1,12 +1,11 @@ -import { Fragment, useEffect, useState, type ChangeEvent, type FormEvent } from "react"; +import { useEffect, useState, type ChangeEvent, type FormEvent } from "react"; import Banner from "../components/Banner"; -import { isRecipeMeal, RecipeIngredient, type IngredientsById, type RecipeIngredientSection, type RecipeInstruction, type SectionId, type SectionsById } from "../types/recipe"; +import { isRecipeMeal, type RecipeIngredient, type RecipeIngredientSection, type RecipeIngredientUnit, type RecipeInstruction } from "../types/recipe"; import InstructionList from "../components/forms/InstructionList"; import ValidationErrorList from "../components/forms/ValidationErrorList"; import IngredientSection from "../components/forms/IngredientSection"; -import { section } from "motion/react-client"; -import IngredientItem from "../components/forms/IngredientItem"; -import { Reorder, useDragControls } from "motion/react"; +import { Reorder } from "motion/react"; +import IngredientList from "../components/forms/IngredientList"; // TODO: Move this interface CreateRecipeForm { @@ -59,41 +58,18 @@ export default function Create() { image: null, }); // Store complex values elsewhere - const [instructions, setInstructions] = useState([{ Id: crypto.randomUUID(), Content: "" }, { Id: crypto.randomUUID(), Content: "" }]); + const [instructions, setInstructions] = useState([ + { Id: crypto.randomUUID(), Content: "" } + ]); - // Ingredients const [sections, setSections] = useState([ - { Id: "a", Name: "Section 1" }, - { Id: "b", Name: "Section 2" }, - { Id: "c", Name: "Section 3" } + { Id: "initial-section", Name: "Unnamed group" }, ]); const [ingredients, setIngredients] = useState([ - { Id: crypto.randomUUID(), SectionId: "a", Name: "Ingredient 1", Amount: 1, Unit: "lb" }, - { Id: crypto.randomUUID(), SectionId: "a", Name: "Ingredient 2", Amount: 1, Unit: "lb" }, - { Id: crypto.randomUUID(), SectionId: "b", Name: "Ingredient 3", Amount: 1, Unit: "lb" }, - { Id: crypto.randomUUID(), SectionId: "c", Name: "Ingredient 4", Amount: 1, Unit: "lb" }, - { Id: crypto.randomUUID(), SectionId: "c", Name: "Ingredient 5", Amount: 1, Unit: "lb" }, - { Id: crypto.randomUUID(), SectionId: "c", Name: "Ingredient 6", Amount: 1, Unit: "lb" }, + { Id: crypto.randomUUID(), SectionId: "initial-section", Name: "", Amount: 0, Unit: "" }, ]); - const sectionChangeHandler = (id: string, name: string) => { - setSections(prev => - prev.map(section => - section.Id === id ? { ...section, Name: name } : section - ) - ); - } - - const ingredientChangeHandler = (id: string, name: string) => { - setIngredients(prev => - prev.map(ing => - ing.Id === id ? { ...ing, Name: name } : ing - ) - ); - } - - // VALIDATION STATE const [validation, setValidation] = useState({ title: true, @@ -136,7 +112,6 @@ export default function Create() { setValidation(state); } - // HANDLERS const changeHandler = (e: ChangeEvent) => { const { name, value } = e.target; @@ -186,22 +161,76 @@ export default function Create() { setInstructions([...instructions, { Id: crypto.randomUUID(), Content: "" }]); } + 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(); - console.log("@inputs", inputs); - }, [inputs, instructions]); - - // useEffect(() => { - // console.log("@validation", validation); - // }, [validation]); - - useEffect(() => { - console.log("@instructions", instructions); - }, [instructions]); - + }, [inputs, instructions, ingredients]); useEffect(() => { // The form is only valid when every item has been touched, and every item is valid! @@ -448,32 +477,49 @@ export default function Create() { Ingredients * -

Please provide a list of ingredients and their quantities.

+

+ Please provide a list of ingredients and their quantities. Ingredients can be added into + groups. If only a single group exists, the group will be ignored when the recipe is created + and the ingredients will appear as a single list. +

- {sections.map((section, i) => { - const controls = useDragControls(); + {sections.map((section, i) => ( + 1} + > - return ( - + + + + ))}
@@ -492,7 +538,7 @@ export default function Create() {