(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 {
|
||||
validation: CreateRecipeFormToggles;
|
||||
validation: CreateRecipeFormEntries;
|
||||
}
|
||||
|
||||
const MESSAGES: Record<keyof CreateRecipeFormToggles, string> = {
|
||||
const MESSAGES: Record<keyof CreateRecipeFormEntries, string> = {
|
||||
title: "Invalid title provided.",
|
||||
description: "Invalid description provided.",
|
||||
prepTime: "Invalid preparation time provided.",
|
||||
@ -22,7 +22,7 @@ export default function ValidationErrorList({ validation }: ValidationErrorListP
|
||||
{Object.entries(validation)
|
||||
.filter(([, isValid]) => !isValid)
|
||||
.map(([name]) => {
|
||||
const key = name as keyof CreateRecipeFormToggles;
|
||||
const key = name as keyof CreateRecipeFormEntries;
|
||||
return (
|
||||
<p key={name} className="text-sm text-red-500">
|
||||
{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 { isRecipeMeal, type RecipeIngredient, type RecipeIngredientSection, type RecipeIngredientUnit, type RecipeInstruction } from "../types/recipe";
|
||||
import InstructionList from "../components/forms/InstructionList";
|
||||
@ -6,25 +6,14 @@ import ValidationErrorList from "../components/forms/ValidationErrorList";
|
||||
import IngredientSection from "../components/forms/IngredientSection";
|
||||
import { Reorder } from "motion/react";
|
||||
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
|
||||
interface CreateRecipeForm {
|
||||
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 {
|
||||
export interface CreateRecipeFormEntries {
|
||||
title: boolean;
|
||||
description: 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";
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
// FORM STATE
|
||||
const [inputs, setInputs] = useState<CreateRecipeForm>({
|
||||
title: "",
|
||||
description: "",
|
||||
tagInput: "",
|
||||
tags: [],
|
||||
prepTime: "",
|
||||
cookTime: "",
|
||||
servingSize: "",
|
||||
category: "",
|
||||
difficulty: "",
|
||||
ingredients: [{ name: "", quantity: "" }],
|
||||
image: null,
|
||||
});
|
||||
// Store complex values elsewhere
|
||||
// Inputs
|
||||
const [title, setTitle] = useState<string>("");
|
||||
const [description, setDescription] = useState<string>("");
|
||||
const [tags, setTags] = useState<string[]>([]);
|
||||
const [prepTime, setPrepTime] = useState<string>("");
|
||||
const [cookTime, setCookTime] = useState<string>("");
|
||||
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<CreateRecipeFormToggles>({
|
||||
// Validation State
|
||||
const [validation, setValidation] = useState<CreateRecipeFormEntries>({
|
||||
title: true,
|
||||
description: true,
|
||||
prepTime: true,
|
||||
@ -82,7 +89,10 @@ export default function Create() {
|
||||
ingredients: true,
|
||||
instructions: true,
|
||||
});
|
||||
const [dirty, setDirty] = useState<CreateRecipeFormToggles>({
|
||||
const [isFormValid, setIsFormValid] = useState<boolean>(false);
|
||||
|
||||
// Dirty State
|
||||
const [dirty, setDirty] = useState<CreateRecipeFormEntries>({
|
||||
title: false,
|
||||
description: false,
|
||||
prepTime: false,
|
||||
@ -93,74 +103,31 @@ export default function Create() {
|
||||
ingredients: true, // This can be ignored 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
|
||||
const validate = () => {
|
||||
const state = { ...validation };
|
||||
state.title = dirty.title ? (inputs.title.length >= 1 && inputs.title.length <= 128) : true;
|
||||
state.description = dirty.description ? (inputs.description.length >= 1 && inputs.description.length <= 1000) : true;
|
||||
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 ? (inputs.prepTime !== "" && Number(inputs.prepTime) >= 0 && Number(inputs.prepTime) <= 120) : true;
|
||||
state.cookTime = dirty.cookTime ? (inputs.cookTime !== "" && Number(inputs.cookTime) >= 0 && Number(inputs.cookTime) <= 120) : true;
|
||||
state.servingSize = dirty.servingSize ? (inputs.servingSize !== "" && Number(inputs.servingSize) >= 1 && Number(inputs.servingSize) <= 16) : 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 ? (inputs.category !== "" && isRecipeMeal(inputs.category)) : true;
|
||||
state.difficulty = dirty.difficulty ? (inputs.difficulty !== "" && Number(inputs.difficulty) >= 1 && Number(inputs.difficulty) <= 5) : true;
|
||||
// state.instructions = instructions?.filter(x => x.content === "").length === 0; // All of them are not empty
|
||||
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);
|
||||
}
|
||||
|
||||
// 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)
|
||||
}));
|
||||
}
|
||||
|
||||
// Instruction handlers
|
||||
const addInstructionHandler = () => {
|
||||
setInstructions([...instructions, { Id: crypto.randomUUID(), Content: "" }]);
|
||||
}
|
||||
|
||||
// Ingredient Handlers
|
||||
const sectionChangeHandler = (id: string, name: string) => {
|
||||
setSections(prev =>
|
||||
prev.map(section =>
|
||||
@ -230,7 +197,7 @@ export default function Create() {
|
||||
useEffect(() => {
|
||||
// Execute validation every time inputs change
|
||||
validate();
|
||||
}, [inputs, instructions, ingredients]);
|
||||
}, [title, description, prepTime, cookTime, servingSize, category, difficulty, instructions, ingredients]);
|
||||
|
||||
useEffect(() => {
|
||||
// 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);
|
||||
}, [validation, dirty]);
|
||||
|
||||
useEffect(() => {
|
||||
console.debug("@validation", validation);
|
||||
}, [validation]);
|
||||
|
||||
useEffect(() => {
|
||||
console.debug("@dirty", dirty);
|
||||
}, [dirty]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Banner content="Create Your Masterpiece" />
|
||||
@ -254,235 +229,145 @@ export default function Create() {
|
||||
</p>
|
||||
<div>
|
||||
{/* Title Input */}
|
||||
<div className="flex flex-col">
|
||||
<label htmlFor="title" className="text-sm">
|
||||
Recipe Title
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<p className="text-xs pt-1 pb-2 text-gray-700">
|
||||
Please provide a unique title for your recipe. This is the most important part!
|
||||
</p>
|
||||
<input
|
||||
className={`${!validation.title ? "border-red-500" : ""} ${INPUT_CLASSES}`}
|
||||
type="text"
|
||||
<RecipeCreateFormInput
|
||||
label="Recipe Title"
|
||||
name="title"
|
||||
value={inputs.title}
|
||||
onChange={changeHandler}
|
||||
required
|
||||
maxLength={128}
|
||||
minLength={1}
|
||||
desc="Please provide a unique title for your recipe. This is the most important part!"
|
||||
placeholder="e.g., Classic Chicken Curry"
|
||||
required
|
||||
valid={validation.title}
|
||||
value={title}
|
||||
setValue={setTitle}
|
||||
setDirty={setDirty}
|
||||
maxLength={128}
|
||||
error="Please enter a title. Between 1-128 characters."
|
||||
classes={INPUT_CLASSES}
|
||||
/>
|
||||
{!validation.title && (
|
||||
<p className="text-xs text-red-500 my-1">Please enter a title. Between 1-128 characters.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description Input */}
|
||||
<div className="flex flex-col my-4">
|
||||
<label htmlFor="description" className="text-sm">
|
||||
Description
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<p className="text-xs pt-1 pb-2 text-gray-700">
|
||||
Please provide a description for your recipe. This can be short and sweet or long and detailed!
|
||||
</p>
|
||||
<textarea
|
||||
className={`${!validation.description ? "border-red-500" : ""} ${INPUT_CLASSES} min-h-32`}
|
||||
<RecipeCreateFormTextArea
|
||||
label="Description"
|
||||
name="description"
|
||||
value={inputs.description}
|
||||
onChange={changeHandler}
|
||||
rows={4}
|
||||
desc="Please provide a description for your recipe. This can be short and sweet or long and detailed!"
|
||||
placeholder="A brief description of your delicious recipe..."
|
||||
required
|
||||
valid={validation.description}
|
||||
value={description}
|
||||
setValue={setDescription}
|
||||
setDirty={setDirty}
|
||||
error="Please enter a description. Between 1-1000 characters."
|
||||
rows={4}
|
||||
maxLength={1024}
|
||||
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>
|
||||
parentClasses="my-4"
|
||||
classes={INPUT_CLASSES}
|
||||
/>
|
||||
|
||||
{/* Tag Input */}
|
||||
<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={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"
|
||||
<RecipeCreateFormTagsInputs
|
||||
tags={tags}
|
||||
setTags={setTags}
|
||||
classes={INPUT_CLASSES}
|
||||
/>
|
||||
<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 */}
|
||||
<div className="my-4 flex gap-x-2">
|
||||
<div className="flex flex-col flex-grow w-1/3">
|
||||
<label htmlFor="prepTime" className="text-sm">
|
||||
Prep Time
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<p className="text-xs pt-1 pb-2 text-gray-700">
|
||||
Please provide the estimated prep time (minutes).
|
||||
</p>
|
||||
<input
|
||||
className={`${!validation.prepTime ? "border-red-500" : ""} ${INPUT_CLASSES}`}
|
||||
type="number"
|
||||
<RecipeCreateFormInput
|
||||
label="Prep Time"
|
||||
name="prepTime"
|
||||
value={inputs.prepTime}
|
||||
onChange={changeHandler}
|
||||
required
|
||||
min="0"
|
||||
max="120"
|
||||
type="number"
|
||||
desc="Please provide the estimated prep time (minutes)."
|
||||
placeholder="e.g., 20"
|
||||
/>
|
||||
{!validation.prepTime && (
|
||||
<p className="text-xs text-red-500 my-1"> Please enter a time (minutes). </p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col flex-grow w-1/3">
|
||||
<label htmlFor="cookTime" className="text-sm">
|
||||
Cook Time
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<p className="text-xs pt-1 pb-2 text-gray-700">
|
||||
Please provide the estimated cook time (minutes).
|
||||
</p>
|
||||
<input
|
||||
className={`${!validation.cookTime ? "border-red-500" : ""} ${INPUT_CLASSES}`}
|
||||
type="number"
|
||||
name="cookTime"
|
||||
value={inputs.cookTime}
|
||||
onChange={changeHandler}
|
||||
required
|
||||
valid={validation.prepTime}
|
||||
value={prepTime}
|
||||
setValue={setPrepTime}
|
||||
setDirty={setDirty}
|
||||
error="Please enter a time (minutes)."
|
||||
parentClasses="flex-grow w-1/3"
|
||||
min="0"
|
||||
max="120"
|
||||
placeholder="e.g., 45"
|
||||
max="300"
|
||||
classes={INPUT_CLASSES}
|
||||
/>
|
||||
{!validation.cookTime && (
|
||||
<p className="text-xs text-red-500 my-1"> Please enter a time (minutes). </p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col flex-grow w-1/3">
|
||||
<label htmlFor="servingSize" className="text-sm">
|
||||
Serving Size
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<p className="text-xs pt-1 pb-2 text-gray-700">
|
||||
Please provide the estimated serving size.
|
||||
</p>
|
||||
<input
|
||||
className={`${!validation.servingSize ? "border-red-500" : ""} ${INPUT_CLASSES}`}
|
||||
|
||||
<RecipeCreateFormInput
|
||||
label="Cook Time"
|
||||
name="cookTime"
|
||||
type="number"
|
||||
name="servingSize"
|
||||
value={inputs.servingSize}
|
||||
onChange={changeHandler}
|
||||
max="16"
|
||||
min="1"
|
||||
desc="Please provide the estimated cook time (minutes)."
|
||||
placeholder="e.g., 45"
|
||||
required
|
||||
placeholder="e.g., 4"
|
||||
valid={validation.cookTime}
|
||||
value={cookTime}
|
||||
setValue={setCookTime}
|
||||
setDirty={setDirty}
|
||||
error="Please enter a time (minutes)."
|
||||
parentClasses="flex-grow w-1/3"
|
||||
min="0"
|
||||
max="300"
|
||||
classes={INPUT_CLASSES}
|
||||
/>
|
||||
|
||||
<RecipeCreateFormInput
|
||||
label="Serving Size"
|
||||
name="servingSize"
|
||||
type="number"
|
||||
desc="Please provide the estimated serving size."
|
||||
placeholder="e.g., 4"
|
||||
required
|
||||
valid={validation.servingSize}
|
||||
value={servingSize}
|
||||
setValue={setServingSize}
|
||||
setDirty={setDirty}
|
||||
error="Please enter a serving size."
|
||||
parentClasses="flex-grow w-1/3"
|
||||
min="1"
|
||||
max="16"
|
||||
classes={INPUT_CLASSES}
|
||||
/>
|
||||
{!validation.servingSize && (
|
||||
<p className="text-xs text-red-500 my-1"> Please enter a serving size. </p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dropdown Inputs */}
|
||||
<div className="my-4 flex gap-x-2">
|
||||
<div className="flex flex-col flex-grow w-1/3">
|
||||
<label htmlFor="category" className="text-sm">
|
||||
Category
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<p className="text-xs pt-1 pb-2 text-gray-700">
|
||||
Please provide the meal category.
|
||||
</p>
|
||||
<select
|
||||
className={`${!validation.category ? "border-red-500" : ""} ${INPUT_CLASSES}`}
|
||||
<RecipeCreateDropdownInput
|
||||
label="Category"
|
||||
name="category"
|
||||
value={inputs.category}
|
||||
onChange={changeHandler}
|
||||
desc="Please provide the meal category."
|
||||
required
|
||||
>
|
||||
<option value="">Select a category</option>
|
||||
<option value="breakfast">Breakfast</option>
|
||||
<option value="lunch">Lunch</option>
|
||||
<option value="dinner">Dinner</option>
|
||||
<option value="dessert">Dessert</option>
|
||||
<option value="snack">Snack</option>
|
||||
<option value="side">Side</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
{!validation.category && (
|
||||
<p className="text-xs text-red-500 my-1"> Please select a category. </p>
|
||||
)}
|
||||
</div>
|
||||
<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}`}
|
||||
valid={validation.category}
|
||||
value={category}
|
||||
setValue={setCategory}
|
||||
setDirty={setDirty}
|
||||
options={CATEGORY_OPTIONS}
|
||||
error="Please select a category."
|
||||
parentClasses="flex-grow w-1/3"
|
||||
classes={INPUT_CLASSES}
|
||||
/>
|
||||
|
||||
<RecipeCreateDropdownInput
|
||||
label="Difficulty"
|
||||
name="difficulty"
|
||||
value={inputs.difficulty}
|
||||
onChange={changeHandler}
|
||||
desc="Please provide a baseline difficulty."
|
||||
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>
|
||||
valid={validation.difficulty}
|
||||
value={difficulty}
|
||||
setValue={setDifficulty}
|
||||
setDirty={setDirty}
|
||||
options={DIFFICULTY_OPTIONS}
|
||||
error="Please select a difficulty."
|
||||
parentClasses="flex-grow w-1/3"
|
||||
classes={INPUT_CLASSES}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* TODO: Ingredient Inputs */}
|
||||
<div className="flex flex-col my-4">
|
||||
<label htmlFor="ingredients" className="text-sm">
|
||||
Ingredients
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<p className="text-xs py-1 text-gray-700">
|
||||
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>
|
||||
|
||||
<RecipeCreateFormWrapper
|
||||
label="Ingredients"
|
||||
name="ingredients"
|
||||
desc="Please provide a list of ingredients and their quantities."
|
||||
required
|
||||
parentClasses="my-4"
|
||||
>
|
||||
<Reorder.Group
|
||||
axis="y"
|
||||
values={sections}
|
||||
@ -497,7 +382,6 @@ export default function Create() {
|
||||
removeIngredientSectionHandler={removeIngredientSectionHandler}
|
||||
allowDelete={sections.length > 1}
|
||||
>
|
||||
|
||||
<IngredientList
|
||||
classes={INPUT_CLASSES}
|
||||
section={section}
|
||||
@ -521,28 +405,29 @@ export default function Create() {
|
||||
</IngredientSection>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
|
||||
</div>
|
||||
</RecipeCreateFormWrapper>
|
||||
|
||||
{/* Instruction Inputs */}
|
||||
<div className="flex flex-col my-4">
|
||||
<label htmlFor="instructions" className="text-sm">
|
||||
Instructions
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<p className="text-xs py-1 text-gray-700">
|
||||
Please provide a list of instructions. You do not need to include step number, they will be added automatically!
|
||||
</p>
|
||||
|
||||
<InstructionList instructions={instructions} setInstructions={setInstructions} />
|
||||
|
||||
<RecipeCreateFormWrapper
|
||||
label="Instructions"
|
||||
name="instructions"
|
||||
desc="Please provide a list of instructions. You do not need to include step number, they will be added automatically!"
|
||||
required
|
||||
parentClasses="my-4"
|
||||
>
|
||||
<>
|
||||
<InstructionList
|
||||
instructions={instructions}
|
||||
setInstructions={setInstructions}
|
||||
/>
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
</>
|
||||
</RecipeCreateFormWrapper>
|
||||
|
||||
{/* TODO: Images Input */}
|
||||
<div className="flex flex-col my-4">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user