├── .env
├── .gitignore
├── README.md
├── firebase.json
├── package-lock.json
├── package.json
├── public
├── index.html
└── robots.txt
└── src
├── App.js
├── App.test.js
├── components
└── ErrorMessage
│ ├── ErrorMessage.css
│ └── ErrorMessage.js
├── hooks
└── useQueryString.js
├── index.css
├── index.js
├── scenes
├── CreateList
│ ├── CreateList.css
│ └── CreateList.js
├── EditList
│ ├── AddItem
│ │ ├── AddItem.css
│ │ └── AddItem.js
│ ├── EditList.css
│ ├── EditList.js
│ └── ItemList
│ │ └── ItemList.js
└── JoinList
│ ├── JoinList.css
│ └── JoinList.js
├── services
└── firestore.js
└── setupTests.js
/.env:
--------------------------------------------------------------------------------
1 | # Add your Firebase web app configurations.
2 | # See Project Settings for your Firebase project at https://console.firebase.google.com.
3 | REACT_APP_FIREBASE_API_KEY=
4 | REACT_APP_FIREBASE_AUTH_DOMAIN=
5 | REACT_APP_FIREBASE_PROJECT_ID=
6 |
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # firebase
15 | /.firebase
16 | .firebaserc
17 |
18 | # misc
19 | .DS_Store
20 | .env.local
21 | .env.development.local
22 | .env.test.local
23 | .env.production.local
24 |
25 | npm-debug.log*
26 | yarn-debug.log*
27 | yarn-error.log*
28 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Hooks and Firebase Demo - Live Grocery List
2 |
3 | The Live Grocery List app is a demonstration of using React Hooks to manage app state that is backed by a Firebase firestore backend. This app demonstrates using building in React hooks as well as custom hooks.
4 |
5 | ## Setup
6 |
7 | This application uses Firebase services. Configuration required to connect to Firebase is defined in the [.env](.env) file (or .env.local) in the root of this repository.
8 |
9 | Before building or running the app, you must add your Firebase project configuration. Configuration can be obtained from *Project Settings* in your Firebase project at [https://console.firebase.google.com](https://console.firebase.google.com).
10 |
11 | ## Available Scripts
12 |
13 | In the project directory, you can run:
14 |
15 | `npm start` to run the app in development mode
16 | `npm test` to launch the test runner in interactive watch mode
17 | `npm run build` to bundle the app for production
18 | `npm run eject` to eject React Scripts dependency
19 |
20 | ## Live Demo
21 |
22 | Try a live version of this app at [https://fir-with-react-hooks.firebaseapp.com/](https://fir-with-react-hooks.firebaseapp.com/).
23 |
--------------------------------------------------------------------------------
/firebase.json:
--------------------------------------------------------------------------------
1 | {
2 | "hosting": {
3 | "public": "build",
4 | "ignore": [
5 | "firebase.json",
6 | "**/.*",
7 | "**/node_modules/**"
8 | ],
9 | "rewrites": [
10 | {
11 | "source": "**",
12 | "destination": "/index.html"
13 | }
14 | ]
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "firebase-with-react-hooks",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "@testing-library/jest-dom": "^4.2.4",
7 | "@testing-library/react": "^9.4.0",
8 | "@testing-library/user-event": "^7.2.1",
9 | "firebase": "^9.6.4",
10 | "react": "^16.12.0",
11 | "react-dom": "^16.12.0",
12 | "react-scripts": "3.3.1"
13 | },
14 | "scripts": {
15 | "start": "react-scripts start",
16 | "build": "react-scripts build",
17 | "test": "react-scripts test",
18 | "eject": "react-scripts eject"
19 | },
20 | "eslintConfig": {
21 | "extends": "react-app"
22 | },
23 | "browserslist": {
24 | "production": [
25 | ">0.2%",
26 | "not dead",
27 | "not op_mini all"
28 | ],
29 | "development": [
30 | "last 1 chrome version",
31 | "last 1 firefox version",
32 | "last 1 safari version"
33 | ]
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 | Grocery List
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react';
2 |
3 | import * as FirestoreService from './services/firestore';
4 |
5 | import CreateList from './scenes/CreateList/CreateList';
6 | import JoinList from './scenes/JoinList/JoinList';
7 | import EditList from './scenes/EditList/EditList';
8 | import ErrorMessage from './components/ErrorMessage/ErrorMessage';
9 |
10 | import useQueryString from './hooks/useQueryString'
11 |
12 |
13 | function App() {
14 |
15 | const [user, setUser] = useState();
16 | const [groceryList, setGroceryList] = useState();
17 | const [userId, setUserId] = useState();
18 | const [error, setError] = useState();
19 |
20 | // Use a custom hook to subscribe to the grocery list ID provided as a URL query parameter
21 | const [groceryListId, setGroceryListId] = useQueryString('listId');
22 |
23 | // Use an effect to authenticate and load the grocery list from the database
24 | useEffect(() => {
25 | FirestoreService.authenticateAnonymously()
26 | .then(userCredential => {
27 | setUserId(userCredential.user.uid);
28 | if (groceryListId) {
29 | FirestoreService.getGroceryList(groceryListId)
30 | .then(groceryList => {
31 | if (groceryList.exists) {
32 | setError(null);
33 | setGroceryList(groceryList.data());
34 | } else {
35 | setError('grocery-list-not-found');
36 | setGroceryListId();
37 | }
38 | })
39 | .catch(() => setError('grocery-list-get-fail'));
40 | }
41 | })
42 | .catch(() => setError('anonymous-auth-failed'));
43 | }, [groceryListId, setGroceryListId]);
44 |
45 | function onGroceryListCreate(groceryListId, userName) {
46 | setGroceryListId(groceryListId);
47 | setUser(userName);
48 | }
49 |
50 | function onCloseGroceryList() {
51 | setGroceryListId();
52 | setGroceryList();
53 | setUser();
54 | }
55 |
56 | function onSelectUser(userName) {
57 | setUser(userName);
58 | FirestoreService.getGroceryList(groceryListId)
59 | .then(updatedGroceryList => setGroceryList(updatedGroceryList.data()))
60 | .catch(() => setError('grocery-list-get-fail'));
61 | }
62 |
63 | // render a scene based on the current state
64 | if (groceryList && user) {
65 | return ;
66 | } else if(groceryList) {
67 | return (
68 |
69 |
70 |
71 |
72 | );
73 | }
74 | return (
75 |
76 |
77 |
78 |
79 | );
80 | }
81 |
82 | export default App;
83 |
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import { render } from '@testing-library/react';
3 | import App from './App';
4 |
5 | test('renders learn react link', () => {
6 | const { getByText } = render();
7 | const linkElement = getByText(/Our grocery list/i);
8 | expect(linkElement).toBeInTheDocument();
9 | });
10 |
--------------------------------------------------------------------------------
/src/components/ErrorMessage/ErrorMessage.css:
--------------------------------------------------------------------------------
1 | .error {
2 | font-family: sans-serif;
3 | font-weight: bold;
4 | color: #a33535;
5 | background-color: #e0caca;
6 | padding: 5px;
7 | text-align: center;
8 | }
--------------------------------------------------------------------------------
/src/components/ErrorMessage/ErrorMessage.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './ErrorMessage.css';
3 |
4 | function ErrorMessage(props) {
5 |
6 | const { errorCode } = props;
7 |
8 | function getErrorMessage() {
9 | switch(errorCode) {
10 | case 'anonymous-auth-failed':
11 | return 'Anonymous authentication failed. Try again.'
12 | case 'grocery-list-not-found':
13 | return 'The grocery list could not be found. Try creating a new one.';
14 | case 'grocery-list-get-fail':
15 | return 'Failed to retrieve the grocery list. Try again.';
16 | case 'add-list-item-error':
17 | return 'Failed to add grocery item to list. Try again.';
18 | case 'create-list-error':
19 | return 'Failed to create the grocery list. Try again.';
20 | case 'add-user-to-list-error':
21 | return 'Failed to add user to the grocery list. Try again.';
22 | case 'grocery-item-desc-req':
23 | return 'grocery item description required';
24 | case 'duplicate-item-error':
25 | return 'grocery item on list already';
26 | case 'user-name-required':
27 | return 'your name is required';
28 | case 'grocery-list-item-get-fail':
29 | return 'failed to get grocery list items';
30 | default:
31 | return 'Oops, something went wrong.';
32 | }
33 | }
34 |
35 | return errorCode ? {getErrorMessage()}
: null;
36 | };
37 |
38 | export default ErrorMessage;
--------------------------------------------------------------------------------
/src/hooks/useQueryString.js:
--------------------------------------------------------------------------------
1 | import { useState, useCallback } from 'react';
2 |
3 | // This custom hook keeps a state value in sync with a query parameter in the website's URL.
4 | // This allows the URL to be used to restore state on full-page reload or if the URL is shared/bookmarked.
5 | function useQueryString(key) {
6 |
7 | const [ paramValue, setParamValue ] = useState(getQueryParamValue(key));
8 |
9 | // The useCallback hook is only called when one of its dependencies change.
10 | // In this case, we only want to update the browser URL if the query string needs to change.
11 | const onSetValue = useCallback(
12 | newValue => {
13 | setParamValue(newValue);
14 | updateQueryStringWithoutReload(newValue ? `${key}=${newValue}` : '');
15 | },
16 | [key, setParamValue]
17 | );
18 |
19 | function getQueryParamValue(key) {
20 | return new URLSearchParams(window.location.search).get(key);
21 | }
22 |
23 | // update a query string without causing a browser reload
24 | function updateQueryStringWithoutReload(queryString) {
25 | const { protocol, host, pathname } = window.location;
26 | const newUrl = `${protocol}//${host}${pathname}?${queryString}`;
27 | window.history.pushState({ path: newUrl }, '', newUrl);
28 | }
29 |
30 | return [paramValue, onSetValue];
31 | }
32 |
33 | export default useQueryString;
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 20px;
3 | font-family: 'Sriracha', cursive;
4 | background: rgb(141, 185, 211);
5 | color: #003366;
6 | }
7 |
8 | a {
9 | color: #003366;
10 | }
11 |
12 | header {
13 | text-align: center;
14 | }
15 |
16 | footer {
17 | text-align: center;
18 | }
19 |
20 | h2 {
21 | margin-top: 5px;
22 | }
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import App from './App';
5 |
6 | ReactDOM.render(, document.getElementById('root'));
7 |
--------------------------------------------------------------------------------
/src/scenes/CreateList/CreateList.css:
--------------------------------------------------------------------------------
1 | .create-container {
2 | display: flex;
3 | flex-direction: column;
4 | align-items: center;
5 | justify-content: stretch;
6 | }
7 |
8 | .create-container div {
9 | max-width: 500px;
10 | text-align: center;
11 | }
12 |
--------------------------------------------------------------------------------
/src/scenes/CreateList/CreateList.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import './CreateList.css';
3 | import * as FirestoreService from '../../services/firestore';
4 | import ErrorMessage from '../../components/ErrorMessage/ErrorMessage';
5 |
6 | function CreateList(props) {
7 |
8 | const { onCreate, userId } = props;
9 |
10 | const [ error, setError ] = useState();
11 |
12 | function createGroceryList(e) {
13 | e.preventDefault();
14 | setError(null);
15 |
16 | const userName = document.createListForm.userName.value;
17 | if (!userName) {
18 | setError('user-name-required');
19 | return;
20 | }
21 |
22 | FirestoreService.createGroceryList(userName, userId)
23 | .then(docRef => {
24 | onCreate(docRef.id, userName);
25 | })
26 | .catch(reason => setError('create-list-error'));
27 | }
28 |
29 | return (
30 |
31 |
32 | Welcome to the Grocery List app!
33 |
34 |
44 |
45 | );
46 | }
47 |
48 | export default CreateList;
--------------------------------------------------------------------------------
/src/scenes/EditList/AddItem/AddItem.css:
--------------------------------------------------------------------------------
1 | .button-group button {
2 | margin-right: 10px;
3 | font-size: 1.1rem;
4 | color: #003366;
5 | }
--------------------------------------------------------------------------------
/src/scenes/EditList/AddItem/AddItem.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import './AddItem.css';
3 | import * as FirestoreService from '../../../services/firestore';
4 | import ErrorMessage from '../../../components/ErrorMessage/ErrorMessage'
5 |
6 |
7 | function AddItem(props) {
8 |
9 | const { groceryListId, userId } = props;
10 |
11 | const [error, setError] = useState('');
12 |
13 | function addItem(e) {
14 | e.preventDefault();
15 | setError(null);
16 |
17 | const itemDesc = document.addItemForm.itemDesc.value;
18 | if (!itemDesc) {
19 | setError('grocery-item-desc-req');
20 | return;
21 | }
22 |
23 | FirestoreService.addGroceryListItem(itemDesc, groceryListId, userId)
24 | .then(() => document.addItemForm.reset())
25 | .catch(reason => {
26 | if (reason.message === 'duplicate-item-error') {
27 | setError(reason.message);
28 | } else {
29 | setError('add-list-item-error');
30 | }
31 | });
32 | }
33 |
34 | return (
35 |
41 | );
42 | }
43 |
44 | export default AddItem;
--------------------------------------------------------------------------------
/src/scenes/EditList/EditList.css:
--------------------------------------------------------------------------------
1 | .edit-container {
2 | display: flex;
3 | justify-content: center;
4 | flex-wrap: wrap;
5 | }
6 |
7 | .add-item-column {
8 | min-width: 300px;
9 | width: 25%;
10 | background-color: rgb(247, 237, 202);
11 | padding: 20px;
12 | border-radius: 2%;
13 | margin: 5px;
14 | text-align: center;
15 | }
16 |
17 | .list-column {
18 | min-width: 300px;
19 | width: 25%;
20 | background-color: rgb(247, 237, 202);
21 | padding: 20px;
22 | border-radius: 2%;
23 | margin: 5px;
24 | text-align: center;
25 | }
--------------------------------------------------------------------------------
/src/scenes/EditList/EditList.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import './EditList.css';
3 | import AddItem from './AddItem/AddItem';
4 | import ItemList from './ItemList/ItemList';
5 |
6 | function EditList(props) {
7 |
8 | const { groceryListId, user, onCloseGroceryList, userId } = props;
9 |
10 | function onCreateListClick(e) {
11 | e.preventDefault();
12 | onCloseGroceryList();
13 | }
14 |
15 | return (
16 |
34 | );
35 | }
36 |
37 | export default EditList;
--------------------------------------------------------------------------------
/src/scenes/EditList/ItemList/ItemList.js:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState } from 'react';
2 | import * as FirestoreService from '../../../services/firestore';
3 | import ErrorMessage from '../../../components/ErrorMessage/ErrorMessage';
4 |
5 | function ItemList(props) {
6 |
7 | const { groceryListId } = props;
8 |
9 | const [ groceryItems, setGroceryItems ] = useState([]);
10 | const [ error, setError ] = useState();
11 |
12 | // Use an effect hook to subscribe to the grocery list item stream and
13 | // automatically unsubscribe when the component unmounts.
14 | useEffect(() => {
15 | const unsubscribe = FirestoreService.streamGroceryListItems(groceryListId,
16 | (querySnapshot) => {
17 | const updatedGroceryItems =
18 | querySnapshot.docs.map(docSnapshot => docSnapshot.data());
19 | setGroceryItems(updatedGroceryItems);
20 | },
21 | (error) => setError('grocery-list-item-get-fail')
22 | );
23 | return unsubscribe;
24 | }, [groceryListId, setGroceryItems]);
25 |
26 | const groceryItemElements = groceryItems
27 | .map((groceryItem, i) => {groceryItem.name}
);
28 |
29 | return (
30 |
31 |
32 |
{groceryItemElements}
33 |
34 | );
35 | }
36 |
37 | export default ItemList;
--------------------------------------------------------------------------------
/src/scenes/JoinList/JoinList.css:
--------------------------------------------------------------------------------
1 | .join-container {
2 | display: flex;
3 | justify-content: center;
4 | flex-wrap: wrap;
5 | }
6 |
7 | .join-container div {
8 | text-align: center;
9 | }
--------------------------------------------------------------------------------
/src/scenes/JoinList/JoinList.js:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import './JoinList.css';
3 | import ErrorMessage from '../../components/ErrorMessage/ErrorMessage';
4 | import * as FirestoreService from '../../services/firestore';
5 |
6 | function JoinList(props) {
7 |
8 | const { users, groceryListId, onSelectUser, onCloseGroceryList, userId } = props;
9 |
10 | const [ error, setError ] = useState();
11 |
12 | function addExistingUser(e) {
13 | e.preventDefault();
14 | onSelectUser(e.target.innerText);
15 | }
16 |
17 | function getUserButtonList() {
18 | const buttonList = users.map(user => );
19 | return {buttonList}
;
20 | }
21 |
22 | function addNewUser(e) {
23 | e.preventDefault();
24 | setError(null);
25 |
26 | const userName = document.addUserToListForm.name.value;
27 | if (!userName) {
28 | setError('user-name-required');
29 | return;
30 | }
31 |
32 | if (users.find(user => user.name === userName)) {
33 | onSelectUser(userName);
34 | } else {
35 | FirestoreService.addUserToGroceryList(userName, groceryListId, userId)
36 | .then(() => onSelectUser(userName))
37 | .catch(() => setError('add-user-to-list-error'));
38 | }
39 | }
40 |
41 | function onCreateListClick(e) {
42 | e.preventDefault();
43 | onCloseGroceryList();
44 | }
45 |
46 | return (
47 |
48 |
49 | Welcome to the Grocery List app!
50 |
51 |
66 |
67 | );
68 | }
69 |
70 | export default JoinList;
--------------------------------------------------------------------------------
/src/services/firestore.js:
--------------------------------------------------------------------------------
1 | import { initializeApp } from "firebase/app";
2 | import {
3 | getFirestore,
4 | query,
5 | orderBy,
6 | onSnapshot,
7 | collection,
8 | getDoc,
9 | getDocs,
10 | addDoc,
11 | updateDoc,
12 | doc,
13 | serverTimestamp,
14 | arrayUnion
15 | } from "firebase/firestore";
16 | import { getAuth, signInAnonymously} from "firebase/auth";
17 |
18 | const firebaseConfig = {
19 | apiKey: process.env.REACT_APP_FIREBASE_API_KEY,
20 | authDomain: process.env.REACT_APP_FIREBASE_AUTH_DOMAIN,
21 | projectId: process.env.REACT_APP_FIREBASE_PROJECT_ID
22 | };
23 |
24 | const app = initializeApp(firebaseConfig);
25 | const db = getFirestore(app)
26 |
27 | export const authenticateAnonymously = () => {
28 | return signInAnonymously(getAuth(app));
29 | };
30 |
31 | export const createGroceryList = (userName, userId) => {
32 | const groceriesColRef = collection(db, 'groceryLists')
33 | return addDoc(groceriesColRef, {
34 | created: serverTimestamp(),
35 | createdBy: userId,
36 | users: [{
37 | userId: userId,
38 | name: userName
39 | }]
40 | });
41 | };
42 |
43 | export const getGroceryList = (groceryListId) => {
44 | const groceryDocRef = doc(db, 'groceryLists', groceryListId)
45 | return getDoc(groceryDocRef);
46 | };
47 |
48 | export const getGroceryListItems = (groceryListId) => {
49 | const itemsColRef = collection(db, 'groceryLists', groceryListId, 'items')
50 | return getDocs(itemsColRef)
51 | }
52 |
53 | export const streamGroceryListItems = (groceryListId, snapshot, error) => {
54 | const itemsColRef = collection(db, 'groceryLists', groceryListId, 'items')
55 | const itemsQuery = query(itemsColRef, orderBy('created'))
56 | return onSnapshot(itemsQuery, snapshot, error);
57 | };
58 |
59 | export const addUserToGroceryList = (userName, groceryListId, userId) => {
60 | const groceryDocRef = doc(db, 'groceryLists', groceryListId)
61 | return updateDoc(groceryDocRef, {
62 | users: arrayUnion({
63 | userId: userId,
64 | name: userName
65 | })
66 | });
67 | };
68 |
69 | export const addGroceryListItem = (item, groceryListId, userId) => {
70 | return getGroceryListItems(groceryListId)
71 | .then(querySnapshot => querySnapshot.docs)
72 | .then(groceryListItems => groceryListItems.find(groceryListItem => groceryListItem.data().name.toLowerCase() === item.toLowerCase()))
73 | .then( (matchingItem) => {
74 | if (!matchingItem) {
75 | const itemsColRef = collection(db, 'groceryLists', groceryListId, 'items')
76 | return addDoc(itemsColRef, {
77 | name: item,
78 | created: serverTimestamp(),
79 | createdBy: userId
80 | });
81 | }
82 | throw new Error('duplicate-item-error');
83 | });
84 | };
--------------------------------------------------------------------------------
/src/setupTests.js:
--------------------------------------------------------------------------------
1 | // jest-dom adds custom jest matchers for asserting on DOM nodes.
2 | // allows you to do things like:
3 | // expect(element).toHaveTextContent(/react/i)
4 | // learn more: https://github.com/testing-library/jest-dom
5 | import '@testing-library/jest-dom/extend-expect';
6 |
--------------------------------------------------------------------------------