January 16, 2022

Starting Full-stack Serveless Application Development with aws amplify - Part 2

In this last part, we are going to finalize our product listing web app by allow users to manage their product listing.

We will be using AWS AppSync, a managed GraphQL service that will enable us to build GraphQL APIs by connecting clients to serveless backends.

AWS AppSync is a fully managed service that makes it easy to develop GraphQL APIs by handling the heavy lifting of securely connecting to data sources like AWS DynamoDB, Lambda, and more.

AWS AppSync Architecture

GraphQL allows client to request what data it needs which allows a single endpoint to query all of our APIs hence faster load times and reduction of app complexity .

In GraphQL, a contract called GraphQL Schema is signed between a client and server that allows all possible types of operations that the client can perform on the backend to be defined.

GraphQL has three main operations

  1. Query - For fetching data from GraphQL API (Similar to GET in REST)
  2. Mutation - Changing data (Similar to POST/PUT/DELETE in REST)
  3. Subscription - Watching for data changes in realtime

Now, back to our project, let’s add our GraphQL API by running below command

amplify add api

Please complete the prompts as below :-

? Select from one of the below mentioned services: GraphQL
? Here is the GraphQL API that we will create. Select a setting to edit or continue Continue
? Choose a schema template: Single object with fields (e.g., “Todo” with ID, name, description)
GraphQL schema compiled successfully.
✔ Do you want to edit the schema now? (Y/n) · yes
? Choose your default editor: Visual Studio Code

Now, let’s edit the GraphQL schema located here amplify/backend/api/myapi/schema. Let’s go ahead and edit the schema to match our product listing fields.

type Product @model {
  id: ID!
  name: String!
  description: String
  price: Float
  image: String
  createdBy: String
  mobile: String
  address: String
}

Once the schema has been edited, let’s deploy it by running

amplify push --y

The —y flag above will accept all default options.

The command we just run above will do 3 main things

  1. Create the AppSync API
  2. Create a DynamoDB table
  3. Create the local GraphQL operations in a folder located at src/graphql that you can use to query the API

That’s it, our backend has been deployed succesful.

Let’s create a product

Now that our backend is all ready to go. Let’s jump right back to where we left off with our frontend .

Back to our Header.js file , let’s add another menu to allow authenticated users to create products . Let’s add another menu item as below :-

import AddIcon from "@mui/icons-material/Add";
const Header = ({ onHomeClick,onAddProductClick }) => {

<img
  src="https://codecotzbucket.s3.us-east-2.amazonaws.com/logo.png"
  alt="BuyMine Logo"
  className="header__logo"
  onClick={onHomeClick}
/>

 {isAuthenticated && (
            <div className="header__menuItem" onClick={onAddProductClick}>
              <AddIcon className="header__menuItemAvatar" />
              <div className="header__menuItemText">Add Product</div>
            </div>
          )}
}

Now if you refresh the page and authenticated you should see Add Product menu item.

Note that the Header component now should have two props , onHomeClick which would take us to homepage once clicked and onAddProductClick prop which would trigger our Add Product component to show .

Go ahead and modify App.js file as below:-

import { useState } from "react";
import Header from "./components/Header/Header";
const App = () => {
  const [canViewHome, setCanViewHome] = useState(true);
  const [canViewAddProduct, setCanViewAddProduct] = useState(false);

  const goHome = () => {
    setCanViewHome(true);
    setCanViewAddProduct(false);
  };

  const goToAddProduct = () => {
    setCanViewAddProduct(true);
    setCanViewHome(false);
  };

  return (
    <div className="App">
      <Header onAddProductClick={goToAddProduct} onHomeClick={goHome} />
      {canViewHome && <h1>Expected Product List Here</h1>}
      {canViewAddProduct && <h1>Add Product Component</h1>}
    </div>
  );
};

export default App;

Now when you click Add Product menu you should see title with add Add Product Component in it while clicking our logo should take us home with the message Expected Product List Here displayed .

From there let’s go ahead and add our AddProduct component. Inside components folder, create a new folder called AddProduct and inside it AddProduct.css and AddProduct.js file as below:-

AddProduct.css

.AddProduct {
  display: flex;
  flex-direction: column;
  width: 50%;
  margin: 20px;
}
.AddProduct button {
  margin-top: 15px;
  background-color: #f7bf50;
  color: black;
}

.AddProduct .label,
.AddProduct p {
  color: #333;
}

AddProduct.JS

import { useEffect, useState } from "react";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import FileUploadIcon from "@mui/icons-material/FileUpload";
import { useAuthStatus } from "../../hooks/Auth";
import "./AddProduct.css";

