(REFACTOR): Yet another rewrite...
The validation has been kept in the parent Create.tsx file, and the inputs have been moved out to their owns components. The instructions and ingredients need validation.
This commit is contained in:
parent
357e8772e7
commit
deecc01c7e
@ -1,10 +1,10 @@
|
|||||||
import type { CreateRecipeFormToggles } from "../../pages/Create";
|
import type { CreateRecipeFormEntries } from "../../pages/Create";
|
||||||
|
|
||||||
interface ValidationErrorListProps {
|
interface ValidationErrorListProps {
|
||||||
validation: CreateRecipeFormToggles;
|
validation: CreateRecipeFormEntries;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MESSAGES: Record<keyof CreateRecipeFormToggles, string> = {
|
const MESSAGES: Record<keyof CreateRecipeFormEntries, string> = {
|
||||||
title: "Invalid title provided.",
|
title: "Invalid title provided.",
|
||||||
description: "Invalid description provided.",
|
description: "Invalid description provided.",
|
||||||
prepTime: "Invalid preparation time provided.",
|
prepTime: "Invalid preparation time provided.",
|
||||||
@ -22,7 +22,7 @@ export default function ValidationErrorList({ validation }: ValidationErrorListP
|
|||||||
{Object.entries(validation)
|
{Object.entries(validation)
|
||||||
.filter(([, isValid]) => !isValid)
|
.filter(([, isValid]) => !isValid)
|
||||||
.map(([name]) => {
|
.map(([name]) => {
|
||||||
const key = name as keyof CreateRecipeFormToggles;
|
const key = name as keyof CreateRecipeFormEntries;
|
||||||
return (
|
return (
|
||||||
<p key={name} className="text-sm text-red-500">
|
<p key={name} className="text-sm text-red-500">
|
||||||
{MESSAGES[key]}
|
{MESSAGES[key]}
|
||||||
|
|||||||
63
web/src/components/inputs/RecipeCreateFormDropdown.tsx
Normal file
63
web/src/components/inputs/RecipeCreateFormDropdown.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, type ChangeEvent, type Dispatch, type SetStateAction } from "react";
|
||||||
|
import type { CreateRecipeFormEntries } from "../../pages/Create";
|
||||||
|
|
||||||
|
export interface RecipeCreateDropdownOption {
|
||||||
|
value: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecipeCreateFormDropdownProps {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
desc: string;
|
||||||
|
required?: boolean;
|
||||||
|
valid: boolean;
|
||||||
|
value: string;
|
||||||
|
setValue: Dispatch<SetStateAction<string>>;
|
||||||
|
setDirty: Dispatch<SetStateAction<CreateRecipeFormEntries>>;
|
||||||
|
options: RecipeCreateDropdownOption[];
|
||||||
|
error: string;
|
||||||
|
parentClasses?: string;
|
||||||
|
classes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RecipeCreateDropdownInput({ label, name, desc, required = false, valid, value, setDirty, setValue, options, error, parentClasses = "", classes }: RecipeCreateFormDropdownProps) {
|
||||||
|
const handleChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
||||||
|
setDirty(prev => ({ ...prev, [name]: true }));
|
||||||
|
setValue(e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.debug(`@${name}`, value);
|
||||||
|
}, [name, value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col ${parentClasses}`}>
|
||||||
|
<label htmlFor={name} className="text-sm">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs pt-1 pb-2 text-gray-700">
|
||||||
|
{desc}
|
||||||
|
</p>
|
||||||
|
<select
|
||||||
|
className={`${!valid ? "border-red-500" : ""} ${classes}`}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
required={required}
|
||||||
|
>
|
||||||
|
{options?.map(opt => (
|
||||||
|
<option key={opt.value} value={opt.value}>{opt.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{!valid && (
|
||||||
|
<p className="text-xs text-red-500 my-1">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
63
web/src/components/inputs/RecipeCreateFormInput.tsx
Normal file
63
web/src/components/inputs/RecipeCreateFormInput.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { useEffect, type ChangeEvent, type Dispatch, type InputHTMLAttributes, type SetStateAction } from "react";
|
||||||
|
import type { CreateRecipeFormEntries } from "../../pages/Create";
|
||||||
|
|
||||||
|
interface RecipeCreateFormInputProps
|
||||||
|
extends Omit<
|
||||||
|
InputHTMLAttributes<HTMLInputElement>,
|
||||||
|
"value" | "onChange" | "name" | "type" | "placeholder" | "required"
|
||||||
|
> {
|
||||||
|
label: string;
|
||||||
|
name: string; // ENSURE THE NAME MATCHES THE VALUE IN THE ENTRIES TYPE
|
||||||
|
desc: string;
|
||||||
|
placeholder: string;
|
||||||
|
type?: string;
|
||||||
|
required?: boolean;
|
||||||
|
valid: boolean;
|
||||||
|
value: string;
|
||||||
|
setValue: Dispatch<SetStateAction<string>>;
|
||||||
|
setDirty: Dispatch<SetStateAction<CreateRecipeFormEntries>>;
|
||||||
|
error: string;
|
||||||
|
parentClasses?: string;
|
||||||
|
classes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RecipeCreateFormInput({ label, name, desc, placeholder, type = "text", required = false, valid, value, setDirty, setValue, error, parentClasses = "", classes, ...inputProps }: RecipeCreateFormInputProps) {
|
||||||
|
|
||||||
|
const handleChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
setDirty(prev => ({...prev, [name]: true}));
|
||||||
|
setValue(e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.debug(`@${name}`, value);
|
||||||
|
}, [name, value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col ${parentClasses}`}>
|
||||||
|
<label htmlFor={name} className="text-sm">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs pt-1 pb-2 text-gray-700">
|
||||||
|
{desc}
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
className={`${!valid ? "border-red-500" : ""} ${classes}`}
|
||||||
|
type={type}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
required={required}
|
||||||
|
placeholder={placeholder}
|
||||||
|
{...inputProps}
|
||||||
|
/>
|
||||||
|
{!valid && (
|
||||||
|
<p className="text-xs text-red-500 my-1">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
70
web/src/components/inputs/RecipeCreateFormTagsInput.tsx
Normal file
70
web/src/components/inputs/RecipeCreateFormTagsInput.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { useState, type ChangeEvent, type Dispatch, type FormEvent, type SetStateAction } from "react";
|
||||||
|
|
||||||
|
interface RecipeCreateFormTagsInputsProps {
|
||||||
|
tags: string[];
|
||||||
|
setTags: Dispatch<SetStateAction<string[]>>;
|
||||||
|
classes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecipeCreateFormTagsInputs({ tags, setTags, classes }: RecipeCreateFormTagsInputsProps) {
|
||||||
|
const [input, setInput] = useState<string>("");
|
||||||
|
|
||||||
|
const changeHandler = (e: ChangeEvent<HTMLInputElement>) => setInput(e.target.value);
|
||||||
|
|
||||||
|
const tagCreationHandler = (e: FormEvent<HTMLFormElement>) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// why would anyone try this lol
|
||||||
|
if (input.trim() === "") return;
|
||||||
|
|
||||||
|
// Tag already exists, clear input and exit
|
||||||
|
if (tags.includes(input.toLowerCase())) {
|
||||||
|
setInput("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInput("");
|
||||||
|
setTags(prev => [...prev, input.toLowerCase()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagDeletionHandler = (tag: string) => {
|
||||||
|
if (!tag) return;
|
||||||
|
setTags(prev => prev.filter(t => t !== tag));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={tagCreationHandler} className="my-4 flex flex-col gap-x-2">
|
||||||
|
<div className="flex flex-col flex-grow">
|
||||||
|
<label htmlFor="tags" className="text-sm">
|
||||||
|
Recipe Tags
|
||||||
|
</label>
|
||||||
|
<p className="text-xs pt-1 pb-2 text-gray-700">
|
||||||
|
Please provide a list of tags. <span className="italic">e.g., easy, dairy-free, gluten-free, high protein.</span>
|
||||||
|
</p>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={input}
|
||||||
|
onChange={changeHandler}
|
||||||
|
name="tagInput"
|
||||||
|
maxLength={32}
|
||||||
|
enterKeyHint="done"
|
||||||
|
placeholder="e.g., Healthy"
|
||||||
|
className={classes}
|
||||||
|
/>
|
||||||
|
<input type="hidden" name="tags" id="tags" value="" />
|
||||||
|
</div>
|
||||||
|
<ul id="tag-list" className="my-2 flex gap-1 flex-wrap">
|
||||||
|
{tags?.map(tag =>
|
||||||
|
<li
|
||||||
|
className="flex text-xs items-center bg-blue-100 w-fit px-2 py-1 rounded-full gap-x-1 select-none cursor-pointer hover:bg-blue-200 duration-300"
|
||||||
|
key={tag}
|
||||||
|
>
|
||||||
|
<button tabIndex={-1} type="button" onClick={() => tagDeletionHandler(tag)}>
|
||||||
|
× {tag}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
web/src/components/inputs/RecipeCreateFormTextArea.tsx
Normal file
60
web/src/components/inputs/RecipeCreateFormTextArea.tsx
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
import { useEffect, type ChangeEvent, type Dispatch, type InputHTMLAttributes, type SetStateAction, type TextareaHTMLAttributes } from "react";
|
||||||
|
import type { CreateRecipeFormEntries } from "../../pages/Create";
|
||||||
|
|
||||||
|
interface RecipeCreateFormInputProps
|
||||||
|
extends Omit<
|
||||||
|
TextareaHTMLAttributes<HTMLTextAreaElement>,
|
||||||
|
"value" | "onChange" | "name" | "type" | "placeholder" | "required"
|
||||||
|
> {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
desc: string;
|
||||||
|
placeholder: string;
|
||||||
|
required?: boolean;
|
||||||
|
valid: boolean;
|
||||||
|
value: string;
|
||||||
|
setValue: Dispatch<SetStateAction<string>>;
|
||||||
|
setDirty: Dispatch<SetStateAction<CreateRecipeFormEntries>>;
|
||||||
|
error: string;
|
||||||
|
parentClasses?: string;
|
||||||
|
classes: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RecipeCreateFormTextArea({ label, name, desc, placeholder, required = false, valid, value, setDirty, setValue, error, parentClasses = "", classes, ...inputProps }: RecipeCreateFormInputProps) {
|
||||||
|
const handleChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setDirty(prev => ({...prev, [name]: true}));
|
||||||
|
setValue(e.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.debug(`@${name}`, value);
|
||||||
|
}, [name, value]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col ${parentClasses}`}>
|
||||||
|
<label htmlFor={name} className="text-sm">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs pt-1 pb-2 text-gray-700">
|
||||||
|
{desc}
|
||||||
|
</p>
|
||||||
|
<textarea
|
||||||
|
className={`${!valid ? "border-red-500" : ""} ${classes}`}
|
||||||
|
name={name}
|
||||||
|
value={value}
|
||||||
|
onChange={handleChange}
|
||||||
|
required={required}
|
||||||
|
placeholder={placeholder}
|
||||||
|
{...inputProps}
|
||||||
|
/>
|
||||||
|
{!valid && (
|
||||||
|
<p className="text-xs text-red-500 my-1">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
28
web/src/components/inputs/RecipeCreateFormWrapper.tsx
Normal file
28
web/src/components/inputs/RecipeCreateFormWrapper.tsx
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
interface RecipeCreateFormWrapperProps {
|
||||||
|
label: string;
|
||||||
|
name: string;
|
||||||
|
desc: string;
|
||||||
|
required: boolean;
|
||||||
|
parentClasses: string;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RecipeCreateFormWrapper({label, name, desc, required = false, parentClasses, children }: RecipeCreateFormWrapperProps) {
|
||||||
|
const normalized_name = name.toLowerCase().replaceAll(" ", "-");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`flex flex-col ${parentClasses}`}>
|
||||||
|
<label htmlFor={normalized_name} className="text-sm">
|
||||||
|
{label}
|
||||||
|
{required && <span className="text-red-500">*</span>}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs py-1 text-gray-700">
|
||||||
|
{desc}
|
||||||
|
</p>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, type ChangeEvent, type FormEvent } from "react";
|
import { useEffect, useState, type FormEvent } 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 { isRecipeMeal, type RecipeIngredient, type RecipeIngredientSection, type RecipeIngredientUnit, type RecipeInstruction } from "../types/recipe";
|
||||||
import InstructionList from "../components/forms/InstructionList";
|
import InstructionList from "../components/forms/InstructionList";
|
||||||
@ -6,25 +6,14 @@ import ValidationErrorList from "../components/forms/ValidationErrorList";
|
|||||||
import IngredientSection from "../components/forms/IngredientSection";
|
import IngredientSection from "../components/forms/IngredientSection";
|
||||||
import { Reorder } from "motion/react";
|
import { Reorder } from "motion/react";
|
||||||
import IngredientList from "../components/forms/IngredientList";
|
import IngredientList from "../components/forms/IngredientList";
|
||||||
|
import RecipeCreateFormInput from "../components/inputs/RecipeCreateFormInput";
|
||||||
|
import RecipeCreateDropdownInput, { type RecipeCreateDropdownOption } from "../components/inputs/RecipeCreateFormDropdown";
|
||||||
|
import RecipeCreateFormTextArea from "../components/inputs/RecipeCreateFormTextArea";
|
||||||
|
import RecipeCreateFormWrapper from "../components/inputs/RecipeCreateFormWrapper";
|
||||||
|
import RecipeCreateFormTagsInputs from "../components/inputs/RecipeCreateFormTagsInput";
|
||||||
|
|
||||||
// TODO: Move this
|
// TODO: Move this
|
||||||
interface CreateRecipeForm {
|
export interface CreateRecipeFormEntries {
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
tagInput: string; // current tag being typed
|
|
||||||
tags: string[]; // all tags
|
|
||||||
prepTime: number | "";
|
|
||||||
cookTime: number | "";
|
|
||||||
servingSize: number | "";
|
|
||||||
category: string;
|
|
||||||
difficulty: string; // We use this as a number...
|
|
||||||
ingredients: { name: string; quantity: string }[];
|
|
||||||
// Instructions are stored elsewhere
|
|
||||||
image: File | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// TODO: Move this
|
|
||||||
export interface CreateRecipeFormToggles {
|
|
||||||
title: boolean;
|
title: boolean;
|
||||||
description: boolean;
|
description: boolean;
|
||||||
prepTime: boolean;
|
prepTime: boolean;
|
||||||
@ -42,36 +31,54 @@ export interface CreateRecipeFormToggles {
|
|||||||
*/
|
*/
|
||||||
const INPUT_CLASSES = "border border-gray-300 px-4 py-2 rounded-lg focus:outline-none focus:ring-blue-500 focus:ring-2 duration-200 ease-in-out transition-all shadow-sm";
|
const INPUT_CLASSES = "border border-gray-300 px-4 py-2 rounded-lg focus:outline-none focus:ring-blue-500 focus:ring-2 duration-200 ease-in-out transition-all shadow-sm";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options passed to the category dropdown.
|
||||||
|
*/
|
||||||
|
const CATEGORY_OPTIONS: RecipeCreateDropdownOption[] = [
|
||||||
|
{ value: "", name: "Select a category" },
|
||||||
|
{ value: "breakfast", name: "Breakfast" },
|
||||||
|
{ value: "lunch", name: "Lunch" },
|
||||||
|
{ value: "dinner", name: "Dinner" },
|
||||||
|
{ value: "dessert", name: "Dessert" },
|
||||||
|
{ value: "snack", name: "Snack" },
|
||||||
|
{ value: "side", name: "Side" },
|
||||||
|
{ value: "other", name: "Other" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options passed to the difficulty dropdown.
|
||||||
|
*/
|
||||||
|
const DIFFICULTY_OPTIONS: RecipeCreateDropdownOption[] = [
|
||||||
|
{ value: "", name: "Select a difficulty" },
|
||||||
|
{ value: "1", name: "Beginner" },
|
||||||
|
{ value: "2", name: "Easy" },
|
||||||
|
{ value: "3", name: "Intermediate" },
|
||||||
|
{ value: "4", name: "Challenging" },
|
||||||
|
{ value: "5", name: "Extreme" },
|
||||||
|
];
|
||||||
|
|
||||||
export default function Create() {
|
export default function Create() {
|
||||||
// FORM STATE
|
// Inputs
|
||||||
const [inputs, setInputs] = useState<CreateRecipeForm>({
|
const [title, setTitle] = useState<string>("");
|
||||||
title: "",
|
const [description, setDescription] = useState<string>("");
|
||||||
description: "",
|
const [tags, setTags] = useState<string[]>([]);
|
||||||
tagInput: "",
|
const [prepTime, setPrepTime] = useState<string>("");
|
||||||
tags: [],
|
const [cookTime, setCookTime] = useState<string>("");
|
||||||
prepTime: "",
|
const [servingSize, setServingSize] = useState<string>("");
|
||||||
cookTime: "",
|
const [category, setCategory] = useState<string>("");
|
||||||
servingSize: "",
|
const [difficulty, setDifficulty] = useState<string>("");
|
||||||
category: "",
|
|
||||||
difficulty: "",
|
|
||||||
ingredients: [{ name: "", quantity: "" }],
|
|
||||||
image: null,
|
|
||||||
});
|
|
||||||
// Store complex values elsewhere
|
|
||||||
const [instructions, setInstructions] = useState<RecipeInstruction[]>([
|
const [instructions, setInstructions] = useState<RecipeInstruction[]>([
|
||||||
{ Id: crypto.randomUUID(), Content: "" }
|
{ Id: crypto.randomUUID(), Content: "" }
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [sections, setSections] = useState<RecipeIngredientSection[]>([
|
const [sections, setSections] = useState<RecipeIngredientSection[]>([
|
||||||
{ Id: "initial-section", Name: "Unnamed group" },
|
{ Id: "initial-section", Name: "Unnamed group" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const [ingredients, setIngredients] = useState<RecipeIngredient[]>([
|
const [ingredients, setIngredients] = useState<RecipeIngredient[]>([
|
||||||
{ Id: crypto.randomUUID(), SectionId: "initial-section", Name: "", Amount: 0, Unit: "" },
|
{ Id: crypto.randomUUID(), SectionId: "initial-section", Name: "", Amount: 0, Unit: "" },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// VALIDATION STATE
|
// Validation State
|
||||||
const [validation, setValidation] = useState<CreateRecipeFormToggles>({
|
const [validation, setValidation] = useState<CreateRecipeFormEntries>({
|
||||||
title: true,
|
title: true,
|
||||||
description: true,
|
description: true,
|
||||||
prepTime: true,
|
prepTime: true,
|
||||||
@ -82,7 +89,10 @@ export default function Create() {
|
|||||||
ingredients: true,
|
ingredients: true,
|
||||||
instructions: true,
|
instructions: true,
|
||||||
});
|
});
|
||||||
const [dirty, setDirty] = useState<CreateRecipeFormToggles>({
|
const [isFormValid, setIsFormValid] = useState<boolean>(false);
|
||||||
|
|
||||||
|
// Dirty State
|
||||||
|
const [dirty, setDirty] = useState<CreateRecipeFormEntries>({
|
||||||
title: false,
|
title: false,
|
||||||
description: false,
|
description: false,
|
||||||
prepTime: false,
|
prepTime: false,
|
||||||
@ -93,74 +103,31 @@ export default function Create() {
|
|||||||
ingredients: true, // This can be ignored since they're self contained
|
ingredients: true, // This can be ignored since they're self contained
|
||||||
instructions: true, // This we can ignore since they're self contained
|
instructions: true, // This we can ignore since they're self contained
|
||||||
});
|
});
|
||||||
const [isFormValid, setIsFormValid] = useState<boolean>(false);
|
|
||||||
|
|
||||||
// Validate the dirty inputs, one at a time
|
// Validate the dirty inputs, one at a time
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
const state = { ...validation };
|
const state = { ...validation };
|
||||||
state.title = dirty.title ? (inputs.title.length >= 1 && inputs.title.length <= 128) : true;
|
state.title = dirty.title ? (title.length >= 1 && title.length <= 128) : true;
|
||||||
state.description = dirty.description ? (inputs.description.length >= 1 && inputs.description.length <= 1000) : true;
|
state.description = dirty.description ? (description.length >= 1 && description.length <= 1000) : true;
|
||||||
|
|
||||||
state.prepTime = dirty.prepTime ? (inputs.prepTime !== "" && Number(inputs.prepTime) >= 0 && Number(inputs.prepTime) <= 120) : true;
|
state.prepTime = dirty.prepTime ? (prepTime !== "" && Number(prepTime) >= 0 && Number(prepTime) <= 300) : true;
|
||||||
state.cookTime = dirty.cookTime ? (inputs.cookTime !== "" && Number(inputs.cookTime) >= 0 && Number(inputs.cookTime) <= 120) : true;
|
state.cookTime = dirty.cookTime ? (cookTime !== "" && Number(cookTime) >= 0 && Number(cookTime) <= 300) : true;
|
||||||
state.servingSize = dirty.servingSize ? (inputs.servingSize !== "" && Number(inputs.servingSize) >= 1 && Number(inputs.servingSize) <= 16) : true;
|
state.servingSize = dirty.servingSize ? (servingSize !== "" && Number(servingSize) >= 1 && Number(servingSize) <= 16) : true;
|
||||||
|
|
||||||
state.category = dirty.category ? (inputs.category !== "" && isRecipeMeal(inputs.category)) : true;
|
state.category = dirty.category ? (category !== "" && isRecipeMeal(category)) : true;
|
||||||
state.difficulty = dirty.difficulty ? (inputs.difficulty !== "" && Number(inputs.difficulty) >= 1 && Number(inputs.difficulty) <= 5) : true;
|
state.difficulty = dirty.difficulty ? (difficulty !== "" && Number(difficulty) >= 1 && Number(difficulty) <= 5) : true;
|
||||||
// state.instructions = instructions?.filter(x => x.content === "").length === 0; // All of them are not empty
|
|
||||||
|
// TODO: How do I validate the instructions and ingredients
|
||||||
|
|
||||||
setValidation(state);
|
setValidation(state);
|
||||||
}
|
}
|
||||||
|
|
||||||
// HANDLERS
|
// Instruction handlers
|
||||||
const changeHandler = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
|
||||||
const { name, value } = e.target;
|
|
||||||
setInputs(prev => ({
|
|
||||||
...prev,
|
|
||||||
[name]: value,
|
|
||||||
}));
|
|
||||||
setDirty(prev => ({
|
|
||||||
...prev,
|
|
||||||
[name]: true,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const tagCreationHandler = (e: FormEvent<HTMLFormElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
|
|
||||||
// why would anyone try this lol
|
|
||||||
if (inputs.tagInput.trim() === "") return;
|
|
||||||
|
|
||||||
// Tag already exists, clear input and exit
|
|
||||||
if (inputs.tags.includes(inputs.tagInput.toLowerCase())) {
|
|
||||||
setInputs(prev => ({
|
|
||||||
...prev,
|
|
||||||
tagInput: "",
|
|
||||||
}));
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setInputs(prev => ({
|
|
||||||
...prev,
|
|
||||||
tags: [...prev.tags, inputs.tagInput.toLowerCase()],
|
|
||||||
tagInput: "",
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const tagDeletionHandler = (tag: string) => {
|
|
||||||
if (!tag) return;
|
|
||||||
|
|
||||||
setInputs(prev => ({
|
|
||||||
...prev,
|
|
||||||
tags: prev.tags.filter(t => t !== tag)
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const addInstructionHandler = () => {
|
const addInstructionHandler = () => {
|
||||||
setInstructions([...instructions, { Id: crypto.randomUUID(), Content: "" }]);
|
setInstructions([...instructions, { Id: crypto.randomUUID(), Content: "" }]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ingredient Handlers
|
||||||
const sectionChangeHandler = (id: string, name: string) => {
|
const sectionChangeHandler = (id: string, name: string) => {
|
||||||
setSections(prev =>
|
setSections(prev =>
|
||||||
prev.map(section =>
|
prev.map(section =>
|
||||||
@ -230,7 +197,7 @@ export default function Create() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Execute validation every time inputs change
|
// Execute validation every time inputs change
|
||||||
validate();
|
validate();
|
||||||
}, [inputs, instructions, ingredients]);
|
}, [title, description, prepTime, cookTime, servingSize, category, difficulty, instructions, ingredients]);
|
||||||
|
|
||||||
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!
|
||||||
@ -239,6 +206,14 @@ export default function Create() {
|
|||||||
setIsFormValid(allValid && allDirty);
|
setIsFormValid(allValid && allDirty);
|
||||||
}, [validation, dirty]);
|
}, [validation, dirty]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.debug("@validation", validation);
|
||||||
|
}, [validation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.debug("@dirty", dirty);
|
||||||
|
}, [dirty]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Banner content="Create Your Masterpiece" />
|
<Banner content="Create Your Masterpiece" />
|
||||||
@ -254,235 +229,145 @@ export default function Create() {
|
|||||||
</p>
|
</p>
|
||||||
<div>
|
<div>
|
||||||
{/* Title Input */}
|
{/* Title Input */}
|
||||||
<div className="flex flex-col">
|
<RecipeCreateFormInput
|
||||||
<label htmlFor="title" className="text-sm">
|
label="Recipe Title"
|
||||||
Recipe Title
|
name="title"
|
||||||
<span className="text-red-500">*</span>
|
desc="Please provide a unique title for your recipe. This is the most important part!"
|
||||||
</label>
|
placeholder="e.g., Classic Chicken Curry"
|
||||||
<p className="text-xs pt-1 pb-2 text-gray-700">
|
required
|
||||||
Please provide a unique title for your recipe. This is the most important part!
|
valid={validation.title}
|
||||||
</p>
|
value={title}
|
||||||
<input
|
setValue={setTitle}
|
||||||
className={`${!validation.title ? "border-red-500" : ""} ${INPUT_CLASSES}`}
|
setDirty={setDirty}
|
||||||
type="text"
|
maxLength={128}
|
||||||
name="title"
|
error="Please enter a title. Between 1-128 characters."
|
||||||
value={inputs.title}
|
classes={INPUT_CLASSES}
|
||||||
onChange={changeHandler}
|
/>
|
||||||
required
|
|
||||||
maxLength={128}
|
|
||||||
minLength={1}
|
|
||||||
placeholder="e.g., Classic Chicken Curry"
|
|
||||||
/>
|
|
||||||
{!validation.title && (
|
|
||||||
<p className="text-xs text-red-500 my-1">Please enter a title. Between 1-128 characters.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Description Input */}
|
{/* Description Input */}
|
||||||
<div className="flex flex-col my-4">
|
<RecipeCreateFormTextArea
|
||||||
<label htmlFor="description" className="text-sm">
|
label="Description"
|
||||||
Description
|
name="description"
|
||||||
<span className="text-red-500">*</span>
|
desc="Please provide a description for your recipe. This can be short and sweet or long and detailed!"
|
||||||
</label>
|
placeholder="A brief description of your delicious recipe..."
|
||||||
<p className="text-xs pt-1 pb-2 text-gray-700">
|
required
|
||||||
Please provide a description for your recipe. This can be short and sweet or long and detailed!
|
valid={validation.description}
|
||||||
</p>
|
value={description}
|
||||||
<textarea
|
setValue={setDescription}
|
||||||
className={`${!validation.description ? "border-red-500" : ""} ${INPUT_CLASSES} min-h-32`}
|
setDirty={setDirty}
|
||||||
name="description"
|
error="Please enter a description. Between 1-1000 characters."
|
||||||
value={inputs.description}
|
rows={4}
|
||||||
onChange={changeHandler}
|
maxLength={1024}
|
||||||
rows={4}
|
minLength={1}
|
||||||
required
|
parentClasses="my-4"
|
||||||
maxLength={1024}
|
classes={INPUT_CLASSES}
|
||||||
minLength={1}
|
/>
|
||||||
placeholder="A brief description of your delicious recipe..."
|
|
||||||
></textarea>
|
|
||||||
{!validation.description && (
|
|
||||||
<p className="text-xs text-red-500 my-1">
|
|
||||||
Please enter a description. Between 1-1000 characters.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tag Input */}
|
{/* Tag Input */}
|
||||||
<form onSubmit={tagCreationHandler} className="my-4 flex flex-col gap-x-2">
|
<RecipeCreateFormTagsInputs
|
||||||
<div className="flex flex-col flex-grow">
|
tags={tags}
|
||||||
<label htmlFor="tags" className="text-sm">
|
setTags={setTags}
|
||||||
Recipe Tags
|
classes={INPUT_CLASSES}
|
||||||
</label>
|
/>
|
||||||
<p className="text-xs pt-1 pb-2 text-gray-700">
|
|
||||||
Please provide a list of tags. <span className="italic">e.g., easy, dairy-free, gluten-free, high protein.</span>
|
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={inputs.tagInput}
|
|
||||||
onChange={changeHandler}
|
|
||||||
name="tagInput"
|
|
||||||
maxLength={32}
|
|
||||||
enterKeyHint="done"
|
|
||||||
placeholder="e.g., Healthy"
|
|
||||||
className="border border-gray-300 px-4 py-2 rounded-lg focus:outline-none focus:ring-blue-500 focus:ring-2 duration-200 ease-in-out transition-all shadow-sm"
|
|
||||||
/>
|
|
||||||
<input type="hidden" name="tags" id="tags" value="" />
|
|
||||||
</div>
|
|
||||||
<ul id="tag-list" className="my-2 flex gap-1 flex-wrap">
|
|
||||||
{inputs.tags.map(tag =>
|
|
||||||
<li
|
|
||||||
className="flex text-xs items-center bg-blue-100 w-fit px-2 py-1 rounded-full gap-x-1 select-none cursor-pointer hover:bg-blue-200 duration-300"
|
|
||||||
key={tag}
|
|
||||||
>
|
|
||||||
<button tabIndex={-1} type="button" onClick={() => tagDeletionHandler(tag)}>
|
|
||||||
× {tag}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
)}
|
|
||||||
</ul>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{/* Time Input */}
|
{/* Time Input */}
|
||||||
<div className="my-4 flex gap-x-2">
|
<div className="my-4 flex gap-x-2">
|
||||||
<div className="flex flex-col flex-grow w-1/3">
|
<RecipeCreateFormInput
|
||||||
<label htmlFor="prepTime" className="text-sm">
|
label="Prep Time"
|
||||||
Prep Time
|
name="prepTime"
|
||||||
<span className="text-red-500">*</span>
|
type="number"
|
||||||
</label>
|
desc="Please provide the estimated prep time (minutes)."
|
||||||
<p className="text-xs pt-1 pb-2 text-gray-700">
|
placeholder="e.g., 20"
|
||||||
Please provide the estimated prep time (minutes).
|
required
|
||||||
</p>
|
valid={validation.prepTime}
|
||||||
<input
|
value={prepTime}
|
||||||
className={`${!validation.prepTime ? "border-red-500" : ""} ${INPUT_CLASSES}`}
|
setValue={setPrepTime}
|
||||||
type="number"
|
setDirty={setDirty}
|
||||||
name="prepTime"
|
error="Please enter a time (minutes)."
|
||||||
value={inputs.prepTime}
|
parentClasses="flex-grow w-1/3"
|
||||||
onChange={changeHandler}
|
min="0"
|
||||||
required
|
max="300"
|
||||||
min="0"
|
classes={INPUT_CLASSES}
|
||||||
max="120"
|
/>
|
||||||
placeholder="e.g., 20"
|
|
||||||
/>
|
<RecipeCreateFormInput
|
||||||
{!validation.prepTime && (
|
label="Cook Time"
|
||||||
<p className="text-xs text-red-500 my-1"> Please enter a time (minutes). </p>
|
name="cookTime"
|
||||||
)}
|
type="number"
|
||||||
</div>
|
desc="Please provide the estimated cook time (minutes)."
|
||||||
<div className="flex flex-col flex-grow w-1/3">
|
placeholder="e.g., 45"
|
||||||
<label htmlFor="cookTime" className="text-sm">
|
required
|
||||||
Cook Time
|
valid={validation.cookTime}
|
||||||
<span className="text-red-500">*</span>
|
value={cookTime}
|
||||||
</label>
|
setValue={setCookTime}
|
||||||
<p className="text-xs pt-1 pb-2 text-gray-700">
|
setDirty={setDirty}
|
||||||
Please provide the estimated cook time (minutes).
|
error="Please enter a time (minutes)."
|
||||||
</p>
|
parentClasses="flex-grow w-1/3"
|
||||||
<input
|
min="0"
|
||||||
className={`${!validation.cookTime ? "border-red-500" : ""} ${INPUT_CLASSES}`}
|
max="300"
|
||||||
type="number"
|
classes={INPUT_CLASSES}
|
||||||
name="cookTime"
|
/>
|
||||||
value={inputs.cookTime}
|
|
||||||
onChange={changeHandler}
|
<RecipeCreateFormInput
|
||||||
required
|
label="Serving Size"
|
||||||
min="0"
|
name="servingSize"
|
||||||
max="120"
|
type="number"
|
||||||
placeholder="e.g., 45"
|
desc="Please provide the estimated serving size."
|
||||||
/>
|
placeholder="e.g., 4"
|
||||||
{!validation.cookTime && (
|
required
|
||||||
<p className="text-xs text-red-500 my-1"> Please enter a time (minutes). </p>
|
valid={validation.servingSize}
|
||||||
)}
|
value={servingSize}
|
||||||
</div>
|
setValue={setServingSize}
|
||||||
<div className="flex flex-col flex-grow w-1/3">
|
setDirty={setDirty}
|
||||||
<label htmlFor="servingSize" className="text-sm">
|
error="Please enter a serving size."
|
||||||
Serving Size
|
parentClasses="flex-grow w-1/3"
|
||||||
<span className="text-red-500">*</span>
|
min="1"
|
||||||
</label>
|
max="16"
|
||||||
<p className="text-xs pt-1 pb-2 text-gray-700">
|
classes={INPUT_CLASSES}
|
||||||
Please provide the estimated serving size.
|
/>
|
||||||
</p>
|
|
||||||
<input
|
|
||||||
className={`${!validation.servingSize ? "border-red-500" : ""} ${INPUT_CLASSES}`}
|
|
||||||
type="number"
|
|
||||||
name="servingSize"
|
|
||||||
value={inputs.servingSize}
|
|
||||||
onChange={changeHandler}
|
|
||||||
max="16"
|
|
||||||
min="1"
|
|
||||||
required
|
|
||||||
placeholder="e.g., 4"
|
|
||||||
/>
|
|
||||||
{!validation.servingSize && (
|
|
||||||
<p className="text-xs text-red-500 my-1"> Please enter a serving size. </p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Dropdown Inputs */}
|
{/* Dropdown Inputs */}
|
||||||
<div className="my-4 flex gap-x-2">
|
<div className="my-4 flex gap-x-2">
|
||||||
<div className="flex flex-col flex-grow w-1/3">
|
<RecipeCreateDropdownInput
|
||||||
<label htmlFor="category" className="text-sm">
|
label="Category"
|
||||||
Category
|
name="category"
|
||||||
<span className="text-red-500">*</span>
|
desc="Please provide the meal category."
|
||||||
</label>
|
required
|
||||||
<p className="text-xs pt-1 pb-2 text-gray-700">
|
valid={validation.category}
|
||||||
Please provide the meal category.
|
value={category}
|
||||||
</p>
|
setValue={setCategory}
|
||||||
<select
|
setDirty={setDirty}
|
||||||
className={`${!validation.category ? "border-red-500" : ""} ${INPUT_CLASSES}`}
|
options={CATEGORY_OPTIONS}
|
||||||
name="category"
|
error="Please select a category."
|
||||||
value={inputs.category}
|
parentClasses="flex-grow w-1/3"
|
||||||
onChange={changeHandler}
|
classes={INPUT_CLASSES}
|
||||||
required
|
/>
|
||||||
>
|
|
||||||
<option value="">Select a category</option>
|
<RecipeCreateDropdownInput
|
||||||
<option value="breakfast">Breakfast</option>
|
label="Difficulty"
|
||||||
<option value="lunch">Lunch</option>
|
name="difficulty"
|
||||||
<option value="dinner">Dinner</option>
|
desc="Please provide a baseline difficulty."
|
||||||
<option value="dessert">Dessert</option>
|
required
|
||||||
<option value="snack">Snack</option>
|
valid={validation.difficulty}
|
||||||
<option value="side">Side</option>
|
value={difficulty}
|
||||||
<option value="other">Other</option>
|
setValue={setDifficulty}
|
||||||
</select>
|
setDirty={setDirty}
|
||||||
{!validation.category && (
|
options={DIFFICULTY_OPTIONS}
|
||||||
<p className="text-xs text-red-500 my-1"> Please select a category. </p>
|
error="Please select a difficulty."
|
||||||
)}
|
parentClasses="flex-grow w-1/3"
|
||||||
</div>
|
classes={INPUT_CLASSES}
|
||||||
<div className="flex flex-col flex-grow w-1/3">
|
/>
|
||||||
<label htmlFor="difficulty" className="text-sm">
|
|
||||||
Difficulty
|
|
||||||
<span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<p className="text-xs pt-1 pb-2 text-gray-700">
|
|
||||||
Please provide a baseline difficulty.
|
|
||||||
</p>
|
|
||||||
<select
|
|
||||||
className={`${!validation.category ? "border-red-500" : ""} ${INPUT_CLASSES}`}
|
|
||||||
name="difficulty"
|
|
||||||
value={inputs.difficulty}
|
|
||||||
onChange={changeHandler}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="">Select a difficulty</option>
|
|
||||||
<option value="1">Beginner</option>
|
|
||||||
<option value="2">Easy</option>
|
|
||||||
<option value="3">Intermediate</option>
|
|
||||||
<option value="4">Challenging</option>
|
|
||||||
<option value="5">Extreme</option>
|
|
||||||
</select>
|
|
||||||
{!validation.difficulty && (
|
|
||||||
<p className="text-xs text-red-500 my-1"> Please select a difficulty. </p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* TODO: Ingredient Inputs */}
|
{/* TODO: Ingredient Inputs */}
|
||||||
<div className="flex flex-col my-4">
|
<RecipeCreateFormWrapper
|
||||||
<label htmlFor="ingredients" className="text-sm">
|
label="Ingredients"
|
||||||
Ingredients
|
name="ingredients"
|
||||||
<span className="text-red-500">*</span>
|
desc="Please provide a list of ingredients and their quantities."
|
||||||
</label>
|
required
|
||||||
<p className="text-xs py-1 text-gray-700">
|
parentClasses="my-4"
|
||||||
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.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Reorder.Group
|
<Reorder.Group
|
||||||
axis="y"
|
axis="y"
|
||||||
values={sections}
|
values={sections}
|
||||||
@ -497,7 +382,6 @@ export default function Create() {
|
|||||||
removeIngredientSectionHandler={removeIngredientSectionHandler}
|
removeIngredientSectionHandler={removeIngredientSectionHandler}
|
||||||
allowDelete={sections.length > 1}
|
allowDelete={sections.length > 1}
|
||||||
>
|
>
|
||||||
|
|
||||||
<IngredientList
|
<IngredientList
|
||||||
classes={INPUT_CLASSES}
|
classes={INPUT_CLASSES}
|
||||||
section={section}
|
section={section}
|
||||||
@ -521,28 +405,29 @@ export default function Create() {
|
|||||||
</IngredientSection>
|
</IngredientSection>
|
||||||
))}
|
))}
|
||||||
</Reorder.Group>
|
</Reorder.Group>
|
||||||
|
</RecipeCreateFormWrapper>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Instruction Inputs */}
|
{/* Instruction Inputs */}
|
||||||
<div className="flex flex-col my-4">
|
<RecipeCreateFormWrapper
|
||||||
<label htmlFor="instructions" className="text-sm">
|
label="Instructions"
|
||||||
Instructions
|
name="instructions"
|
||||||
<span className="text-red-500">*</span>
|
desc="Please provide a list of instructions. You do not need to include step number, they will be added automatically!"
|
||||||
</label>
|
required
|
||||||
<p className="text-xs py-1 text-gray-700">
|
parentClasses="my-4"
|
||||||
Please provide a list of instructions. You do not need to include step number, they will be added automatically!
|
>
|
||||||
</p>
|
<>
|
||||||
|
<InstructionList
|
||||||
<InstructionList instructions={instructions} setInstructions={setInstructions} />
|
instructions={instructions}
|
||||||
|
setInstructions={setInstructions}
|
||||||
<button
|
/>
|
||||||
onClick={addInstructionHandler}
|
<button
|
||||||
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"
|
onClick={addInstructionHandler}
|
||||||
>
|
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 Instruction Step
|
>
|
||||||
</button>
|
Add Instruction Step
|
||||||
</div>
|
</button>
|
||||||
|
</>
|
||||||
|
</RecipeCreateFormWrapper>
|
||||||
|
|
||||||
{/* TODO: Images Input */}
|
{/* TODO: Images Input */}
|
||||||
<div className="flex flex-col my-4">
|
<div className="flex flex-col my-4">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user