DEV Community

Jinto Jose
Jinto Jose

Posted on

Creating FullStack Todo App - ReactJS+MaterialUI+FireBase+GithubAction+UnitTest

Before we start

If you prefer to watch instead of reading..

Also, if you want to read about creating the exact same application in Angular, you can read here.

What are we building

I saw this challenge in FrontendMentor 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 and Material UI for the Frontend and Firebase 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

home page screenshot

ReactJS setup

We will use Create React App for creating a basic React application setup. We will also use Typescript.

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 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

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];
};
Enter fullscreen mode Exit fullscreen mode

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

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>
    );
};
Enter fullscreen mode Exit fullscreen mode

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

#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;
}

Enter fullscreen mode Exit fullscreen mode

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

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>
);
Enter fullscreen mode Exit fullscreen mode

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

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} />;
};

Enter fullscreen mode Exit fullscreen mode

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

src/index.tsx

root.render(
    <React.StrictMode>
            <AppThemeProvider>
                <CssBaseline />
                <AppRouterProvider />
            </AppThemeProvider>
    </React.StrictMode>
);
Enter fullscreen mode Exit fullscreen mode

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

src/App.tsx

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

export default App;
Enter fullscreen mode Exit fullscreen mode

Firebase Setup

We will go to Firebase Console 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

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

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

const firebaseConfig = {
   ...
};

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

Enter fullscreen mode Exit fullscreen mode

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

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

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;
        }
    }, []);
}

Enter fullscreen mode Exit fullscreen mode

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

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
            });
    };

}

Enter fullscreen mode Exit fullscreen mode

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

const q = query(
                collection(db, 'todos'),
                where('userId', '==', user.uid)
            );

Enter fullscreen mode Exit fullscreen mode

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

import {
    doc,
    deleteDoc,
} from 'firebase/firestore';

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

Enter fullscreen mode Exit fullscreen mode

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

import {
    updateDoc,
    doc,
} from 'firebase/firestore';

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

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

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;
              });

}
Enter fullscreen mode Exit fullscreen mode

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

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}
                    />
   ...
)

}

Enter fullscreen mode Exit fullscreen mode

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

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}</>;
};

Enter fullscreen mode Exit fullscreen mode

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

src/providers/route.tsx

import { ProtectedRoute } from '../components/protected-route';

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

Enter fullscreen mode Exit fullscreen mode

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

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

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 and Jest 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

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();
});

Enter fullscreen mode Exit fullscreen mode

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 from React Testing Library

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

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,
        });
    });
});

Enter fullscreen mode Exit fullscreen mode

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.

Please let me know your thoughts on this.

Top comments (0)