Getting Started With AWS Amplify - Part Two - Building the UI
July 27, 2020
If you haven’t already, you can find part one here. It describes how to get started, get Amplify initiated, and set up the API. In this post we will get our client side app up and running with React.
Building the UI
We already have our Create React App set up, so now we just have to write out some components and then wire up our Amplify code to get it all working.
First we need to let our React app know about Amplify. Luckily this is super easy. All we do is reference our aws-exports.js
file that Amplify created for us, and pass that into the Amplify …..
Open up src/index.js
and add the following code below the last import.
import Amplify from 'aws-amplify'import config from './aws-exports'Amplify.configure(config)
Boom! Now we are set up and ready to start using AWS.
There are a handful of components and styles that we need to build out, so let’s do that first.
In your src
directory, add a new folder called components
then add the following files:
Ask.jsBackHome.jsHero.jsQuestion.jsQuestionItem.js
Again in your src
directory add a folder called styles
and then a file called main.scss
Copy the following and paste it into that file.
* { box-sizing: border-box;}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre { margin: 0;}a { text-decoration: none; color: inherit;}h1,h2,h3,h4,h5,h6 { color: black; font-size: inherit; font-weight: inherit;}html,body { height: 100%;}body { background-color: #faf089;}input { border: 2px solid black; width: 100%; height: 3rem; font-size: 1.2rem; padding: 0rem 1rem; flex: 1; outline: none; &.ask { margin-bottom: 1rem; &:focus { box-shadow: 0 0 0 3px black; } }}.flex-center { display: flex; justify-content: center;}.container { width: 100%; margin-right: auto; margin-left: auto; padding-right: 1rem; padding-left: 1rem; display: flex; flex-direction: column;}@media (min-width: 640px) { .container { max-width: 640px; }}@media (min-width: 768px) { .container { max-width: 768px; }}@media (min-width: 1024px) { .container { max-width: 1024px; }}@media (min-width: 1280px) { .container { max-width: 1280px; }}@media (min-width: 1440px) { .container { max-width: 1440px; }}.hero { text-align: center;}.navbar { display: flex; justify-content: flex-end; padding: 1rem; button { margin-left: 1rem; }}.hero { margin-top: 5rem; h1 { margin-bottom: 2rem; font-size: 2.5rem; font-weight: bold; }}.question-item { display: flex; align-items: center; justify-content: space-between; padding: 2rem 0rem; border-bottom: 1px solid #cbd5e0; flex-direction: column; text-align: center; &:first-child { padding-top: 0px; } &:last-child { padding-bottom: 0px; border-bottom: 0px; } .question { font-size: 2rem; font-weight: bold; margin-bottom: 1rem; } .answer { margin-bottom: 2rem; color: #4a5568; }}@media (min-width: 640px) { .question-item { flex-direction: row; text-align: left; padding: 1rem 0rem; .answer { margin-bottom: 1rem; } }}button { background-color: black; padding: 0.5rem 0.7rem; font-weight: 600; font-size: 16px; color: white; outline: none; border: none; // margin-top: 5px; // border-radius: 0.25rem; cursor: pointer; &:hover { background-color: #363636; } &.teal { background-color: #4fd1c5; color: #2d3748; &:hover { background-color: #38b2ac; } }}.card { box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); border-radius: 0.4rem; padding: 1rem; background-color: #fff; flex: 1; margin-top: 5rem; margin-bottom: 3rem; .heading { font-size: 2rem; font-weight: bold; margin-bottom: 1rem; } .sub-heading { font-size: 1.2rem; font-weight: bold; margin-bottom: 0.8rem; } .sub-text { font-size: 1rem; font-weight: bold; margin-bottom: 0.3rem; color: #4a5568; } .answer-section { margin-top: 1.5rem; border-top: 1px solid #cbd5e0; padding-top: 1.5rem; padding-bottom: 1rem; }}
App.js
import React, { useEffect, useState } from "react";import { BrowserRouter as Router, Switch, Route } from "react-router-dom";import { API } from "aws-amplify";// import all of our componentsimport Ask from "./components/Ask";import Question from "./components/Question";import QuestionItem from "./components/QuestionItem";import Hero from "./components/Hero";// import our query from the generated GraphQLimport { questionsByDate } from "./graphql/queries";import "./styles/main.scss";function App() { const [allQuestions, setAllQuestions] = useState([]); const [submittingQuestion, setSubmittingQuestion] = useState(false); const [submittingAnswer, setSubmittingAnswer] = useState(false); useEffect(() => { fetchQuestions(); // refetch data when a question or answer have been submitted }, [submittingAnswer, submittingQuestion]); // fetch our questions from the API via Amplify async function fetchQuestions() { try { const questionData = await API.graphql({ query: questionsByDate, variables: { type: "QUESTION", sortDirection: "DESC", }, }); const questionsArray = questionData.data.questionsByDate.items; setAllQuestions(questionsArray); } catch (err) { console.log("FETCH QUESTIONS ERROR:: ", err); } } return ( <Router> <div className="container"> <Switch> <Route path="/ask"> <Ask setSubmittingQuestion={setSubmittingQuestion} /> </Route> <Route path="/question/:id"> <Question setSubmittingAnswer={setSubmittingAnswer} /> </Route> <Route path="/"> <Hero /> <div className="card"> {allQuestions.map((question) => ( <QuestionItem questionPost={question} key={question.id} /> ))} </div> </Route> </Switch> </div> </Router> );}export default App;
Let’s walk through the code a bit.
We will be handling some state, so we set those properties up using useState
We want to refetch our data when someone asks a or answers a question, so we can use useEffect
Our query actually takes place in the fetchQuestions
function. We create an async function, then call the API passing it our questionsByDate
query from the generated GraphQL Amplify made for us. If the call is successful, we set the data to state, if not we log what the error is.
In our return function, we have a very simple Router setup with a couple of routes and then a div that wraps our list of questions, each rendered in a <QuestionItem>
component.
Ask.js
import React, { useState } from "react";import { API } from "aws-amplify";import { createQuestion } from "../graphql/mutations";import BackHome from "./BackHome";const Ask = ({ setSubmittingQuestion }) => { const [question, setQuestion] = useState(""); const [saving, setSaving] = useState(false); const [sent, setSent] = useState(false); function onChangeText(e) { e.persist(); setQuestion(e.target.value); } async function handleAsk(e) { e.preventDefault(); try { if (!question) return; setSaving(true); setSubmittingQuestion(true); // Save the question to our DB await API.graphql({ query: createQuestion, variables: { input: { content: question, type: "QUESTION", }, }, }); setSaving(false); setSent(true); setSubmittingQuestion(false); setQuestion(""); } catch (err) { setSent(false); setSaving(false); setSubmittingQuestion(false); console.log("ERROR SAVING QUESTION:: ", err); } } return ( <> <div className="card"> {saving && <p>sending question...</p>} {!sent ? ( <form onSubmit={handleAsk}> <h1 className="heading">ask me anything</h1> <div> <input placeholder="be nice" name="question" onChange={onChangeText} className="ask" /> </div> <div className="flex-center"> <button type="submit">ask</button> </div> </form> ) : ( <p className="heading">question sent</p> )} </div> <BackHome /> </> );};export default Ask;
Again, nothing too crazy going on, but in our handleAsk
function, we can see where we are calling the API in order to save the question to the database. We import the createQuestion
mutation from the graphql
folder and pass that into the API call along with the question text from the input. We have a couple uses of useState in order to handle the loading, and sent states in the UI.
BackHome.js
import React from "react";import { Link } from "react-router-dom";const BackHome = () => { return ( <Link to="/" className="flex-center"> <button>back home</button> </Link> );};export default BackHome;
We have a back home button on various screens, so we just abstracted it into its own component. Nothing fancy here.
Hero.js
import React from "react";import { Link } from "react-router-dom";const Hero = () => { return ( <div className="hero"> <Link to="/"> <h1>Ron Swanson</h1> </Link> <Link to="/ask"> <button>ask me anything</button> </Link> </div> );};export default Hero;
Same as our BackHome component, this is just used in a couple places, we it makes sense to break it into it’s own component.
Question.js
import React, { useEffect, useState } from "react";import { useParams } from "react-router";import { API } from "aws-amplify";import { getQuestion } from "../graphql/queries";import { createAnswer } from "../graphql/mutations";import BackHome from "./BackHome";const Question = ({ setSubmittingAnswer }) => { const { id } = useParams(); const [loading, setLoading] = useState(true); const [question, setQuestion] = useState({}); const [answerInput, setAnswerInput] = useState(""); useEffect(() => { fetchQuestion(); // eslint-disable-next-line }, [id]); async function fetchQuestion() { try { const question = await API.graphql({ query: getQuestion, variables: { id, }, }); setQuestion(question.data.getQuestion); setLoading(false); } catch (err) { setLoading(false); console.log("FETCH QUESTION ERROR ", err); } } function onChangeAnswer(e) { e.persist(); setAnswerInput(e.target.value); } async function handleAddAnswer(e) { e.preventDefault(); const answerInfo = { questionID: question.id, content: answerInput, }; setSubmittingAnswer(true); try { await API.graphql({ query: createAnswer, variables: { input: answerInfo, }, }); setSubmittingAnswer(false); setAnswerInput(""); fetchQuestion(); } catch (err) { setSubmittingAnswer(false); console.log("ERROR ", err); } } return ( <> <div className="card"> {!loading && ( <> <h1 className="heading">{question.content}</h1> <p className="sub-text"> {(question.answer && question.answer.content) || "This question has not been answered yet"} </p> {!question.answer && ( <div className="answer-section"> <h3 className="sub-heading">add answer</h3> <form style={{ display: "flex", alignItems: "stretch" }} onSubmit={handleAddAnswer} > <input placeholder="super helpful answer" onChange={onChangeAnswer} value={answerInput} style={{ flex: 1 }} /> <button type="submit">add answer</button> </form> </div> )} </> )} </div> <BackHome /> </> );};export default Question;
When a user clicks on a question from the home page, they will be taken to a page that will show the full question and answer, and allows us to write an answer.
We use React Router’s useParams
hook to pull out the id
from the url. We then use the id to fetch the question using the getQuestion
query.
When we want to write an answer, we used the API
from Amplify with the createAnswer
mutation and pass it the current question.id
and the answer text.
Lastly, if a question already has an answer, we don’t show the answer form. {!question.answer && (...)}
QuestionItem.js
import React from "react";import { Link } from "react-router-dom";const QuestionItem = ({ questionPost }) => { const { content, answer, id } = questionPost; return ( <Link to={`/question/${id}`} className="question-item"> <div> <h2 className="question">{content}</h2> <p className="answer">{(answer && answer.content) || ""}</p> </div> <button>read more</button> </Link> );};export default QuestionItem;
Finally we have our QuestionItem
component. This is rendered on the home page for each question that comes back to us. Pretty simple component. If there is an answer, we show that, and we also pass the id to the <Link>
component so our Question
component can use that to fetch the full question and answer from the database.
You can now go to your terminal and run either yarn start
or npm start
. You’ll see a pretty empty app so far, but you should now be able to ask a question, fetch the questions and answer a question.
Woot!
As you might have noticed, anyone can ask a question and answer a question, which seems silly, in the next post we will get that fixed.
You can find the code for all the parts here: Amplify Tutorial - Part Two