(FIX): Extracted a little bit so the Create page is cleaner.
This commit is contained in:
parent
deecc01c7e
commit
8873727585
@ -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
|
||||
|
||||
84
web/src/hooks/useIngredients.ts
Normal file
84
web/src/hooks/useIngredients.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
48
web/src/hooks/validation.ts
Normal file
48
web/src/hooks/validation.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user