From 47360518ce2f0e73ffa94760f873c27b9cf1cf33 Mon Sep 17 00:00:00 2001 From: Mikayla Dobson <93477693+innocuous-symmetry@users.noreply.github.com> Date: Sun, 19 Feb 2023 11:16:26 -0600 Subject: [PATCH] fraction support on addRecipe, working on state management --- .../components/derived/IngredientSelector.tsx | 107 +++++++-- client/src/components/pages/AddRecipe.tsx | 206 +++++++++++------- client/src/components/ui/Toast.tsx | 19 ++ client/src/sass/components/Toast.scss | 18 ++ client/src/util/types.ts | 10 +- server/routes/index.ts | 30 ++- 6 files changed, 289 insertions(+), 101 deletions(-) create mode 100644 client/src/components/ui/Toast.tsx create mode 100644 client/src/sass/components/Toast.scss diff --git a/client/src/components/derived/IngredientSelector.tsx b/client/src/components/derived/IngredientSelector.tsx index c7cbf5a..6f48b43 100644 --- a/client/src/components/derived/IngredientSelector.tsx +++ b/client/src/components/derived/IngredientSelector.tsx @@ -2,22 +2,17 @@ import { Autocomplete, TextField } from "@mui/material" import { ChangeEvent, useEffect, useRef, useState } from "react"; import { useAuthContext } from "../../context/AuthContext"; import { DropdownData, IIngredient } from "../../schemas"; +import { IngredientFieldData } from "../../util/types"; import { Button } from "../ui"; interface IngredientSelectorProps { position: number ingredients: IIngredient[] units: DropdownData[] + getRowState: (input: IngredientFieldData) => void destroy: (position: number) => void } -interface RowState { - quantity: number - measurement: string | null - ingredientSelection: string | null - ingredients: IIngredient[] -} - const createIngredient = (name: string, userid: string | number) => { return { name: name, @@ -25,34 +20,102 @@ const createIngredient = (name: string, userid: string | number) => { } as IIngredient } -function IngredientSelector({ position, ingredients, units, destroy }: IngredientSelectorProps) { +const quantityOptions: readonly any[] = [ + { name: "1/8" , value: 0.125 }, + { name: "1/4" , value: 0.250 }, + { name: "1/3" , value: 0.333 }, + { name: "3/8" , value: 0.375 }, + { name: "1/2" , value: 0.500 }, + { name: "5/8" , value: 0.625 }, + { name: "2/3" , value: 0.666 }, + { name: "3/4" , value: 0.750 }, + { name: "7/8" , value: 0.875 }, + { name: "1 1/4" , value: 1.250 }, + { name: "1 1/3" , value: 1.333 }, + { name: "1 1/2" , value: 1.500 }, + { name: "1 2/3" , value: 1.666 }, + { name: "1 3/4" , value: 1.750 }, + { name: "2 1/4" , value: 2.250 }, + { name: "2 1/3" , value: 2.333 }, + { name: "2 1/2" , value: 2.500 }, + { name: "2 3/4" , value: 2.750 }, + { name: "3 1/4" , value: 3.250 }, + { name: "3 1/2" , value: 3.500 }, + { name: "3 3/4" , value: 3.750 }, + { name: "4 1/4" , value: 4.250 }, + { name: "4 1/2" , value: 4.500 }, + { name: "4 3/4" , value: 4.750 }, + { name: "5 1/4" , value: 5.250 }, + { name: "5 1/2" , value: 5.500 }, + { name: "5 3/4" , value: 5.750 }, +] + +function IngredientSelector({ position, ingredients, units, getRowState, destroy }: IngredientSelectorProps) { const { user } = useAuthContext(); const [ingredientOptions, setIngredientOptions] = useState(ingredients.map(each => each.name)); - const [measurementUnits, setMeasurementUnits] = useState(units.map(each => each.name)); - - const [rowState, setRowState] = useState({ - quantity: 0, - measurement: null, - ingredientSelection: null, + const [rowState, setRowState] = useState({ + quantity: undefined, + rowPosition: position, + measurement: undefined, + ingredientSelection: undefined, ingredients: ingredients }) + const [quantityError, setQuantityError] = useState(null); + useEffect(() => { - console.log("Row " + position + " state changed:"); - console.log(rowState); - }, [rowState]) + getRowState(rowState); + }, [rowState, setRowState]) + + function validateQuantity(input: any) { + const value = new Number(input).valueOf(); + + if (Number.isNaN(value)) { + console.log('is nan'); + setQuantityError("Please provide a valid input (number)"); + return; + } + + if (!input) { + console.log('is null'); + setRowState({ ...rowState, quantity: undefined }); + setQuantityError(null); + return; + } + + setQuantityError(null); + setRowState({ ...rowState, quantity: value }); + } return ( @@ -103,7 +168,7 @@ function IngredientSelector({ position, ingredients, units, destroy }: Ingredien return { ...prev, ingredients: ingredients, - ingredientSelection: null + ingredientSelection: undefined } }) } diff --git a/client/src/components/pages/AddRecipe.tsx b/client/src/components/pages/AddRecipe.tsx index f5bcb1c..1687f84 100644 --- a/client/src/components/pages/AddRecipe.tsx +++ b/client/src/components/pages/AddRecipe.tsx @@ -2,15 +2,19 @@ import { useAuthContext } from "../../context/AuthContext"; import { useCallback, useEffect, useState } from "react"; import { Button, Card, Divider, Page, Panel } from "../ui" import { DropdownData, IIngredient, IRecipe } from "../../schemas"; -import { useSelectorContext } from "../../context/SelectorContext"; import IngredientSelector from "../derived/IngredientSelector"; import Protect from "../../util/Protect"; import API from "../../util/API"; import { v4 } from "uuid"; import RichText from "../ui/RichText"; import { Autocomplete, TextField } from "@mui/material"; +import { IngredientFieldData } from "../../util/types"; +import Toast from "../ui/Toast"; -const AddRecipe = () => { +export default function AddRecipe() { + /********************************** + * STATE AND CONTEXT + *********************************/ const { user, token } = useAuthContext(); // received recipe data @@ -21,76 +25,30 @@ const AddRecipe = () => { const [measurements, setMeasurements] = useState([]); const [courseData, setCourseData] = useState([]); const [ingredientFields, setIngredientFields] = useState>([]); + const [ingredientFieldData, setIngredientFieldData] = useState>([]); const [optionCount, setOptionCount] = useState(0); // status reporting const [toast, setToast] = useState(<>) - // store all ingredients on page mount - useEffect(() => { - token && (async() => { - const ingredients = new API.Ingredient(token); - const _dropdowns = new API.Dropdowns(token); - const ingredientList = await ingredients.getAll(); - const measurementList = await _dropdowns.getAllMeasurements(); - const courseList = await _dropdowns.getAllCourses(); - - if (ingredientList) { - setIngredients((prev) => [...prev, ...ingredientList]); - } - - if (measurementList) { - setMeasurements((prev) => [...prev, ...measurementList]); - } - - if (courseList) { - setCourseData((prev) => [...prev, ...courseList]); - } - })(); - }, [token]) - - useEffect(() => { - if (ingredients.length && measurements.length) { - setIngredientFields([]); - } - - }, [ingredients, measurements]) - - // once user information is available, store it in recipe data - useEffect(() => { - user && setInput((prev: IRecipe) => { - return { - ...prev, - authoruserid: user.id! - } - }) - }, [user]) - - // submit handler - const handleCreate = async () => { - if (!token) return; - - for (let field of Object.keys(input)) { - if (!input[field as keyof IRecipe]) { - return; - } - } - - const recipe = new API.Recipe(token); - const result = await recipe.post(input); - - const recipeID = result.recipe.id; - const recipeName = result.recipe.name; - - setToast( - -

Created recipe {recipeName} successfully!

-

View your new recipe here!

-
- ) - } + /********************************** + * CALLBACKS FOR CHILD COMPONENTS + *********************************/ + // callback to retrieve state from ingredient rows + const getRowState = useCallback((value: IngredientFieldData) => { + setIngredientFieldData((prev) => { + const newState = prev; + newState[value.rowPosition] = value; + return newState; + }); + }, [ingredientFieldData]) + // callback passed to each ingredient row to enable the row to be closed const destroySelector = useCallback((position: number) => { + setIngredientFieldData((prev) => { + return [...prev.filter(each => each.rowPosition !== position)]; + }) + setIngredientFields((prev) => { const newState = new Array(); @@ -106,15 +64,118 @@ const AddRecipe = () => { }) }, [ingredientFields]); - function handleNewOption() { - setIngredientFields((prev) => [...prev, ]) - setOptionCount(prev => prev + 1); - } + /********************************** + * PAGE MOUNT BEHAVIOR AND SIDE EFFECTS + *********************************/ + // store all ingredients on page mount + useEffect(() => { + token && (async() => { + const ingredients = new API.Ingredient(token); + const _dropdowns = new API.Dropdowns(token); + const ingredientList = await ingredients.getAll(); + const measurementList = await _dropdowns.getAllMeasurements(); + const courseList = await _dropdowns.getAllCourses(); + + function filterDuplicateEntries(receivedList: any[], previousState: any) { + let newEntries = []; + for (let each of receivedList) { + if (previousState.includes(each)) { + continue; + } + + newEntries.push(each); + } + + return newEntries; + } + + if (ingredientList) { + setIngredients((prev) => { + let newEntries = filterDuplicateEntries(ingredientList, prev); + return [...prev, ...newEntries]; + }); + } + + if (measurementList) { + setMeasurements((prev) => { + let newEntries = filterDuplicateEntries(measurementList, prev); + return [...prev, ...newEntries]; + }); + } + + if (courseList) { + setCourseData((prev) => { + let newEntries = filterDuplicateEntries(courseList, prev); + return [...prev, ...newEntries]; + }); + } + })(); + }, [token]) + + // mount the ingredient selection section once dependencies have loaded + useEffect(() => { + if (ingredients.length && measurements.length) { + setIngredientFields([]); + } + }, [ingredients, measurements]) useEffect(() => { - console.log(courseData); - }, [courseData]) + console.log(ingredientFieldData); + }, [getRowState]); + + /********************************** + * PAGE SPECIFIC FUNCTIONS + *********************************/ + // submit handler + const handleCreate = async () => { + if (!user || !token) return; + + // inject current user id into recipe entry + setInput({ ...input, authoruserid: user.id! }); + + for (let field of Object.keys(input)) { + // account for an edge case where this state may not have been set yet + if (field == 'authoruserid' as keyof IRecipe) { + continue; + } + + if (!input[field as keyof IRecipe]) { + return; + } + } + + const recipe = new API.Recipe(token); + const result = await recipe.post(input); + + if (result) { + const recipeID = result.recipe.id; + const recipeName = result.recipe.name; + + setToast( + +

Created recipe {recipeName} successfully!

+

View your new recipe here!

+
+ ) + } else { + setToast( + +

Error creating your recipe

+

Please refresh the browser window and try again.

+
+ ) + } + } + + // logic for inserting a new ingredient row + function handleNewOption() { + setIngredientFields((prev) => [...prev, ]) + setOptionCount(prev => prev + 1); + } + /********************************** + * RENDER + *********************************/ return (

