(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";
|
import type { CreateRecipeFormEntries } from "../../pages/Create";
|
||||||
|
|
||||||
interface RecipeCreateFormInputProps
|
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) {
|
export default function RecipeCreateFormTextArea({ label, name, desc, placeholder, required = false, valid, value, setDirty, setValue, error, parentClasses = "", classes, ...inputProps }: RecipeCreateFormInputProps) {
|
||||||
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
setDirty(prev => ({...prev, [name]: true}));
|
setDirty(prev => ({ ...prev, [name]: true }));
|
||||||
setValue(e.target.value);
|
setValue(e.target.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ interface RecipeCreateFormWrapperProps {
|
|||||||
children: ReactNode;
|
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(" ", "-");
|
const normalized_name = name.toLowerCase().replaceAll(" ", "-");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
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 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 InstructionList from "../components/forms/InstructionList";
|
||||||
import ValidationErrorList from "../components/forms/ValidationErrorList";
|
import ValidationErrorList from "../components/forms/ValidationErrorList";
|
||||||
import IngredientSection from "../components/forms/IngredientSection";
|
import IngredientSection from "../components/forms/IngredientSection";
|
||||||
@ -11,6 +11,8 @@ import RecipeCreateDropdownInput, { type RecipeCreateDropdownOption } from "../c
|
|||||||
import RecipeCreateFormTextArea from "../components/inputs/RecipeCreateFormTextArea";
|
import RecipeCreateFormTextArea from "../components/inputs/RecipeCreateFormTextArea";
|
||||||
import RecipeCreateFormWrapper from "../components/inputs/RecipeCreateFormWrapper";
|
import RecipeCreateFormWrapper from "../components/inputs/RecipeCreateFormWrapper";
|
||||||
import RecipeCreateFormTagsInputs from "../components/inputs/RecipeCreateFormTagsInput";
|
import RecipeCreateFormTagsInputs from "../components/inputs/RecipeCreateFormTagsInput";
|
||||||
|
import { useIngredients } from "../hooks/useIngredients";
|
||||||
|
import { validateCreateRecipeForm } from "../hooks/validation";
|
||||||
|
|
||||||
// TODO: Move this
|
// TODO: Move this
|
||||||
export interface CreateRecipeFormEntries {
|
export interface CreateRecipeFormEntries {
|
||||||
@ -54,7 +56,6 @@ const DIFFICULTY_OPTIONS: RecipeCreateDropdownOption[] = [
|
|||||||
{ value: "2", name: "Easy" },
|
{ value: "2", name: "Easy" },
|
||||||
{ value: "3", name: "Intermediate" },
|
{ value: "3", name: "Intermediate" },
|
||||||
{ value: "4", name: "Challenging" },
|
{ value: "4", name: "Challenging" },
|
||||||
{ value: "5", name: "Extreme" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function Create() {
|
export default function Create() {
|
||||||
@ -67,15 +68,10 @@ export default function Create() {
|
|||||||
const [servingSize, setServingSize] = useState<string>("");
|
const [servingSize, setServingSize] = useState<string>("");
|
||||||
const [category, setCategory] = useState<string>("");
|
const [category, setCategory] = useState<string>("");
|
||||||
const [difficulty, setDifficulty] = useState<string>("");
|
const [difficulty, setDifficulty] = useState<string>("");
|
||||||
|
|
||||||
const [instructions, setInstructions] = useState<RecipeInstruction[]>([
|
const [instructions, setInstructions] = useState<RecipeInstruction[]>([
|
||||||
{ Id: crypto.randomUUID(), Content: "" }
|
{ 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
|
// Validation State
|
||||||
const [validation, setValidation] = useState<CreateRecipeFormEntries>({
|
const [validation, setValidation] = useState<CreateRecipeFormEntries>({
|
||||||
@ -100,104 +96,39 @@ export default function Create() {
|
|||||||
servingSize: false,
|
servingSize: false,
|
||||||
category: false,
|
category: false,
|
||||||
difficulty: false,
|
difficulty: false,
|
||||||
ingredients: true, // This can be ignored since they're self contained
|
ingredients: true, // Can this be ignored since they're self contained?
|
||||||
instructions: true, // This we can ignore since they're self contained
|
instructions: true, // Can this be ignored since they're self contained?
|
||||||
});
|
});
|
||||||
|
|
||||||
// Validate the dirty inputs, one at a time
|
// Import ingredients
|
||||||
const validate = () => {
|
const {
|
||||||
const state = { ...validation };
|
sections,
|
||||||
state.title = dirty.title ? (title.length >= 1 && title.length <= 128) : true;
|
ingredients,
|
||||||
state.description = dirty.description ? (description.length >= 1 && description.length <= 1000) : true;
|
setSections,
|
||||||
|
sectionChange,
|
||||||
state.prepTime = dirty.prepTime ? (prepTime !== "" && Number(prepTime) >= 0 && Number(prepTime) <= 300) : true;
|
ingredientChange,
|
||||||
state.cookTime = dirty.cookTime ? (cookTime !== "" && Number(cookTime) >= 0 && Number(cookTime) <= 300) : true;
|
setSectionIngredients,
|
||||||
state.servingSize = dirty.servingSize ? (servingSize !== "" && Number(servingSize) >= 1 && Number(servingSize) <= 16) : true;
|
addIngredient,
|
||||||
|
removeIngredient,
|
||||||
state.category = dirty.category ? (category !== "" && isRecipeMeal(category)) : true;
|
addSection,
|
||||||
state.difficulty = dirty.difficulty ? (difficulty !== "" && Number(difficulty) >= 1 && Number(difficulty) <= 5) : true;
|
removeSection,
|
||||||
|
} = useIngredients();
|
||||||
// TODO: How do I validate the instructions and ingredients
|
|
||||||
|
|
||||||
setValidation(state);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Instruction handlers
|
// Instruction handlers
|
||||||
const addInstructionHandler = () => {
|
const addInstructionHandler = () => {
|
||||||
setInstructions([...instructions, { Id: crypto.randomUUID(), Content: "" }]);
|
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
|
// EFFECTS
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Execute validation every time inputs change
|
// Execute validation every time inputs change
|
||||||
validate();
|
setValidation(
|
||||||
}, [title, description, prepTime, cookTime, servingSize, category, difficulty, instructions, ingredients]);
|
validateCreateRecipeForm(
|
||||||
|
{ title, description, prepTime, cookTime, servingSize, category, difficulty },
|
||||||
|
dirty
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [title, description, prepTime, cookTime, servingSize, category, difficulty, instructions, ingredients, dirty]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// The form is only valid when every item has been touched, and every item is valid!
|
// 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
|
<IngredientSection
|
||||||
key={section.Id}
|
key={section.Id}
|
||||||
section={section}
|
section={section}
|
||||||
onChange={sectionChangeHandler}
|
onChange={sectionChange}
|
||||||
removeIngredientSectionHandler={removeIngredientSectionHandler}
|
removeIngredientSectionHandler={removeSection}
|
||||||
allowDelete={sections.length > 1}
|
allowDelete={sections.length > 1}
|
||||||
>
|
>
|
||||||
<IngredientList
|
<IngredientList
|
||||||
@ -387,17 +318,17 @@ export default function Create() {
|
|||||||
section={section}
|
section={section}
|
||||||
ingredients={ingredients}
|
ingredients={ingredients}
|
||||||
setSectionIngredients={setSectionIngredients}
|
setSectionIngredients={setSectionIngredients}
|
||||||
ingredientChangeHandler={ingredientChangeHandler}
|
ingredientChangeHandler={ingredientChange}
|
||||||
removeIngredientHandler={removeIngredientHandler}
|
removeIngredientHandler={removeIngredient}
|
||||||
/>
|
/>
|
||||||
<button
|
<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"
|
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
|
Add ingredient
|
||||||
</button>
|
</button>
|
||||||
<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"
|
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
|
Add group header
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user