This is continued from Make a note taking app with Firestore - Part 1.2 Markdown editor
Background
A single database is shared by all the users but we want the user only able to access his/her own data. Thus, we need to setup the rules for this.
What to do?
1. Create the database
Open the Firebase console. Goto Cloud Firestore and create a new database.

Next we have to choose the initial mode for the database. If you are doing some POC, you can start in test mode so it allows read/write access by default. We already know what we want to do, so we just choose to start in production mode.

Next choose the physical location of the Firestore database. Choose a location that is closest to you so the connection is faster.

Now wait for awhile for the database to be created. Then we can craft the rules.
2. Setup the rules
Goto Rules tab and input the rules:

We have allow the user to access to /users/{user}/** , so the user will not be able to access other people's data because the {user} will always has to match his/her user ID. The user is not allowed to access data other than that.
3. Add navigation to the web application
We will use react-router-dom to add navigation to the web application. We will want to navigate to the document based on an ID in the URL. Run these commands to install the library:
npm i react-router-dom npm i -D @types/react-router-dom
We will now change the HomePanel to add a sidebar to it so the user can choose the document from a list. Replace ./src/components/HomePanel.tsx with these:
import './HomePanel.css'; import firebase from 'firebase'; import React, { useContext, useMemo } from 'react'; import { BrowserRouter, Route } from 'react-router-dom'; import LoggedInContext from '../contexts/LoggedInContext'; import DeleteButton from './DeleteButton'; import DocList from './DocList'; import EditPanel from './EditPanel'; import NewButton from './NewButton'; import ProfilePanel from './ProfilePanel'; const HomePanel = () => { const loggedIn = useContext(LoggedInContext); const uid = loggedIn.user!.uid; const docRefs = useMemo(() => firebase.firestore().collection('users').doc(uid).collection('docs'), [uid]); return ( <BrowserRouter> <div className="content-panel"> <Route path="/:id" children={<EditPanel docRefs={docRefs} />} /> </div> <div className="sidebar"> <Route path="/:id?" children={<DocList docRefs={docRefs} />} /> </div> <div className="topbar"> <div className="container"> <div className="title">Write it down</div> <div className="button-panel"> <NewButton docRefs={docRefs} /> <Route path="/:id" children={<DeleteButton docRefs={docRefs} />} /> </div> <ProfilePanel /> </div> </div> </BrowserRouter> ); }; export default HomePanel;
Next we update the styling so the sidebar will be stick to the left and the editor will fill the remaining space. Replace the .content-panel in ./src/components/HomePanel.css with these:
.sidebar { width: 200px; max-width: 100%; position: fixed; left: 0; top: 2.5em; bottom: 0; background: #eee; overflow: auto; } .sidebar .button { padding: .5em; } .sidebar .button>button { display: block; width: 100%; } .content-panel { position: fixed; left: 200px; top: 2.5em; right: 0; bottom: 0; }
Next we will implement the NewButton. It will create a new document when user click on it. Create ./src/components/NewButton.tsx and put these:
import React from 'react'; import { useHistory } from "react-router-dom"; const NewButton = (props: { docRefs: firebase.firestore.CollectionReference<firebase.firestore.DocumentData>, }) => { const history = useHistory(); const newDoc = async () => { const docRef = await props.docRefs.add({ title: 'New', content: '' }); history.push(`/${docRef.id}`); }; return ( <button type="button" onClick={newDoc}>New</button> ); }; export default NewButton;
Next we will implement the DeleteButton. It will delete existing document when user click on it. Create ./src/components/DeleteButton.tsx and put these:
import React from 'react'; import { useParams } from "react-router-dom"; const DeleteButton = (props: { docRefs: firebase.firestore.CollectionReference<firebase.firestore.DocumentData>, }) => { const { id } = useParams(); const deleteDoc = async () => { if (window.confirm('Delete this?')) { await props.docRefs.doc(id).delete(); } }; return ( <button type="button" onClick={deleteDoc}>Delete</button> ); }; export default DeleteButton;
Next we will implement the DocList. It will list out all the documents belong to the logged in user. Since it has the list of the documents, we will make it navigate to the first available document if the ID in the URL is not correct. Create ./src/components/DocList.tsx and put these:
import React, { useEffect, useState } from 'react'; import { useHistory, useParams } from 'react-router-dom'; import DocListItem from './DocListItem'; const DocList = (props: { docRefs: firebase.firestore.CollectionReference<firebase.firestore.DocumentData>, onClickItem?: () => void, }) => { const { id } = useParams(); const history = useHistory(); const [list, setList] = useState<firebase.firestore.QueryDocumentSnapshot<firebase.firestore.DocumentData>[]>(); useEffect(() => { return props.docRefs.onSnapshot(newDocRefs => { setList(newDocRefs.docs.sort((a, b) => compare(a.data().title, b.data().title))); }); }, [props.docRefs]); useEffect(() => { if (list && !list.find(i => i.id == id)) { if (list.length) { history.push(`/${list[0].id}`); } else { history.push('/'); } } }, [id, history, list]); return ( <div> {list?.map(doc => (<DocListItem key={doc.id} doc={doc} onClick={props.onClickItem} />))} </div> ); }; function compare(a: any, b: any) { return typeof a === 'string' && typeof b === 'string' ? a.localeCompare(b) : !a && b ? -1 : a && !b ? 1 : a < b ? -1 : a > b ? 1 : 0; } export default DocList;
Next we will implement the DocListItem. It is just a link to navigate to the document when user click on it. Create ./src/components/DocListItem.tsx and put these:
import './DocListItem.css'; import React from 'react'; import { Link, useParams } from 'react-router-dom'; const DocListItem = (props: { doc: firebase.firestore.QueryDocumentSnapshot<firebase.firestore.DocumentData>, onClick?: () => void, }) => { const { id } = useParams(); const data = props.doc.data(); const title = data.title || '<untitled>'; return ( <div className="doc-list-item"> <Link to={`/${props.doc.id}`} className={props.doc.id == id ? 'active' : ''} onClick={props.onClick}>{title}</Link> </div> ); }; export default DocListItem;
We will need to add some styling to the list item also so it looks nicer. Create ./src/components/DocListItem.css and put these:
.doc-list-item>a { display: block; padding: .5em; color: #000; text-decoration: none; } .doc-list-item>a:hover { background: #eef; } .doc-list-item>a.active { background: #ddf; }
Next we will need to change the editor so it load the content from the Firestore.
4. Update the editor to work on real content
First, we replace the ./src/components/EditPanel.tsx with these:
import '../../node_modules/simplemde/debug/simplemde.css'; import './EditPanel.css'; import React, { useEffect, useMemo, useRef } from 'react'; import { useParams } from 'react-router-dom'; import SimpleMDE from 'simplemde'; const EditPanel = (props: { docRefs: firebase.firestore.CollectionReference<firebase.firestore.DocumentData>, }) => { const { id } = useParams(); const docRef = useMemo(() => props.docRefs.doc(id), [id, props.docRefs]); const ref = useRef<HTMLTextAreaElement>(null); useEffect(() => { let isChangingFromSnapshot = false; let timer: any; const editor = new SimpleMDE({ element: ref.current!, autofocus: true, toolbar: false, status: false, }); // TODO update Firestore document content if editor content changed // TODO update editor content if Firestore document content changed return () => { // TODO clean up snapshot listener editor.toTextArea(); clearTimeout(timer); }; }, [docRef]); return ( <div className="edit-panel"> <textarea ref={ref}></textarea> </div> ); }; // TODO helper functions export default EditPanel;
Now it will get the document from Firestore based on the ID in the URL. Next we need to populate the editor content from the Firestore document content. Replace // TODO update editor content if Firestore document content changed with these:
const unsubscribeOnSnapshot = docRef.onSnapshot(doc => { if (!doc.metadata.hasPendingWrites) { clearTimeout(timer); timer = setTimeout(() => { isChangingFromSnapshot = true; editor.value(doc.data()?.content); isChangingFromSnapshot = false; }, 100); } });
This will subscribe to the snapshot event emitted from the Firestore document whenever the content is changed. It can be triggered due to changes from other instances of the web application. Thus the web application can sync the changes across different instances. We don't need to update for every event, we just need to latest one. So we will throttle the event to make it less CPU intensive. The event handler will update the editor content with the latest Firestore document content.
We will need to unsubscribe from the event when it's not needed anymore. Replace // TODO clean up snapshot listener with these:
unsubscribeOnSnapshot();
Next we will add event handler to update the Firestore document content when the editor content is changed. Replace // TODO update Firestore document content if editor content changed with these:
editor.codemirror.on('change', async () => { if (!isChangingFromSnapshot) { const newContent = editor.value(); const newDoc = { title: extractTitle(newContent), content: newContent }; await docRef.set(newDoc); } });
This will subscribe to the change event emitted from the editor. The event handler will update the Firestore document content with the editor content. Remember that we update the editor content when the Firestore document content is changed. It will trigger this event also. So we need the isChangingFromSnapshot flag to skip the event handler if it's triggered due to Firestore document content is changed.
Next we will add some helper functions to extract the title from the content. Replace // TODO helper functions with these:
function extractTitle(content: string) { const match = extractFirstLine(content)?.match(/^[^0-9a-zA-Z]*(.*)$/); return match && match[1]; } function extractFirstLine(content: string) { return content && content.split('\n') .filter(l => !l.match(/^\s+$/)) .shift(); }
This will extract the first line from the content as the title.
Now you have a note taking web application with sync capability. You can start the web application by running this command:
npm start
Next we will do Make a note taking app with Firestore - Part 2.1 Mobile application