fraction support on addRecipe, working on state management
This commit is contained in:
@@ -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<RowState>({
|
||||
quantity: 0,
|
||||
measurement: null,
|
||||
ingredientSelection: null,
|
||||
const [rowState, setRowState] = useState<IngredientFieldData>({
|
||||
quantity: undefined,
|
||||
rowPosition: position,
|
||||
measurement: undefined,
|
||||
ingredientSelection: undefined,
|
||||
ingredients: ingredients
|
||||
})
|
||||
|
||||
const [quantityError, setQuantityError] = useState<null | string>(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 (
|
||||
<table className="ingredient-widget"><tbody>
|
||||
<tr>
|
||||
<td className="quantity-of-unit">
|
||||
<TextField variant="outlined" label="Quantity" onChange={(e) => setRowState({...rowState, quantity: parseFloat(e.target.value) })} />
|
||||
<Autocomplete
|
||||
freeSolo
|
||||
autoHighlight
|
||||
options={quantityOptions.map(each => 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) => (
|
||||
<TextField
|
||||
{...params}
|
||||
variant="outlined"
|
||||
color={(rowState.quantity == null) ? (quantityError ? "error" : "info") : (quantityError ? "error" : "success")}
|
||||
label={quantityError ?? "Quantity"}
|
||||
onChange={(e) => validateQuantity(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</td>
|
||||
<td className="ingredient-unit">
|
||||
<Autocomplete
|
||||
autoHighlight
|
||||
options={measurementUnits}
|
||||
options={units.map(each => each.name)}
|
||||
className="ui-creatable-component"
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
@@ -62,7 +125,9 @@ function IngredientSelector({ position, ingredients, units, destroy }: Ingredien
|
||||
/>
|
||||
)}
|
||||
onChange={(event, value) => {
|
||||
if (value) {
|
||||
setRowState({ ...rowState, measurement: value });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
@@ -103,7 +168,7 @@ function IngredientSelector({ position, ingredients, units, destroy }: Ingredien
|
||||
return {
|
||||
...prev,
|
||||
ingredients: ingredients,
|
||||
ingredientSelection: null
|
||||
ingredientSelection: undefined
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<DropdownData[]>([]);
|
||||
const [courseData, setCourseData] = useState<DropdownData[]>([]);
|
||||
const [ingredientFields, setIngredientFields] = useState<Array<JSX.Element>>([]);
|
||||
const [ingredientFieldData, setIngredientFieldData] = useState<Array<IngredientFieldData>>([]);
|
||||
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([<IngredientSelector key={v4()} position={optionCount} ingredients={ingredients} units={measurements} destroy={destroySelector} />]);
|
||||
}
|
||||
|
||||
}, [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(
|
||||
<Card>
|
||||
<p>Created recipe {recipeName} successfully!</p>
|
||||
<p>View your new recipe <a href={`/recipe/${recipeID}`}>here!</a></p>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
/**********************************
|
||||
* 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<JSX.Element>();
|
||||
|
||||
@@ -106,15 +64,118 @@ const AddRecipe = () => {
|
||||
})
|
||||
}, [ingredientFields]);
|
||||
|
||||
/**********************************
|
||||
* 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([<IngredientSelector key={v4()} position={optionCount} ingredients={ingredients} units={measurements} getRowState={getRowState} destroy={destroySelector} />]);
|
||||
}
|
||||
}, [ingredients, measurements])
|
||||
|
||||
useEffect(() => {
|
||||
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(
|
||||
<Toast>
|
||||
<p>Created recipe {recipeName} successfully!</p>
|
||||
<p>View your new recipe <a href={`/recipe/${recipeID}`}>here!</a></p>
|
||||
</Toast>
|
||||
)
|
||||
} else {
|
||||
setToast(
|
||||
<Toast variant="fail">
|
||||
<p>Error creating your recipe</p>
|
||||
<p>Please refresh the browser window and try again.</p>
|
||||
</Toast>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// logic for inserting a new ingredient row
|
||||
function handleNewOption() {
|
||||
setIngredientFields((prev) => [...prev, <IngredientSelector position={optionCount + 1} key={v4()} ingredients={ingredients} units={measurements} destroy={destroySelector} />])
|
||||
setIngredientFields((prev) => [...prev, <IngredientSelector position={optionCount + 1} key={v4()} ingredients={ingredients} units={measurements} getRowState={getRowState} destroy={destroySelector} />])
|
||||
setOptionCount(prev => prev + 1);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
console.log(courseData);
|
||||
}, [courseData])
|
||||
|
||||
/**********************************
|
||||
* RENDER
|
||||
*********************************/
|
||||
return (
|
||||
<Protect redirect="/add-recipe">
|
||||
<h1>Add a New Recipe</h1>
|
||||
@@ -143,6 +204,7 @@ const AddRecipe = () => {
|
||||
getOptionLabel={(option) => option.name}
|
||||
/>}
|
||||
</div>
|
||||
<Button onClick={() => console.log(ingredientFieldData)}>Ingredient Field Data</Button>
|
||||
|
||||
{ ingredients && (
|
||||
<>
|
||||
@@ -170,5 +232,3 @@ const AddRecipe = () => {
|
||||
</Protect>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddRecipe;
|
||||
19
client/src/components/ui/Toast.tsx
Normal file
19
client/src/components/ui/Toast.tsx
Normal file
@@ -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<ToastProps> = ({ children, variant = "success" }) => {
|
||||
return (
|
||||
<div className={`ui-component-toast toast-variant-${variant}`}>
|
||||
{ children }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Toast;
|
||||
18
client/src/sass/components/Toast.scss
Normal file
18
client/src/sass/components/Toast.scss
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<IIngredient>
|
||||
}
|
||||
|
||||
/**
|
||||
* Type declaration for react-select dropdown options
|
||||
*/
|
||||
|
||||
@@ -12,12 +12,20 @@ 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();
|
||||
|
||||
let REQUESTCOUNT = 0;
|
||||
|
||||
export const routes = async (app: Express) => {
|
||||
// unprotected routes
|
||||
authRoute(app);
|
||||
// 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) => {
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user