Code Notes by James Priest
<– back to Restaurant Review Code Notes homepage
The nanodegree only had three stages to complete in order to meet all requirements.
I’m adding a fourth stage which will include necessary changes to properly host this app as part of my online portfolio.
In order to do this there are a few things that need to happen. I need to:
Items one and two really fall under the realm of back end services and back end programming but I figured this was a good full-stack exercise.
Requirements so far:
dbhelper.js
to use new DB sourcereviews
datareviews
dataWhat I need in a nutshell is a NoSQL DB that exposes a set of RESTful APIs for CRUD (Create, Read, Update, & Delete) operations.
The first thing I did was look into some NoSQL solutions. What I focused on was
There were many, many others but this was a good start.
While PouchDB sounds great for my next offline project, it requires me to integrate and code against the PouchDB js library in my client app.
Maybe that’ll be a stage 5 rollout but for now I’d like to refactor my client code as little as possible.
So what I found was great in concept but these are server-based solutions that still needed to be hosted somewhere.
I did not want to take on the cost of spinning up a VM in order to host this so I decided to look into Database-as-a-Service (DBaaS) hosted solutions.
Let me first define two ends of a broad spectrum with regards to ways of providing a software solution.
Prior to cloud computing, these were the two primary ways to provide software solutions to your clients.
Now we have a broad range of services that fall somewhere on the spectrum between these two extremes.
Figure 1: On-Premises, Iaas, Paas, Saas
Starting with the lowest level of service and abstraction…
Seeing as I only need a DB solution that exposes a REST API, this narrowed my search.
Still, there are many offered solutions though, including:
See NoSQL Hosted examples on Wikipedia.
These all offer pay as you go service models and require additional setup for REST API access. These are definitely larger scale options than I wanted to invest in.
I looked into FaunaDB which was recommended by Netlify.
Again, I wanted a simple, no-cost solution that offers both NoSQL DB storage and REST API access out-of-the-box if possible.
Here’s what I finally decided on:
Perfect! Looks like restdb.io fits the bill.
Right from the homepage it became clear that this product had the features I needed and was straight-forward enough for me to get up and running quickly.
restdb.io is probably the easiest online NoSQL database backend for web and serverless applications.
- Simple to use
- model your information quickly.
- add data with the data management application
- schema and REST API are instantly available.
- Developer friendly
- online database tool is designed for the modern web
- high performance REST API
- NoSQL MongoDB query language
- server-side codehooks
- secure Ajax/CORS.
This Database-as-a-Service (DBaaS) offering was great in that it simply offered the DB back end along with a management panel and REST API without forcing me to worry about:
It stripped away all of the unnecessary stuff that many of the big cloud companies require you to manage. It just offers some basic, simple services with straight-forward pricing.
The other great thing is that I can start with a Development account for free. This allows me to do the following
The last thing that made using this service a joy is that the management panel UI is intuitive and the documentation is simple and easy to follow.
The first thing to do was create the DB.
Once the database is created we can Add a Collection. A collection is the equivalent of a table or spreadsheet.
We name the collection, give it a description, and choose an icon.
Next we’re taken to the collection detail page where we can define the fields of that collection.
Here I defined my restaurant fields.
From here we can add fields
This is were we can define the field type and apply any other constraints we’d like.
Here are the fields I created for my restaurants
collection
One thing to note is the last field is a parent-child relation that I defined between restaurants and reviews. It allows a resturant to have many review child records associated.
Here’s the schema I created for the reviews
collections.
Next I added the data for each restaurant.
Figure 11: Add Restaurant Data
Once I was done the collection looked like this.
The next thing I did was add reviews records
Once the Reviews collection was complete it looked like this.
Since we defined that relationship in the Restaurant schema we can now view and add review records while browsing restaurants.
Figure 15: Parent-Child Relation
What we need to do is identify each API endpoint being used and figure out what the equivalent call would be to our hosted DB solution.
First thing is to identify the endpoints currently being used to connect to the Stage-3 localhost server.
http://localhost:1337/restaurants/
http://localhost:1337/reviews/?restaurant_id=<restaurant_id>
http://localhost:1337/reviews/
{
"restaurant_id": <restaurant_id>,
"name": <reviewer_name>,
"rating": <rating>,
"comments": <comment_text>
}
http://localhost:1337/restaurants/<restaurant_id>/?is_favorite=true
http://localhost:1337/restaurants/<restaurant_id>/?is_favorite=false
This is where using an app like Postman really benefits and streamlines development.
The advantage of an app like this is
The first thing I did was create a collection to group all my old API requests made to the stage-3 localhost DB server.
Figure 16: Postman Old API Collection
The next step in the process was to create the equivalent REST API call to my new data source and ensure it worked properly.
Figure 17: Postman New API Collection
Now that I’ve created and tested each of the necessary HTTP requests in Postman, I can list them out here.
All endpoints require the following headers. These headers specify the type of content we’re sending and provide an api key for secure access.
<CORS API key>
Here are the updated endpoints.
https://restaurantdb-ae6c.restdb.io/rest/restaurants
https://restaurantdb-ae6c.restdb.io/rest/reviews?
q={"restaurant_id": 1}
https://restaurantdb-ae6c.restdb.io/rest/reviews
{
"restaurant_id": <restaurant_id>,
"name": <reviewer_name>,
"rating": <rating>,
"comments": <comment_text>
}
https://restaurantdb-ae6c.restdb.io/rest/restaurants/<_id>
{
"is_favorite": true
}
https://restaurantdb-ae6c.restdb.io/rest/restaurants/<_id>
{
"is_favorite": false
}
Next we look at making any updates necessary to connect to our new data source.
The first change we need to make is to our Service Worker. Right now I’m intercepting all Ajax call so I can return a cache value if we have it.
This needs to be updated to filter any requests to the new SB source.
self.addEventListener('fetch', event => {
const request = event.request;
const requestUrl = new URL(request.url);
// 1. filter Ajax Requests
// if (requestUrl.port === '1337') { // <- old
if (requestUrl.host.includes('restaurantdb-ae6c.restdb.io')) { // <- new
// more code...
}
});
The next change is to the database connection string.
static get DATABASE_URL() {
// const port = 1337; // Change this to your server port // <- old
// return `http://localhost:${port}`; // <- old
return 'https://restaurantdb-ae6c.restdb.io/rest'; // <- new
}
We also need to create a new static method that will append a set of required headers to each and every request.
static get DB_HEADERS() {
return {
'x-apikey': '<CORS API key>',
'Content-Type': 'application/json',
'Cache-Control': 'no-cache'
};
}
Now that we have the DB host URL and headers updated it’s time to update our first endpoint.
This is responsible for pulling down the list of restaurants from which the index page is built.
static fetchRestaurants(callback) {
fetch(DBHelper.DATABASE_URL + '/restaurants', { // <- new
headers: DBHelper.DB_HEADERS // <- new
}) // <- new
.then(response => {
if (!response.ok) {
throw Error(`Request failed. Returned status: ${response.statusText}`);
}
const restaurants = response.json();
return restaurants;
})
.then(restaurants => callback(null, restaurants))
.catch(err => callback(err, null));
}
The code also kicks off the process of writing each record to our local IDB restaurants
object store.
The next thing we want to update is the fetchRestaurantReviewsById
method.
This gets invoked on each restaurant’s detail page and pulls down just that restaurant’s reviews.
static fetchRestaurantReviewsById(id, callback) {
const url = `${DBHelper.DATABASE_URL}/reviews?q={"restaurant_id": ${id}}`;
fetch(url, {
headers: DBHelper.DB_HEADERS
})
.then(response => response.json())
.then(data => callback(null, data))
.catch(err => callback(err, null));
}
The url
has been updated and fetch now includes the proper headers.
The next thing to do is update the Service Worker to properly grab restaurant_id
from the QueryString fetch request to the database.
The fetch URL is different now so we need to modify the code below. Once we have the restaurant_id
we can call idbReviewResponse
to query & return data from our local IDB so we don’t make unnecessary fetch requests.
self.addEventListener('fetch', event => {
const request = event.request;
const requestUrl = new URL(request.url);
// 1. filter Ajax Requests
if (requestUrl.host.includes('restaurantdb-ae6c.restdb.io')) {
// 2. Only cache GET methods
if (event.request.method !== 'GET') {
console.log('filtering out non-GET method');
return;
}
console.log('fetch intercept', ++i, requestUrl.href);
if (request.url.includes('reviews')) {
const qObj = JSON.parse(requestUrl.searchParams.get('q')); // <- new
const id = +qObj.restaurant_id; // <- new
event.respondWith(idbReviewResponse(request, id));
} else {
event.respondWith(idbRestaurantResponse(request));
}
}
// more code...
});
Next I navigate to one of the restaurant detail pages to test that the data is coming through properly.
I see the page displays restaurant reviews and I see review records in the reviews store under IndexedDB.
The first thing I did was to create the button code in my detail page.
const createReviewHTML = (review, i) => {
const li = document.createElement('li');
const ctrlDiv = document.createElement('div');
ctrlDiv.classList.add('ctrl-div');
const editBtn = document.createElement('button');
editBtn.id = 'review-edit-btn' + i;
editBtn.classList.add('review_btn');
editBtn.classList.add('review-edit-btn');
editBtn.dataset.reviewId = review._id;
editBtn.innerHTML = 'Edit';
editBtn.setAttribute('aria-label', 'edit review');
editBtn.title = 'Edit Review';
editBtn.addEventListener('click', (e) => editRecord(e, review));
ctrlDiv.appendChild(editBtn);
const delBtn = document.createElement('button');
delBtn.id = 'review-del-btn' + i;
delBtn.classList.add('review_btn');
delBtn.classList.add('review-del-btn');
delBtn.dataset.reviewId = review._id;
delBtn.dataset.restaurantId = review._parent_id;
delBtn.dataset.reviewName = review.name;
delBtn.innerHTML = 'x';
delBtn.setAttribute('aria-label', 'delete review');
delBtn.title = 'Delete Review';
delBtn.addEventListener('click', delRecord);
ctrlDiv.appendChild(delBtn);
li.appendChild(ctrlDiv);
// more code...
}
This creates both buttons and assigns the record’s review.id
to the button’s dataset so it can be retrieved in the click handler.
This is what the stub handlers look like.
const editRecord = (e, review) => {
const review_id = e.target.dataset.reviewId;
console.log(review_id);
console.log(review);
};
const delRecord = (e) => {
const review_id = e.target.dataset.reviewId;
console.log(review_id);
};
The CSS changes involve styling the various buttons.
.ctrl-div {
display: flex;
flex-direction: row;
justify-content: space-between;
border-bottom: 1px solid #ccc;
padding-bottom: 10px;
margin-bottom: 10px;
}
.review_btn {
padding: 0;
min-height: 40px;
cursor: pointer;
}
#review-edit-btn, #review-del-btn {
font-weight: bold;
}
#review-add-btn {
font-size: 1.6em;
}
#review-edit-btn {
font-size: 0.825em;
}
Here are the screenshots of the new set of buttons.
Figure 20: Button Screenshot 1
Figure 21: Button Screenshot 2
Figure 22: Button Screenshot 3
Some of the fields I manually created ended up being unnecessary. These are fields like primary keys, date created/updated, and foreign key relationships that restdb.io already handles internally. They are known as system fields and are usually preceded by an underscore.
Since this was the case, it was duplicate work on my part to track this information manually.
So the first step was to do away with the following duplicate fields.
This left me with a streamlined restaurants table.
I then got rid of the extra reviews table fields as well.
This left me with a reduced reviews table.
The database uses system fields in order to track primary/foreign key relationships as well as date create, date updated, and other fields.
These fields start with an underscore.
Here’s a restaurants record.
Next is a screenshot of a review record with the system fields exposed.
The next set of changes are to the code in order to reflect the new field names.
First we start with updates to the IDB store.
This is were the IDB data store is defined. It has to be updated in order to reflect the new keys. id
becomes _id
and restaurant_id
becomes _parent_id
.
const dbPromise = idb.open('udacity-restaurant-db', 3, upgradeDB => {
switch (upgradeDB.oldVersion) {
case 0:
// upgradeDB.createObjectStore('restaurants',
// { keyPath: 'id', unique: true }); <- old
upgradeDB.createObjectStore('restaurants',
{ keyPath: '_id', unique: true }); // <- new
case 1:
const reviewStore = upgradeDB.createObjectStore('reviews',
{ autoIncrement: true });
// reviewStore.createIndex('restaurant_id', 'restaurant_id'); <- old
reviewStore.createIndex('restaurant_id', '_parent_id'); // <- new
case 2:
upgradeDB.createObjectStore('offline', { autoIncrement: true });
}
});
self.dbPromise = dbPromise;
These changes now produce the following in the browser’s IDB database.
restaurant_id
field now becomes _parent_id
.
self.addEventListener('fetch', event => {
const request = event.request;
const requestUrl = new URL(request.url);
// 1. filter Ajax Requests
if (requestUrl.host.includes('restaurantdb-ae6c.restdb.io')) {
// 2. Only cache GET methods
if (event.request.method !== 'GET') {
console.log('filtering out non-GET method');
return;
}
console.log('fetch intercept', ++i, requestUrl.href);
if (request.url.includes('reviews')) {
const qObj = JSON.parse(requestUrl.searchParams.get('q')); //<- here
const id = qObj._parent_id; //<- here
event.respondWith(idbReviewResponse(request, id));
} else {
event.respondWith(idbRestaurantResponse(request));
}
// other code
}
};
All instances of restaurant.id
in code become restaurant._id
.
const fillRestaurantHTML = (restaurant = self.restaurant) => {
// other code...
// fill reviews
DBHelper.fetchRestaurantReviewsById(restaurant._id, fillReviewsHTML); // new
};
const createReviewHTML = (review) => {
// other code...
editBtn.dataset.reviewId = review._id; // <- new
// other code...
const createdDate = review._created ?
new Date(review._created).toLocaleDateString() : // <- new
'Pending';
// other code...
const updatedDate = review._changed ? // <- new
new Date(review._changed).toLocaleDateString() :
'Pending';
// other code...
}
This file is responsible for each of the fetch requests. Most of the changes occur in url
.
static fetchRestaurants(callback) {
fetch(DBHelper.DATABASE_URL + '/restaurants?metafields=true', { // <- new
headers: DBHelper.DB_HEADERS
})
.then(response => {
if (!response.ok) {
throw Error(`Request failed. Returned status: ${response.statusText}`);
}
const restaurants = response.json();
return restaurants;
})
.then(restaurants => callback(null, restaurants))
.catch(err => callback(err, null));
}
static fetchRestaurantReviewsById(restaurant_id, callback) {
const url = `${DBHelper.DATABASE_URL}/reviews?
q={"_parent_id":"${restaurant_id}"}&metafields=true`; // <- new
fetch(url, {
headers: DBHelper.DB_HEADERS
})
.then(response => response.json())
.then(data => callback(null, data))
.catch(err => callback(err, null));
}
static fetchRestaurantById(id, callback) {
// fetch all restaurants with proper error handling.
DBHelper.fetchRestaurants((error, restaurants) => {
if (error) {
callback(error, null);
} else {
const restaurant = restaurants.find(r => r._id == id); // <- new
if (restaurant) { // Got the restaurant
callback(null, restaurant);
} else { // Restaurant does not exist in the database
callback('Restaurant does not exist', null);
}
}
});
}
static urlForRestaurant(restaurant) {
return (`./restaurant.html?id=${restaurant._id}`); // <- new
}
static imageUrlForRestaurant(restaurant) {
return (`/img/${restaurant.photograph}-300.jpg`); // <- new
}
static imageSrcsetForIndex(restaurant) {
return (`/img/${restaurant.photograph}-300.jpg 1x,
/img/${restaurant.photograph}-600_2x.jpg 2x`); // <- new
}
static imageSrcsetForRestaurant(restaurant) {
return (`/img/${restaurant.photograph}-300.jpg 300w,
/img/${restaurant.photograph}-400.jpg 400w,
/img/${restaurant.photograph}-600_2x.jpg 600w,
/img/${restaurant.photograph}-800_2x.jpg 800w`); // <- new
}
The DB call to update the favorite status uses a PATCH
method.
The PATCH
method is used to update a single field within a record.
static toggleFavorite(restaurant, callback) {
const is_favorite = JSON.parse(restaurant.is_favorite);
const id = +restaurant.id;
const db_id = restaurant._id;
restaurant.is_favorite = !is_favorite;
const url = `${DBHelper.DATABASE_URL}/restaurants/${db_id}`;
const method = 'PATCH';
const headers = DBHelper.DB_HEADERS;
const body = JSON.stringify({ "is_favorite": !is_favorite });
fetch(url, {
headers: headers,
method: method,
body: body
})
.then(response => response.json())
.then(data => callback(null, data))
.catch(err => {
// We are offline
// Update restaurant record in local IDB
DBHelper.updateIDBRestaurant(restaurant)
.then(() => {
// add to queue...
console.log('Add favorite request to queue');
console.log(`DBHelper.addRequestToQueue(${url}, ${headers},
${method}, ${body})`);
DBHelper.addRequestToQueue(url, headers, method, body)
.then(offline_key => console.log('offline_key', offline_key));
});
callback(err, null);
});
}
This now allows us to toggle the favorite button from both the main page and the detail page.
This piece of functionality was working with the old data source. So, what we need to do is update the Fetch call.
static createRestaurantReview(restaurant_id, name, rating, comments, callback) {
const url = `${DBHelper.DATABASE_URL}/restaurants/${restaurant_id}/reviews`;
const method = 'POST'; // ^ new ^
const headers = DBHelper.DB_HEADERS; // <- new
const data = {
name: name,
rating: +rating,
comments: comments
};
const body = JSON.stringify(data);
fetch(url, {
headers: headers, // <- new
method: method,
body: body
})
.then(response => response.json())
.then(data => callback(null, data))
.catch(err => {
// We are offline...
// Save review to local IDB
data._parent_id = restaurant_id; // Add this to provide IDB foreign key
DBHelper.createIDBReview(data) // ^ new ^
.then(review_key => {
// Get review_key and save it with review to offline queue
console.log('returned review_key', review_key);
DBHelper.addRequestToQueue(url, headers, method, body, review_key)
.then(offline_key => console.log('offline_key', offline_key));
});
callback(err, null);
});
}
The other thing we need to do is make a small update to the calling function.
const saveAddReview = (e) => {
e.preventDefault();
const form = e.target;
if (form.checkValidity()) {
console.log('is valid');
const restaurant_id = self.restaurant._id; // <- here
The next step was to test that everything posted properly.
There are two changes that need to happen in order for POSTS to work properly.
static processQueue() {
// Open offline queue & return cursor
dbPromise.then(db => {
if (!db) return;
const tx = db.transaction(['offline'], 'readwrite');
const store = tx.objectStore('offline');
return store.openCursor();
})
.then(function nextRequest (cursor) {
// console.log('cursor', cursor.value.data.name, cursor.value.data);
console.log('cursor.value', cursor.value);
const offline_key = cursor.key;
const url = cursor.value.url;
const headers = cursor.value.headers;
const method = cursor.value.method;
const data = cursor.value.data;
const review_key = cursor.value.review_key;
// const body = data ? JSON.stringify(data) : '';
const body = data;
// more code...
The first thing I did was create the HTML & CSS.
<div id="confirm_delete_modal" class="modal" role="dialog" aria-modal="true"
aria-labelledby="confirm-delete-header">
<button class="close-btn" aria-label="close" title="Close">x</button>
<div class="modal_form_container">
<h2 id="confirm-delete-header">Confirm Delete</h2>
<div id="confirm_form">
<p>Are you sure you wish to delete the review by
<b id="review_name"></b>?</p>
<div class="confirm-buttons">
<div class="fieldset">
<button id="cancel_btn">Cancel</button>
</div>
<div class="fieldset right">
<button id="delete_confirm_btn">Delete</button>
</div>
</div>
</div>
</div>
</div>
Most of the modal css was already written for the review input form but I needed re-use this css for my delete review modal.
To do this I needed to turn some existing id’s into classes. The style rules remained the same.
#modal
-> .modal
#modal.show
-> .show
#modal .close-btn
-> .modal > .close-btn
#review_form_container
-> .modal_form_container
#review_form_container h2
-> .modal_form_container h2
Next I created the minimal style for the confirm modal.
#confirm_form {
width: 300px;
}
.confirm-buttons {
display: flex;
justify-content: space-between;
}
Next I needed to take the code written for the “Add new review” pop-up (modal) and make it available for the “Confirm delete” modal.
There were a few functions that got renamed and refactored.
openModal()
-> wireUpModal()
closeModal()
-> closeAddReviewModal()
I added the following.
openAddReviewModal()
openConfirmDeleteModal()
closeConfirmDeleteModal()
This is the open modal code for both forms.
const openAddReviewModal = () => {
const modal = document.getElementById('add_review_modal');
wireUpModal(modal, closeAddReviewModal);
// submit form
const form = document.getElementById('review_form');
form.addEventListener('submit', addReview, false);
};
const openConfirmDeleteModal = (e) => {
const modal = document.getElementById('confirm_delete_modal');
wireUpModal(modal, closeConfirmDeleteModal);
const nameContainer = document.getElementById('review_name');
nameContainer.textContent = e.target.dataset.reviewName;
const cancelBtn = document.getElementById('cancel_btn');
cancelBtn.onclick = closeConfirmDeleteModal;
const delConfirmBtn = document.getElementById('delete_confirm_btn');
delConfirmBtn.dataset.reviewId = e.target.dataset.reviewId;
delConfirmBtn.dataset.restaurantId = e.target.dataset.restaurantId;
delConfirmBtn.onclick = delReview;
};
Here’s the close modal code for both forms.
const closeConfirmDeleteModal = () => {
const modal = document.getElementById('confirm_delete_modal');
// Hide the modal and overlay
modal.classList.remove('show');
modalOverlay.classList.remove('show');
// Set focus back to element that had it before the modal was opened
focusedElementBeforeModal.focus();
};
const closeAddReviewModal = () => {
const modal = document.getElementById('add_review_modal');
// Hide the modal and overlay
modal.classList.remove('show');
modalOverlay.classList.remove('show');
const form = document.getElementById('review_form');
form.reset();
// Set focus back to element that had it before the modal was opened
focusedElementBeforeModal.focus();
};
This is the new wireUpModal
method to create an ARIA compliant and keyboard navigatable modal dialog.
const wireUpModal = (modal, closeModal) => {
// Save current focus
focusedElementBeforeModal = document.activeElement;
// Listen for and trap the keyboard
modal.addEventListener('keydown', trapTabKey);
// Listen for indicators to close the modal
modalOverlay.addEventListener('click', closeModal);
// Close btn
let closeBtns = document.querySelectorAll('.close-btn');
// closeBtn.addEventListener('click', closeModal);
closeBtns = Array.prototype.slice.call(closeBtns);
closeBtns.forEach(btn => btn.addEventListener('click', closeModal));
// Find all focusable children
var focusableElementsString = 'a[href], area[href], input:not([disabled]),' +
'select:not([disabled]), textarea:not([disabled]), button:not([disabled]),' +
'iframe, object, embed, [tabindex="0"], [contenteditable]';
var focusableElements = modal.querySelectorAll(focusableElementsString);
// Convert NodeList to Array
focusableElements = Array.prototype.slice.call(focusableElements);
var firstTabStop = focusableElements[0];
var lastTabStop = focusableElements[focusableElements.length - 1];
// Show the modal and overlay
modal.classList.add('show');
modalOverlay.classList.add('show');
// Focus second child
setTimeout(() => {
firstTabStop.focus();
focusableElements[1].focus();
}, 200);
function trapTabKey(e) {
// Check for TAB key press
if (e.keyCode === 9) {
// SHIFT + TAB
if (e.shiftKey) {
if (document.activeElement === firstTabStop) {
e.preventDefault();
lastTabStop.focus();
}
// TAB
} else {
if (document.activeElement === lastTabStop) {
e.preventDefault();
firstTabStop.focus();
}
}
}
// ESCAPE
if (e.keyCode === 27) {
closeModal();
}
}
};
Next is the code that actually deletes the review.
const delReview = (e) => {
const review_id = e.target.dataset.reviewId;
const restaurant_id = e.target.dataset.restaurantId;
console.log(review_id);
DBHelper.deleteRestaurantReview(review_id, restaurant_id, (error, result) => {
console.log('got delete callback');
if (error) {
showOffline();
} else {
console.log(result);
DBHelper.delIDBReview(review_id, restaurant_id);
}
// update idb
idbKeyVal.getAllIdx('reviews', 'restaurant_id', restaurant_id)
.then(reviews => {
// console.log(reviews);
fillReviewsHTML(null, reviews);
closeConfirmDeleteModal();
document.getElementById('review-add-btn').focus();
});
});
};
static deleteRestaurantReview(review_id, restaurant_id, callback) {
const url = `https://restaurantdb-ae6c.restdb.io/rest/reviews/${review_id}`;
const method = 'DELETE';
const headers = DBHelper.DB_HEADERS;
fetch(url, {
headers: headers,
method: method
})
.then(response => response.json())
.then(data => callback(null, data))
.catch(err => {
// We are offline...
// Delete from local IDB
console.log('what err:', err);
DBHelper.delIDBReview(review_id, restaurant_id)
.then(() => {
// add request to queue
console.log('Add delete review request to queue');
console.log(`DBHelper.addRequestToQueue(${url}, ${headers},
${method}, '')`);
DBHelper.addRequestToQueue(url, headers, method)
.then(offline_key => console.log('offline_key', offline_key));
// console.log('implement offline for delete review');
});
callback(err, null);
});
}
static delIDBReview(review_id, restaurant_id) {
return idbKeyVal.openCursorIdxByKey('reviews', 'restaurant_id', restaurant_id)
.then(function nextCursor(cursor) {
if (!cursor) return;
console.log(cursor.value.name);
if (cursor.value._id === review_id) {
console.log('we matched');
cursor.delete();
return;
}
return cursor.continue().then(nextCursor);
});
}
openCursorIdxByKey(store, idx, key) {
return dbPromise.then(db => {
return db.transaction(store, 'readwrite')
.objectStore(store)
.index(idx)
.openCursor(key);
});
}
Here’s a screenshot of the confirm dialog.
It works online and offline.
If the app is offline the request is saved locally and sent once the connection is re-established. Any changes are also reflected locally immediately.
The record appears in the local IDB data store.
Here’s the Console output confirming the operation.
Figure 35: Offline Confirmation
Once we go online again we have the Console output confirm the delete operation.
Figure 36: Offline Confirmation
The first thing is to attach a new event handler to the edit button.
const createReviewHTML = (review, i) => {
const li = document.createElement('li');
const ctrlDiv = document.createElement('div');
ctrlDiv.classList.add('ctrl-div');
const editBtn = document.createElement('button');
editBtn.id = 'review-edit-btn' + i;
editBtn.classList.add('review_btn');
editBtn.classList.add('review-edit-btn');
editBtn.dataset.reviewId = review._id;
editBtn.innerHTML = 'Edit';
editBtn.setAttribute('aria-label', 'edit review');
editBtn.title = 'Edit Review';
editBtn.addEventListener('click', // <- here
(e) => openEditReviewModal(e, review)); // <- here
ctrlDiv.appendChild(editBtn);
// more code...
Event handler we just wired up will open the input form with the fields pre-populated.
const openEditReviewModal = (e, review) => {
const modal = document.getElementById('add_review_modal');
wireUpModal(modal, closeAddReviewModal);
document.getElementById('add-review-header').innerText = 'Edit Review';
document.querySelector('#reviewName').value = review.name;
switch (review.rating) {
case 1:
document.getElementById('star1').checked = true;
break;
case 2:
document.getElementById('star2').checked = true;
break;
case 3:
document.getElementById('star3').checked = true;
break;
case 4:
document.getElementById('star4').checked = true;
break;
case 5:
document.getElementById('star5').checked = true;
break;
}
document.querySelector('#reviewComments').value = review.comments;
// submit form
const form = document.getElementById('review_form');
const review_id = e.target.dataset.reviewId;
form.dataset.reviewId = review_id;
form.addEventListener('submit', editReview, false);
};
This also wires up the submit button to the editReview
handler.
The handler makes sure the form is valid and then attempts to save to the database.
const editReview = (e) => {
e.preventDefault();
const form = e.target;
const review_id = e.target.dataset.reviewId;
if (form.checkValidity()) {
const restaurant_id = self.restaurant._id;
const name = document.querySelector('#reviewName').value;
const rating = document.querySelector('input[name=rate]:checked').value;
const comments = document.querySelector('#reviewComments').value;
// attempt save to database server
DBHelper.updateRestaurantReview(review_id, restaurant_id, name, rating,
comments, (error, review) => {
console.log('got update callback');
form.reset();
if (error) {
console.log('We are offline. Review has been saved to the queue.');
showOffline();
} else {
console.log('Received updated record from DB Server', review);
DBHelper.updateIDBReview(review_id, restaurant_id, review);
}
idbKeyVal.getAllIdx('reviews', 'restaurant_id', restaurant_id)
.then(reviews => {
fillReviewsHTML(null, reviews);
closeEditReviewModal();
});
});
}
};
This method formats the data and makes the fetch request to update the record. If the request fails it save the request to the queue.
static updateRestaurantReview(review_id, restaurant_id, name, rating,
comments, callback) {
const url = `${DBHelper.DATABASE_URL}/reviews/${review_id}`;
const method = 'PUT';
const headers = DBHelper.DB_HEADERS;
const data = {
name: name,
rating: +rating,
comments: comments
};
const body = JSON.stringify(data);
fetch(url, {
headers: headers,
method: method,
body: body
})
.then(response => response.json())
.then(data => callback(null, data))
.catch(err => {
// We are offline...
// Save review to local IDB
data._id = review_id;
data._parent_id = restaurant_id; // Add this to provide IDB foreign key
// create review object (since it's not coming back from DB)
const nowDate = new Date();
const review = {
name: name,
rating: +rating,
comments: comments,
_changed: nowDate.toISOString()
};
DBHelper.updateIDBReview(review_id, restaurant_id, review)
.then((review_key) => {
// Get review_key and save it with review to offline queue
console.log('Add update review to queue');
DBHelper.addRequestToQueue(url, headers, method, body, review_key)
.then(offline_key => console.log('returned offline_key', offline_key));
});
callback(err, null);
});
}
The updateIDBReview
method loops through the local IDB reviews until it finds the record we’re updating. It then updates the fields and calls cursor.update
.
static updateIDBReview(review_id, restaurant_id, review) {
return idbKeyVal.openCursorIdxByKey('reviews', 'restaurant_id', restaurant_id)
.then(function nextCursor(cursor) {
if (!cursor) return;
var updateData = cursor.value;
if (cursor.value._id === review_id) {
console.log('we matched');
updateData.name = review.name;
updateData.rating = review.rating;
updateData.comments = review.comments;
updateData._changed = review._changed;
cursor.update(updateData);
console.log('heres the primary key:', cursor.primaryKey);
return cursor.primaryKey;
}
return cursor.continue().then(nextCursor);
});
}
The last step is to close the modal and give focus back to the last element that had focus.
const closeEditReviewModal = () => {
const modal = document.getElementById('add_review_modal');
// Hide the modal and overlay
modal.classList.remove('show');
modalOverlay.classList.remove('show');
const form = document.getElementById('review_form');
form.reset();
delete form.dataset.reviewId;
form.removeEventListener('submit', editReview, false);
// Set focus back to element that had it before the modal was opened
focusedElementBeforeModal.focus();
};
Here’s what the dialog looks like now that it’s hooked up.
Figure 37: Edit Record Buttons
I’ve updated the display of the star rating so that it is actually displays the number of stars rather than printing out the an integer.
const createReviewHTML = (review, i) => {
const li = document.createElement('li');
const ctrlDiv = document.createElement('div');
ctrlDiv.classList.add('ctrl-div');
const editBtn = document.createElement('button');
editBtn.id = 'review-edit-btn' + i;
editBtn.classList.add('review_btn');
editBtn.classList.add('review-edit-btn');
editBtn.dataset.reviewId = review._id;
editBtn.innerHTML = 'Edit';
editBtn.setAttribute('aria-label', 'edit review');
editBtn.title = 'Edit Review';
editBtn.addEventListener('click', (e) => openEditReviewModal(e, review));
ctrlDiv.appendChild(editBtn);
const rating = document.createElement('div');
rating.classList.add('static-rate');
const star1 = document.createElement('label');
const star2 = document.createElement('label');
const star3 = document.createElement('label');
const star4 = document.createElement('label');
const star5 = document.createElement('label');
star1.textContent = 'star1';
star2.textContent = 'star2';
star3.textContent = 'star3';
star4.textContent = 'star4';
star5.textContent = 'star5';
switch (true) {
case (review.rating > 4):
star5.classList.add('gold');
case (review.rating > 3):
star4.classList.add('gold');
case (review.rating > 2):
star3.classList.add('gold');
case (review.rating > 1):
star2.classList.add('gold');
case (review.rating > 0):
star1.classList.add('gold');
}
rating.appendChild(star1);
rating.appendChild(star2);
rating.appendChild(star3);
rating.appendChild(star4);
rating.appendChild(star5);
rating.dataset.rating = review.rating;
ctrlDiv.appendChild(rating);
const delBtn = document.createElement('button');
delBtn.id = 'review-del-btn' + i;
delBtn.classList.add('review_btn');
delBtn.classList.add('review-del-btn');
delBtn.dataset.reviewId = review._id;
delBtn.dataset.restaurantId = review._parent_id;
delBtn.dataset.reviewName = review.name;
delBtn.innerHTML = 'x';
delBtn.setAttribute('aria-label', 'delete review');
delBtn.title = 'Delete Review';
delBtn.addEventListener('click', openConfirmDeleteModal);
ctrlDiv.appendChild(delBtn);
li.appendChild(ctrlDiv);
// ... more code
The display now looks like this.
The date displays an updated date when the record has been edited.
const createReviewHTML = (review, i) => {
const li = document.createElement('li');
const ctrlDiv = document.createElement('div');
ctrlDiv.classList.add('ctrl-div');
// more code...
const createdAt = document.createElement('p');
createdAt.classList.add('createdAt');
const createdDate = review._created ?
new Date(review._created).toLocaleDateString() :
'Pending';
createdAt.innerHTML = `<strong>${createdDate}</strong>`;
li.appendChild(createdAt);
if (review._changed > review._created) {
const updatedAt = document.createElement('p');
const updatedDate = review._changed ?
new Date(review._changed).toLocaleTimeString('en-US', {
hour: 'numeric',
minute:'numeric' }) + ', ' +
new Date(review._changed).toLocaleDateString({
month: '2-digit',
day: '2-digit',
year: 'numeric' }) :
'Pending';
updatedAt.innerHTML = `Last updated: <span>${updatedDate}</span>`;
updatedAt.classList.add('updatedAt');
li.appendChild(updatedAt);
}
The UI now shows a cleaner date and time output.
I had to adjust the input forms to accommodate on-screen keyboards.
/* adjust for device onscreen keyboards */
@media screen and (max-height: 540px) {
.modal {
top: 10px;
transform: translate(-50%, 0);
}
.modal_form_container {
overflow-y: scroll;
height: 230px;
}
#confirm_delete_modal {
top: 1%;
}
}
@media screen and (max-height: 300px) {
.modal_form_container {
height: 180px;
}
}
We can see how this translates in the next set of screenshots.
Figure 42: Portrait Form with Keyboard
Figure 44: Landscape Form with Keyboard
The first thing I did was click the New site from git button. This allowed me to link to my GitHub account and choose a repo I wanted to deploy from.
Once configured the Site details tab I was ready to move on to the Deploy settings tab.
Here I entered specified the Build command, Publish directory, and Production branch settings.
Once that was done I manually deployed the site with Deploy button.
It quickly failed but it did show what the issue was.
The first thing I needed to do was update my NPM packages to fix any security vulnerabilities.
I did this with the following command.
npm audit fix
This updated my old compromised packages and updated my package.json
and package-lock.json
manifests as well.
I committed my changes and because I had continuous deploy set up, Netlify automatically built the site once more, incorporating my changes.
This time the security vulnerabilities were gone but I still got the same error.
After doing some research I found out this package was pulled due to a security violation but I had packages that depended on the bad version of the library.
As it turns out, all versions of event-stream > 3.3.4
are subject to this vulnerability.
Figure 49: Package vulnerability message on GitHub
What I did next was install a downgraded version of this package as a dev dependency with the following command.
npm install --save-dev event-stream@3.3.4
This now updated my package.json
with the following.
The next log showed me that the build was able to install all dependencies.
Figure 51: Dependencies Installed!
The only problem is that we still had an error.
This was because I had depended on a file I created at the root to hold my Google Maps API key. I specified this file in .gitignore
so my API key wouldn’t be copied to GitHub.
The only problem is that Netlify didn’t have access to this either.
I needed to rely on convention and use a .env
file to hold my API keys.
The file looks like this in VS code.
This would allow me to set the Build environment variables option in Netlify to hold my API keys.
Figure 54: Netlify Build environment variables
Next I had to install a package that would allow me to easily access these values from my gulpfile.js
build file.
This was done with the following command
npm install --save-dev dotenv
Figure 55: dotenv in package.json
Now I needed to include the package in gulpfile.js
and reference it in two places.
require('dotenv').config();
var GM_API_KEY
gulp.task('html', function () {
// var apiKey = fs.readFileSync('GM_API_KEY', 'utf8');
var apiKey = process.env.GM_API_KEY;
// more code...
});
gulp.task('html:dist', function () {
// var apiKey = fs.readFileSync('GM_API_KEY', 'utf8');
var apiKey = process.env.GM_API_KEY;
// more code...
});
Once I committed and pushed my changes, Netlify went to work.
The log displayed success this time. My site was live!
Since I needed to do the same with the RestDB API key, I cleaned up the code a bit.
require('dotenv').config(); // <- new code
var GM_API_KEY = process.env.GM_API_KEY; // <- new code
var RESTDB_API_KEY = process.env.RESTDB_API_KEY; // <- new code
gulp.task('html', function () {
// var apiKey = fs.readFileSync('GM_API_KEY', 'utf8');
return gulp.src('app/*.html')
.pipe($.stringReplace('<API_KEY_HERE>', GM_API_KEY)) // <- new code
// more code...
});
gulp.task('html:dist', function () {
// var apiKey = fs.readFileSync('GM_API_KEY', 'utf8');
return gulp.src('app/*.html')
.pipe($.stringReplace('<API_KEY_HERE>', GM_API_KEY)) // <- new code
// more code...
});
// DBHelper
gulp.task('dbhelper', function () {
var bundler = browserify([
'./app/js/idbhelper.js',
'./app/js/dbhelper.js'
], { debug: false }); // ['1.js', '2.js']
return bundler
.transform(babelify, {sourceMaps: false}) // required for 'import'
.bundle() // concat
.pipe(source('dbhelper.min.js')) // get text stream w/ destination filename
.pipe(buffer()) // required to use stream w/ other plugins
.pipe($.stringReplace('<RESTDB_API_KEY>', RESTDB_API_KEY)) // <- new code
.pipe(gulp.dest('.tmp/js/'));
});
// Optimize DBHelper
gulp.task('dbhelper:dist', function () {
var bundler = browserify([
'./app/js/idbhelper.js',
'./app/js/dbhelper.js'
], {debug: true}); // ['1.js', '2.js']
return bundler
.transform(babelify, {sourceMaps: true}) // required for 'import'
.bundle() // concat
.pipe(source('dbhelper.min.js')) // get text stream w/ destination filename
.pipe(buffer()) // required to use stream w/ other plugins
.pipe($.stringReplace('<RESTDB_API_KEY>', RESTDB_API_KEY)) // <- new code
// more code...
});
Now when I commit and push my changes this is what Netlify’s Deploys page shows me.
Now when I test the URL I am able to see the site come up.