A grocery list app — IndexedDB edition
Earlier, I wrote an article on how to create a grocery list application in HTML, CSS, and JavaScript. In that article, I used local storage. Another type of client-side storage is IndexedDB. In this article, I’ll show you how to create the same application using IndexedDB.
About the application
The user will be able to add items to a grocery list, which will be displayed to the user, of course. The user may edit an item’s name, or delete it. The user may delete all items from the list with a click of a button. The data will be persistent, so that if the user closes the web browser and reopens it, the list is still there.
Here’s what the application will look like:
The starter HTML
Let’s start with the following HTML document:
<!DOCTYPE html>
<html>
<head>
<title>Grocery list</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="fontawesome/css/all.min.css">
</head>
<body>
<section class="centered">
<h3>Grocery list</h3>
<form class="grocery">
<div class="form-elements">
<input type="text" id="item" placeholder="Bread">
<button type="submit" class="btn-add" disabled>Add</button>
</div>
</form>
<div class="grocery-container">
<div class="grocery-list">
<article class="grocery-item">
<p class="item-title">Bananas</p>
<div class="btn-container">
<button class="btn-edit">
<i class="fas fa-edit"></i>
</button>
<button class="btn-delete">
<i class="fas fa-trash"></i>
</button>
</div>
</article>
</div>
<button type="button" class="btn-clear">Clear list</button>
</div>
</section>
<script src="app.js"></script>
</body>
</html>
In the head
section, I am linking to the Fontawesome stylesheet and our own stylesheet. In the body
section, we have a section
element enclosing everything. The first thing in the section
element is a heading followed by a form
element that has a text box input for the user to type in the grocery list item name, and a button to add the item to the list. Note that we use the placeholder
attribute to set a helper text for the user as a guide to what the text box is for. Also, the button is initially disabled since adding an item with no name doesn’t make sense. We’ll enable the button once the user types in an item name. We enclose the text box and the button inside a div
element for ease of styling.
Next up, we have a div
element with a class of grocery-container
that encloses the grocery list and the “Clear list” button. The grocery list itself is a div
element that has an article
for each grocery item. We show a sample grocery item markup in the above example. There’s a p
element with the item’s name, and a div
element with a class of btn-container
enclosing two buttons, which we render using Fontawesome icons.
At the end of the body
element, we link to our JavaScript file. Before we put anything in the JavaScript file, let’s apply some CSS styles so that the page’s outlook starts to improve.
The starter CSS
Let’s examine the global styles first:
*, ::before, ::after {
padding: 0;
margin: 0;
box-sizing: border-box;
}
body {
background: #ccc;
}
We start with a CSS reset to get rid of differences due to browser-specific default stylesheets. We give the page a grey background color.
Next, let’s style the page so that the contents appear centred horizontally and a bit spaced from the top.
.centered {
margin: 2rem auto;
max-width: 600px;
min-height: 200px;
padding: 2rem;
background-color: #fff;
border-radius: 2px;
box-shadow: 0 0 2px 2px rgba(0, 0, 0, 0.3);
}
.centered h3 {
text-align: center;
margin-bottom: 1.5rem;
}
We use the .centered
CSS class selector to style the top-level section
element. We use the margin
property to space the div
container 2rem
apart from the top and bottom while the left and right margins are adjusted accordingly. We set the maximum width to 600 pixels. We shouldn’t need more than that. We set the minimum height to 200 pixels. That way, the page wouldn’t look half bad even when it doesn’t have any items in the list. We set the padding
property to space the container’s contents comfortably away from the edges. We give this container a white background color, rounded edges, and a box shadow. We then select the h3
heading and configure it to center align the text. We also set the bottom margin so that the form
element is spaced away from the heading.
Next, let’s style the form
element.
.form-elements {
display: grid;
grid-template-columns: 2fr 1fr;
}
.form-elements input, .btn-submit {
padding: 8px;
}
#item {
border-radius: 8px 0 0 8px;
}
.btn-submit {
border: none;
border-radius: 0 8px 8px 0;
}
We control the element layout in the form using CSS grid. We configure it to split two-thirds of the width to the first column, i.e., the text box, and 1/3 to the button. We give a bit of padding to the form elements so that the text is comfortably separated from the control’s edges. We select the text box using the ID selector and set its top left and bottom left edges to rounded, while the others are kept straight. When we specify four values for the border-radius
property, the first one is the top left border radius, the second one top right, the third one bottom right, and the last one bottom left. Think of it as starting at the top left and then going clockwise around the text box. We do the opposite rounding on the button. This creates an effect where the text box and the button appear to be part of the same control group like a capsule. We also get rid of the border on the button.
Next, let’s style the grocery list.
.grocery-item {
width: 100%;
display: flex;
justify-content: space-between;
margin: 1rem auto;
}
.grocery-item p {
display: block;
width: 80%;
}
We set the grocery item to occupy all of its parent’s width. We use CSS Flexbox to layout its contents, i.e., the item name and the control buttons. We configure Flexbox to put the two items in the container at the opposite edges with the space-between
directive. We set the top and left margins so that the items are somewhat displaced from each other. Next, we select the item name element and set its width to occupy most of the parent’s width. But for that, we have to set the p
element’s display
property to block
.
Next, let’s style the edit and delete buttons.
.btn-delete {
color: rgba(255, 0, 0, 0.6);
margin-left: 10px;
cursor: pointer;
border: none;
}
.btn-edit {
color: rgba(0, 255, 0, 0.6);
cursor: pointer;
border: none;
}
For both buttons, we set an appropriate background color, set the cursor
to pointer
so that hovering over the button gives the user a visual clue as to the active nature of the element. We also get rid of the border around the Fontawesome icons. To space the two buttons horizontally, we set the left margin on the delete button.
With this styling, our page looks like the following:
Before we dive into JavaScript, let’s create some styles for a “toast” element. This is a div
element that we’ll display momentarily horizontally centred near the top edge of the page to indicate the success or failure of an action.
.toast {
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
height: 2rem;
text-align: center;
width: 300px;
line-height: 2rem;
border-radius: 4px;
}
.success {
background-color: #198754;
color: rgba(255, 255, 255, 0.8)
}.danger {
background-color: #d9534f;
color: rgba(255, 255, 255, 0.8)
}
We define a class named toast
. To be able to position the toast element independent of the page contents, we set its position
to absolute
. To specify its position, we set the top
property to 10 pixels to set the toast to display near the top edge of the parent element. We set the left
property to 50%
, followed by a translate transform in the horizontal (x) direction to centre the toast element horizontally. We give the toast element a matching height
and line-height
values so that the text is vertically centred. We give the toast element a width of 300 pixels. Finally, to give it rounded edges, we use the border-radius
property.
To display successful “item added” messages, we define a class of success
with a green background color. For “item deleted” messages, we define a class of danger
with a red background color. Both of these classes have a greyish text color.
Enabling the “Add” button
We need to enable the “Add” button once the user types in an item’s name. To do that, we’ll subscribe to the input
event of the text box.
const newItem = document.getElementById('item')
const submitButton = document.querySelector('.btn-add')
newItem.addEventListener('input', () => {
if (newItem.value === '') {
submitButton.disabled = true
}
else {
submitButton.disabled = false
}
})
We acquire objects corresponding to the text box and the “Add” button using the document.getElementById()
method. We use the addEventListener()
method to subscribe to the input
method using an anonymous function. Our function disables the “Add” button if the value in the text box is empty, or enables it otherwise.
Initializing IndexedDB storage
First, we need to configure our data store in IndexedDB. Here’s what we need to do:
const dbName = 'MyGroceryApp'
const dbVersion = 3
const osName = 'groceries'
let db;
init()
function init() {
const request = indexedDB.open(dbName, dbVersion);
request.onerror = (event) => {
console.error(`Database error: ${event.target.errorCode}`);
};
request.onsuccess = (event) => {
db = event.target.result;
};
request.onupgradeneeded = (event) => {
db = event.target.result;
db.createObjectStore(osName, { autoIncrement: true });
};
}
We need to give our database a name, and a version number. We also need to have a name for an object store inside that database to store the actual data. We define a string variable dbName
to hold the database name. We define another variable to store the database version. We define a string variable to hold the name of the object store inside our database. We define a function named init()
and call it. Since our script tag is near the end of the body
tag, this function will be called once the DOM has been constructed.
In the init()
function, we first call the open()
method on the indexedDB
object, passing it the database name and version number. We store the result in a variable named request
. There are three possible outcomes to this request. First, if our request to open the database succeeds, the callback stored in the onsuccess
handler is called. Second, if an error occurs, an onerror
callback is called. Finally, if an upgrade is needed, an onupgradeneeded
handler is called. This handler is also called if the database didn’t exist and is being created. In this handler, we need to create our object store and store any initial data in it, depending on our application. In our case, there’s no initial data to store. Let’s discuss each of our event handlers one by one.
In the onerror
handler, we are simply displaying the error message in the console. Later on, once we’ve defined a helper function to display toast messages, we can use that, instead. In the onsuccess
event handler, we store the database object through the event
argument into a global variable named db
. In the onupgradeneeded
handler, we create our object store using the createObjectStore()
method of the db
object. It takes two arguments. The first is the name of the object store for which we use the variable osName
that we declared earlier. The second argument is about the key in the object store. We can either pass the name of an object attribute that should be treated as a key, or, as we are doing, ask IndexedDB to create an auto-increment key itself.
Adding an item
Now, let’s write the code to add a new item to the list when the “Add” button is clicked.
const form = document.querySelector('.grocery')
const list = document.querySelector('.grocery-list')
form.addEventListener('submit', addItem)
function addItem(e) {
e.preventDefault()
const value = newItem.value
storeItem(value)
.then( key => {
const article = createArticle(value, key)
list.appendChild(article)
displayMessage(`${value} successfully added to the list`, 'success')
if (list.children.length > 0) {
itemContainer.classList.add('show-groceries')
}
newItem.value = ''
})
.catch( error => {
console.log(error)
})
}
function createArticle(name) {
const article = document.createElement('article')
article.classList.add('grocery-item')
article.innerHTML = `<p class="item-title">${name}</p>
<div class="btn-container">
<button class="btn-edit">
<i class="fas fa-edit"></i>
</button>
<button class="btn-delete">
<i class="fas fa-trash"></i>
</button>
</div>`
const delButton = article.querySelector('.btn-delete')
const editButton = article.querySelector('.btn-edit')
delButton.onclick = deleteItem
editButton.onclick = editItem
return article
}
function storeItem(value) {
return new Promise((resolve, reject) => {
const transaction = db.transaction([osName], "readwrite");
const objectStore = transaction.objectStore(osName);
const addRequest = objectStore.add(value);
addRequest.onsuccess = function(event) {
const key = event.target.result;
resolve(key)
};
addRequest.onerror = function(event) {
reject(`Error storing data: ${event.target.error.message}`)
}
transaction.oncomplete = function(event) {
console.log("Transaction completed.");
};
})
}
function displayMessage(message, level) {
const toast = document.createElement('div')
toast.classList.add('toast')
toast.innerText = message
toast.classList.add(level)
document.body.appendChild(toast)
setTimeout(() => {
document.body.removeChild(toast)
}, 2000)
}
We acquire objects corresponding to the form
element and the grocery list using the document.querySelector()
method. Eventually, we’d enable editing an existing item in the grocery list. To keep track of the state of the application, we declare a flag named editing
and set it initially to false
. We then subscribe to the form’s submit
event using a function named addItem()
. The thing about form
elements is that they submit
to the server. But we don’t have a server. So, the first thing we do in the submit
handler is to call preventDefault()
so that the form isn’t submitted to the server. Next, we acquire the value in the text box and store it in the variable named value
.
Next, we obtain the name typed in by the user from the newItem
object that we tied up to the text box. We, then, call a function named storeItem()
and pass it the value
typed by the user. The function storeItem()
returns a Promise
. It creates a new transaction on the IndexedDB object store, specifying the name of the object store, and the transaction mode. There are two modes readonly
and readwrite
. We use readwrite
since we want to be able to write data to the object store. We obtain an object representing the store from the transaction
object. We then create a write request to save the new item to the object store using the add()
method. Similar to the open()
method, the add()
method also has three event handlers onsuccess
, onerror
and oncomplete
. The onsuccess
handler signals the success of the request
that we created. The onerror
handler signals that an error occurred. The oncomplete
handler indicates the transaction (not just the request) has completed.
In the onsuccess
callback, we obtain the key that IndexedDB auto-generated for our new value, and resolve
the promise with that key. In case of an error, we reject
the promise with the error message. On completion of the transaction, we display a success message in the JavaScript console.
In the addItem()
function, we have the .then()
and .catch()
blocks to handle the resolve
and reject
parts of the promise, respectively. In the .then()
part, the item addition to the IndexedDB object store has been successful, but the item hasn’t been added to the page DOM. To do that, we call a createArticle()
function, which uses the list item markup that we had in the HTML template, and inserts the item’s name, and the key from the IndexedDB transaction as the data-id
attribute. We also query for the edit and delete buttons and hook up event handlers to them. We’ll define these functions later. Finally, we return the top-level DOM element that we created to addItem()
which appends it to the grocery list DOM element. To indicate the status of the action, we call the displayMessage()
function which accepts a status message, and a severity level as arguments. In this case, we pass in a success message, and the level of success
. This function creates a div
element, sets its innerText
property to the message passed as argument, assigns it two classes, namely toast
, and the string passed as the severity level. We add this div
element to the page DOM. Since the toast
class is positioned absolute
, it is displayed prominently horizontally centred near the top of the screen. But we don’t want this message to be displayed permanently, so we use the setTimeout()
method to remove this div
element from the DOM after two seconds.
We still wouldn’t be able to see the grocery list item, because the visibility
is set to hidden
on the grocery-container
. To make the item visible, we check the length of the grocery list and add the show-groceries
class to the list container, so that list items are displayed. Finally, we set the text box value to an empty string, so that the user can enter another item.
Delete event handler
Let’s enable the delete button and implement the delete item functionality. Here’s what we need to do:
- Write a click event handler for the delete button.
- In the click event handler, first we need to prompt the user if they are certain they want to delete the item.
- Next, in the event handler, we want to find the parent
article
element and remove it from the DOM. - We also want to remove the item using its key from the IndexedDB object store
Let’s begin. Our delete event handler is tied to the button
element. So, when we receive a click event on the button
element, we need to get the article
element, which is the parent of its parent element. Here’s the event handler:
async function deleteItem(e) {
const item = e.currentTarget.parentElement.parentElement
const value = item.querySelector('p').innerText
try {
const choice = await showModal(`Are you sure you want to delete ${value}`)
if (choice){
list.removeChild(item)
const id = item.getAttribute('data-id')
removeItem(id)
.then( result => {
displayMessage(`Item ${value} removed.`, 'danger')
if (list.children.length === 0) {
itemContainer.classList.remove('show-groceries')
}
})
.catch(error => {
displayMessage(error, 'danger')
})
}
}
catch(error) {
console.log(error)
}
}
Note that the button
element also has a child i
element. The click may originate from the i
element. In that case, the e.target
property would point to the i
element. However, since the i
element does not have a click event handler, that event will be bubbled up to its parent, the button
element, where it is caught and handled.
As discussed, we reach the parent of the button
element’s parent. We obtain the item’s name using the innerText
property. Now, we need to display a modal dialog to let the user confirm the delete. Since this is going to be an asynchronous operation — the user may click the buttons at a time of their chosing — we declare deleteItem()
as async
. Accordingly, we call showModal()
with the await
keyword. The showModal()
function will return a Promise
, which will be resolved with a true
if the user presses “Yes”, otherwise, it will be resolved with a false
. Our code will await
that resolution on this call to showModal()
.
If the user presses “Yes”, and a true
is returned, we remove the grocery item from the list. We obtain the item’s data-id
attribute so that we can use that as key to remove the item from the IndexedDB object store. We call a removeItem()
function with that key as argument. That function is defined as:
function removeItem(key) {
return new Promise ( (resolve, reject) => {
const request = db
.transaction([osName], "readwrite")
.objectStore(osName)
.delete(parseInt(key));
request.onsuccess = (event) => {
resolve('Item deleted')
};
request.onerror = (event) => {
reject(event.target.error.message)
}
})
}
Since this is an asynchronous operation, we rely on returning a Promise
. As before, we create a readwrite
transaction on the database with a request using the delete()
method to remove the item with index equal to the value of key
. We have the familiar onsuccess
and onerror
handlers that either resolve
and reject
the promise, respectively.
Back in the calling function, if the Promise
is resolved, we display a success message with a class of danger
. If there are no longer any items in the list, we remove the show-groceries
class from the container. In case the Promise
was rejected, we display an error message. We then display a toast message to inform the user of the completion of the request. If the list has now become empty, we hide it from the display by removing the CSS class show-groceries
from it.
Now, let’s look at the showModal()
function.
function showModal(message) {
return new Promise( (resolve) => {
preModal()
const div = document.createElement('div')
div.classList.add('modal')
div.innerHTML = `<p>${message}</p><div class="btn-row"><button id="yes">Yes</button><button id="no">No</button></div>`
document.body.appendChild(div)
document.getElementById('yes').addEventListener('click', function() {
document.querySelector('.modal').remove()
postModal()
resolve(true)
})
document.getElementById('no').addEventListener('click', function() {
document.querySelector('.modal').remove()
postModal()
resolve(false)
})
})
}
function preModal() {
submitButton.disabled = true
newItem.disabled = true
const editButtons = document.querySelectorAll('.btn-edit')
const deleteButtons = document.querySelectorAll('.btn-delete')
editButtons.forEach( btn => btn.onclick = null)
deleteButtons.forEach( btn => btn.onclick = null)
const clearButton = document.querySelector('.btn-clear')
clearButton.disabled = true
}
function postModal() {
submitButton.disabled = false
newItem.disabled = false
const editButtons = document.querySelectorAll('.btn-edit')
const deleteButtons = document.querySelectorAll('.btn-delete')
editButtons.forEach( btn => {
btn.onclick = editItem
btn.style.cursor = 'pointer'
})
deleteButtons.forEach( btn => {
btn.onclick = deleteItem
btn.style.cursor = 'pointer'
})
const clearButton = document.querySelector('.btn-clear')
clearButton.disabled = false
}
The showModal()
function accepts the message to be displayed in the modal dialog. The entire function implementation is wrapped inside a return
statement. The function needs to return a Promise
. The argument to the Promise
constructor is a function reference named resolve
. We can call this function with an argument of true
if the user clicks the “Yes”, button or with false
otherwise. This will cause the waiting call to showModal()
to be resolved with a value of either true
or false
.
When a modal dialog is displayed, every other control on the page should be disabled. That’s what preModal()
achieves. First, we disable the “Add” button, and the new item name text box. Next, we locate all the “Edit” and “Delete” buttons and disable them by setting their onclick
properties to null
. For this, we use the forEach()
method. Finally, we locate and disable the “Clear” button.
We create a div
element with a p
element and two button
elements. We use a template literal string to assign the message
argument o the p
element. We give the “Yes” and “No” buttons unique IDs. We assign a CSS class of modal
to the div
element, and a class of btn-row
to the container enclosing the two button
elements. We define these CSS classes as:
.modal {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 30rem;
height: 5rem;
overflow: auto;
background-color: rgba(0,0,0,0.4);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.btn-row {
display: flex;
margin-top: 1rem;
}
.btn-row button{
margin-right: 1rem;
padding: 0.2rem 0.5rem;
}
We first use a class selector to select the outer div
element. We set its position
to fixed
so that it is displayed in a fixed place. To place it horizontally, and vertically centered on the page, we set its top
and left
positions to 50%
and then apply a transform of translation along the x and y directions by 50%. We assign a width and a height to the modal dialog. We assign auto
to the overflow
property so that if the message is too big, it is automatically handled. We specify the use of Flexbox to centre the contents of the modal dialog horizontally and vertically centred. Since we want the text and the buttons to be displaed on separate rows, we assign column
to the flex-direction
property.
We want the buttons to be shown in a row, so we set the display
property to flex
on the btn-row
class as well. This will have a default value of row
for the flex-direction
property, which achieves our goal. We give a bit of margin on the top of the row of buttons so that they are spaced away from the dialog text.
By default, the buttons will be just big enough for their content, so we apply a bit of padding to the button
elements that are successors of the btn-row
class. Also, the two buttons will be too close to each other, so we apply a bit of right margin.
Back to the JavaScript. We use the addEventListener()
method to hook up anonymous functions to the “Yes” and “No” buttons. In each event handler, we remove the modal dialog from the DOM, and call a postModal()
function to re-enable all the active elements on the page, which were disabled in the preModal()
function. We resolve(true)
in the “Yes” event handler, and resolve(false)
in the “No” event handler.
Edit event handler
When the user clicks the edit button, we’ll copy the respective item’s name to the “New item” text box, change the “Add” button to a “Save” button. and set focus to the text box. Once the user has edited the item’s name, we’ll save the changes to the respective grocery list item. Here’s the code:
let editing
let elementEditing
function editItem(e) {
preModal()
elementEditing = e.currentTarget.parentElement.parentElement
newItem.disabled = false
submitButton.disabled = false
const pElement = elementEditing.querySelector('p')
newItem.value = pElement.innerText
newItem.focus()
submitButton.innerText = 'Save'
editing = true
}
When editing an item, we want to disable all the clickable elements, except for the text box and the “Save” button. We have already written the code to disable and enable these elements, except the existing code disables the text box, too. No worries, we just call that preModal()
function, and enable the text box and the button afterwards. The e
argument represents the button
element for the grocery list item that was clicked. We acquire an object representing the corresponding article
element and store it in a global variable named elementEditing
. This variable will come handy in the “Save” button event handler. We also acquire an object representing the p
element descendant of this article
element. We copy the name of the selected item to the text box, set focus on the text box and set focus to it. We change the button text from “Add” to “Save”. We set a global flag variable editing
to true
to indicate to the “Save” event handler that we are editing an item, and not adding a new item. The distinction is necessary since we use the same form for both actions.
We already have a form submit event handler registered. We repurpose it to handle “Save” event as well as the “Add” event.
function addItem(e) {
e.preventDefault()
const value = newItem.value
if (editing) {
const id = elementEditing.getAttribute('data-id')
const pElement = elementEditing.querySelector('p')
pElement.innerText = newItem.value
updateItem(id, newItem.value)
.then( result => {
displayMessage('Item successfully updated', 'success')
if (list.children.length > 0) {
itemContainer.classList.add('show-groceries')
}
newItem.value = ''
})
.catch( error => {
displayMessage('Item could not be updated', 'danger')
})
postModal()
submitButton.innerText = 'Add'
newItem.value = ''
editing = false
}
else {
storeItem(value)
.then( key => {
const article = createArticle(value, key)
list.appendChild(article)
displayMessage(`${value} successfully added to the list`, 'success')
if (list.children.length > 0) {
itemContainer.classList.add('show-groceries')
}
newItem.value = ''
})
.catch( error => {
console.log(error)
})
}
}
function updateItem(id, newValue) {
return new Promise((resolve, reject) => {
const transaction = db.transaction([osName], "readwrite");
const objectStore = transaction.objectStore(osName);
const request = objectStore.get(parseInt(id));
request.onerror = (event) => {
reject(event.target.error.message)
};
request.onsuccess = (event) => {
const requestUpdate = objectStore.put(newValue, parseInt(id));
requestUpdate.onerror = (event) => {
reject(event.target.error.message)
};
requestUpdate.onsuccess = (event) => {
resolve('Item updated')
};
};
})
}
In the addItem()
function, the else
part has the code that we wrote earlier to add an item. We add an if
statement to check if we are in edit mode. If so, we acquire the data-id
attribute so that we can use when communicating with IndexedDB to edit the stored value against that key. We acquire an object representing the p
element descendant of the article
being edited. We set its innerText
to the value from the text box. We call a function named updateItem()
that accepts the key and the new value for it as arguments. In it, we use the get()
method to read in the current value against the given key. Technically, that isn’t really needed in our application, but is the usual flow with IndexedDB in case we are storing more complex objects rather than simple strings. We use the put()
method to update the value against the key received as argument. The rest of the details should be familiar by now.
Back in the addItem()
function, we call postModal()
to enable all the active elements that we disabled. We change the button text back to “Add”, set the text box blank, and reset the editing
flag to false
so that the form is ready to add a new item.
The clear all button
Finally, let’s implement the “Clear” button that removes all the items from the DOM and the IndexedDB store.
const clearButton = document.querySelector('.btn-clear')
clearButton.onclick = clear
async function clear() {
try {
const choice = await showModal('Are you sure you want to delete all items?')
if (choice){
clearStore()
.then( result => {
list.innerHTML = ''
displayMessage('All items removed from list.', 'danger')
itemContainer.classList.remove('show-groceries')
})
.catch( error => {
displayMessage(error, 'danger')
})
}
}
catch(error) {
console.log(error)
}
}
function clearStore(key) {
return new Promise ( (resolve, reject) => {
const request = db
.transaction([osName], "readwrite")
.objectStore(osName)
.clear();
request.onsuccess = (event) => {
resolve('All items deleted')
};
request.onerror = (event) => {
reject(event.target.error.message)
}
})
}
This should all be familiar by now. Except in the clearStore()
function, we are using the .clear()
method of the object store.
That’s all folks!
That concludes our project. You may get the source code from this github repository. If you’d like to copy-paste, here’s the HTML:
<!DOCTYPE html>
<html>
<head>
<title>Grocery list</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="fontawesome/css/all.min.css">
</head>
<body>
<section class="centered">
<h3>Grocery list</h3>
<form class="grocery">
<div class="form-elements">
<input type="text" id="item" placeholder="Bread">
<button type="submit" class="btn-add" disabled>Add</button>
</div>
</form>
<div class="grocery-container">
<div class="grocery-list">
</div>
<button type="button" class="btn-clear">Clear list</button>
</div>
</section>
<script src="app.js"></script>
</body>
</html>
Here’s the CSS:
*, ::before, ::after {
padding: 0;
margin: 0;
box-sizing: border-box;
}
body {
background: #ccc;
}
.centered {
margin: 2rem auto;
max-width: 600px;
min-height: 200px;
padding: 2rem;
background-color: #fff;
border-radius: 2px;
box-shadow: 0 0 2px 2px rgba(0, 0, 0, 0.3);
}
.centered h3 {
text-align: center;
margin-bottom: 1.5rem;
}
.form-elements {
display: grid;
grid-template-columns: 2fr 1fr;
}
.form-elements input, .btn-submit {
padding: 8px;
}
#item {
border-radius: 8px 0 0 8px;
}
.btn-submit {
border: none;
border-radius: 0 8px 8px 0;
}
.grocery-item {
width: 100%;
display: flex;
justify-content: space-between;
margin: 1rem auto;
}
.grocery-item p {
display: block;
width: 80%;
}
.btn-delete {
color: rgba(255, 0, 0, 0.6);
margin-left: 10px;
cursor: pointer;
border: none;
}
.btn-edit {
color: rgba(0, 255, 0, 0.6);
cursor: pointer;
border: none;
}
.success {
background-color: #198754;
color: rgba(255, 255, 255, 0.8)
}
.danger {
background-color: #d9534f;
color: rgba(255, 255, 255, 0.8)
}
.toast {
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
height: 2rem;
text-align: center;
width: 300px;
line-height: 2rem;
border-radius: 4px;
}
.grocery-container {
visibility: hidden;
}
.show-groceries {
visibility: visible;
}
.modal {
position: fixed;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 30rem;
height: 5rem;
overflow: auto;
background-color: rgba(0,0,0,0.4);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.btn-row {
display: flex;
margin-top: 1rem;
}
.btn-row button{
margin-right: 1rem;
padding: 0.2rem 0.5rem;
}
.btn-clear {
padding: 0.3rem;
}
Here’s the JavaScript:
const form = document.querySelector('.grocery')
const newItem = document.getElementById('item')
const submitButton = document.querySelector('.btn-add')
const itemContainer = document.querySelector('.grocery-container')
const list = document.querySelector('.grocery-list')
let elementEditing;
const dbName = 'MyGroceryApp'
const dbVersion = 3
const osName = 'groceries'
const clearButton = document.querySelector('.btn-clear')
let editing = false
let db;
form.addEventListener('submit', addItem)
clearButton.onclick = clear
init()
function displayList() {
const tx = db.transaction(osName, "readonly");
const store = tx.objectStore(osName)
store.openCursor().onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const article = createArticle(cursor.value, cursor.key)
list.appendChild(article)
cursor.continue();
} else {
console.log("No more entries!");
if (list.children.length > 0) {
itemContainer.classList.add('show-groceries')
}
}
};
}
function init() {
const request = indexedDB.open(dbName, dbVersion);
request.onerror = (event) => {
displayMessage(`Database error: ${event.target.errorCode}`), 'danger';
};
request.onsuccess = (event) => {
db = event.target.result;
displayList()
};
request.onupgradeneeded = (event) => {
db = event.target.result;
db.createObjectStore(osName, { autoIncrement: true });
};
}
newItem.addEventListener('input', () => {
if (newItem.value === '') {
submitButton.disabled = true
}
else {
submitButton.disabled = false
}
})
function storeItem(value) {
return new Promise((resolve, reject) => {
const transaction = db.transaction([osName], "readwrite");
const objectStore = transaction.objectStore(osName);
const addRequest = objectStore.add(value);
addRequest.onsuccess = function(event) {
const key = event.target.result;
resolve(key)
};
addRequest.onerror = function(event) {
reject(`Error storing data: ${event.target.error.message}`)
}
transaction.oncomplete = function(event) {
console.log("Transaction completed.");
};
})
}
function updateItem(id, newValue) {
return new Promise((resolve, reject) => {
const transaction = db.transaction([osName], "readwrite");
const objectStore = transaction.objectStore(osName);
const request = objectStore.get(parseInt(id));
request.onerror = (event) => {
reject(event.target.error.message)
};
request.onsuccess = (event) => {
const requestUpdate = objectStore.put(newValue, parseInt(id));
requestUpdate.onerror = (event) => {
reject(event.target.error.message)
};
requestUpdate.onsuccess = (event) => {
resolve('Item updated')
};
};
})
}
function removeItem(key) {
return new Promise ( (resolve, reject) => {
const request = db
.transaction([osName], "readwrite")
.objectStore(osName)
.delete(parseInt(key));
request.onsuccess = (event) => {
resolve('Item deleted')
};
request.onerror = (event) => {
reject(event.target.error.message)
}
})
}
function clearStore(key) {
return new Promise ( (resolve, reject) => {
const request = db
.transaction([osName], "readwrite")
.objectStore(osName)
.clear();
request.onsuccess = (event) => {
resolve('All items deleted')
};
request.onerror = (event) => {
reject(event.target.error.message)
}
})
}
function addItem(e) {
e.preventDefault()
const value = newItem.value
if (editing) {
const id = elementEditing.getAttribute('data-id')
const pElement = elementEditing.querySelector('p')
pElement.innerText = newItem.value
updateItem(id, newItem.value)
.then( result => {
displayMessage('Item successfully updated', 'success')
if (list.children.length > 0) {
itemContainer.classList.add('show-groceries')
}
newItem.value = ''
})
.catch( error => {
displayMessage('Item could not be updated', 'danger')
})
postModal()
submitButton.innerText = 'Add'
newItem.value = ''
editing = false
}
else {
storeItem(value)
.then( key => {
const article = createArticle(value, key)
list.appendChild(article)
displayMessage(`${value} successfully added to the list`, 'success')
if (list.children.length > 0) {
itemContainer.classList.add('show-groceries')
}
newItem.value = ''
})
.catch( error => {
console.log(error)
})
}
}
function createArticle(name, id) {
const article = document.createElement('article')
article.setAttribute('data-id', id)
article.classList.add('grocery-item')
article.innerHTML = `<p class="item-title">${name}</p>
<div class="btn-container">
<button class="btn-edit">
<i class="fas fa-edit"></i>
</button>
<button class="btn-delete">
<i class="fas fa-trash"></i>
</button>
</div>`
const delButton = article.querySelector('.btn-delete')
const editButton = article.querySelector('.btn-edit')
delButton.onclick = deleteItem
editButton.onclick = editItem
return article
}
function displayMessage(message, level) {
const toast = document.createElement('div')
toast.classList.add('toast')
toast.innerText = message
toast.classList.add(level)
document.body.appendChild(toast)
setTimeout(() => {
document.body.removeChild(toast)
}, 2000)
}
async function deleteItem(e) {
const item = e.currentTarget.parentElement.parentElement
const value = item.querySelector('p').innerText
try {
const choice = await showModal(`Are you sure you want to delete ${value}`)
if (choice){
list.removeChild(item)
const id = item.getAttribute('data-id')
removeItem(id)
.then( result => {
displayMessage(`Item ${value} removed.`, 'danger')
if (list.children.length === 0) {
itemContainer.classList.remove('show-groceries')
}
})
.catch(error => {
displayMessage(error, 'danger')
})
}
}
catch(error) {
console.log(error)
}
}
function editItem(e) {
preModal()
elementEditing = e.currentTarget.parentElement.parentElement
newItem.disabled = false
submitButton.disabled = false
const pElement = elementEditing.querySelector('p')
newItem.value = pElement.innerText
newItem.focus()
submitButton.innerText = 'Save'
editing = true
}
function showModal(message) {
return new Promise( (resolve) => {
preModal()
const div = document.createElement('div')
div.classList.add('modal')
div.innerHTML = `<p>${message}</p><div class="btn-row"><button id="yes">Yes</button><button id="no">No</button></div>`
document.body.appendChild(div)
document.getElementById('yes').addEventListener('click', function() {
document.querySelector('.modal').remove()
postModal()
resolve(true)
})
document.getElementById('no').addEventListener('click', function() {
document.querySelector('.modal').remove()
postModal()
resolve(false)
})
})
}
function preModal() {
submitButton.disabled = true
newItem.disabled = true
const editButtons = document.querySelectorAll('.btn-edit')
const deleteButtons = document.querySelectorAll('.btn-delete')
editButtons.forEach( btn => {
btn.onclick = null
btn.style.cursor = 'auto'
})
deleteButtons.forEach( btn => {
btn.onclick = null
btn.style.cursor = 'auto'
})
const clearButton = document.querySelector('.btn-clear')
clearButton.disabled = true
}
function postModal() {
submitButton.disabled = false
newItem.disabled = false
const editButtons = document.querySelectorAll('.btn-edit')
const deleteButtons = document.querySelectorAll('.btn-delete')
editButtons.forEach( btn => {
btn.onclick = editItem
btn.style.cursor = 'pointer'
})
deleteButtons.forEach( btn => {
btn.onclick = deleteItem
btn.style.cursor = 'pointer'
})
const clearButton = document.querySelector('.btn-clear')
clearButton.disabled = false
}
async function clear() {
try {
const choice = await showModal('Are you sure you want to delete all items?')
if (choice){
clearStore()
.then( result => {
list.innerHTML = ''
displayMessage('All items removed from list.', 'danger')
itemContainer.classList.remove('show-groceries')
})
.catch( error => {
displayMessage(error, 'danger')
})
}
}
catch(error) {
console.log(error)
}
}