Intro
- This project is meant to be done after finishing the JavaScript tutorial series.
- This is a continuation of the Beginning JavaScript To-Do list project.
- We will make changes to that project using concepts from the JavaScript tutorial series. Some of the main changes include:
- Modules: Put the To-Do seed items in a separate file.
- Dates: Add a due date field to the To Do items.
- LocalStorage: Use the localStorage Web API to save To-Do items.
- Regular Expressions: Use regular expressions to escape HTML tags in task and details strings.
- Error: Throw an error if there is no internet connection.
- Create a project folder called todo-list-intermediate.
- We will first go over the HTML document, the seed-todos module, and the todos JavaScript file.
- The code is available in the javascript-tutorial repository at github.com/LearnByCheating/javascript-tutorial in the todo-list-intermediate folder. The Readme file has instructions on how to run the code in the Browser environment.
- It is important to be able to read code so go through the code until you understand every block of code and every line of code. Refer to the CheatSheet as much as necessary to help get you familiar with using the CheatSheet as a reference.
The HTML document
- Add an index.html file to the todo-list-intermediate folder. Populate it with the below HTML.
- The index.html file is mostly the same as the Beginning version. The changes are highlighted in bold and include:
- The script tag has a type attribute set to "modules". We put the seed To-Do items in a separate file. JavaScript files that import other files must be treated as modules.
- Add a Clear All button that will clear all the items in the list.
- Add a due date field to the Add To-Do Item Form.
- Instead of seeding the page with To-Do items automatically, add a "Seed To-Dos" button to the bottom of the page.
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<title>ToDo App</title>
<link rel="icon" type="image/x-icon" href="todo_icon_128x128.png">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css">
<script type="module" src="todos.js" defer></script>
</head>
<body class="bg-light container">
<div class="card my-3">
<h1 class="card-header bg-dark text-light d-flex align-items-center">
Todo List
<button id="clearAll-btn" class="btn btn-ls btn-sm btn-outline-danger ms-auto">Clear All</button>
<button id="add-btn" class="btn btn-ls btn-outline-light ms-4">Add Task</button>
</h1>
<form id="add-form" class="card-body container text-light bg-secondary d-none">
<div class="my-2">
<label for="task" class="form-label mb-0">Task</label>
<input type="text" id="task" class="form-control">
</div>
<div class="mb-2">
<label for="due" class="form-label mb-0">Due</label>
<input type="date" id="due" class="form-control">
</div>
<div class="mb-3">
<label for="details" class="form-label mb-0">Details</label>
<textarea id="details" class="form-control" rows="2"></textarea>
</div>
<div class="mb-3">
<button id="submit-btn" type="submit" class="btn btn-outline-light col-2">Save</button>
<button id="cancel-btn" class="btn btn-outline-light col-2 ms-2 float-end">Cancel</button>
</div>
</form>
<ul id="list" class="list-group"></ul>
<button id="seed-btn" class="btn btn-primary d-none">Seed JavaScript To-Dos</button>
</body>
</html>
Seed-todos module
- Instead of putting our seed todo array directly in the todos.js file, we will put them in a separate module called seed-todos.js and export them.
- This example list has a To-Do item for each major JavaScript topic.
seed-todos.js
const getDueDate = (days) => {
const today = Date.now();
const oneDay = 86400000;
return new Date(today + (oneDay * days));
};
const todos = [
{
id: 1, task: 'JavaScript General', due: getDueDate(1), done: false, details: 'Data types, global object, built-in global objects',
},
{
id: 2, task: 'Literals and Variables', due: getDueDate(2), done: false, details: 'Literals, primitive vs object variables, const and let vs var, scope, destructuring assignment',
},
{
id: 3, task: 'Expressions and Operators', due: getDueDate(3), done: false, details: 'Expressions, assignment operators, arithmetic operators, comparison operators, logical operators, string operators',
},
{
id: 4, task: 'Conditional Statements and Booleans', due: getDueDate(4), done: false, details: 'Conditionals (if statements, ternary statements, and switch statements) and Booleans (false/false).',
},
{
id: 5, task: 'Loops', due: getDueDate(5), done: false, details: 'For...of, for...in, for, while, and do...while loops.',
},
{
id: 6, task: 'Functions', due: getDueDate(6), done: false, details: 'Function declarations, arrow functions, methods, parameters, rest parameters, an return statements',
},
{
id: 7, task: 'Strings', due: getDueDate(7), done: false, details: 'String literals, template strings, string methods.',
},
{
id: 8, task: 'Numbers and Math', due: getDueDate(8), done: false, details: 'Number literals, Math Global Object.',
},
{
id: 9, task: 'Arrays', due: getDueDate(9), done: false, details: 'Creating arrays, static and instance properties and methods, shallow copies, sets.',
},
{
id: 10, task: 'Regular Expressions', due: getDueDate(10), done: false, details: 'Regular Expression literals and objects, syntax, methods, string regexp methods.',
},
{
id: 11, task: 'Dates', due: getDueDate(11), done: false, details: 'Number literals, Math Global Object.',
},
{
id: 12, task: 'Objects', due: getDueDate(12), done: false, details: 'Object literals, properties, methods, this, spread operator, static and instance properties and methods.',
},
{
id: 13, task: 'Classes', due: getDueDate(13), done: false, details: 'Class declarations, static and instance properties and methods, and inheritance.',
},
{
id: 14, task: 'Modules', due: getDueDate(14), done: false, details: 'CommonJS require and module.exports, import and export statements.',
},
{
id: 15, task: 'Promises', due: getDueDate(15), done: false, details: 'Callback functions, existing promises, create promises, thenables, async/await.',
},
{
id: 16, task: 'Web APIs', due: getDueDate(16), done: false, details: 'Console, timers, web storage, fetch.',
},
{
id: 17, task: 'DOM', due: getDueDate(17), done: false, details: 'Select and minipulate nodes and elements, traverse the DOM, events, forms.'
},
{
id: 18, task: 'JSON', due: getDueDate(18), done: false, details: 'JSON syntax, JSON global object, stringify and parse methods.'
},
{
id: 19, task: 'Errors', due: getDueDate(19), done: false, details: 'Standard built-in errors, user-defined exceptions, throw, error handling.'
},
];
export default todos;
- Export the todos array at the bottom of the file.
The JavaScript file
- Add a todos.js JavaScript file to the todo-list-intermediate folder. The entire file is below. Excluding empty lines it is about 170 lines of code. Ideally, you should understand what every block of code does, and what every line of code within those blocks does. We already covered the Beginning JavaScript concepts in the first version of this project. Below is the code. We will go over the things that were added.
todos.js
import seedTodos from './seed-todos.js';
const addForm = document.getElementById('add-form');
const task = document.getElementById('task');
const due = document.getElementById('due');
const details = document.getElementById('details');
const seedBtn = document.getElementById('seed-btn');
const clearAllBtn = document.getElementById('clearAll-btn');
const addBtn = document.getElementById('add-btn');
const submitBtn = document.getElementById('submit-btn');
const cancelBtn = document.getElementById('cancel-btn');
const list = document.getElementById('list');
try {
if (!navigator.onLine) {
throw new Error('No Connection. Bootstrap and Bootstrap icon CDNs require an internet connection.');
}
} catch (err) {
const errMsgElem = document.createElement('li');
errMsgElem.classList.add('list-group-item', 'bg-danger', 'px-3');
errMsgElem.textContent = err.toString();
list.insertAdjacentElement('beforebegin', errMsgElem);
}
const createLi = (todo, showDetails = false) => {
const listItem = `<input type="checkbox" data-done class="form-check-input"${todo.done ? ' checked' : ''}/>
<strong${todo.done ? ' class="text-decoration-line-through"' : ''}>${todo.task}</strong>
<i data-remove class="bi bi-x-circle-fill text-danger float-end" role="button"></i>
<i data-edit class="bi bi-pencil-square float-end mx-1 text-dark" role="button"></i>
<i data-details class="bi bi-info-circle-fill text-dark float-end ms-2" role="button"></i>
<span class="float-end align-bottom">${todo.due ? `Due: ${new Date(todo.due).toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })}` : ''}</span>
<p id="details-${todo.id}" class="mb-0${showDetails ? '' : ' d-none'}">${todo.details}</p>`;
return listItem;
};
let highestId = 0;
// Get todos from storage and display
const getTodos = () => {
let todos = { ...localStorage };
todos = Object.values(todos);
if (todos.length > 0) {
todos = todos.map((todo) => JSON.parse(todo));
todos.sort((a, b) => a.id - b.id);
// Alternatively, sort by two properties: done then id.
// todos.sort((a, b) => Number(a.done) - Number(b.done) || b.id - a.id);
highestId = todos[todos.length - 1].id;
let listItems = '';
todos.forEach((todo) => {
listItems += `<li id="${todo.id}" class="list-group-item">${createLi(todo)}</li>`;
});
list.innerHTML = listItems;
} else {
seedBtn.classList.remove('d-none');
}
};
getTodos();
// Seed To Dos.
seedBtn.addEventListener('click', () => {
seedTodos.forEach((todo) => {
localStorage.setItem(todo.id, JSON.stringify(todo));
});
getTodos();
seedBtn.classList.add('d-none');
});
// Display form.
addBtn.addEventListener('click', () => {
addForm.classList.remove('d-none');
});
// Hide form.
cancelBtn.addEventListener('click', () => {
task.value = '';
due.value = '';
details.value = '';
addForm.classList.add('d-none');
});
class Todo {
constructor(task, due, details) {
this.id = highestId + (++Todo.count);
this.task = task;
this.due = due;
this.details = details;
this.done = false;
}
static count = 0;
}
// Handle Add Todo form submission.
submitBtn.addEventListener('click', (e) => {
e.preventDefault();
const taskVal = task.value.trim().replace(/</g, '<').replace(/>/g, '>');
const detailsVal = details.value.trim().replace(/</g, '<').replace(/>/g, '>');
const newTodo = new Todo(taskVal, due.value, detailsVal);
const newTodoJson = JSON.stringify(newTodo);
localStorage.setItem(newTodo.id.toString(), newTodoJson);
const newLi = document.createElement('li');
newLi.id = newTodo.id;
newLi.classList.add('list-group-item');
newLi.innerHTML = createLi(newTodo);
list.append(newLi);
task.value = '';
due.value = '';
details.value = '';
addForm.classList.add('d-none');
if (!seedBtn.classList.contains('d-none')) {
seedBtn.classList.add('d-none');
}
});
// Edit a ToDo Item
const createEdit = (todo) => {
const editForm = `<div class="form-floating mb-2">
<input type="text" id="task-${todo.id}" class="form-control mb-2" value="${todo.task}">
<label for="task">Task:</label>
</div>
<div class="form-floating mb-2">
<input type="date" id="due-${todo.id}" class="form-control mb-2" value="${todo.due.substring(0, 10)}">
<label for="Due">Due:</label>
</div>
<div class="form-floating mb-2">
<textarea id="details-${todo.id}" class="form-control" style="height: 80px">${todo.details}</textarea>
<label for="details">Details:</label>
</div>
<button data-edit-submit class="btn btn-sm btn-outline-light col-md-2">Save Edit</button>
<button data-edit-cancel class="btn btn-sm btn-outline-light col-md-2 float-end">Cancel</button>`;
return editForm;
};
// Handle clicks on todo items.
list.addEventListener('click', (e) => {
const li = e.target.parentElement;
const { id } = li;
// Handle Done
if (e.target.hasAttribute('data-done')) {
const todo = JSON.parse(localStorage.getItem(id));
const isDone = e.target.checked;
if (isDone) {
todo.done = true;
localStorage.setItem(id, JSON.stringify(todo));
e.target.nextElementSibling.classList.add('text-decoration-line-through');
} else {
todo.done = false;
localStorage.setItem(id, JSON.stringify(todo));
e.target.nextElementSibling.classList.remove('text-decoration-line-through');
}
// Handle toggle details
} else if (e.target.hasAttribute('data-details')) {
const todoDetails = document.getElementById(`details-${id}`);
if (todoDetails.classList.contains('d-none')) {
todoDetails.classList.remove('d-none');
} else {
todoDetails.classList.add('d-none');
}
// Handle Edit
} else if (e.target.hasAttribute('data-edit')) {
const todo = JSON.parse(localStorage.getItem(id));
li.classList.add('bg-secondary');
li.innerHTML = createEdit(todo);
// Handle submit edit form
} else if (e.target.hasAttribute('data-edit-submit')) {
const todo = JSON.parse(localStorage.getItem(id));
const editTask = document.getElementById(`task-${id}`);
const editDue = document.getElementById(`due-${id}`);
const editDetails = document.getElementById(`details-${id}`);
todo.task = editTask.value.trim().replace(/</g, '<').replace(/>/g, '>');
todo.due = editDue.value;
todo.details = editDetails.value.trim().replace(/</g, '<').replace(/>/g, '>');
localStorage.setItem(id, JSON.stringify(todo));
li.classList.remove('bg-secondary');
const showDetails = true;
li.innerHTML = createLi(todo, showDetails);
// Handle cancel edit form
} else if (e.target.hasAttribute('data-edit-cancel')) {
const todo = JSON.parse(localStorage.getItem(id));
li.classList.remove('bg-secondary');
const showDetails = true;
li.innerHTML = createLi(todo, showDetails);
// Handle remove todo
} else if (e.target.hasAttribute('data-remove')) {
localStorage.removeItem(id);
e.target.parentElement.remove();
}
});
// Remove all stored Todos
clearAllBtn.addEventListener('click', () => {
if (confirm('Confirm remove all todos') === true) {
localStorage.clear();
list.innerHTML = '';
}
seedBtn.classList.remove('d-none');
});
Throw an error if there is no internet connection.
- The application is styled using the Bootstrap content development network which requires an internet connection.
- Check that the user is online. If not, display an error message.
try {
if (!navigator.onLine) {
throw new Error('No Connection. Bootstrap and Bootstrap icon CDNs require an internet connection.');
}
} catch (err) {
const errMsgElem = document.createElement('li');
errMsgElem.classList.add('list-group-item', 'bg-danger', 'px-3');
errMsgElem.textContent = err.toString();
list.insertAdjacentElement('beforebegin', errMsgElem);
}
- Use the navigator Web API to check if you are online.
- If not, create and throw an error object.
- Put everything in a try...catch statement.
- Catch the error, then in the catch clause:
- Create an li element.
- Add classes to it including "bg-danger" which changes the background color to red.
- Set the textContent to the error message.
- Insert the list item with the error message at the beginning of the list.
Use the localStorage Web API to save To-Do items
- In the Beginning JavaScript version of this app we did not save the To-Do items anywhere. When you close the web page all the To-Do items disappear. That, of course, is a major problem. We need to persist the data somewhere. When web pages save data it is usually to a database, but for our app we will stick with JavaScript and use the localStorage Web API.
- Local storage allows you to save small amounts of data (up to 5MB) in the web browser. The storage has no expiration date and persists between sessions. That means you can close the browser, open it again and the data will still be there.
- Let's see how this works. When the form to create a new To-Do item is filled out and the submit button clicked, the item will be saved in local storage and displayed on the web page. Below is the handler function that executes when the submit button is clicked.
submitBtn.addEventListener('click', (e) => {
e.preventDefault();
const taskVal = task.value.trim().replace(/</g, '<').replace(/>/g, '>');
const detailsVal = details.value.trim().replace(/</g, '<').replace(/>/g, '>');
const newTodo = new Todo(taskVal, due.value, detailsVal);
const newTodoJson = JSON.stringify(newTodo);
localStorage.setItem(newTodo.id.toString(), newTodoJson);
const newLi = document.createElement('li');
newLi.id = newTodo.id;
newLi.classList.add('list-group-item');
newLi.innerHTML = createLi(newTodo);
list.append(newLi);
task.value = '';
due.value = '';
details.value = '';
addForm.classList.add('d-none');
if (!seedBtn.classList.contains('d-none')) {
seedBtn.classList.add('d-none');
}
});
- Let's break this down:
- Event Listener: The Listener method listens for the "click" event on the submit button.
submitBtn.addEventListener('click', handlerFunction);
- Handler function: The handler function gets invoke when the button is clicked. It passes in the event object as the argument (we name it e). The preventDefault() method prevents the form from being automatically submitted to the server.
- Get the values from the task and details fields.
const taskVal = task.value.trim().replace(/</g, '<').replace(/>/g, '>');
const detailsVal = details.value.trim().replace(/</g, '<').replace(/>/g, '>');
- Trim whitespace: The trim() method trims any spaces before or after the values.
- Escape HTML: It the user enters HTML tags in the task or details fields, they will be displayed in the browser as HTML.
- HTML entities: To prevent this, we can escape the "<" and ">" characters by replacing them with with HTML entities. The HTML entities for those characters are < and >. They display as "<" and ">" but are treated as text not HTML.
- Replace(): The replace method uses a regular expression to replace the "<" character with "<" and ">" with ">".
- Instantiate a new Todo item and assign it to the newTodo variable.
const newTodo = new Todo(taskVal, due.value, detailsVal);
- Convert the newTodo object to a JSON string.
const newTodoJson = JSON.stringify(newTodo);
- Add the newTodoJson string to the browser's localStorage.
localStorage.setItem(newTodo.id.toString(), newTodoJson);
- Create a new li (list item) element. Add the id and class. Set the innerHTML by calling the createLi function passing in the newTodo object.
- Append the new list item to the list element.
- Hide the form by adding the "d-none" (display: none) class to the form element.
addForm.classList.add('d-none');
- Hide the seedBtn (seed button) element if it is not already hidden.
Load To-Do items from storage
- When the web page opens, the getTodos function loads the To-Do items from local storage and displays them on the page.
let highestId = 0;
// Get todos from storage and display
const getTodos = () => {
let todos = { ...localStorage };
todos = Object.values(todos);
if (todos.length > 0) {
todos = todos.map((todo) => JSON.parse(todo));
todos.sort((a, b) => a.id - b.id);
highestId = todos[todos.length - 1].id;
let listItems = '';
todos.forEach((todo) => {
listItems += `<li id="${todo.id}" class="list-group-item">${createLi(todo)}</li>`;
});
list.innerHTML = listItems;
} else {
seedBtn.classList.remove('d-none');
}
};
getTodos();
- This block includes an arrow function expression and a function call.
- Let's go through the function body.
- Get all the items from localStorage and assign them to the todos variable. Use the spread operator to put them in an object literal.
let todos = { ...localStorage };
- The items are stored in name-value pairs. The name is the id number. The value is the To-Do object in JSON string format. We just want the values.
todos = Object.values(todos);
- Check if the todos array length is greater than 0.
if (todos.length > 0) {...}
- If array length is greater than 0 then there are To-Do items in local storage.
- Since localStorage only stores strings, the todo items are converted to a JSON string before begin stored.
- When retrieving the todos they must be converted from JSON back into a JavaScript object. Use the Array map and the JSON parse methods.
todos = todos.map((todo) => JSON.parse(todo));
- Sort the todos by their ids from lowest to highest.
todos.sort((a, b) => a.id - b.id);
- Get the highest id number from the list. The last item in the list is the highest id.
highestId = todos[todos.length - 1].id;
- Declare a variable named listItems, then iterate over the todos array. For each item create an li element string. Call the createLi function to set the li element's HTML content.
- Set the list element's innerHTML property to the listItems value.
list.innerHTML = listItems;
- If the todos array length is not greater than 0, then there are no To-Do items in local storage. Display the seed button by removing the "d-none" class.
seedBtn.classList.remove('d-none');
Seed the To-Do items
- The HTML document has a "Seed JavaScript To-Dos" button.
<button id="seed-btn" class="btn btn-primary d-none">Seed JavaScript To-Dos</button>
- Get the button element at the top of the todos.js file and assign it to variable seedBtn.
const seedBtn = document.getElementById('seed-btn');
- The seed-todos.js file is a module that exports an array of To-Do list items.
- Import the seedTodos array at the top of the todos.js file.
import seedTodos from './seed-todos.js';
- Register a listener method on the seedBtn element.
seedBtn.addEventListener('click', () => {
seedTodos.forEach((todo) => {
localStorage.setItem(todo.id, JSON.stringify(todo));
});
getTodos();
seedBtn.classList.add('d-none');
});
- Event listener: When the seedBtn button is clicked, invoke the handler function.
seedBtn.addEventListener('click', () => {...}
- Handler function: In the handler function, chain the forEach iterator to the seedTodos array, and iterate over all the seed To-Do items in the array.
seedTodos.forEach((todo) => {...}
- Storage: Save each todo object to localStorage. The key is the object's id property. The value is the whole object converted to a JSON string.
localStorage.setItem(todo.id, JSON.stringify(todo));
- Call the getTodos function. That will get all the items from localStorage, convert them to HTML li elements, and display them on the list.
- Lastly, hide the seed button by adding the "d-none" class to it.
seedBtn.classList.add('d-none');
Add a due date field to the To-Do items
- The below explanations are specific to the due date field, but they also apply to the To-Do items in general.
- Todo class: Update the Todo class to add the due field.
- Seed To-Dos: Seed To-Do items with the date.
- The seed-todos.js module has a number of To-Do items. Each item has a due date property. To get the value call the getDueDate(num) function. The num argument is the number of days after the current date. So passing in 7 would return a date 7 days from today.
- Example:
{ id: 7, task: 'Text', due: getDueDate(7), done: false, details: 'Text.' }
- getDueDate() function: The getDueDate() function is defined at the top of the seed-todos module file.
- Listener method: There is a listener function listening for the "click" event on the seed button.
- getTodos() function: The getTodos function, gets the To-Do items from localStorage, converts them from JSON strings to a JavaScript object, then iterates over them to create one big string of all the li (list item) elements. Once the iterator is done, the list element's innerHTML is set to the listItems value.
- The todos.forEach() iterator's callback function calls the createLi() function for each todo item. This function creates the li's inner HTML to display on the web page list. Below is the createLi function with just the due date field.
const createLi = (todo, showDetails = false) => {
const listItem = `...
<span class="float-end align-bottom">
${todo.due ?
`Due: ${new Date(todo.due).toLocaleDateString(undefined, { month: 'short', day: 'numeric', timeZone: 'UTC' })}`
: ''}
</span>
...`;
return listItem;
};
- Use the span tag. Span is for inline text.
- Use a ternary conditional statement to check if the due property has a value.
todo.due ? /* if it has a value return this */ : /* if not return this */
- If so, use the toLocaleDateString method to display the date in the format of mmm dd.
- If not, set the value to an empty string so it displays nothing.
- New To-Do form: Add the due date field to the form to create new To-Do items.
- Add the due field to the form on the index.html page.
- When the form is submitted:
- Get the values including the Due date, create a newTodo object, convert it to a JSON string, and store it in localStorage.
- Create a new li (list item) element with the new To-Do item data. Then append it to the end of the list.
const due = document.getElementById('due');
...
submitBtn.addEventListener('click', (e) => {
...
const newTodo = new Todo(taskVal, due.value, detailsVal);
const newTodoJson = JSON.stringify(newTodo);
localStorage.setItem(newTodo.id.toString(), newTodoJson);
const newLi = document.createElement('li');
newLi.id = newTodo.id;
newLi.classList.add('list-group-item');
newLi.innerHTML = createLi(newTodo);
list.append(newLi);
...
});
- Edit a To-Do item: Create an edit form. When submitted, update the local storage and display the updated list.
- Create the edit form: When the user clicks the edit icon
the edit listener method gets triggered.
- Handle the edit form submission:
list.addEventListener('click', (e) => {
...
else if (e.target.hasAttribute('data-edit-submit')) {
const todo = JSON.parse(localStorage.getItem(id));
const editDue = document.getElementById(`due-${id}`);
...
todo.due = editDue.value;
localStorage.setItem(id, JSON.stringify(todo));
...
li.innerHTML = createLi(todo, showDetails);
}
...
}
- When the edit form submit button is clicked get the todo item from local storage.
- Get the edit form values from the web page including the due date.
- Store the new todo object in local storage.
- Display the updated To-Do item on the list.
Conclusion
- This concludes the JavaScript tutorial series. Make sure you understand what all the code blocks do (the bigger picture) and what the individual lines of code do.
- You should be familiar with the JavaScript CheatSheet so you can refer back to the CheatSheet when working on your own projects.