Add a New Recipe

@@ -143,6 +204,7 @@ const AddRecipe = () => { getOptionLabel={(option) => option.name} />} + { ingredients && ( <> @@ -170,5 +232,3 @@ const AddRecipe = () => {
) } - -export default AddRecipe; \ No newline at end of file diff --git a/client/src/components/ui/Toast.tsx b/client/src/components/ui/Toast.tsx new file mode 100644 index 0000000..2a509b6 --- /dev/null +++ b/client/src/components/ui/Toast.tsx @@ -0,0 +1,19 @@ +import { FC } from "react"; +import "/src/sass/components/Toast.scss"; + +type StyleVariant = "success" | "fail" | "warning" + +interface ToastProps { + children: JSX.Element | JSX.Element[] + variant?: StyleVariant +} + +const Toast: FC = ({ children, variant = "success" }) => { + return ( +
+ { children } +
+ ) +} + +export default Toast; diff --git a/client/src/sass/components/Toast.scss b/client/src/sass/components/Toast.scss new file mode 100644 index 0000000..07d32e0 --- /dev/null +++ b/client/src/sass/components/Toast.scss @@ -0,0 +1,18 @@ +.ui-component-toast { + position: absolute; + bottom: 1rem; + right: 1rem; + padding: 1rem; + + .success { + background-color: green; + } + + .fail { + background-color: red; + } + + .warning { + background-color: yellow; + } +} \ No newline at end of file diff --git a/client/src/util/types.ts b/client/src/util/types.ts index 152b119..da28396 100644 --- a/client/src/util/types.ts +++ b/client/src/util/types.ts @@ -1,7 +1,7 @@ import { ChangeEvent, ChangeEventHandler, Dispatch, FC, ReactNode, SetStateAction } from "react"; import { useNavigate } from "react-router-dom"; import { Form } from "../components/ui"; -import { IUser } from "../schemas"; +import { DropdownData, IIngredient, IUser } from "../schemas"; export interface PortalBase { children?: ReactNode | ReactNode[] @@ -46,6 +46,14 @@ interface CheckboxProps { FormElement: typeof Form } +export interface IngredientFieldData { + rowPosition: number + quantity: number | undefined + measurement: DropdownData['name'] | undefined + ingredientSelection: IIngredient['name'] | IIngredient | undefined + ingredients: Array +} + /** * Type declaration for react-select dropdown options */ diff --git a/server/routes/index.ts b/server/routes/index.ts index bf1a015..4a59903 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -12,13 +12,21 @@ import { friendRouter } from "./friend"; import { cuisineRouter } from "./cuisine"; import { courseRouter } from "./course"; import { dropdownValueRouter } from "./dropdownValues"; +import { IUser } from "../schemas"; +import { User } from "../models/user"; dotenv.config(); -export const routes = async (app: Express) => { - // unprotected routes - authRoute(app); +let REQUESTCOUNT = 0; +export const routes = async (app: Express) => { + // simple request counting middleware + app.use('/', (req, res, next) => { + REQUESTCOUNT++; + console.log("count: ", REQUESTCOUNT); + next(); + }) + // middleware to check for auth on cookies on each request in protected routes app.use('/app', async (req, res, next) => { // pull jwt from request headers @@ -27,18 +35,28 @@ export const routes = async (app: Express) => { if (!token) { res.status(403).send("Unauthorized, did not receive token"); } else { - jwt.verify(token, process.env.SESSIONSECRET as string, (err, data) => { + jwt.verify(token, process.env.SESSIONSECRET as string, async (err, data: any) => { if (err) { res.status(403).send(err); } else { - // @ts-ignore - req.user = data.user; + const userInstance = new User(); + const foundUser = await userInstance.getOneByID(data.user.id); + + if (foundUser) { + req.user = data.user as IUser; + } else { + res.status(403).send("Unauthorized, user not registered"); + } + next(); } }) } }) + // unprotected routes + authRoute(app); + // protected routes userRoute(app); friendRouter(app);
- setRowState({...rowState, quantity: parseFloat(e.target.value) })} /> + each.name)} + className="ui-creatable-component" + onChange={(event, value) => { + console.log(value); + validateQuantity(quantityOptions.filter(option => (option.name) == value)[0]['value'] as number); + }} + renderInput={(params) => ( + validateQuantity(e.target.value)} + /> + )} + /> each.name)} className="ui-creatable-component" renderInput={(params) => ( )} onChange={(event, value) => { - setRowState({ ...rowState, measurement: value }); + if (value) { + setRowState({ ...rowState, measurement: value }); + } }} />