Published 18th July 2022
In the previous article we have completed the CRUD functionalities for our application and refactored it a bit. Now it's time to take it to the next level. This time we will be documenting our project a bit (we won't go crazy about it, don't worry but we do need documentation for every project, we will refactor/change some more stuff and add some more features/improvements).
In order to start working on our project from this article's point onwards, you need to clone this repo . Next run:
git reset --hard d785ee6f4eeaf3a479ca2d582eb9251b3507addf
Get your .env for the back-end and you are ready to go. Check my previous articles if you are lost up to this point.
Before we get started, I just noticed we get this warning when we try to add a new meal item:
In order to fix this warning, just remove the below 2 lines from the processInput() method in the MainCard.js:
// delete these const newMeals = [...mealItems, newMealItem]; setMealItems(newMeals);
The first thing we need to do for our little project is to document it a bit. We will be adding markdown file explaining how to install and run the project.. Inside the project (so outside the front and back directories) create a README.md file and paste the below inside:
# Calorie Counting App ## Features -full-stack app allowing for calorie and protein tracking for your meals -can add/remove/edit/view meal items ## Tech -app uses the MERN stack (MongoDB, Express, React and NodeJS) -for the DB we use a mongoDB atlas cloud instance ## Installation ```sh cd back yarn cd ../front yarn ``` ## Running the app ```sh cd front yarn dev ```
This is very minimal, but whenever you clone any repo, you should look at this file (all repos should have one) so that you quickly understand what the project is about and how to run it fast.
This is a good first step, but now I want us to document a bit our code too. Let's start again in the 'front' directory with the BasicButton component. Inside the file, right before the function declaration paste:
/* * @BasicButton (functional component) * * @props: { * clickHandler: function, * color: string, * caption: string, * startIcon: component, * style: object, * variant: string * } */
Through this little comment we are specifying the type of component and the props it can get. Another way to document front-end components would also be to add prop-types or even something like Typescript, but for starters this is good.
Now, you might ask 'why bother doing this? It's pretty obvious what the props are from the way the component is written.' And you would be right (or almost). Our components are pretty clear and straightforward for now, but what if we change something or make them more complex? At that point in time, we or whoever else would be working on the project, will be grateful for these comments.
Some comments detractors might say that comments are bad because usually when a developer changes the code, they won't bother editing the comment (thus comments remain outdated and reflect bad information). What I would answer to that is that if I have it in pull request rules to change the comment whenever you change the code (so your PR would NOT get approved unless you touch the comments too), there's no issue with that.
We could also add a 3rd parameter to the comment (namely @returns but these functional components return only some JSX so I won't bother with that).
Let's do the Header, inside the file before the function declaration add:
/* * @Header (functional component) */
This component gets no props, so that's all we need (but if in future, we decide to make it 'smart' and feed it some data through the props, we can update our comment).
Now do the MealItems component:
/* * @MealItems (functional component) * * @props: { * items: array, * deleteItem: function, * editItem: function, * } * * @returns multiple MealItem components * and passes down the delete and edit handlers to them */
Note that this time I have added the @returns parameter in my comment because it's important for us to keep track of the fact that the MealItems list gets the delete and edit handlers and passes them further down as props to the MealItem array it renders. Tracking props and state is hard (especially when the application grows so this comment is relevant here). Next, do the Spinner:
/* * @Spinner (functional component) */
Next, let's move to the CustomTextField.js:
/* * @CustomTextField (functional component) * * @props: { * id: string, * placeholder: string, * type: string, * changeHandler: function * } * * @returns an instance of the StyledTextField component * and passes all the props down to it */
Paste one in the StyledTextField too:
/* * @StyledTextField * custom created styled component */
Next, let's do the MainCard:
/* * @MainCard (functional component - container * component holds all state and children components) * * @returns container component holding all the children components * and state */
Do the Item too:
/* * @Item * custom created styled component */
Do the MealItem too:
/* * @MealItem (functional component) * * @props: { * item: object, * deleteItem: function, * editItem: function, * } * * @returns MealItem instance displaying the meals and the associated data */
Finally, do the MealItemStyles too:
/* * @MealItemStyles * custom created style objects */
Add 1 more comment in the http.js:
/* * http service for making back-end calls */
We are pretty much done with documenting the front-end code. Let's move to the back-end now. We will start by moving the endpoints from the server.js into a dedicated routes file. Create a routes directory in the 'back' one and inside create a meals.js file. Inside of it paste:
import express from "express"; import { addItem, getItems, clearItems, removeItem, editItem, } from "../db/db.js"; import { MealItem } from "../db/models/MealItem.js"; export const mealRoutes = express(); /* * GET /meals * * @returns array of meal items */ mealRoutes.get("/meals", async (req, res) => { const meals = await getItems(MealItem); return res.send(meals); }); /* * POST /meals (creates new meal) * * @payload: * { * mealItem: string, * mealQty: number, * mealCals: number, * mealProtein: number * } * * @returns success reply */ mealRoutes.post("/meals", async (req, res) => { await addItem(req.body, MealItem); res.send(JSON.stringify({ msg: "post successfull" })); }); /* * POST /meals/clearAll (deletes all meals) * * @returns success reply */ mealRoutes.post("/meals/clearAll", async (req, res) => { await clearItems(MealItem); res.send(JSON.stringify({ msg: "delete all successfull" })); }); /* * POST /meals/:id (deletes meal item based on id) * * @returns success reply */ mealRoutes.post("/meals/:id", async (req, res) => { const { id } = req.params; await removeItem(id, MealItem); res.send(JSON.stringify({ msg: `id ${id} deleted successfully` })); }); /* * POST /meals/:id (edits meal item based on id) * * @payload: * { * mealItem: string, * mealQty: number, * mealCals: number, * mealProtein: number, * idToEdti: string * } * * @returns success reply */ mealRoutes.post("/edit/meal", async (req, res) => { await editItem(req.body, MealItem); const { idToEdit: id } = req.body; res.send(JSON.stringify({ msg: `id ${id} edited successfully` })); });
Next, replace server.js with:
import express from "express"; import cors from "cors"; //routes import { mealRoutes } from "./routes/meals.js"; //app config const app = express(); app.use(cors()); app.use(express.json()); //mount mealRoutes app.use("/", mealRoutes); //universal request handler app.use("*", (req, res) => { if (req.method === "POST") { res.statusCode = 405; return res.send("Method not allowed"); } else { res.statusCode = 404; return res.send("Page not found.."); } }); app.listen(4000);
Let's now do the db.js file, replace its contents as per below:
import { config } from "dotenv"; const { mongoURI } = config().parsed; import mongoose from "mongoose"; /* * opens DB connection */ export const connectDB = () => { try { mongoose.connect(mongoURI); console.log("MongoDB connected successfully!"); } catch (err) { console.log(err); } }; /* * adds item to the Meals table * * @params: * { * payload (contains data used to create new DB meal item) * model: mongoose model (MealItem) * } */ export const addItem = async (payload, model) => { connectDB(); const { mealItem, mealQty, mealCals, mealProtein } = payload; const newMealItem = new model({ mealItem, mealQty, mealCals, mealProtein, }); try { await newMealItem.save(); } catch (err) { console.log(err); } }; /* * gets meal items from DB * * @params: * { * model: mongoose model (MealItem) * } */ export const getItems = async (model) => { connectDB(); try { const meals = await model.find({}); return meals; } catch (err) { console.log(err); } }; /* * clears meal items from DB * * @params: * { * model: mongoose model (MealItem) * } */ export const clearItems = async (model) => { connectDB(); try { await model.deleteMany({}); } catch (err) { console.log(err); } }; /* * removes 1 meal item from DB * * @params: * { * id: string (id used to find the item to remove) * model: mongoose model (MealItem) * } */ export const removeItem = async (id, model) => { connectDB(); try { await model.remove({ _id: id }); } catch (err) { console.log(err); } }; /* * edits 1 meal item from DB * * @params: * { * payload (contains id and data used to update DB meal item) * model: mongoose model (MealItem) * } */ export const editItem = async (payload, model) => { connectDB(); const { idToEdit: _id, mealItem, mealQty, mealCals, mealProtein } = payload; try { await model.findOneAndUpdate( { _id }, { mealItem, mealQty, mealCals, mealProtein } ); } catch (err) { console.log(err); } };
Finally, replace the contets of MealItems.js too:
import mongoose from "mongoose"; /* * MealItemSchema (object schema for the MealItem element) */ const MealItemSchema = { mealItem: String, mealQty: Number, mealCals: Number, mealProtein: Number, }; //export the model and define table for mongoDB Atlas export const MealItem = mongoose.model("Meals", MealItemSchema);
So far we are done with commenting our code. For those of you that find this pedantic or excessive, I will tell you this: there have been numberous times when I had to dig into some ancient codebase with 0 comments or any relevant documentation. In such situations I would waste entire days understanding some specific bit of logic or trying to decipher the 'broader' business logic/context. In such situations, comments such as the ones we've added would have been extremely useful.
Now let's improve a bit the look and feel of our current interface (so that we can build more features in it). I want to make the MealItems look better, so let's look a bit into MealItemStyles.js , replace it as per below:
import { styled } from "@mui/material/styles"; import DeleteIcon from "@mui/icons-material/Delete"; import EditIcon from "@mui/icons-material/Edit"; /* * @MealItemStyles * custom created style objects */ export const mealStyles = { border: "2px solid #fff", borderRadius: "15px", marginTop: "1rem", marginBottom: "1rem", }; export const btnStyles = { backgroundColor: "#fff", display: "inline-block", margin: "0.5rem", width: "8rem", }; export const DeleteBtn = styled(DeleteIcon)` background-color: white; padding: 0.5rem; color: red; &:active { opacity: 0.9; } &:hover { cursor: pointer; background-color: red; color: white; transition: 0.3s; } border-radius: 100%; margin-bottom: 0.5rem; `; export const EditBtn = styled(EditIcon)` background-color: white; padding: 0.5rem; color: #4141d8; &:active { opacity: 0.9; } &:hover { cursor: pointer; background-color: #4141d8; color: white; transition: 0.3s; } border-radius: 100%; margin-bottom: 0.5rem; `;
Next, replace the contents of MealItem.js:
import Grid from "@mui/material/Grid"; import Typography from "@mui/material/Typography"; import { mealStyles, DeleteBtn, EditBtn } from "./MealItemStyles"; /* * @MealItem (functional component) * * @props: { * item: object, * deleteItem: function, * editItem: function, * } * * @returns MealItem instance displaying the meals and the associated data */ export const MealItem = ({ item, deleteItem, editItem }) => { return ( <Grid container style={mealStyles} item xs={12} md={7}> <Grid item xs={2}> <EditBtn onClick={() => editItem(item)} /> </Grid> <Grid item xs={8}> <Typography variant="h5" component="div"> {item.mealItem} </Typography> </Grid> <Grid item xs={2}> <DeleteBtn onClick={() => deleteItem(item._id)} /> </Grid> <Grid item xs={4}> <Typography variant="h6" component="div"> Qty: {item.mealQty.toFixed()} g </Typography> </Grid> <Grid item xs={4}> <Typography variant="h6" component="div"> Cal: {item.mealCals.toFixed()} </Typography> </Grid> <Grid item xs={4}> <Typography variant="h6" component="div"> Protein: {item.mealProtein.toFixed()} </Typography> </Grid> <Grid item xs={6}></Grid> </Grid> ); };
In MealItems.js get rid of the 'Meals' word in 'Meals Total Calories'.
Next, I want that the meal to be highlighted when we edit it, and the user to get scrolled to the top of the page. First add the below line in the MainCard.js in the editItem() method:
window.scrollTo({ top: 0, behavior: "smooth" });
Enough for the scrolling, now let's see for the edit highlighting. Unfortunately, we'll have again to pass 1 prop through 2 lvls of components. First in MealItems.js add the following prop:
idToEdit = null
* Don't forget to pass that prop in the MainCard.js and as its value put the idToEdit.
Next, define the prop in the MealItems.js too same as above ( idToEdit = null ). Also don't forget to pass it in the map() call too to all the MealItem instances.
Finally in the MealItem.js add the same prop definition. If you cannot follow any of these steps, please look at the commit for this article (bottom down), and check the changes I made in there.
Now, in the container grid component, above all the other item grids, add:
{idToEdit === item._id && ( <Grid item xs={12}> <Typography style={{ backgroundColor: "orange", borderRadius: "5px", margin: "0.5rem", }} variant="h5" component="div" > Item Is being Edited </Typography> </Grid> )}
Now we have the edited item highlighted while it is in the 'edit state'. With the smooth scrolling up top, to show the user where they should edit, this works much better than before. However, we did (and it's not the first time) something bad with this implementation. We did what's called 'prop drilling', namely we ve been passing props through more than one lvl of component hierarchy. We can do that but this will get messy quickly. We need to implement a cleaner way of passing data around. Enter React context.
We will create a MealsService.service.js file inside the 'services' directory in the front. Inside of it paste:
import React, { createContext, useEffect, useState } from "react"; import { getData } from "./http"; export const MealItemsContext = createContext(); /* * Service for handling MealItems globally thoroughout our app */ export const MealItemsProvider = ({ children }) => { const [meals, setMeals] = useState([]); const fetchItems = async () => { const items = await getData("http://localhost:4000/meals"); setMeals(items); }; useEffect(() => { fetchItems(); }); return ( <MealItemsContext.Provider value={{ meals }}> {children} </MealItemsContext.Provider> ); };
Next, import the MealItemsProvider in App.js and wrap the MainCard in it.
Finally, in the MainCard.js import useContext and the MealItemsContext. Next, add this line inside the function declaration above all pieces of state:
const { meals } = useContext(MealItemsContext);
Finally, replace the useEffect call:
useEffect(() => { // fetchItems(); no more http call from component, data is initialized at service lvl setMealItems(meals); }, [meals]);
Now, your app will work just as before, but we are not making an http call from MainCard any longer. Data is initialized at the service level and injected in our component. The bext thing about context is that it can be used at any child lvl component (so we can start removing the items prop from the MealItems.js). Replace its contents as per below:
import React, { useContext } from "react"; import { MealItemsContext } from "../services/MealsService.service"; import Grid from "@mui/material/Grid"; import Typography from "@mui/material/Typography"; import { MealItem } from "./MealItem/MealItem"; /* * @MealItems (functional component) * * @props: { * deleteItem: function, * editItem: function, * idToEdit: string * } * * @returns multiple MealItem components * and passes down the delete and edit handlers to them */ export const MealItems = ({ deleteItem, editItem, idToEdit = null }) => { const { meals } = useContext(MealItemsContext); let totalCals = 0; let totalP = 0; meals.map((i) => (totalCals += i.mealCals)); meals.map((i) => (totalP += i.mealProtein)); if (meals.length === 0) { return; } else { return ( <Grid container spacing={1} justifyContent="center"> <Grid item xs={12} md={7}> <Typography variant="h4" component="div"> Meal Items: </Typography> </Grid> {meals.map((i) => { return ( <MealItem item={i} deleteItem={deleteItem} editItem={editItem} key={i._id} idToEdit={idToEdit} /> ); })} <Grid item xs={12} md={7}> <Typography variant="h4" component="div"> Total Calories: {totalCals.toFixed(2)} <br />({totalP.toFixed(2)} g Protein) </Typography> </Grid> </Grid> ); } };
Next, let's work a bit more on our MealsService.service.js. I want us to move more of the state and logic from the MainCard.js into it. Let's start with the fetchItems method. Add it in the value object of the context provider:
value={{ meals, fetchItems }}
Next, in the MainCard we can destructure it from the useContext call and delete it. The app will work just as before, but we have the isLoading logic in the MainCard. I want it moved in the service too. Replace its contents as per below:
import React, { createContext, useEffect, useState } from "react"; import { getData, postData } from "./http"; export const MealItemsContext = createContext(); /* * Service for handling MealItems globally thoroughout our app */ export const MealItemsProvider = ({ children }) => { //state const [meals, setMeals] = useState([]); const [isLoading, setIsLoading] = useState(false); const [mealItem, setMealItem] = useState(""); const [mealQty, setMealQty] = useState(0); const [mealCals, setMealCals] = useState(0); const [mealProtein, setProtein] = useState(0); const [idToEdit, setId] = useState(null); const fetchItems = async () => { setIsLoading(true); const items = await getData("http://localhost:4000/meals"); setIsLoading(false); setMeals(items); }; const deleteItem = async (id) => { setIsLoading(true); const resData = await postData(`http://localhost:4000/meals/${id}`); console.log(resData); setIsLoading(false); await fetchItems(); }; const deleteMeals = async () => { setIsLoading(true); const resData = await postData("http://localhost:4000/meals/clearAll"); console.log(resData); setIsLoading(false); setMeals([]); clearInput(); }; const clearInput = () => { setMealItem(""); setMealQty(0); setMealCals(0); setProtein(0); }; const editItem = (itemToEdit) => { const { _id, mealItem, mealQty, mealCals, mealProtein } = itemToEdit; setMealItem(mealItem); setMealQty(mealQty); setMealCals(mealCals); setProtein(mealProtein); setId(_id); window.scrollTo({ top: 0, behavior: "smooth" }); }; const cancelEdit = () => { setId(null); clearInput(); }; const saveEditMeal = async () => { const editItem = processMealInput(); editItem.idToEdit = idToEdit; const resData = await postData("http://localhost:4000/edit/meal", editItem); await fetchItems(); console.log(resData); setId(null); }; const saveMeal = async () => { const newMealItem = processMealInput(); if (newMealItem) { const resData = await postData( "http://localhost:4000/meals", newMealItem ); console.log(resData); await fetchItems(); } }; const processMealInput = () => { if (!mealItem || !mealQty || !mealCals || !mealProtein) { console.log("boo! bad input! X__X"); } else { const totalCalories = (mealQty * mealCals) / 100; const totalProtein = (mealQty * mealProtein) / 100; const newMealItem = { mealItem, mealQty, mealCals: totalCalories, mealProtein: totalProtein, }; clearInput(); return newMealItem; } }; useEffect(() => { fetchItems(); }, []); return ( <MealItemsContext.Provider value={{ meals, fetchItems, isLoading, deleteItem, deleteMeals, clearInput, processMealInput, editItem, idToEdit, saveEditMeal, cancelEdit, saveMeal, mealItem, setMealItem, mealQty, setMealQty, mealProtein, setProtein, mealCals, setMealCals, }} > {children} </MealItemsContext.Provider> ); };
Now that we have moved all the logic and the state from the MainCard.js, replace its contents too:
import React, { useContext } from "react"; import Grid from "@mui/material/Grid"; import Box from "@mui/material/Box"; import { MealItemsContext } from "../../services/MealsService.service"; import { BasicButton } from "../BasicButton"; import { Header } from "../Header"; import { CustomTextField } from "../CustomTextField/CustomTextField"; import { MealItems } from "../MealItems"; import { Spinner } from "../Spinner"; import { Item } from "./Item"; /* * @MainCard (functional component - container * component holds all state and children components) * * @returns container component gets data injected from MealsService */ export const MainCard = () => { const { isLoading, deleteItem, deleteMeals, mealItem, setMealItem, mealQty, setMealQty, mealProtein, setProtein, mealCals, setMealCals, editItem, idToEdit, saveEditMeal, cancelEdit, saveMeal, } = useContext(MealItemsContext); return ( <Box> <Grid container spacing={1} justifyContent="center"> <Grid item xs={12} md={7}> <Item> <Header /> {isLoading &&<Spinner />} {!isLoading && ( <> <CustomTextField id="mealItem" placeholder="Meal Item" changeHandler={(e) => setMealItem(e.target.value)} value={mealItem} /> <CustomTextField id="mealQuantity" type="number" placeholder="Meal Quantity" changeHandler={(e) => setMealQty(e.target.value)} value={mealQty} /> <CustomTextField id="mealCalories" placeholder="Meal Calories/100g" type="number" changeHandler={(e) => setMealCals(e.target.value)} value={mealCals} /> <CustomTextField id="mealProtein" placeholder="Meal Protein/100g" type="number" changeHandler={(e) => setProtein(e.target.value)} value={mealProtein} /> {!idToEdit &&<BasicButton clickHandler={saveMeal} />} <BasicButton color="error" caption="Clear All" clickHandler={deleteMeals} /> {idToEdit && ( <> <BasicButton color="success" caption="Save Edit" clickHandler={saveEditMeal} /> <BasicButton color="secondary" caption="Cancel" clickHandler={cancelEdit} /> </> )} </> )} </Item> <Item> <MealItems deleteItem={deleteItem} editItem={editItem} idToEdit={idToEdit} /> </Item> </Grid> </Grid> </Box> ); };
Now let's clean up MealItems.js and get rid of its props. Remove them and move them into the destructuring from the context:
const { meals, deleteItem, editItem, idToEdit } = useContext(MealItemsContext);
Next is the MealItem.js. In this component we can get rid of the following props: deleteItem, editItem and idToEdit. Get them from the context and remove the prop definition. Grab them from the MealItemsContext in the MealItem.js component and please do note that you can now remove them from the context of MealItems.js (we had grabbed them here because we were passing them as props further down the component chain).
I think we did enough cleaning for now. In the next article we will further improve the application and add more features. I hope by this time you have a pretty clear idea as of how useful the React context is for cleaning up our components of useless logic and props.
Thanks for keeping up with me through so much content. If you want to see the specific modifications I made during this article, you have the commithere.