const AddProduct = () => {
  const initialFormState = {
    name: "",
    description: "",
    price: 0.0,
    image: null,
    createdBy: "",
    mobile: "",
    address: "",
  };
  const [formData, setFormData] = useState(initialFormState);
  const { user, isAuthenticated } = useAuthStatus();

  useEffect(() => {
    if (isAuthenticated) {
      setFormData({
        ...formData,
        createdBy: user.name,
        mobile: user.phone_number,
        address: user.address,
      });
    }
  }, []);

  const createProduct = async () => {
    console.log(JSON.stringify(formData));
    setFormData(initialFormState);
  };

  const onFileChange = async (e) => {
    let file = e.target.files[0];
    if (!file) return;
    setFormData({ ...formData, image: file.name });
  };
  return (
    <div className="AddProduct">
      <h1>Add Product</h1>
      <TextField
        onChange={(e) => setFormData({ ...formData, name: e.target.value })}
        value={formData.name}
        label="Product name"
        margin="dense"
        required
      />
      <TextField
        onChange={(e) =>
          setFormData({ ...formData, description: e.target.value })
        }
        label="Product description"
        value={formData.description}
        margin="dense"
        multiline
        maxRows={4}
        rows={4}
        required
      />
      <TextField
        onChange={(e) => setFormData({ ...formData, price: e.target.value })}
        label="Product Price"
        margin="normal"
        value={formData.price}
        required
      />
      {formData.image && <p>Image Uploaded : {formData.image}</p>}
      <Button variant="text" component="label">
        <FileUploadIcon />
        {!formData.image ? "Upload Product Image" : "Update Image"}
        <input type="file" onChange={onFileChange} hidden />
      </Button>
      <Button variant="contained" onClick={createProduct}>
        CREATE{" "}
      </Button>
    </div>
  );
};
export default AddProduct;

Now inside App.js , in canViewAddProduct condition check replace h1 with our actual Add Product Component

import AddProduct from "./components/AddProduct/AddProduct";

 {canViewAddProduct && <AddProduct />}

Now when you click Add Product , we should be presented with the screen as below:- Add Product Screen

From there , go on and try to create a product and check console as we have log the values that we will submit inside createProduct function. Once submitted , you will see that we have manage to catch all form field and what is left is sending the values to the API.

See sample console log from my test.

{
  "name": "Product X",
  "description": "Product X Description",
  "price": "100.5",
  "image": "product_x_image.png",
  "createdBy": "robert",
  "mobile": "+255712239123",
  "address": "102, XXX Street , Dar-es-salaam"
}

Now before sending the data , we need a way to store our image and in our case we will add amazon storage service which relies on Amazon S3.

Go ahead and add storage by running below command.

amplify add storage

Then follow commands as below:-

? Select from one of the below mentioned services: Content (Images, audio, video, etc.)
✔ Provide a friendly name for your resource that will be used to label this category in the project: · <name>
✔ Provide bucket name: · <name>
✔ Who should have access: · Auth and guest users
✔ What kind of access do you want for Authenticated users? · create/update, read, delete
✔ What kind of access do you want for Guest users? · read
✔ Do you want to add a Lambda Trigger for your S3 Bucket? (y/N) · no

Let’s deploy our changes by running amplify push --y

Now that our service is deployed,let’s get back to our AddProduct.js and implement two changes .

first inside our createProduct function we will use API class to send a mutation to the GraphQL API so as we can store our data to dynamo db .

Second inside onFileChange , we will use Storage class of amplify to store our image to s3 :-

We have also include a message when a product has been created succesful. Our final AddProduct component is as below:-

import { useEffect, useState } from "react";
import { API, Storage } from "aws-amplify";
import { createProduct as createProductMutation } from "../../graphql/mutations";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import Alert from "@mui/material/Alert";

import FileUploadIcon from "@mui/icons-material/FileUpload";
import { useAuthStatus } from "../../hooks/Auth";
import "./AddProduct.css";

