Learn Full-Stack Web Development with React and GraphQL by Building a Sticky Note App - Part three

Learn Full-Stack Web Development with React and GraphQL by Building a Sticky Note App - Part three

The Full stack Development

ยท

39 min read

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 : the id of the sticky note to be saved

  • color: the color of the note

  • contents: the note's content,

  • isSaved: the isSaved 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 to false.

  • 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

ย