Creating FullStack Todo App - ReactJS+MaterialUI+FireBase+GithubAction+UnitTest
Tags: fullstack, javascript, react, firebase
Published: 2022-10-04 21:36:38

## Before we start

If you prefer to watch instead of reading..

{% embed https://www.youtube.com/watch?v=UiQo3L4aht0&t=4123s %}

Also, if you want to read about creating the exact same application in Angular, you can read [here](https://dev.to/jintoppy/creating-fullstack-todo-app-angularmaterialfirebasegithubactionunittesteslint-51k6).

## What are we building

I saw this challenge in [FrontendMentor](https://www.frontendmentor.io/challenges/todo-app-Su1_KokOW) for a todo application. I thought to make it a bit more interesting by adding user login and also, store the data on Backend. 

We will use [ReactJS](https://reactjs.org/) and [Material UI](https://mui.com/) for the Frontend and [Firebase](https://firebase.google.com/) for storing the data and Authentication.

We will also add CI/CD with Github Actions and host the application on Firebase.

This is what the final version looks like.


![login page screenshot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/uhl8usnep99did5xnll0.png)


![home page screenshot](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/oixldldvmfy6fzlp74nz.png)

## ReactJS setup

We will use [**Create React App**](https://create-react-app.dev/) for creating a basic React application setup. We will also use [Typescript](https://typescriptlang.org/). 

`npx create-react-app react-firebase-todo --template typescript
cd react-firebase-todo
npm start
`

Application will open on _http://localhost:3000_


## Material Setup

Material UI has two style engine options. By default it uses [Emotion](https://emotion.sh/docs/introduction) as the style engine. We will follow the same. 

`npm install @mui/material @emotion/react @emotion/styled`

This is enough to use any of the Material components. But, if we want to customise themes, we need to use _ThemeProvider_. We need to also add base Material styles. 

In our application, we have Dark and Light theme and we should be able to switch between themes. 

Whenever the theme is changed, we need to inform the same across the application. For all these, we will create a custom Theme Provider on top of Material UI's theme provider. 

First, we will create two themes. To create theme, we can use _createTheme_ from Material. We will create two themes. 

src/theme.ts
```TS
import { createTheme } from '@mui/material/styles';
import { green, purple, pink } from '@mui/material/colors';

export enum Theme {
    DARK = 'dark',
    LIGHT = 'light',
}

const lightTheme = createTheme({
    palette: {
        primary: {
            main: purple[500],
        },
        secondary: {
            main: green[500],
        },
    },
});

const darkTheme = createTheme({
    palette: {
        mode: 'dark',
        primary: {
            main: 'hsl(235, 24%, 19%)',
        },
        secondary: {
            main: pink[500],
        },
    },
});

const themes = {
    [Theme.DARK]: darkTheme,
    [Theme.LIGHT]: lightTheme,
};

export const getTheme = (theme: Theme) => {
    return themes[theme];
};
```
Here, for the dark theme, we are giving mode as dark, so that, the dark mode styles will be applied. 

Now, we will create the custom theme provider. We will also save the theme name to **sessionStorage** so that, we can persist the selected theme on page refresh.  By default, we are setting the theme as Light. 

src/providers/theme.tsx
```TS
import React, { createContext, FC, ReactNode, useState } from 'react';
import { ThemeProvider } from '@mui/material/styles';

import { Theme, getTheme } from '../theme';

type ThemeContextType = {
    currentTheme: Theme;
    setTheme: ((theme: Theme) => void) | null;
};

export const AppThemeContext = createContext<ThemeContextType>({
    currentTheme: Theme.LIGHT,
    setTheme: null,
});

type Props = {
    children: ReactNode;
};

export const AppThemeProvider: FC<Props> = ({ children }) => {
    const addOrRemoveBodyClass = (theme: Theme) => {
        if (theme === Theme.DARK) {
            document.body.classList.add('dark-theme');
        } else {
            document.body.classList.remove('dark-theme');
        }
    };

    const getCurrentTheme = () => {
        const currentTheme = sessionStorage.getItem('theme')
            ? (sessionStorage.getItem('theme') as Theme)
            : Theme.LIGHT;
        addOrRemoveBodyClass(currentTheme);
        return currentTheme;
    };

    const currentTheme = getCurrentTheme();

    const [theme, _setCurrentTheme] = useState<Theme>(currentTheme);

    const setTheme = (theme: Theme) => {
        sessionStorage.setItem('theme', theme);
        addOrRemoveBodyClass(theme);
        _setCurrentTheme(theme);
    };

    const value = {
        currentTheme: theme,
        setTheme,
    };

    return (
        <AppThemeContext.Provider value={value}>
            <ThemeProvider theme={getTheme(theme)}>{children}</ThemeProvider>
        </AppThemeContext.Provider>
    );
};
```

Here, we are also adding a class to document body, if the theme is dark. This is because, we want to give a different background image on dark theme. 

Lets add the style in index.css

```css
#root {
    background-image: url('./images/bg-desktop-light.jpg');
    background-repeat: no-repeat;
}

@media screen and (max-width: 375px) {
    #root {
        background-image: url('./images/bg-mobile-light.jpg');
    }
    .dark-theme #root {
        background-image: url('./images/bg-mobile-dark.jpg');
    }
}

.dark-theme #root {
    background-image: url('./images/bg-desktop-dark.jpg');
    background-repeat: no-repeat;
}

```
_root_ is the id of the root element for our React application. 

And, for the material basic styles, we will add the CssBaseLine

src/index.tsx

```TS
import CssBaseline from '@mui/material/CssBaseline';
const root = ReactDOM.createRoot(
    document.getElementById('root') as HTMLElement
);
root.render(
    <React.StrictMode>
            <AppThemeProvider>
                <CssBaseline />
                <App />
        </AuthProvider>
    </React.StrictMode>
);
```

## React Router Setup

First, we need to install React Router

`npm install react-router-dom`

We need two pages. One for Login and one for Home page to display the todo items. 

Lets create two simple React components and we will create a custom provider for this also. 

We can keep the header same in both pages. Only the content area need to change based on the route. So, we can keep one parent component for the layout and we will render the route component inside the layout component. We will keep App component as the Layout component. 

src/providers/route.tsx
```TS
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import App from '../App';
import { HomePage } from '../components/home-page';
import { LoginPage } from '../components/login-page';


const router = createBrowserRouter([
    {
        path: '/',
        element: <App />,
        children: [
            {
                path: '/',
                element: <HomePage />,
            },
            {
                path: '/login',
                element: <LoginPage />,
            },
        ],
    },
]);

export const AppRouterProvider = () => {
    return <RouterProvider router={router} />;
};

```

Instead of App component, we will render AppRouterProvider in root level. 

src/index.tsx
```TS
root.render(
    <React.StrictMode>
            <AppThemeProvider>
                <CssBaseline />
                <AppRouterProvider />
            </AppThemeProvider>
    </React.StrictMode>
);
```

And, how do we tell React Router where to render the child component inside App component? Using the Outlet. 

src/App.tsx
```TS
import { Outlet } from 'react-router-dom';
function App() {
    return (
        <div>
            <header>
            </header>
            <main>
                <Outlet />
            </main>
        </div>
    );
}

export default App;
```

## Firebase Setup

We will go to [Firebase Console](https://console.firebase.google.com) and add a new project. 

Copy the Firebase config. Create firebase.ts file in src folder and copy the firebase config to that. 

Also, We will need to enable Authentication and FireStore. In Authentication, we will enable both Google and Email/Password based Login.

In FireStore, we will create a collection "todos". For a Todo, we will need a title, and a status to know if its completed or not, and also, we need to associate with a user. 

Lets create a document under the collection.

 
![Firestore Add todo](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ihuheuouekny8qm56n6n.png)


Now, we can try to try to use the same in our application. 

First, we will install Firebase package. 

`npm install firebase`

We will need to get the Firestore database instance. And, this database instance is needed in all components which need the data. So, we will export from where we create. 

src/firebase.ts

```TS
import { initializeApp } from 'firebase/app';
import { getFirestore } from 'firebase/firestore';

const firebaseConfig = {
   ...
};

const app = initializeApp(firebaseConfig);
export const db = getFirestore(app);

```

Now, in our HomePage component, we can read the collection to get the todos. 

src/components/home-page/home-page.tsx
```TS
import React, { FC, useContext, useEffect, useState } from 'react';
import { db } from '../../firebase';
import {
    collection,
    onSnapshot,
    doc,
    where,
    query,
} from 'firebase/firestore';

export const HomePage = () => {
   const [todos, setTodos] = useState<Todo[] | null>(null);
   useEffect(() => {
            const q = query(
                collection(db, 'todos')
            );

            const subscribeToTodos = () => {
                return onSnapshot(q, (querySnapshot) => {
                    const todos: Todo[] = [];
                    querySnapshot.forEach((doc) => {
                        const todoItem = {
                            id: doc.id,
                            ...doc.data(),
                        };
                        todos.push(todoItem as Todo);
                    });
                    setTodos(todos);
                });
            };

            const unsub = subscribeToTodos();

            return unsub;
        }
    }, []);
}

```

_onSnapshot_ callback will be triggered whenever there is a data change. 

Now, we need to setup the Authentication also. We need the authentication information also to be available across the application. So, we will create Auth Provider and read/write user data with sessionStorage.

And, we will use the AuthProvider on top level so that, all the components will have access to logged in user. 

Now, in LoginPage component, we need to do both Google and Email/Password login. First we will get the Google provider and also the auth instance. 

src/components/login-page/login-page.tsx
```TS
import {
    GoogleAuthProvider,
    getAuth,
    signInWithPopup,
    createUserWithEmailAndPassword,
    signInWithEmailAndPassword,
} from 'firebase/auth';

const provider = new GoogleAuthProvider();
const auth = getAuth();

export const LoginPage = () => {
  const signInWithGoogle = () => {
        signInWithPopup(auth, provider)
            .then((result) => {
                // set user to context
            })
    };

  const signUp = () => {
        createUserWithEmailAndPassword(auth, email, password)
            .then((userCredential) => {
                // set user to context
            });
    };

  const loginWithEmailAndPassword = () => {
        signInWithEmailAndPassword(auth, email, password)
            .then((userCredential) => {
                // set user to context
            });
    };

}

```

Now, we can change query in home component to get only the todos of the logged in user. 

We will change the query like the following 

src/components/home-page/home-page.tsx
```TS
const q = query(
                collection(db, 'todos'),
                where('userId', '==', user.uid)
            );

```

## Delete Todo

To delete a todo, we can use the deleteDoc method. We will add a Delete button for each todo, and on click of the button, we will call the deleteTodo method.

src/components/home-page/home-page.tsx
```TS
import {
    doc,
    deleteDoc,
} from 'firebase/firestore';

export const HomePage = () => {
  const deleteTodo = (todo: Todo) => {
        if (todo.id) {
            deleteDoc(doc(db, 'todos', todo.id));
        }
  };
}

```



## Set Todo as Completed

This is an update operation. We need to change the value of _isCompleted_ property of Todo to true. 

We will be adding RadioButton on each Todo item and on press of the radio button, we will call handleRadioCheck method. 

src/components/home-page/home-page.tsx
```TS
import {
    updateDoc,
    doc,
} from 'firebase/firestore';

const handleRadioCheck = (todo: Todo) => {
        if (todo.id) {
            const docReference = doc(db, 'todos', todo.id);
            updateDoc(docReference, {
                isCompleted: true,
            });
        }
    };
```

## Filter Todo Items

We want to filter Todo items based on status, _All_, _Active_, or _Completed_. We will create a FilterState and use the useState to set the active filter state. We can. use this value to filter the todo items. 

src/components/home-page/home-page.tsx

```TS
enum FilterState {
    ALL = 'All',
    ACTIVE = 'Active',
    COMPLETED = 'Completed',
}

export const HomePage = () => {
  const [activeFilter, setActiveFilter] = useState<FilterState>(
        FilterState.ALL
    );

const filteredTodos =
        activeFilter === FilterState.ALL
            ? todos
            : todos?.filter((todo) => {
                  const filterCondition =
                      activeFilter === FilterState.ACTIVE ? false : true;

                  return todo.isCompleted === filterCondition;
              });

}
```

And, we will be rendering the _filteredTodos_ instead of _todos_.

## Add Todo


We will create an AddTodo Component. We just need a text field. On Enter Key Press, we want to create a new Todo item. 

src/components/add-todo/add-todo.tsx
```TS
import { collection, addDoc } from 'firebase/firestore';
import TextField from '@mui/material/TextField';
import { db } from '../../firebase';

export const AddTodo = () => {
   const onKeyPress = (event: KeyboardEvent<HTMLInputElement>) => {
        if (event.key === 'Enter') {
            const inputValue = (event.target as HTMLInputElement).value;
            if (inputValue) {
                addDoc(collection(db, 'todos'), {
                    title: inputValue,
                    isCompleted: false,
                    userId: user?.uid,
                });
            }
        }
    };
return (
   ...
    <TextField
                        id="input-with-sx"
                        fullWidth
                        label="Add Todo"
                        variant="standard"
                        onKeyPress={onKeyPress}
                    />
   ...
)

}

```

## Protected Routes

Since we are showing todo items of the logged in user, we don't want the home page to be accessible to user who is not logged in. We want to check if user is available in Auth Context. If not, redirect back to login page. We will make it a generic component as this is a common feature. 

We will create a ProtectedRoute component for adding the above check.

src/components/protected-route/protected-route.tsx
```TS
import React, { FC, ReactNode, useContext } from 'react';
import { Navigate } from 'react-router-dom';
import { AuthContext } from '../../providers/auth';

type Props = {
    children: ReactNode;
};

export const ProtectedRoute: FC<Props> = ({ children }) => {
    const { user } = useContext(AuthContext);

    if (!user) {
        return <Navigate to="/login" replace={true} />;
    }

    return <>{children}</>;
};

```

And, our Router will be updated to use this ProtectedRoute component.

src/providers/route.tsx
```TS
import { ProtectedRoute } from '../components/protected-route';

const router = createBrowserRouter([
    {
        path: '/',
        element: <App />,
        children: [
            {
                path: '/',
                element: (
                    <ProtectedRoute>
                        <HomePage />
                    </ProtectedRoute>
                ),
            },
            {
                path: '/login',
                element: <LoginPage />,
            },
        ],
    },
]);

```

## Firebase Hosting

To deploy to Firebase from the command prompt, we need Firebase CLI. 

`npm install -g firebase-tools`

After that, we can initialise a Firebase project, by running

`firebase init`

It will ask some questions. 

![Firebase init](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/thwjiy9wlhmvcnkwnz5d.png)

We can select Github Actions also. So, Firebase will generate two files under _.github/workflows_ directory. This is the default path for Github Actions. Github will automatically detect the workflow files and create the Actions.

Now, we can deploy using `firebase deploy`

 

## Github Code Actions - CI/CD

Firebase has generated two Action workflows. One for deploying to Firebase App whenever code is pushed to **main** branch. Other one is two run whenever a pull request is created. 

Now, if we push to main branch, _build and deploy_ action will be triggered automatically. 

Same way,  if we create a pull request, _build and preview_ action will be triggered. 


![Pull Request](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/fpk0f2i8ofcdaba0vry6.png)

Firebase will also create a preview url and comment on Pull Request. This way, we can preview our changes before merging to the main branch. 

## Unit Test

 We are using [React Testing Library](https://testing-library.com/) and [Jest](https://jestjs.io/) for Unit Testing React components. These are already setup in Create React App.

Since App component needs to be aware of Outlet in the testing, we will create a dummy Routing setup in the test file. 

src/App.test.tsx
```TS
import React from 'react';
import { render, screen } from '@testing-library/react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
import App from './App';

const DummyComp = () => <span>Hello</span>;

const router = createBrowserRouter([
    {
        path: '/',
        element: <App />,
        children: [
            {
                path: '/',
                element: <DummyComp />,
            },
        ],
    },
]);

const renderApp = () => {
    render(<RouterProvider router={router} />);
};

test('renders learn react link', () => {
    renderApp();
    const loginButton = screen.getByText('Login');
    expect(loginButton).toBeInTheDocument();
    expect(screen.getByText('Hello')).toBeInTheDocument();
});

```

We can use different selectors. Here, we are trying to get a button by Text and checking its presence. 

Now, let's look into how to test a user event. We will add a test for AddTodo component. 

We can use [userEvent](https://testing-library.com/docs/user-event/intro) from React Testing Library

src/components/add-todo/add-todo.test.tsx

```TS
import React, { ReactElement, ReactNode } from 'react';
import { render, screen } from '@testing-library/react';
import { collection, addDoc } from 'firebase/firestore';

import { AddTodo } from './add-todo';
import userEvent from '@testing-library/user-event';

const setup = (jsx: ReactElement) => {
    return {
        user: userEvent,
        ...render(jsx),
    };
};

jest.mock('firebase/firestore');

describe('<AddTodo />', () => {
    (addDoc as jest.Mock).mockImplementation(jest.fn());


    it('should trigger addDoc on enter keypress', async () => {
        const { user } = setup(<AddTodo />);
        const inputEl = screen.getByLabelText('Add Todo');
        await user.type(inputEl, 'hello{Enter}');
        expect(addDoc).toHaveBeenCalledWith(undefined, {
            title: 'hello',
            isCompleted: false,
            userId: undefined,
        });
    });
});

```

We are also mocking addDoc method using jest.mock as we don't want to make the real Firebase addDoc call. Instead, we just want to know if the addDoc is called with expected values. 

## Conclusion

With this, we are able to Login, Add Todo, Delete Todo, Mark Todo as Completed, Filter Todos, Logout and also setup CI/CD and added Unit Tests. 

You can get full code [here](https://github.com/jintoppy/react-firebase-todo).

Please let me know your thoughts on this.