const AddProduct = () => {
  const initialFormState = {
    name: "",
    description: "",
    price: 0.0,
    image: null,
    createdBy: "",
    mobile: "",
    address: "",
  };
  const [formData, setFormData] = useState(initialFormState);
  const { user, isAuthenticated } = useAuthStatus();
  const [message, setMessage] = useState("");

  useEffect(() => {
    if (isAuthenticated) {
      setFormData({
        ...formData,
        createdBy: user.name,
        mobile: user.phone_number,
        address: user.address,
      });
    }
  }, []);

  const createProduct = async () => {
    await API.graphql({
      query: createProductMutation,
      variables: { input: formData },
    });
    setFormData(initialFormState);
    setMessage("Product has been created succesfull");
    setTimeout(() => {
      setMessage("");
    }, 7000);
  };

  const onFileChange = async (e) => {
    let file = e.target.files[0];
    if (!file) return;
    setFormData({ ...formData, image: file.name });
    await Storage.put(file.name, file);
  };

  return (
    <div className="AddProduct">
      <h1>Add Product</h1>
      {message !== "" && (
        <Alert severity="success">
          The product has been created succesfully
        </Alert>
      )}

      <TextField
        onChange={(e) => setFormData({ ...formData, name: e.target.value })}
        value={formData.name}
        label="Product name"
        margin="dense"
        required
      />
      <TextField
        onChange={(e) =>
          setFormData({ ...formData, description: e.target.value })
        }
        label="Product description"
        value={formData.description}
        margin="dense"
        multiline
        maxRows={4}
        rows={4}
        required
      />
      <TextField
        onChange={(e) => setFormData({ ...formData, price: e.target.value })}
        label="Product Price"
        margin="normal"
        value={formData.price}
        required
      />
      {formData.image && <p>Image Uploaded : {formData.image}</p>}
      <Button variant="text" component="label" className="label">
        <FileUploadIcon />
        {!formData.image ? "Upload Product Image" : "Update Image"}
        <input type="file" onChange={onFileChange} hidden />
      </Button>
      <Button variant="contained" onClick={createProduct}>
        CREATE{" "}
      </Button>
    </div>
  );
};
export default AddProduct;

Now from here , let’s go ahead and add our product. Hope everything goes well and product has been created.

From there, let’s get back to App.js and display our products. Let’s create a function called getProducts which will use API class to query products using listProducts graphQL query .

Once the product is retrieved, we will use Storage API get method to retrieve the image from S3 using the image name. From here , let’s call getProducts when our component mounts using useEffect. Our updated App.js file should look as below:-

import { useState, useEffect } from "react";
import { API, Storage } from "aws-amplify";
import { listProducts } from "./graphql/queries";
import AddProduct from "./components/AddProduct/AddProduct";
import Header from "./components/Header/Header";
const App = () => {
  const [canViewHome, setCanViewHome] = useState(true);
  const [canViewAddProduct, setCanViewAddProduct] = useState(false);
  const [isLoading, setIsLoading] = useState(false);
  const [products, setProducts] = useState([]);

  useEffect(() => {
    getProducts();
  }, []);

  const goHome = () => {
     setIsLoading(true);
    setCanViewHome(true);
    setCanViewAddProduct(false);
    getProducts();

  };

  const goToAddProduct = () => {
    setCanViewAddProduct(true);
    setCanViewHome(false);
  };

  const getProducts = async () => {
    setIsLoading(true);
    const response = await API.graphql({ query: listProducts });
    const products = response.data.listProducts.items;
    console.log("Products  = ", products);
    await Promise.all(
      products.map(async (product) => {
        if (product.image) {
          const image = await Storage.get(product.image);
          product.image = image;
        }
        return product;
      })
    );
    setProducts(products);
    setIsLoading(false);
  };

  console.log(products);

  return (
    <div className="App">
      <Header onAddProductClick={goToAddProduct} onHomeClick={goHome} />
      {canViewHome && <h1>Expected Product List Here</h1>}
      {canViewAddProduct && <AddProduct />}
    </div>
  );
};

export default App;

Now if all goes well, you should be able to get products.

From here, let’s add Product component that will display our product. Go ahead inside components folder create a folder called Product and inside it Product.css and Product.js as below:-

Product.css

.product {
  flex-direction: column;
  display: flex;
  flex: 1 0 21%;
  margin: 5px;
  justify-content: center;
  align-items: center;
  margin-bottom: 15px;
  cursor: pointer;
}
.productImage {
  width: 240px;
  height: 240px;
  object-fit: contain;
  margin-bottom: 15px;
}
.productTitle {
  max-height: 37px;
  font-size: 0.875rem;
  color: rgb(17, 24, 32);
  text-align: center;
  font-weight: normal;
  margin-top: 5px;
}
.productPrice {
  font-size: 14px;
  margin: 0;
  font-weight: bolder;
}

Product.js

import "./Product.css";
const Product = ({ product, viewProduct }) => {
  return (
    <div className="product" onClick={() => viewProduct(product)}>
      <img src={product.image} alt={product.name} className="productImage" />
      <h3 className="productPrice">$ {product.price}</h3>
      <h2 className="productTitle">{product.name}</h2>
    </div>
  );
};
export default Product;

