Learn Full-Stack Web Development with React and GraphQL by Building a Sticky Note App - Part three
The Full stack Development
Table of contents
- Introduction
- Prerequisites
- Setting the Project
- What is Apollo Client?
- Installing the Apollo Client and GraphQL
- Setting up the InMemoryCache
- Making the ApolloClient instance available to all the React components in our App.
- Updating the global styles
- Creating the AuthNavigationBar Component
- Creating the MainNavigationBar Component
- Creating the ErrorPage Component
- Creating the Loading Component
- Creating the user and note Mutations
- Creating the note Queries
- Defining the local-only fields
- Updating Home Page
- Updating the INoteCardProps Interface
- Defining the IAllNoteData interfaces
- Updating the NoteCard component
- Updating the NoteContainer Component
Introduction
Hello again!๐ In the previous part of the series, we covered how to build the backend of the Sticky Note app using GraphQL, Apollo Server 4, Nexus and MongoDB. In this section, we will be integrating our backend API with the frontend and enabling communication between the two. This will allow the frontend to send requests to and receive data from our GraphQL API.
Prerequisites
We will be using the frontend code that we built in the first part of this series to connect to the GraphQL API that we created in the second part. It is important that you check out both of these sections before proceeding, as the knowledge gained in those parts will be essential for completing this section.
Setting the Project
To get started with this section, we will first need to clone the GitHub repository that contains the code from the first part of this tutorial. To do this, open your terminal and run the following command:
git clone https://github.com/tope-olajide/sticky-note-react.git
This will create a local copy of the repository on your machine. Once the cloning process is complete, you can navigate to the repository directory by running:
bash cd sticky-note-react
To install all of the necessary dependencies for this project, run the following command in your terminal:
yarn install
What is Apollo Client?
Apollo Client is a comprehensive state management library for JavaScript that enables you to manage both local and remote data with GraphQL. It is designed to help you easily connect to a GraphQL server and retrieve data from it, as well as update and manipulate that data using GraphQL mutations. Apollo Client features automatic caching, reliable change detection, and a simple, declarative API that makes it easy to work with GraphQL in your JavaScript applications.
Installing the Apollo Client and GraphQL
Run the following command to install Apollo Client and GraphQL
yarn add @apollo/client graphql
Setting up the InMemoryCache
Apollo GraphQL's InMemoryCache
is a powerful and flexible caching library that is designed to work with GraphQL servers. It is an in-memory data store that stores the results of GraphQL queries, mutations, and subscriptions in a cache.
Create a new file called cache.ts
in the root directory, then open it and enter the following code:
import { InMemoryCache } from '@apollo/client';
export const cache: InMemoryCache = new InMemoryCache({ })
This will create an instance of InMemoryCache
. We will use this cache instance to store the data that will be fetched from our GraphQL server. In addition, we will use the InMemoryCache
instead of React useState
hook to store local data that we want to manage within our application.
Initializing the Apollo Client
To initialize the Apollo Client in our project, let's create a new file called client.ts
in the root directory of our project and add the following code to it:
import { ApolloClient, createHttpLink } from '@apollo/client';
import { cache } from './cache';
const link = createHttpLink({
uri: 'http://localhost:4000/graphql',
credentials: 'include'
});
const client = new ApolloClient({
cache,
link,
});
export default client
The createHttpLink
object will handle the communication with our GraphQL server. We passed in two properties, the uri
and credentials
. The credential
options provide the credential policy that will be applied to each API call, and the uri
options contain the URL of our GraphQL server.
We set the value of the credentials
option to include
. If the front-end and back-end have different domain names, you should set the credentials
option to include
. If they have the same domain name, you should use same-origin
instead.
in the following snippets:
const client = new ApolloClient({
cache,
link,
});
We created an instance of the ApolloClient
object by passing in the cache
and link
objects as arguments.
Making the ApolloClient
instance available to all the React components in our App.
To make the client
object we created in the client.ts
file available for use in all our components, we will wrap ApolloProvider
to the root component of our app. ApolloProvider
is a higher-order component (HOC) from the react-apollo
library that helps to provide our React components with the data they need from a GraphQL server.
We will use the ApolloProvider
to provide the client
object to all of the components in the app.
Overwrite everything in pages/_app.tsx
file with the following code:
import '../styles/globals.css'
import { AppProps } from 'next/app'
import { ApolloProvider } from '@apollo/client'
import client from '../client'
import { useEffect, useState } from 'react';
function MyApp({ Component, pageProps }: AppProps) {
const [isClient, setIsClient] = useState(false)
useEffect(() => {
if (typeof window !== "undefined") {
setIsClient(true)
}
}, [])
if (isClient) {
return (
<ApolloProvider client={client}>
<Component {...pageProps} />
</ApolloProvider>
)
}
return(
<>
<h1>Loading...</h1>
</>
)
}
export default MyApp
We are using Next.js to build the front end of our app, which means that the content of our web app will be generated on the server side and sent to the client as a fully rendered HTML page. Later in this tutorial, we will need to access the browser's local storage to store the login status of our users. While the contents of the web app are being generated on the server, the localStorage will not be available for use, thereby causing errors in our app. To fix that, we will only render all our components only when they are available on the client side.
So we created a new state called isClient
and initialized it with false
boolean value. In the useEffect
hook, we checked if the window object was not undefined
. The window object is only present on the client-side, once it is not undefined, we will change the isClient
boolean value to true
.
In the snippets below:
if (isClient) {
return (
<ApolloProvider client={client}>
<Component {...pageProps} />
</ApolloProvider>
)
}
return(
<>
<h1>Loading...</h1>
</>
)
If the isClient
boolean value is true,
we will render the pages wrapped in the ApolloProvider
component, which provides the client
object to the rest of the app via React's context API, otherwise, we'll render a loading text.
Updating the global styles
I made some changes to the CSS file used in this project. Open the styles/global.css
file and overwrite everything in there with the following codes:
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: #F8FAFE;
overflow: auto;
}
.main-container {
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.card, .delete-note-modal{
width: 300px;
height: 350px;
background-color: #f9e675;
box-shadow: 0 8px 16px 0 rgba(0,0,0,0.4);
border: 1px solid rgb(0, 0, 0,.5);
margin: 1rem;
}
.delete-note-modal {
text-align: center;
display: flex;
align-items: center;
flex-direction: column;
justify-content: center;
}
.delete-note-modal h3 {
margin: 0 0 1rem 0;
}
.delete-note-modal button {
width: 90%;
background-color: rgb(31, 15, 15);
}
.card-maximized {
width: 100%;
height: 100%;
background-color: #f9e675;
box-shadow: 0 8px 16px 0 rgba(0,0,0,0.4);
border: 1px solid rgb(0, 0, 0,.5);
position: fixed;
z-index: 10;
overflow: hidden;
display: flex;
flex-direction: column;
top: 0;
}
.card-header {
height: 10%;
}
.card-body {
background-color: rgba(253, 253, 253, 0.4);
height: 80%;
}
.text-area {
width: 100%;
height: 100%;
background-color: transparent;
border: none;
outline: none;
color: black;
z-index: 5;
resize: none;
}
.text-container{
width: 90%;
height: 100%;
margin: 0 auto;
}
.card-footer {
width: 100%;
height: 10%;
border-top: 1px solid rgba(0, 0, 0, 0.4);
}
.icon-container {
display: flex;
justify-content: space-between;
align-items: center;
height: 100%;
}
.right-icon, .left-icon {
display: flex;
}
.icon {
margin: 3px;
color: black;
padding: 5px 7px;
cursor: pointer;
opacity: .8;
font-size: 1.2rem;
}
.icon:hover {
background-color: rgba(10, 10, 9, 0.2);
opacity: 1;
}
.theme-color-container {
width: 100%;
height: 100%;
display: flex;
background-color: black;
}
.theme-color:hover {
opacity: .9;
cursor: pointer;
}
.yellow, .green, .pink, .purple, .blue, .gray, .charcoal {
width: 14.3%;
}
.yellow, #yellow {
background-color: #f9e675;
}
.green, #green {
background-color: #b2ed9e ;
}
.pink, #pink {
background-color: #f2b3e4;
}
.purple, #purple {
background-color: #ceb1ff;
}
.blue, #blue {
background-color: #addcfe;
}
.gray, #gray {
background-color: #dcdee2;
}
.charcoal, #charcoal {
background-color: #77797e;
}
.notes-container {
display: flex;
flex-wrap: wrap;
justify-content: space-evenly;
align-items: flex-start;
}
.no-notes-container, .error-page-container{
display: flex;
flex-direction: column;
width: 350px;
margin: 3rem auto;
align-items: center;
border: 1px solid rgba(0, 0, 0, 0.2);
padding: 3rem 1rem;
box-shadow: 0 2px 4px 0 rgba(0,0,0,0.4);
background-color: #f9e675;
color: black;
border-radius: 5px;
text-align: center;
}
.no-notes-container button, .error-page-container button {
background-color: #000000;
border-radius: 5px;
}
.auth-container{
width: 350px;
margin: 2rem auto;
padding: 2rem;
box-shadow: 0 2px 4px 0 rgba(0,0,0,0.4);
background-color: white;
}
.auth-form{
flex-wrap: wrap;
display: flex;
width:320px;
margin: 0 auto;
justify-content: center;
}
input {
border: 1px solid black;
margin: .5rem;
padding: 1rem;
font-size: 1rem;
width:320px;
outline: none;
}
.auth-navbar{
background-color: rgb(34, 32, 32);
padding: 1rem;box-shadow: 0 2px 4px 0 rgba(0,0,0,0.4);
display: flex;
justify-content: space-between;
align-items: center;
}
.auth-navbar h1{
margin: 0;
font-size: 1.3rem;
padding-left: 1rem;
color: white;
}
.auth-navbar h5{
margin: 0;
}
.auth-navbar .menu {
display: flex;
}
.auth-navbar .menu a{
margin: 0;
color: white;
padding: 0 1rem;
text-transform: uppercase;
text-decoration: none;
}
.auth-navbar .menu a:hover {
opacity: .8;
cursor: pointer;
}
.welcome-text h2{
text-align: center;
text-transform: uppercase;
margin: 2rem 0 0rem 0;
}
button {
border: none;
margin: .5rem;
padding: 1rem;
font-size: 1rem;
width:320px;
outline: none;
background-color: blue;
color: white;
cursor: pointer;
}
button:hover {
opacity: .7;
}
.logout-button{
width: 80px;
padding: .5rem;
border-radius: 3px;
margin: .1rem;
font-size: .9rem;
}
.show-error{
display: flex;
flex-direction: column;
align-items: center;
}
.show-error p {
color: red;
margin: 0;
font-weight: 600;
}
.hide-error{
display: none;
}
.error-icon {
margin:0;
color: red;
padding: .4rem;
}
.snackbar-container {
min-width: 250px;
background-color: rgb(29, 29, 32);
color: #fff;
text-align: center;
border-radius: 2px;
position: fixed;
z-index: 1;
right: 0%;
bottom: 30px;
font-size: 17px;
padding: 0;
margin-right: 30px;
box-shadow: 0 8px 16px 0 rgba(0,0,0,0.4);
}
.hide{
display: none;
}
@media screen and (max-width: 468px) {
.auth-container{
width: 90%;
padding: 2.5rem 0;
}
.auth-form {
width:90%;
}
.auth-navbar h1{
font-size: 1rem;
padding-left: 0.5rem;
}
.auth-navbar .menu h5{
font-size: .7rem;
}
.no-notes-container, .error-page-container{
width: 80%;
}
.no-notes-container button, .error-page-container button {
width: 80%;
}
}
@media screen and (max-width: 280px) {
.auth-container {
width: 100%;
}
}
footer {
width: 100%;
left: 0;
bottom: 0;
background-color: black;
text-align: center;
color: white;
margin-top: 5rem;
}
footer p {
padding: 2rem 0;
}
.loading-container{
width: 100vw;
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.loader{
border: 5px solid #ffd900;
border-top: 5px solid #000000;
border-radius: 50%;
width: 50px;
height: 50px;
animation: spin 2s linear infinite;
margin: 0px;
padding: 0;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
Creating the AuthNavigationBar
Component
The AuthNavigationBar
component is the navigation bar that will be displayed on the sign up and login page. In the components
directory, create a new file called AuthNavigationBar.tsx
and type the following code into it:
import Link from 'next/link'
const AuthNavigationbar = () => {
return (
<>
<div className="auth-navbar">
<h1>Sticky Note</h1>
<div className="menu">
<h5><Link href="/sign-up"> Sign Up </Link></h5>
<h5><Link href="/sign-in"> Log In </Link></h5>
</div>
</div>
</>
)
}
export default AuthNavigationbar
We added two links to the navigation bar: Sign Up
and Log In
. We will create their corresponding pages very soon.
Creating the MainNavigationBar
Component
The MainNavigationBar
component is the navigation bar that will be rendered on the main page after the user has been authenticated.
To create the MainNavigationBar
component, locate components
directory, then create a new file called MainNavigationBar.tsx
and add the following code:
const MainNavigationBar = () => {
return (
<>
<div className="auth-navbar">
<h1>Sticky Note</h1>
<div className="menu">
<button className="logout-button">Logout</button>
</div>
</div>
</>
)
}
export default MainNavigationBar
The MainNavigationBar
component has a logout button that does nothing for now. We will implement the logout feature toward the end of this tutorial.
Creating the ErrorPage
Component
When a user encounters an error on the website, the ErrorPage
component will be used to display an error message to the user.
In the components
directory, create a new file called ErrorPage.tsx
and type in the following code to it:
const ErrorPage = () => {
return (
<>
<section className="error-page-container">
<h3>An Error occured.</h3>
<button onClick={()=>window.location.reload()}> Reload Page </button>
</section>
</>
)
}
export default ErrorPage
Creating the Loading
Component
Create a new file called Loading.tsx
in the components
directory, and add the following code to it:
const Loading = () => {
return (
<>
<section className="loading-container">
<div className="loader"></div>
</section>
</>
)
}
export default Loading;
The loading component will be displayed whenever the application is in the process of fetching all of the user's sticky notes.
Creating the user
and note
Mutations
In GraphQL, a mutation is a type of operation that allows you to create, update, or delete data on the server. Mutations are used to modify data in a database or other persistent storage. In this part, we will create the mutations for user
and note
.
Before implementing those mutations, let's install react-toastify
by running the following command:
yarn add react-toastify
The react-toastify
library will be used to display notifications to the user. Once the installation is complete, open the pages/_app.tsx
file and import the react-toastify
css file:
import "react-toastify/dist/ReactToastify.css";
Creating the user
Mutation
At the root directory, create a new folder called mutations
. Open it up and create a new file called users.ts
. In the user.ts
file, add the following mutations to it:
import { gql } from '@apollo/client';
export const SIGNUP_MUTATION = gql`
mutation SignUp(
$fullname: String!
$email: String!
$password: String!
$username: String!
) {
signupUser(
data: {
fullname: $fullname
email: $email
password: $password
username: $username
}
) {
user{
username
}
}
}
`;
export const LOGIN_MUTATION = gql`
mutation signin($usernameOrEmail: String!, $password: String!) {
signinUser(data: { usernameOrEmail: $usernameOrEmail, password: $password }) {
signupUser{
username
}
}
}
`;
export const LOGOUT_MUTATION = gql`
mutation signoutUser {
signoutUser
}
`
We created three GraphQL mutations: SIGNUP_MUTATION
, LOGIN_MUTATION
, and LOGOUT_MUTATION
.
These mutations can be used to create a new user account, log in an existing user, and log out the current user, respectively.
The SIGNUP_MUTATION
mutation will be used to create a new user account with the given fullname
, email
, password
, and username
data. It takes these four variables as input and passes them to the signupUser
mutation on the server, which creates a new user account with the provided data. The mutation returns the username
of the newly created user.
The LOGIN_MUTATION
mutation will be used to log in an existing user with the given usernameOrEmail
and password
. It passes these variables to the signinUser
mutation on the server, which authenticates the user and returns the username
of the logged in user. We can actually return more data if there's any need for them. The username
was only returned here because it will be used to welcome the user after registration or sign-in.
The LOGOUT_MUTATION
mutation is used to log out the current user. It calls the signoutUser
mutation on the server, which logs out the user and removes the user's session.
Creating the note
Mutation
Let's create another mutation for the notes. Inside the mutation
directory, create another file named note.ts
. Open it up and add the following mutations to it:
import { gql } from '@apollo/client';
export const SAVE_NOTE = gql`
mutation newNote ($data: NoteData!) {
newNote (data: $data) {
color
content
id
}
}
`
export const MODIFY_NOTE = gql `
mutation modifyNote ($data: NoteData!, $noteId: String!) {
modifyNote (data: $data, noteId: $noteId) {
color
content
id
}
}
`
export const DELETE_NOTE = gql `
mutation deleteNote ($noteId: String!) {
deleteNote (noteId: $noteId) {
color
content
id
}
}
`
We created three new mutations namely: SAVE_NOTE
, MODIFY_NOTE
, and DELETE_NOTE
.
These mutations can be used to create a new sticky note, modify an existing sticky note, and delete an existing sticky note, respectively.
The SAVE_NOTE
mutation will be used to create a new sticky note with the given data
. It takes a single variable, $data
, as input and passes it to the newNote
mutation on the server, which creates a new sticky note with the provided data. The mutation returns the color
, content
, and id
of the newly created sticky note.
The MODIFY_NOTE
mutation will be used to modify an existing sticky note with the given data
and noteId
. It takes these two variables as input and passes them to the modifyNote
mutation on the server, which modifies the sticky note with the provided data. The mutation returns the color
, content
, and id
of the modified sticky note.
The DELETE_NOTE
mutation will used to delete an existing sticky note with the given noteId
. It passes the noteId
variable to the deleteNote
mutation on the server, which deletes the sticky note with the provided noteId
. The mutation returns the color
, content
, and id
of the deleted sticky note.
Creating the note
Queries
Next, we will create a query that will be used to fetch all the notes. Create another folder at the root directory of your project called queries
. After that, you will create a new file inside the folder named note.ts
. Open the file and add the following codes:
import { gql } from '@apollo/client';
export const FETCH_ALL_NOTES = gql`
query allNotes {
allNotes {
color
content
id
isSaved @client
isMaximized @client
isSaving @client
isError @client
isDeleteNoteConfirmationVisible @client
}
}
`
We created a new GraphQL query called FETCH_ALL_NOTES
that will be used to fetch all of the sticky notes in the application.
The FETCH_ALL_NOTES
query returns a list of all the sticky notes in the application, including their color
, content
, and id
. It also includes several fields that are marked with the @client
directive, indicating that these fields are stored on the client and not returned by the server. The isSaved
, isMaximized
, isSaving
, isError
, and isDeleteNoteConfirmationVisible
fields are all client-side only fields that are used to store the state of the sticky notes in the client. We will be defining all these local-only fields in a moment,
When the FETCH_ALL_NOTES
query is executed, it will request a list of all the sticky notes from the server and return the color
, content
, and id
of each sticky note in the list. It will also include the client-side fields for each sticky note in the returned data.
Defining the local-only fields
In Apollo Client, a local-only field is a field that is defined in the client-side schema, but not in the server-side schema. This means that the field is not fetched from the server, but rather is used to store local data that is specific to the client. We'll be using these local-only fields to store and manage our local data instead of React state.
To define the local-only fields, open the cache.ts
file and update it with the following codes:
import { InMemoryCache } from '@apollo/client';
export const cache: InMemoryCache = new InMemoryCache({
typePolicies: {
Note: {
fields: {
isMaximized:{
read(isMaximized = false) {
return isMaximized ;
},
},
isDeleteNoteConfirmationVisible:{
read(isDeleteNoteConfirmationVisible = false) {
return isDeleteNoteConfirmationVisible ;
},
},
isSaving:{
read(isSaving = false) {
return isSaving ;
}
},
isError:{
read(isError = false) {
return isError ;
}
},
isSaved:{
read(isSaved = true) {
return isSaved ;
}
}
}
}
}
})
We used the typePolicies
property of the InMemoryCache
object to define local-only fields. The typePolicies
property is an object that maps GraphQL type names to policy objects, which define the behavior for that type.
In this case, the typePolicies
object includes a Note
field that defines custom caching behavior for Note
data in the cache.
The Note
type policy includes fields for isMaximized
, isDeleteNoteConfirmationVisible
, isSaving
, isError
, and isSaved
. Each of these fields has a read
function that specifies the default value for the field when it is not present in the cache. For example, the isMaximized
field has a default value of false
, and the isSaved
field has a default value of true
.
The read
functions for these fields are called whenever a field is accessed in the cache. If the field is present in the cache, the value stored in the cache will be returned. If the field is not present in the cache, the default value specified in the read
function will be returned instead.
Creating the SignUp
Component
In the part, we will create a new component that will be used to register a new user. Navigate to the components
directory and create a new file called SignUp.tsx
. Open it up, then add the following code:
import { useState, ChangeEvent, FormEvent, useEffect } from 'react'
import Footer from './Footer'
import AuthNavigationbar from './AuthNavigationBar';
import { useMutation } from '@apollo/client';
import { ToastContainer, toast } from "react-toastify";
import { SIGNUP_MUTATION } from '../mutations/user';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faSpinner } from '@fortawesome/free-solid-svg-icons';
import { useRouter } from 'next/router'
const SignUp = () => {
const [signup, { loading, data, error }] = useMutation(SIGNUP_MUTATION);
const router = useRouter();
const [userInputs, setUserInputs] = useState({})
useEffect(() => {
if (data) {
setTimeout(() => router.push('/'), 3000);
localStorage.setItem("isLoggedIn", 'true');
const {username} = data.signupUser.user
toast.success(`Welcome, ${username}!`, {
position: "bottom-center"
});
}
if (error) {
toast.error('' + error, {
position: "bottom-center",
autoClose: false
});
}
},[data, error, router]);
const saveUserInputs = (event: ChangeEvent<HTMLInputElement>) => {
setUserInputs({ ...userInputs, [event.target.name]: event.target.value });
}
const handleFormSubmit = async (event: FormEvent) => {
event.preventDefault()
await signup({
variables: userInputs
});
};
return (
<>
<section className='main-container'>
<AuthNavigationbar />
<ToastContainer />
<div className="auth-container">
<form className="auth-form">
<h3>Create Acount</h3>
<input type='text' name="fullname" placeholder="fullname" onChange={(event) => saveUserInputs(event)} minLength={4} />
<input type='text' name="username" placeholder="username" onChange={(event) => saveUserInputs(event)} minLength={3} />
<input type='email' name="email" placeholder="email" onChange={(event) => saveUserInputs(event)} required />
<input type='password' name="password" placeholder="password" onChange={(event) => saveUserInputs(event)} minLength={5} />
<button disabled={loading} onClick={(event) => handleFormSubmit(event)}>{loading?<FontAwesomeIcon icon={faSpinner} spin size="1x"/>:"Create Account"}</button>
</form>
</div>
<Footer />
</section>
</>
)
}
export default SignUp
We created a new component called SignUp
that contains a form that will be used to collect the following user's data: fullname
, username
, email
, and password
. We also render the AuthNavigationbar
, ToastContainer
and Footer
components inside it. The ToastContainer
will be used to display some notifications to the user.
Once a user clicks on the Create Account
button, the handleFormSubmit()
function will be invoked. The handleFormSubmit()
function will execute the signup
mutation function which takes in the userInputs
object as arguments.
In the following snippet:
const [signup, { loading, data, error }] = useMutation(SIGNUP_MUTATION);
We used the useMutation
hook to execute the SIGNUP_MUTATION
. The useMutation
hook returns an object with several properties such as loading
, data
, error
, etc and a function that can be used to execute the mutation, we named this function signup
.
In the code below, we disabled the button and then displayed the spinner
icon instead of the Create Account
text on it when the loading
value is truthy.
<button disabled={loading} onClick={(event) => handleFormSubmit(event)}>{loading?<FontAwesomeIcon icon={faSpinner} spin size="1x"/>:"Create Account"}</button>
In the code below, we used the react-toastify
library to display an error
notification message if an error is encountered while running the signup
mutation:
if (error) { toast.error('' + error, { position: "bottom-center", autoClose: false }); }
In the following snippets:
if (data) { setTimeout(() => router.push('/'), 3000); localStorage.setItem("isLoggedIn", 'true');
const {username} = data.signupUser.user;
toast.success(`Welcome, ${username}!`, { position: "bottom-center" });
}
If the data
returns a truthy value, that means the mutation was successfully executed. We used the react-toastify
library to welcome the user using the username
returned from the execution of the SIGNUP_MUTATION
query and redirect the user to the homepage after three seconds.
We saved the user's login status (isLoggedIn
) to the localStorage. We will update the _app.tsx
file to redirect the user straight to the homepage the next time they visit the website if the isLoggedIn
status of the user is true. We will also create a logout()
function that will change the isLoggedIn
status to false once the user signs out. If the user's token has expired and the isLoggedIn
value is true
, we will redirect the user back to the login page and change the isLoggedIn
value to false
.
Creating the SignUp
Page
To create the SignUp
page in our Next.js app, we need to create a new file in the pages
directory of our project called sign-up.tsx
. Open the sign-up.tsx
file, then import and render the SignUp
component in it like so:
import SignUp from "../components/SignUp"
const SignUpPage = () => {
return (
<>
<SignUp />
</>
)
}
export default SignUpPage
When the sign-up.tsx
page is rendered by Next.js, the SignUpPage
component will be rendered to the page, which will in turn render the SignUp
component.
Now you can navigate to http://localhost:3000/sign-up
on your browser to view the signup page.
Creating the SignIn
Component
Inside the components
directory, create a new file named SignIn.tsx
and add the following code to it:
import { useMutation } from '@apollo/client'
import { faSpinner } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import router from 'next/router'
import { useState, ChangeEvent, FormEvent, useEffect } from 'react'
import { toast, ToastContainer } from 'react-toastify'
import { LOGIN_MUTATION } from '../mutations/user'
import AuthNavigationBar from './AuthNavigationBar'
import Footer from './Footer'
const SignIn = () => {
const [userInputs, setUserInputs] = useState({})
const [login, { loading, data, error }] = useMutation(LOGIN_MUTATION);
useEffect(() => {
if (error) {
toast.error('' + error, {
position: "bottom-center",
autoClose: false
});
}
if (data) {
console.log(data)
const {username} = data.signinUser.user
localStorage.setItem("isLoggedIn", 'true');
setTimeout(() => router.push('/'), 3000);
toast.success(`Welcome back, ${username}`, {
position: "bottom-center"
});
}
}, [data, error]);
const saveUserInputs = (event: ChangeEvent<HTMLInputElement>) => {
setUserInputs({ ...userInputs, [event.target.name]: event.target.value })
}
const handleFormSubmit = async (event: FormEvent) => {
event.preventDefault()
await login({
variables: userInputs
});
};
return (
<>
<section className='main-container'>
<AuthNavigationBar />
<ToastContainer />
<div className="auth-container">
<form className="auth-form">
<h3>Sign In</h3>
<input type='text' name="usernameOrEmail" placeholder="Username or Email" onChange={(event) => saveUserInputs(event)} required />
<input type='password' name="password" placeholder="Password" onChange={(e) => saveUserInputs(e)} required />
<button disabled={loading} onClick={(event) => handleFormSubmit(event)}> {loading ? <FontAwesomeIcon icon={faSpinner} spin size="1x" /> : "Login"}</button>
</form>
</div>
<Footer />
</section>
</>
)
}
export default SignIn
First, we created a new mutate function called login
that will be used to execute our LOGIN_MUTATION
anytime it is invoked. Then we rendered an input form that will be used to collect the user's usernameOrEmail
and password
data. After that, we created a function named handleFormSubmit
. The handleFormSubmit
will call the login
function any time it is invoked. We will call the handleFormSubmit
function any time the Login
button is clicked. The useMutation
hooks returns the following properties: loading
, error
, and data
that will be used to track the mutate function (login
) state. Just like we did in the SignUp
component, if the loading
status value is truthy
we'll display the font awesome spinner icon
on the button instead of the "Login" text.
If the error
value is truthy, we'll display the error notification using the react-toastify
library, and if the data
value if truthy
, a success notification will be displayed, welcoming the user again with the username
returned from the mutation
function. Then the user will be redirected to the homepage after three seconds.
Creating the sign-in
Page
Let's create a new file named sign-in.ts
in the page
directory, just like we did with the sign-up
page. We will import and render the SignIn
component in it like this:
import SignIn from "../components/SignIn"
const SignInPage = () => {
return (
<>
<SignIn />
</>
)
}
export default SignInPage
When the sign-in.tsx
page is rendered by Next.js, the SignInPage
component will be rendered to the page, which will in turn render the SignIn
component.
Now you can navigate to http://localhost:3000/sign-in
on your browser to view the sign in page.
Updating Home Page
Let's update the homepage to render the NoteContainer
if the isLoggedIn
value in the localStorage is true. But if the isLoggedIn
value is false, then the user will be redirected to the login page. Navigate to pages/index.ts
file and open it, and update it with the following:
import NoteContainer from "../components/NoteContainer";
import SignIn from "../components/SignIn";
function HomePage() {
const isLoggedIn = localStorage.getItem("isLoggedIn");
if(isLoggedIn === 'true'){
return <NoteContainer />
}
return <SignIn />
}
export default HomePage
Updating the INoteCardProps
Interface
Update the INoteCardProps
interface in the typings/index.ts
file to include the following properties:
saveUserNote(id: string, color: Theme, contents: string, isSaved: boolean): Promise;
deleteNote(noteId: string, isSaved:boolean): void;
color:Theme;
createNote(noteId: string): void;
Everything should be looking like this now:
export interface INoteCardProps {
createNote(noteId?: string): void;
id:string;
color:Theme;
isMaximized:boolean;
contents: string;
isSaved: boolean;
isSaving: boolean;
isError: boolean;
changeColor(id:string, color:Theme): void;
toggleFullscreen(noteId:string):void;
isDeleteNoteConfirmationVisible:boolean,
toggleDeleteNoteConfirmationMessage(id:string):void;
deleteNote(noteId: string, isSaved:boolean): void;
saveUserNote(id: string, color: Theme, contents: string, isSaved: boolean): Promise<void>;
}
Defining the IAllNoteData
interfaces
Let's create a new file called:IAllNoteData.
We will use this type to define our query result.
Let's open up the typings/index.ts
file and create a interface called IAllNoteData
:
export interface IAllNoteData {
allNotes:INote[]
}
The IAllNoteData
represents data for a collection of notes. It has a single property, allNotes
, which is an array of objects that implement the INote
interface.
Updating the NoteCard
component
As discussed in the first part of this article, the NoteCard
component is a child component to NoteContainer
component. It will be used as a template to render all the sticky note that belongs to a user.
Before we proceed, let's first update the NoteCard.tsx
file with the following code, which includes some changes to the original code:
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { faPlus, faWindowMaximize, faTrash, faSave, faExclamationTriangle, faSpinner, faCheck } from '@fortawesome/free-solid-svg-icons'
import { ChangeEvent, useState } from 'react';
import { INoteCardProps, Theme } from '../typings';
const NoteCard: React.FC<INoteCardProps> = (props) => {
const [noteContents, setNoteContents] = useState('');
const changeNoteColor = (id:string, color:Theme) => {
props.changeColor(id, color);
props.saveUserNote(id, color, noteContents||props.contents, props.isSaved)
}
let timer:NodeJS.Timeout;
const handleContentChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
clearTimeout(timer)
const newTimer: NodeJS.Timeout = setTimeout(() => {
setNoteContents(event.target.value)
props.saveUserNote(props.id, props.color, event.target.value, props.isSaved)
}, 2000)
timer = newTimer;
}
if (props.isDeleteNoteConfirmationVisible) {
return (
<section className="delete-note-modal" id={props.color}>
<h3>This note will be permanently deleted, continue?</h3>
<div>
<button onClick={() => props.deleteNote(props.id, props.isSaved)}> Yes </button>
<button onClick={() => props.toggleDeleteNoteConfirmationMessage(props.id)}> No</button>
</div>
</section>
)
}
return (
<>
<div className={props.isMaximized ? "card-maximized" : "card"} id={props.color}>
<div className="card-header">
<div className="icon-container">
<div className="left-icon">
<div className="icon" onClick={() => props.createNote(props.id)}>{props.isMaximized ?null:<FontAwesomeIcon icon={faPlus} />}</div>
<div className="icon" >{props.isSaving ? <FontAwesomeIcon icon={faSpinner} spin />:props.isError ? <FontAwesomeIcon icon={faExclamationTriangle} />:props.isSaved ? <FontAwesomeIcon icon={faCheck} /> : null}</div>
</div>
<div className="right-icon">
<div className="icon" onClick={() => props.toggleDeleteNoteConfirmationMessage(props.id)} >{props.isMaximized ?null:<FontAwesomeIcon icon={faTrash} /> }</div>
<div className="icon" onClick={()=>props.saveUserNote(props.id, props.color, noteContents||props.contents, props.isSaved)}><FontAwesomeIcon icon={faSave} /></div>
<div className="icon" onClick={() => props.toggleFullscreen(props.id)}><FontAwesomeIcon icon={faWindowMaximize} /></div>
</div>
</div>
</div>
<div className="card-body">
<div className="text-container">
<textarea defaultValue={props.contents} onChange={(e) => handleContentChange(e)} className="text-area"></textarea>
</div>
</div>
<div className="card-footer">
<div className="theme-color-container">
<div className="theme-color yellow" onClick={() => changeNoteColor(props.id, Theme.Yellow)} > </div>
<div className="theme-color green" onClick={() =>changeNoteColor(props.id, Theme.Green)}></div>
<div className="theme-color pink" onClick={() => changeNoteColor(props.id, Theme.Pink)}></div>
<div className="theme-color purple" onClick={() => changeNoteColor(props.id, Theme.Purple)}></div>
<div className="theme-color blue" onClick={() => changeNoteColor(props.id, Theme.Blue)}></div>
<div className="theme-color gray" onClick={() => changeNoteColor(props.id, Theme.Gray)}></div>
<div className="theme-color charcoal" onClick={() => changeNoteColor(props.id, Theme.Charcoal)}></div>
</div>
</div>
</div>
</>
)
}
export default NoteCard
Breaking down the NoteCard
Component
In the following snippets:
<div className="card-body">
<div className="text-container">
<textarea defaultValue={props.contents}
onChange={(e) => handleContentChange(e)}
className="text-area"></textarea>
</div>
</div>
We created the sticky note body, which is made up of the HTML <textarea>
element. Anytime there's a change in the Textarea, the handleContentChange
function will be invoked.
The setTimeout
method in the handleContentChange()
function is used in the following snippets to automatically save a sticky note if the user stops typing after two seconds by calling the saveUserNote
function:
let timer:NodeJS.Timeout;
const handleContentChange = (event: ChangeEvent<HTMLTextAreaElement>) => {
clearTimeout(timer)
const newTimer: NodeJS.Timeout = setTimeout(() => {
setNoteContents(event.target.value)
props.saveUserNote(props.id, props.color, event.target.value, props.isSaved)
}, 2000)
timer = newTimer;
}
If the user did not wait for 2 seconds before typing the next letter, the previous counter is cleared and another timer will start counting the moment the user starts typing again.
The following snippets will call the changeColor
function in the NoteContainer
component with the id
and color
of the selected sticky note:
const changeNoteColor = (id:string, color:Theme) => {
props.changeColor(id, color);
props.saveUserNote(id, color, noteContents||props.contents, props.isSaved)
}
The changeNoteColor
function will be invoked any time the user clicks on any theme color on the sticky note:
changeNoteColor(props.id, Theme.Yellow)} >
changeNoteColor(props.id, Theme.Green)}>
changeNoteColor(props.id, Theme.Pink)}>
changeNoteColor(props.id, Theme.Purple)}>
changeNoteColor(props.id, Theme.Blue)}>
changeNoteColor(props.id, Theme.Gray)}>
changeNoteColor(props.id, Theme.Charcoal)}>
"The following code was used to toggle the class of the element between card-maximized
and card
based on the value of the isMaximized
boolean: "
<div className={props.isMaximized ? "card-maximized" : "card"} id={props.color}>
In the following snippets, we render the faPlus
, faSpinner
,faExclamationTriangle
, faTrash
, faSave
, faWindowMaximize
, faCheck
icons on the sticky notes:
<div className="icon-container">
<div className="left-icon">
<div className="icon" onClick={() => props.createNote(props.id)}>{props.isMaximized ?null:<FontAwesomeIcon icon={faPlus} />}</div>
<div className="icon" >{props.isSaving ? <FontAwesomeIcon icon={faSpinner} spin />:props.isError ? <FontAwesomeIcon icon={faExclamationTriangle} />:props.isSaved ? <FontAwesomeIcon icon={faCheck} /> : null}</div>
</div>
<div className="right-icon">
<div className="icon" onClick={() => props.toggleDeleteNoteConfirmationMessage(props.id)} >{props.isMaximized ?null:<FontAwesomeIcon icon={faTrash} /> }</div>
<div className="icon" onClick={()=>props.saveUserNote(props.id, props.color, noteContents||props.contents, props.isSaved)}><FontAwesomeIcon icon={faSave} /></div>
<div className="icon" onClick={() => props.toggleFullscreen(props.id)}><FontAwesomeIcon icon={faWindowMaximize} /></div>
</div>
</div>
The faSave
icon will be used to manually save a note once a user clicks on it, the icon will also be hidden whenever the sticky note is maximized.
When the application is in the process of saving a sticky note, an icon representing a spinning circle (faSpinner
) will be shown to the user. If the note was saved successfully, the faCheck
icon will be displayed instead of the the faSpinner
icon. If there is an error while saving the note, the faExclamationTriangle
will be displayed.
On the right-icon
section, whenever the faTrash
icon is clicked, the toggleDeleteNoteConfirmationMessage
function will be invoked, we'll talk more about all the functions soon.
The toggleFullscreen
function will be called with the id
of the sticky note if a user clicks on the faWindowMaximize
icon.
Updating the NoteContainer
Component
Let's update the NoteContainer
Component to render the NoteCard
with the latest changes.
Replace the contents of the components/NoteContainer.tsx
file with the following code:
import NoteCard from "./NoteCard"
import MainNavigationBar from './MainNavigationBar';
import Footer from "./Footer";
import { FETCH_ALL_NOTES } from "../queries/note";
import { useMutation, useQuery } from "@apollo/client";
import client from "../client";
import { DELETE_NOTE, MODIFY_NOTE, SAVE_NOTE } from "../mutations/note";
import router from "next/router";
import Loading from "./Loading";
import ErrorPage from "./ErrorPage";
import { IAllNoteData, Theme } from "../typings";
import NoStickyNote from "./NoStickyNotes";
import { toast, ToastContainer } from 'react-toastify'
const NoteContainer = () => {
const { loading, error, data } = useQuery<IAllNoteData>(FETCH_ALL_NOTES);
const [saveNote] = useMutation(SAVE_NOTE);
const [modifyNote] = useMutation(MODIFY_NOTE);
const [deleteNoteMutation] = useMutation(DELETE_NOTE);
const createNote = (currentNoteId?: string) => {
const noteData = client.readQuery<IAllNoteData>({ query: FETCH_ALL_NOTES });
const noteCopy = [...noteData!.allNotes];
const currentNoteIndex: number = noteCopy.findIndex((note) => note.id === currentNoteId);
const emptyNote = {
content: "",
color: Theme.Yellow,
id: `${Date.now()}`,
isMaximized: false,
isDeleteNoteConfirmationVisible: false,
isSaved: false,
isSaving: false,
isError: true
}
noteCopy.splice(currentNoteIndex + 1, 0, emptyNote)
client.writeQuery({
query: FETCH_ALL_NOTES,
data: {
allNotes: [...noteCopy],
},
});
}
const changeColor = (id: string, color: Theme) => {
const noteData = client.readQuery<IAllNoteData>({ query: FETCH_ALL_NOTES });
const updatedNotes = noteData!.allNotes.map((note) => {
if (note.id === id) {
return {
...note, color: color
}
}
return note
});
client.writeQuery({
query: FETCH_ALL_NOTES,
data: {
allNotes: updatedNotes
},
});
}
const toggleFullscreen = (id: string) => {
const noteData = client.readQuery<IAllNoteData>({ query: FETCH_ALL_NOTES });
const updatedNote = noteData!.allNotes.map((note) => {
if (note.id === id) {
return { ...note, isMaximized: !note.isMaximized }
}
return note
})
client.writeQuery({
query: FETCH_ALL_NOTES,
data: {
allNotes: updatedNote
},
});
}
const toggleDeleteNoteConfirmationMessage = (noteId: string) => {
const noteData = client.readQuery<IAllNoteData>({ query: FETCH_ALL_NOTES });
const updatedNote = noteData!.allNotes.map((note) => {
if (note.id === noteId) {
return { ...note, isDeleteNoteConfirmationVisible: !note.isDeleteNoteConfirmationVisible }
}
return note
})
client.writeQuery({
query: FETCH_ALL_NOTES,
data: {
allNotes: updatedNote
},
});
}
const showSavingNoteIcon = (id: string) => {
const noteData = client.readQuery<IAllNoteData>({ query: FETCH_ALL_NOTES });
const updatedNote = noteData!.allNotes.map((note) => {
if (note.id === id) {
return { ...note, isSaving: true }
}
return note
})
client.writeQuery({
query: FETCH_ALL_NOTES,
data: {
allNotes: updatedNote
},
});
}
const hideSavingNoteIcon = (id: string) => {
const noteData = client.readQuery<IAllNoteData>({ query: FETCH_ALL_NOTES });
const updatedNote = noteData!.allNotes.map((note) => {
if (note.id === id) {
return { ...note, isSaving: false }
}
return note
})
client.writeQuery({
query: FETCH_ALL_NOTES,
data: {
allNotes: updatedNote
},
});
}
const showErrorIcon = (id: string) => {
const noteData = client.readQuery<IAllNoteData>({ query: FETCH_ALL_NOTES });
const updatedNote = noteData!.allNotes.map((note) => {
if (note.id === id) {
return { ...note, isError: true }
}
return note
})
client.writeQuery({
query: FETCH_ALL_NOTES,
data: {
allNotes: updatedNote
},
});
}
const hideErrorIcon = (id: string) => {
const noteData = client.readQuery<IAllNoteData>({ query: FETCH_ALL_NOTES });
const updatedNote = noteData!.allNotes.map((note) => {
if (note.id === id) {
return { ...note, isError: false }
}
return note
})
client.writeQuery({
query: FETCH_ALL_NOTES,
data: {
allNotes: updatedNote
},
});
}
const deleteNote = async (noteId: string, isSaved: boolean) => {
const noteData = client.readQuery<IAllNoteData>({ query: FETCH_ALL_NOTES });
const filteredNotes = noteData!.allNotes.filter((note) => {
return note.id !== noteId;
})
client.writeQuery({
query: FETCH_ALL_NOTES,
data: {
allNotes: filteredNotes
},
});
if (isSaved) {
await deleteNoteMutation({ variables: { noteId } });
}
}
const saveUserNote = async (id: string, color: Theme, contents: string, isSaved: boolean) => {
try {
if (isSaved) {
showSavingNoteIcon(id);
hideErrorIcon(id);
await modifyNote({ variables: { data: { color, content: contents }, noteId: id } });
hideSavingNoteIcon(id);
}
else {
showSavingNoteIcon(id);
hideErrorIcon(id);
const savedNote = await saveNote({ variables: { data: { color, content: contents } } });
hideSavingNoteIcon(id);
const noteData = client.readQuery<IAllNoteData>({ query: FETCH_ALL_NOTES });
const updatedNote = noteData!.allNotes.map((note) => {
if (note.id === id) {
return { ...note, isSaved: true, id: savedNote.data.newNote.id, content: savedNote.data.newNote.content }
}
return note
})
client.writeQuery({
query: FETCH_ALL_NOTES,
data: {
allNotes: updatedNote
},
});
}
}
catch (error: any) {
hideSavingNoteIcon(id);
showErrorIcon(id);
if (error.networkError?.message === "Response not successful: Received status code 401") {
toast.error('Your session has expired. Kindly login again', {
position: "bottom-center"
});
localStorage.setItem("isLoggedIn", 'false');
router.push('/sign-in')
}
}
}
if (loading) {
return (
<>
<Loading />
</>
)
}
if (error) {
if (error.networkError?.message === "Response not successful: Received status code 401") {
toast.error('Your session has expired. Kindly login again', {
position: "bottom-center"
});
localStorage.setItem("isLoggedIn", 'false');
router.push('/sign-in')
}
return (
<>
<section className='main-container'>
<MainNavigationBar />
<ErrorPage />
<Footer />
</section>
</>
)
}
if (!data!.allNotes.length) {
return (
<>
<section className='main-container'>
<MainNavigationBar />
<NoStickyNote
createNote={() => createNote()}
/>
<Footer />
</section>
</>
)
}
return (<>
<section className='main-container'>
<MainNavigationBar />
<ToastContainer />
<section className="notes-container">
{data!.allNotes.map((eachNote) => {
return (
<NoteCard
createNote={createNote}
key={eachNote.id}
changeColor={changeColor}
id={eachNote.id}
color={eachNote.color}
toggleFullscreen={toggleFullscreen}
isMaximized={eachNote.isMaximized}
deleteNote={deleteNote}
toggleDeleteNoteConfirmationMessage={toggleDeleteNoteConfirmationMessage}
isDeleteNoteConfirmationVisible={eachNote.isDeleteNoteConfirmationVisible}
contents={eachNote.content}
saveUserNote={saveUserNote}
isSaved={eachNote.isSaved}
isSaving={eachNote.isSaving}
isError={eachNote.isError}
/>
)
})
}
</section>
<Footer />
</section>
</>
)
}
export default NoteContainer
Breaking Down the NoteContainer
component:
Fetching and Rendering all the Sticky Notes
Upon successful login, the first action performed is to retrieve all of the user's notes.
The following query was used to retrieve all of the notes belonging to the user:
const { loading, error, data } = useQuery(FETCH_ALL_NOTES);
The useQuery
hook returns an object with loading
, error
, and data
fields. While the allNotes
query is being fetched, the Loading
component will be rendered using the following code:
if (loading) {
return (
<>
<Loading />
</>
)
}
In the snippets below, if the useQuery
hooks encounters an error while retrieving the user's note, we check to see if the error was caused by an expired token and then redirect them to the login page; otherwise, the ErrorPage component is rendered:
if (error) {
if (error.networkError?.message === "Response not successful: Received status code 401") {
toast.error('Your session has expired. Kindly login again', {
position: "bottom-center"
});
localStorage.setItem("isLoggedIn", 'false');
router.push('/sign-in')
}
return (
<>
<section className='main-container'>
<MainNavigationBar />
<ErrorPage />
<Footer />
</section>
</>
)
}
What if the data was fetched but was empty because the user had not added any sticky notes? We've already created a component called NoStickyNote
that will be rendered to the screen. The NoStickyNote
component will render a text that informs the user that they do not have any sticky notes and a button to create a new sticky note.
The following codes will be used to render the NoStickyNote
component if the data.allNotes
array is empty:
if (!data!.allNotes.length) {
return (
<>
<section className='main-container'>
<MainNavigationBar />
<NoStickyNote
createNote={() => createNote()}
/>
<Footer />
</section>
</>
)
}
Finally, the following snippet will be used to render all the user's notes:
return (<>
<section className='main-container'>
<MainNavigationBar />
<ToastContainer />
<section className="notes-container">
{data!.allNotes.map((eachNote) => {
return (
<NoteCard
createNote={createNote}
key={eachNote.id}
changeColor={changeColor}
id={eachNote.id}
color={eachNote.color}
toggleFullscreen={toggleFullscreen}
isMaximized={eachNote.isMaximized}
deleteNote={deleteNote}
toggleDeleteNoteConfirmationMessage={toggleDeleteNoteConfirmationMessage}
isDeleteNoteConfirmationVisible={eachNote.isDeleteNoteConfirmationVisible}
contents={eachNote.content}
saveUserNote={saveUserNote}
isSaved={eachNote.isSaved}
isSaving={eachNote.isSaving}
isError={eachNote.isError}
/>
)
})
}
</section>
<Footer />
</section>
</>
)
We iterated through the data.allNotes
array using the map()
method, then used the NoteCard
component to render the eachNote
object in the data.allNotes
array.
The NoteCard
component is passed several props, including createNote
, changeColor
, toggleFullscreen
, deleteNote
, toggleDeleteNoteConfirmationMessage
, id
, color
, isMaximized
, isDeleteNoteConfirmationVisible
, contents
, saveUserNote
, isSaved
, isSaving
, and isError
. These props are used to pass data and functions to the NoteCard
component that it can use to display and manipulate the sticky notes. A more detailed explanation of these props will be provided soon.
Creating a new Sticky Note
To create a new sticky note, we will call the createNote()
function in the NoteContainer
component. The createNote()
function has an optional parameter called currentNoteId
. Every sticky note has a plus (+) icon
, once a user click on the icon
we want a new sticky note to be created right beside it. The id
of the sticky note whose plus icon
is clicked is the currentNoteId
.
We made the currentNoteId
parameter optional because sometimes the user does not have to click the plus icon
to create a new note. For example, when a user does not have a sticky note, the NoStickyNotes
component with a Add New
button will be rendered to the screen. Once the Add New
button is pressed we do not need the currentNoteId
parameter because the sticky note that will be created will be the first sticky note.
In the createNote()
function, we used the following line to read the cached data:
const noteData = client.readQuery<IAllNoteData>({ query: FETCH_ALL_NOTES });
After running the FETCH_ALL_NOTES
query to fetch all the user's notes, the result was stored into the cache
we created earlier. We used the client.readQuery
to read the current data of the FETCH_ALL_NOTES
GraphQL and store it into the noteData
variable. The type of noteData
is inferred to be IAllNoteData
from the type argument passed to readQuery
.
In the following line:
const noteCopy = [...noteData.allNotes];
We used the spread operator to copy the noteData
and made modifications to the noteCopy
without touching the original result. Modifying the noteData
directly could lead to unexpected behavior in our application.
We used the following code to get the index of the sticky note that the user clicks on:
const currentNoteIndex: number = noteCopy.findIndex((note) => note.id === currentNoteId);
Then we created a new json object called: emptyNote
:
const emptyNote = {
content: "",
color: Theme.Yellow,
id: `${Date.now()}`,
isMaximized: false,
isDeleteNoteConfirmationVisible: false,
isSaved: false,
isSaving: false,
isError: true
}
This object will be used to initialize a new note.
We used the following code to add a new note right beside the sticky note whose plus
icon was clicked on:
noteCopy.splice(currentNoteIndex + 1, 0, emptyNote)
Then we used the client.writeQuery
to update the cache
with the modified noteCopy
data:
client.writeQuery({ query: FETCH_ALL_NOTES, data: { allNotes: [...noteCopy], }, });
Saving and Modifying the Sticky Notes
Saving and modify the sticky notes is not a straight forward process. First, we have to check if the note has been saved before so that we can call the MODIFY_NOTE
mutation on it, but if the note has not been saved before, we'll make a request to the SAVE_NOTE
mutation instead.
Two types of savings were also implemented here:
auto save and
manual save.
The sticky note will be automatically saved two seconds after a user stops typing on it, and a user can also manually save the note by clicking the save
icon.
To save the note automatically or manually, we will call the saveUserNote()
function.
The saveUserNote()
function takes in the following parameter:
id
: theid
of the sticky note to be savedcolor
: thecolor
of the notecontents
: the note's content,isSaved
: theisSaved
boolean will be used to check if the note has been saved or not.
If the note has already been saved, that means we just have to modify it, which is exactly what the snippets below does:
if (isSaved) {
showSavingNoteIcon(id);
hideErrorIcon(id);
await modifyNote({ variables: { data: { color, content: contents }, noteId: id } });
hideSavingNoteIcon(id);
}
We used the following mutations to modify a note:
await modifyNote({ variables: { data: { color, content: contents }, noteId: id } });
While while the mutation is being executed, we displayed the loading icon by calling the showSavingNoteIcon(id)
function.
We will call the showErrorIcon(id)
to dislay the error icon on the note if an error occured while modifying the note or call the hideErrorIcon(id)
function to clear the error if the error has been resolved.
We will hide the loading icon after the execution is complete by calling hideSavingNoteIcon(id)
function.
If the sticky note has not been saved before, the following snippets will be executed instead:
else {
showSavingNoteIcon(id);
hideErrorIcon(id);
const savedNote = await saveNote({ variables: { data: { color, content: contents } } });
hideSavingNoteIcon(id);
const noteData = client.readQuery<IAllNoteData>({ query: FETCH_ALL_NOTES });
const updatedNote = noteData!.allNotes.map((note) => {
if (note.id === id) {
return { ...note, isSaved: true, id: savedNote.data.newNote.id, content: savedNote.data.newNote.content }
}
return note
})
client.writeQuery({
query: FETCH_ALL_NOTES,
data: {
allNotes: updatedNote
},
});
}
We display the saving icon and hide the error icon while the saveNote
mutation is being executed. Once the note has been successfully saved, we change the id
of the note from the temporary id
we were using before (i.e., the one generated by date.Now()) to the permanent id
generated from our GraphQL server. We also change the isSaved
boolean to true
and update the note content in the cache
with the saved note content
sent from the backend.
If there are any errors while the note is being saved of or modified the following code will be executed:
catch (error: any) {
hideSavingNoteIcon(id);
showErrorIcon(id);
if (error.networkError?.message === "Response not successful: Received status code 401") {
toast.error('Your session has expired. Kindly login again', {
position: "bottom-center"
});
localStorage.setItem("isLoggedIn", 'false');
router.push('/sign-in')
}
}
If the error message says "Response not successful: Received status code 401" that means the user's session as expired. So we will log the user out and redirect the user to the login page, then hide the saving icon and display the error icon on the sticky note.
Changing the color of the notes
We used the changeColor()
function to switch between the theme colors of a note. the function takes in two parameters: the id
of the note and the color
that the user want.
We fetched all the sticky notes in the cache
by running the following query first:
const noteData = client.readQuery<IAllNoteData>({ query: FETCH_ALL_NOTES });
Then in the following code:
const updatedNotes = noteData!.allNotes.map((note) => {
if (note.id === id) {
return {
...note, color: color
}
}
return note
});
We searched for the note that matched the note id
provided by the user using the map() method, then changed the color to the color selected by the user. The updatedNotes
variable was then used to store the new array that was generated by the map function.
The following snippets uses the writeQuery
method to update the cache
with the latest changes:
client.writeQuery({
query: FETCH_ALL_NOTES,
data: {
allNotes: updatedNotes
},
});
Minimizing and Maximizing the sticky note
To minimize and maximize a sticky note, we'll invoke the toggleFullscreen(id)
function. Here's what the toggleFullscreen
function looks like:
const toggleFullscreen = (id: string) => {
const noteData = client.readQuery<IAllNoteData>({ query: FETCH_ALL_NOTES });
const updatedNote = noteData!.allNotes.map((note) => {
if (note.id === id) {
return { ...note, isMaximized: !note.isMaximized }
}
return note
})
client.writeQuery({
query: FETCH_ALL_NOTES,
data: {
allNotes: updatedNote
},
});
}
The toggleFullscreen
function takes in the id
of the note that the user want to minimize or maximize then change its isMaximized
boolean value to the opposite of the previous value. That is, if the current boolean value of isMaximized
is false
it changes it to true
and vice versa. Then modifies the cache
with the updatedNote
.
Deleting a Sticky Note
We called two functions to delete a sticky note, the toggleDeleteNoteConfirmationMessage()
and deleteNote()
function.
We used the deleteNote()
function to permanently remove a user's note. But we just don't want to delete a note once the user presses the trash icon on the sticky note; we also want to confirm it first. That's why we called the toggleDeleteNoteConfirmationMessage()
function first. The toggleDeleteNoteConfirmationMessage()
will render a text asking the user to confirm their action by clicking on a Yes
or No
button. Once the user clicks on the Yes
button, the deleteNote()
function with the id
of the note will be invoked, but if the user clicks on the No
button, the confirmation message will be hidden.
The deleteNote
function takes in two parameters, the id
and isSaved
value of the note. The following statement in the deleteNote
function searches for a note that matches the id
parameter in the cache and then deletes it:
const noteData = client.readQuery<IAllNoteData>({ query: FETCH_ALL_NOTES });
const filteredNotes = noteData!.allNotes.filter((note) => {
return note.id !== noteId;
})
client.writeQuery({
query: FETCH_ALL_NOTES,
data: {
allNotes: filteredNotes
},
});
This is okay if the note has not been saved into the database, and we just want to remove it from the cache
, but if the note has been saved, we need to execute a mutation that will delete the user's note from the database, that is what the following snippets does:
if (isSaved) {
await deleteNoteMutation({ variables: { noteId } });
}
The above statement will execute the query that will permanently delete the user's note from the database.
Logging Out the User
To log the user out, we need to execute the LOGOUT_MUTATION
, clear the user data and redirect to login page. Open the MainNavigationBar.tsx
file in the components directory and update it with the following code:
import { LOGOUT_MUTATION } from "../mutations/users";
import { useMutation } from "@apollo/client";
import router from 'next/router';
import client from "../client";
const MainNavigationBar = () => {
const [signout] = useMutation(LOGOUT_MUTATION);
const logout = () => {
localStorage.setItem("isLoggedIn", 'false');
client.clearStore();
client.cache.gc();
router.push('/sign-in');
signout();
}
return (
<>
<div className="auth-navbar">
<h1>Sticky Note</h1>
<div className="menu">
<button onClick={logout} className="logout-button">Logout</button>
</div>
</div>
</>
)
}
export default MainNavigationBar
We define a function called logout.
The function will be invoked anytime the logout button is clicked. Here's a brief explanation of what the function does:
sets the value of
isLoggedIn
key in the browser's local storage tofalse
.uses the
clearStore
method to delete all the locally cached data.the
client.cache.gc()
triggers garbage collection in the cache, which frees up memory by removing any unused data from memory.redirect the user to the login page