(FIX): Extracted a little bit so the Create page is cleaner.

This commit is contained in:
Hayden Hargreaves 2025-12-17 23:19:53 -07:00
parent deecc01c7e
commit 8873727585
5 changed files with 168 additions and 105 deletions

View File

@ -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

View File

@ -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<RecipeIngredientSection[]>([
{ Id: "initial-section", Name: "Unnamed group" },
]);
const [ingredients, setIngredients] = useState<RecipeIngredient[]>([
{ 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,
};
}

View File

@ -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,
}
}

View File

@ -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<string>("");
const [category, setCategory] = useState<string>("");
const [difficulty, setDifficulty] = useState<string>("");
const [instructions, setInstructions] = useState<RecipeInstruction[]>([
{ Id: crypto.randomUUID(), Content: "" }
]);
const [sections, setSections] = useState<RecipeIngredientSection[]>([
{ Id: "initial-section", Name: "Unnamed group" },
]);
const [ingredients, setIngredients] = useState<RecipeIngredient[]>([
{ Id: crypto.randomUUID(), SectionId: "initial-section", Name: "", Amount: 0, Unit: "" },
]);
// Validation State
const [validation, setValidation] = useState<CreateRecipeFormEntries>({
@ -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() {
<IngredientSection
key={section.Id}
section={section}
onChange={sectionChangeHandler}
removeIngredientSectionHandler={removeIngredientSectionHandler}
onChange={sectionChange}
removeIngredientSectionHandler={removeSection}
allowDelete={sections.length > 1}
>
<IngredientList
@ -387,17 +318,17 @@ export default function Create() {
section={section}
ingredients={ingredients}
setSectionIngredients={setSectionIngredients}
ingredientChangeHandler={ingredientChangeHandler}
removeIngredientHandler={removeIngredientHandler}
ingredientChangeHandler={ingredientChange}
removeIngredientHandler={removeIngredient}
/>
<button
onClick={() => addIngredientHandler(section.Id)}
onClick={() => addIngredient(section.Id)}
className="w-fit px-3 py-1.5 text-blue-800 font-semibold text-xs md:text-sm rounded-lg hover:bg-gray-100 duration-300 cursor-pointer"
>
Add ingredient
</button>
<button
onClick={() => addIngredientSectionHandler(i)}
onClick={() => addSection(i)}
className="w-fit px-3 py-1.5 text-blue-800 font-semibold text-xs md:text-sm rounded-lg hover:bg-gray-100 duration-300 cursor-pointer"
>
Add group header