The Product component will accept two props , one being the product itselt and viewProduct which should trigger a function to open the product details.

Since we already have our products,let’s looop through them and display them using our newly created Product component.

import { useState, useEffect } from "react";
import { API, Storage } from "aws-amplify";
import { listProducts } from "./graphql/queries";
import Product from "./components/Product/Product";
import AddProduct from "./components/AddProduct/AddProduct";
import Header from "./components/Header/Header";
import Backdrop from "@mui/material/Backdrop";
import CircularProgress from "@mui/material/CircularProgress";

const App = () => {
  const [canViewHome, setCanViewHome] = useState(true);
  const [canViewAddProduct, setCanViewAddProduct] = useState(false);
  const [isLoading, setIsLoading] = useState(false);
  const [products, setProducts] = useState([]);
  const [selectedProduct, setSelectedProduct] = useState(null);
  const [canViewDetails, setCanViewDetails] = useState(false);

  useEffect(() => {
    getProducts();
  }, []);

  const goHome = () => {
    setCanViewHome(true);
    setCanViewAddProduct(false);
    setCanViewDetails(false);
  };

  const goToAddProduct = () => {
    setCanViewAddProduct(true);
    setCanViewHome(false);
  };

  const getProducts = async () => {
    setIsLoading(true);
    const response = await API.graphql({ query: listProducts });
    const products = response.data.listProducts.items;
    console.log("Products  = ", products);
    await Promise.all(
      products.map(async (product) => {
        if (product.image) {
          const image = await Storage.get(product.image);
          product.image = image;
        }
        return product;
      })
    );
    setProducts(products);
    setIsLoading(false);
  };

  const viewProduct = (product) => {
    setSelectedProduct(product);
    setCanViewDetails(true);
    setCanViewHome(false);
  };

  return (
    <div className="App">
      <Header onAddProductClick={goToAddProduct} onHomeClick={goHome} />
      {isLoading && (
        <Backdrop
          sx={{ color: "#fff", zIndex: (theme) => theme.zIndex.drawer + 1 }}
          open={isLoading}
        >
          <CircularProgress color="inherit" />
        </Backdrop>
      )}
      {canViewHome &&
        !isLoading &&
        products.map((product) => (
          <Product
            product={product}
            key={product.id}
            viewProduct={viewProduct}
          />
        ))}
      {canViewAddProduct && <AddProduct />}
      {canViewDetails && <h1>Product Details Component</h1>}
    </div>
  );
};

export default App;

From the above, we have added a loader to display as we are calling our products as well as canViewDetails state ready to display our product Info .

Let’s finalize our component list by adding ProductInfo component which will display our product. Go ahead inside components folder and add ProductInfo folder and inside it ProductInfo.css and ProductInfo.js as below:-

ProductInfo.css

.productInfo {
  display: flex;
  margin-top: 20px;
  margin-right: 55px;
}
.productInfo__image {
  display: flex;
  flex-direction: column;
}

.productInfo div.productInfo__image {
  flex: 1 0 30%;
  padding: 0px 10px;
}
.productInfo div.productInfo__desc {
  flex: 1 0 70%;
  padding: 10px 15px;
  box-shadow: 0 1px 2px 1px rgb(0 0 0 / 15%);
}
.productDesc {
  margin: 5px;
}
.productInfo h1,
.productInfo h2 {
  margin: 10px 0px;
}
.productInfo h5 {
  margin-top: 0px;
  margin-bottom: 10px;
}

ProdcutInfo.js

import { useState } from "react";
import { API } from "aws-amplify";
import Button from "@mui/material/Button";
import { useAuthStatus } from "../../hooks/Auth";
import { deleteProduct as deleteProductMutation } from "../../graphql/mutations";
import Alert from "@mui/material/Alert";
import "./ProductInfo.css";

