Create a Voting App with Firestore and JavaScript UI Components
One of the oldest items in my to-do list was a simple voting sample application. I have been planning to write one for years, but never got around to it because I thought it would be hard to handle database deployment and authorizations.
I finally got around to it, thanks to Firestore, Google's new flexible, scalable NoSQL cloud database.
Firestore makes it easy to create and deploy databases, and to handle user authentication and security rules, which were my main roadblocks.
The image below shows what the Customer Voice App application looks like:
Users may browse through suggestions, search for suggestions based on topic and author, sort them by popularity or date. They may also enter new suggestions, edit or delete their own suggestions, or vote on suggestions submitted by others.
The next sections describe the application's design and implementation.
Application Goals and Design
The application should allow users to:
- See suggestions from other users
- Filter and sort the suggestions
- Vote for the suggestions they like
- Remove votes if they change their mind
- Add, edit, and remove suggestions
If we were using a traditional SQL database, we would probably have tables for suggestions, votes, and users. We would use SQL to build views with the vote counts, etc.
Firestore is a little different. It is a NoSQL database, so building views that combine several tables is not easy. But since Firebase supports storing arrays and objects into each data item, we can get away with using a single table, with documents (items) that contain these fields:
Name | Type | Description |
title | string | Suggestion title, a short string. |
description | string | Suggestion description, an optional string. |
author | string | Suggestion author's e-mail. |
votes | string[] | Array with the e-mail of users who voted for this suggestion. |
voteCount | number | Length of the votes array (used for server-side sorting). |
created | Date | Creation date. |
edited | Date | Last edit date. |
voted | Date | Last vote date. |
We chose Firestore because it provides easy set up and deployment as well as excellent performance and security. Above all, with Firestore we do not have to set up and maintain a server for storing our data.
On the client, we decided to use the wijmo.cloud Firestore and Collection classes instead of Google's Firestore client libraries. We chose the Wijmo classes because they are lightweight (much smaller and simpler to use than Google's client libraries) and because they provide better separation of concerns between the data layer and application logic.
Creating the Database
Creating the Firestore database is easy:
- Go to the Firebase console
- Click on "Add Project"
- Select the "Database" option
- Start a new "suggestions" collection
- Create a document to define the collection schema
This is what the Firebase console looks like:
As you can see, creating, inspecting, and editing the database is super-easy.
Securing the Database
And we can also use the console to set up the database security rules:
We will not get into the details of setting up and testing the security rules. Google provides extensive documentation on this topic, but my favorite is the Firestore Security Rules Cookbook, which provides lots of useful examples.
The rules we created for our voting app ensure that:
- Anyone can read
- Logged-in users can add suggestions
- Logged in users can vote for anyone's suggestions (or remove their votes)
- Authors can edit/delete their suggestions
These rules will be enforced by Firestore, on the server. That means our data is safe even if someone manages to hack into our app and try to delete other people's suggestions or add tons of votes.
Rules are written in Firestore's Security Rules language, which looks like a super-simplified version of JavaScript. Let us see what they look like:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /{document=**} {
// 1} anyone can read
allow read: if true;
// 2} logged-in users can add valid suggestions
allow create: if isLoggedIn() && validData() && oneVote();
// 3} logged-in users can vote for anyone's suggestions
allow update: if isLoggedIn() && sameData() && oneVoteDiff();
// 4) authors can edit and delete their suggestions ...
allow update: if isAuthor() && validData() && oneVoteDiff();
allow delete: if isAuthor();
Notice how condition (2) stipulates that logged in users may create valid suggestions, and condition (3) stipulates that logged-in users may update suggestions only by adding or removing one vote (they can't change the suggestion author, title, or description).
All the rules follow the same format: allow (operation): if (condition). In this case, the conditions are implemented as functions, which have access to the document's current data, to the data coming from the request, and to the current user's e-mail.
The functions are implemented as follows:
// functions
function eMail() { // get the current user's e-mail
return request.auth.token.email;
}
function isLoggedIn() { // user is logged in
return eMail() != null;
}
function isAuthor() { // user is the suggestion author
return eMail() == resource.data.author;
}
function validData() { // the incoming data is valid
let n = request.resource.data;
return n.title != null && n.author == eMail() &&
n.votes.size() == n.voteCount;
}
function sameData() { // the incoming data has no changes other than votes
let n = request.resource.data;
let o = resource.data;
return n.author == o.author && n.title == o.title &&
n.description == o.description;
}
function oneVote() { // the incoming data represents 0 or 1 votes
let cnt = request.resource.data.voteCount;
return cnt == 0 || cnt == 1;
}
function oneVoteDiff() { // incoming and current differ by 0 or 1 votes
let diff = request.resource.data.voteCount - resource.data.voteCount;
return diff >= -1 && diff <= 1;
}
The ability to secure the database by defining rules using the same UI we used to create the database is one of my favorite Firestore features.
Being able to create and test these rules right on the server allows our client-side app to talk directly to the database, without an extra server-side component. And if we wanted to apply some logic on the server side, we could do that using Cloud Functions.
Creating the Application
Now that we have a secure database in place, we can create the application that will allow users to see and edit the data.
We will create a TypeScript application using Wijmo's component library to access the data and build the UI. We could have used a framework like React, Angular, or Vue, but as you will see the app is so simple we don't really need one.
Load the Data
The application starts by loading the data from Firestore. It uses a wijmo.cloud.Collection object to load the data into a CollectionView, where it can be sorted, filtered, and grouped on the client side:
import { Firestore, Collection, OAuth2 } from '@grapecity/wijmo.cloud';
const PROJECT_ID = '***';
const API_KEY = '***';
// get the suggestions collection
let store = new Firestore(PROJECT_ID, API_KEY);
let suggestions = new Collection(store, 'suggestions', {
limit: 1000, // load up to 1,000 suggestions
collectionChanged: () => updateSuggestions();
}).orderBy('created', false); // show new suggestions first (by default);
This code will load the last 1,000 suggestions created.
When there are any changes to the data (because of sorting, filtering, adding, editing, etc.), the collectionChanged event will fire and will call our updateSugggestions method to display the suggestions to the user.
The actual application is a little smarter than this. It uses the collectionChanged event parameters to minimize the updates to the DOM. If we were using a JavaScript framework like React, Angular, or View, they would take care of this for us as well.
Display the Data
The updateSuggestions method updates the content of the "suggestions" element as follows:
// render the suggestions
function updateSuggestions() {
let host = getElement('suggestions');
host.innerHTML = '';
suggestions.items.forEach(s => {
createElement(getSuggestionHTML(s), host);
});
}
The createElement method is a utility provided by Wijmo. It creates a new DOM element based on an HTML string and adds it to a given parent element.
The getSuggestionHTML method uses JavaScript templates to build the HTML for a suggestion. The HTML includes buttons to edit and remove the suggestion if the current user is the suggestion author:
// build an HTML string to represent a suggestion
function getSuggestionHTML(s: ISuggestion): string {
// allow author to edit/delete the suggestion
let editRemove = '';
if (auth.user && s.author == auth.user.eMail) {
editRemove = `<div class="edit-remove">
<span class="glyphicon glyphicon-pencil edit"></span>
<span class="glyphicon glyphicon-remove remove"></span>
</div>`;
};
// build HTML for a suggestion
return `<div class="suggestion">
<div class="votes">
<div class="count">${s.voteCount}</div>
<div>vote${s.voteCount == 1 ? '' : 's'}</div>
</div>
<div class="vote">
<div class="vote-icon">${getVoteIcon(s)}</div>
${editRemove}
</div>
<div class="body">
<div class="title">${renderHTML(s.title)}</div>
<div class="description">${renderHTML(s.description) || ''}</div>
</div>`;
}
The template uses a renderHTML function to render the suggestion title and description. Both fields support markdown, so this is where we sanitize and replace the raw text with the html that will be shown to users. Our app uses the Remarkable package to format the markdown content:
import { Remarkable } from 'remarkable';
const markDown = new Remarkable();
function renderHTML(text: string): string {
text = text.replace(/[&<>]/g, (s) => {
switch (s) {
case '&': return '&';
case '<': return '<';
case '>': return '>';
}
});
text = markDown.render(text);
return text;
}
At this point, the application can already load and show the data. To allow users to edit the data, we must first allow them to log in so we can authenticate them.
Authenticate Users
Our app uses the Wijmo's cloud.OAuth2 class to perform user authentication.
We start by instantiating an "auth" object using our app's API key and client ID:
// create OAuth2 object for our app
const auth = new OAuth2(API_KEY, CLIENT_ID);
And we connect the "auth" object to a button that will allow users to log in and out of the app:
// button to log in/out
let oAuthBtn = getElement('auth-btn');
oAuthBtn.addEventListener('click', () => {
if (auth.user) {
auth.signOut(); // user signing in
} else {
auth.signIn(); // user signing ouy
}
});
Finally, we handle the "auth" object's userChanged event to update idToken property on our Firestore object, as well as the button caption and the suggestions on screen:
// update button/view when user logs in or out
auth.userChanged.addHandler(s => {
const user = s.user;
store.idToken = user ? s.idToken : null; // update idToken
oAuthBtn.textContent = user ? 'Sign Out' : 'Sign In';
updateSuggestions();
});
By setting the idToken property on the Firestore object, we enable Firestore authentication, and the rules we set up for our database will be enforced on the server.
Sort the Data
Our UI supports sorting the suggestions so users can look at most votes or most recent ones first. This is implemented by the two buttons highlighted below:
The buttons perform the sort on the client side, so sorts are very fast and inexpensive (no roundtrips to the server):
// sort the suggestions (client-side)
let sds = suggestions.sortDescriptions;
handleClick('btn-popular', () => { // popular, then new
sds.deferUpdate(() => {
sds.clear();
sds.push(new SortDescription('votes.length', false));
sds.push(new SortDescription('created', false));
});
});
handleClick('btn-new', () => { // new, then popular
sds.deferUpdate(() => {
sds.clear();
sds.push(new SortDescription('created', false));
sds.push(new SortDescription('votes.length', false));
});
});
Search and Filter the Data
Our UI also supports searching/filtering, so users can look for suggestions that contain specific products or features, or suggestions made by the user:
The search is also performed on the client-side. Firestore does not support full text search on the server, but even if it did, we probably would not want to use it in this case, since filtering on the client is fast and flexible.
We start by getting the filter text from the user when they type into the filter area or click the clear filter button:
// filter the suggestions (client-site)
let filter = getElement('filter') as HTMLInputElement,
filterTerms = null,
filterTo = null;
filter.addEventListener('input', () => {
if (filterTo) {
clearTimeout(filterTo);
}
filterTo = setTimeout(() => {
filterTerms = filter.value.toLowerCase().split(' ');
suggestions.refresh();
}, 300);
});
handleClick('btn-clear-filter', () => { // clear filter
if (filter.value) {
filter.value = '';
filterTerms = null;
suggestions.refresh();
}
});
This code listens for input events on a 300ms debouncing time out (so the filter is not updated too often as the user types) and refreshes the "suggestions" collection to apply the updated filter after the changes are ready.
The "suggestions" collection has a filter function defined as follows:
// filter by title/description/author
suggestions.filter = (s: ISuggestion) => {
let match = true;
if (filterTerms && filterTerms.length) {
let item = (s.title + s.description + s.author).toLowerCase();
for (let i = 0; i < filterTerms.length && match; i++) {
match = item.indexOf(filterTerms[i]) > -1;
}
}
return match;
}
The filter splits up the search string into separate terms and returns true for items that contain all terms in their "title", "description", or "author" fields.
A user called "Alex" could type "grid alex" on the search box to find all suggestions that contain the term "grid" and were suggested by users with "alex" in their e-mail address.
Create, Edit, and Delete Suggestions
At this point, the app can show, sort, and filter the suggestions. The next step is allowing users to add, edit, and remove suggestions.
Create Suggestions
Our UI has an input field and a button used to add suggestions:
User may type into the field and click the "add" button or click the button directly. This will bring up the suggestion dialog, which is implemented as a Wijmo Popup control:
handleClick('add-suggestion', () => {
if (!auth.user) {
auth.signIn();
} else {
dlgHeader.textContent = 'Add Suggestion';
dlgTitle.value = getValue('add-title');
dlgDesc.value = '';
getSuggestionDialog().show(true, (dlg: Popup) => {
if (dlg.dialogResult == dlg.dialogResultSubmit) {
let title = getValue('dlg-title'),
desc = getValue('dlg-desc');
if (title) {
let now = new Date();
suggestions.addNew({
title: title,
description: desc,
author: auth.user.eMail,
created: now,
edited: now,
voted: now,
votes: [auth.user.eMail],
voteCount: 1
}, true);
setValue('add-title', '');
}
}
});
}
});
The code starts by checking whether the user is logged in. If not, it calls the signIn method on the "oauth" object and returns immediately.
Then the code initializes the dialog, calls the show method to display it to the user, and uses a callback handler to add the new suggestion by calling the addNew method on the "suggestions" collection.
The handler uses the getValue method to perform basic validation and to remove offensive words from the text entered by the user:
function getValue(id: string): string {
let input = getElement(id) as any;
return input.validity.valid
? noBadWords(input.value.trim())
: null;
}
// https://github.com/MauriceButler/badwords
const badWords = /\b(4r5e|5h1t|5hit|a55|…)\b/gi;
function noBadWords(text: string): string {
return text
? text.replace(badWords, match => Array(match.length + 1).join('✱'))
: '';
}
Sanitizing user-entered text on the client is fine, but in real applications it would be safer to use a server function for this. On the server, this logic would be protected from hackers.
Vote, Edit and Delete Suggestions
Our UI includes an "edit area" for each suggestion. It shows the "vote" icon so any user may add or remove his vote from the suggestion. It also shows "edit" and "delete" buttons if the current user is the suggestion author:
The code that handles the three buttons starts with a block that identifies the suggestion and makes sure the user is logged in:
getElement('suggestions').addEventListener('click', (e: MouseEvent) => {
if (e.target instanceof HTMLSpanElement) {
// get the suggestion
let sHost = closest(e.target, '.suggestion') as HTMLElement;
if (sHost) {
let index = getSuggestionIndex(sHost),
s = suggestions.items[index];
// user must be logged in
if (!auth.user) {
auth.signIn();
return;
}
The next block of code handles clicks on the voting button:
// add/remove vote
if (hasClass(e.target, 'up') || hasClass(e.target, 'down')) {
suggestions.editItem(s);
let index = s.votes.indexOf(auth.user.eMail);
if (index < 0) {
s.votes.push(auth.user.eMail);
} else {
s.votes.splice(index, 1);
}
s.voteCount = s.votes.length;
s.voted = new Date();
suggestions.commitEdit();
}
The code calls the editItem method to start editing the suggestion, adds or removes the user's email address from the votes array, and calls commitEdit to update the document on the server.
The next block handles the "edit" button:
// edit suggestion
else if (hasClass(e.target, 'edit')) {
dlgHeader.textContent = 'Edit Suggestion';
dlgTitle.value = s.title;
dlgDesc.value = s.description || '';
getSuggestionDialog().show(true, (dlg: Popup) => {
if (dlg.dialogResult == dlg.dialogResultSubmit) {
let title = getValue('dlg-title'),
desc = getValue('dlg-desc');
if (title) {
suggestions.editItem(s);
s.title = title;
s.description = desc;
s.edited = new Date();
suggestions.commitEdit();
}
}
});
}
We use the same dialog that was used to add new suggestions, only with a different header.
When the user confirms the changes, we call editItem, update the item, and save the modified item by calling commitEdit as before.
The final block handles the "delete" button:
// remove suggestion
else if (hasClass(e.target, 'remove')) {
getConfirmDialog().show(true, (dlg: Popup) => {
if (dlg.dialogResult == dlg.dialogResultSubmit) {
suggestions.remove(s);
}
});
}
The code shows a dialog to confirm that the user really wants to delete the suggestion, in which case it calls the remove method on the "suggestion" collection.
After any of these operations (add, edit, remove), the collection raises the collectionChanged event and the UI is updated automatically.
Next Steps
Our sample application is ready. It is a non-trivial, data-driven, serverless, web app, implemented in about 15k of TypeScript (un-minified) code.
If you use or are planning to use Wijmo's cloud module, I hope the sample will be useful as a complement to the product documentation. If not, I hope it may spark your interest.
Like most interesting samples, this one has a lot of room to grow. Among the features I would like to add are:
- Allows users to add comments to suggestions posted by others,
- Allow users to subscribe and receive notifications when suggestion state changes,
- Add fields to assign priorities and current state to the suggestions,
- Add cloud functions to sanitize and validate the data on the server.
Conclusion
Firestore is great. It provides a way to create and use fast and secure databases easily, without setting up and maintaining servers.
Wijmo's cloud module makes Firestore it even better!
- Use the Collection class to access your data directly through the Firestore REST API, keeping your application lean and fast.
- Use the Snapshot class to access your data through Google's Firestore client library, which provides advanced features like client-side caching (useful in disconnected scenarios) and real time notifications (important for some applications).
- Whether using the Collection or Snapshot classes, you get all the functionality available in Wijmo's CollectionView class, including advanced client-side sorting, filtering, grouping, and editing support.
- Use the OAuth2 class to implement user authorization quickly, easily, and securely.
I hope you like our voting sample. If you'd like to try Wijmo's component library, you can download it here. If you have any questions or suggestions, feel free to send us your feedback. Or add your suggestion directly to our Customer Voice App!