const ProductInfo = ({ product }) => {
  const { user, isAuthenticated } = useAuthStatus();
  const [message, setMessage] = useState("");
  const deleteProduct = async (productId) => {
    await API.graphql({
      query: deleteProductMutation,
      variables: { input: { id: productId } },
    });
    setMessage("Product has been deleted succesfully");
    setTimeout(() => {
      setMessage("");
    }, 7000);
  };

  return (
    <div className="productInfo">
      <div className="productInfo__image">
        <img src={product.image} alt={product.name} className="productImage" />
        {isAuthenticated && user && user.name === product.createdBy && (
          <Button variant="contained" onClick={() => deleteProduct(product.id)}>
            Delete
          </Button>
        )}
      </div>

      <div className="productInfo__desc">
        {message !== "" && (
          <Alert severity="success">
            The product has been created succesfully
          </Alert>
        )}
        <h1>{product.name}</h1>
        <h2 className="productInfo__price">${product.price}</h2>
        <h5 className="productInfo__creator">
          Posted By : {product.createdBy}
        </h5>
        <h5 className="productInfo__creator">Phone number: {product.mobile}</h5>
        <h5 className="productInfo__creator">Address: {product.address}</h5>
        <div>{product.description}</div>
      </div>
    </div>
  );
};

export default ProductInfo;

Note on the ProductInfo component i went ahead and add a delete the product function using GraphQL mutation. From here go ahead and add the ProductInfo component on canViewDetails check in App.js.

Our Final App.js file should be as below:-

import { useState, useEffect } from "react";
import { API, Storage } from "aws-amplify";
import { listProducts } from "./graphql/queries";
import Product from "./components/Product/Product";
import ProductInfo from "./components/ProductInfo/ProductInfo";
import AddProduct from "./components/AddProduct/AddProduct";
import Header from "./components/Header/Header";
import Backdrop from "@mui/material/Backdrop";
import CircularProgress from "@mui/material/CircularProgress";
import "./App.css";

const App = () => {
  const [canViewHome, setCanViewHome] = useState(true);
  const [canViewAddProduct, setCanViewAddProduct] = useState(false);
  const [isLoading, setIsLoading] = useState(false);
  const [products, setProducts] = useState([]);
  const [selectedProduct, setSelectedProduct] = useState(null);
  const [canViewDetails, setCanViewDetails] = useState(false);

  useEffect(() => {
    getProducts();
  }, []);

  const goHome = () => {
    setCanViewHome(true);
    setCanViewAddProduct(false);
    setCanViewDetails(false);
    getProducts();
  };

  const goToAddProduct = () => {
    setCanViewAddProduct(true);
    setCanViewHome(false);
    setCanViewDetails(false);
  };

  const getProducts = async () => {
    setIsLoading(true);
    const response = await API.graphql({ query: listProducts });
    const products = response.data.listProducts.items;
    console.log("Products  = ", products);
    await Promise.all(
      products.map(async (product) => {
        if (product.image) {
          const image = await Storage.get(product.image);
          product.image = image;
        }
        return product;
      })
    );
    setProducts(products);
    setIsLoading(false);
  };

  const viewProduct = (product) => {
    setSelectedProduct(product);
    setCanViewDetails(true);
    setCanViewHome(false);
  };

  return (
    <div className="App">
      <Header onAddProductClick={goToAddProduct} onHomeClick={goHome} />
      {isLoading && (
        <Backdrop
          sx={{ color: "#fff", zIndex: (theme) => theme.zIndex.drawer + 1 }}
          open={isLoading}
        >
          <CircularProgress color="inherit" />
        </Backdrop>
      )}
      <div className="products">
        {canViewHome &&
          !isLoading &&
          products.map((product) => (
            <Product
              product={product}
              key={product.id}
              viewProduct={viewProduct}
            />
          ))}
      </div>

      {canViewAddProduct && <AddProduct />}
      {canViewDetails && <ProductInfo product={selectedProduct} />}
    </div>
  );
};

export default App;

i finally imported App.css file and wrap our products inside a div so as i can display them in a row.

App.css

.products {
  padding: 15px;
  display: flex;
  flex-wrap: wrap;
}

Now we should have our app ready to publish if everything went okay . Let’s go ahead and publish our app.

Before publish let’s add a host by running:- amplify hosting add

From here , let’s publish our ap

amplify publish

Just incase on publish you get his error

"TypeError: MiniCssExtractPlugin is not a constructor"

At this moment, there is an open issue on the create-react-app repo , to fix if your using npm run

npm i -D --save-exact mini-css-extract-plugin@2.4.5

or if you are using run, add below in package.json

"resolutions": {
    "mini-css-extract-plugin": "2.4.5"
  },

if all goes well you should end up with a message like below

✔ Zipping artifacts completed.
✔ Deployment complete!
https://dev.d3k3pym0e8rako.amplifyapp.com

And That’s it folks.Go ahead and share your new web app :-) Incase of any comments please don’t hesitate to rach out to be directly or via comments section.


Robert Rutenge
Front-end Developer | ReactJS enthusiast | Problem Solver . Find me on twitter and Github