Task 5: Object-Oriented Programming
In the previous task, we have refactored our todo app using a functional programming approach, which makes the code more modular and easier to maintain. By using closure, higher-order functions, and immutable data structures, we align with functional programming principles, making the code more predictable and less prone to bugs. In this section, we will explore an object-oriented approach to further improve our app's structure and organization.
Step 1: Clean up main.js
Let’s start with a clean slate!
import "../style.css";
Step 2: Define the Todo
Class
Next, we'll define a Todo
class that represents an individual todo item.
class Todo {
static nextId = 1;
constructor(text) {
this.id = Todo.nextId++;
this.text = text;
this.completed = false;
}
toggle() {
this.completed = !this.completed;
}
}
Todo
is a "class"; notice the syntax:
- We use the
class
keyword to declare it. - There is a special function called
constructor
, which is the class constructor. - The attributes of the class are initialized in the constructor using the
this
keyword. Unlike Java/C++, you don't declare fields. The syntax here is similar to Python class definition. (Python classes are constructed through a special method called__init__()
, which uses theself
parameter instead ofthis
to reference the class's current instance.) - Methods are declared using the method declaration syntax. (This syntax was explored in the chapter on JavaScript Functions).
- Inside a class declaration, unlike in an object literal, method definitions are not separated by a comma.
In the given code, static nextId = 1;
is a static property of the Todo
class. A static property is a property that belongs to the class itself, rather than to instances of the class. In this case, the nextId
property keeps track of the next available ID for a new Todo
instance.
By declaring nextId
as a static property, all instances of the Todo
class share the same nextId
value. This allows us to generate a unique ID for each new Todo
instance without having to worry about conflicts or manually incrementing the ID.
In the constructor, this.id = Todo.nextId++;
assigns the current value of nextId
to the new Todo
instance's id
property and then increments nextId
so that the next new Todo
instance will have a unique ID.
Step 3: Define FILTERS
constant
const FILTERS = {
ALL: "all",
ACTIVE: "active",
COMPLETED: "completed",
};
The FILTERS
constant defines the possible filter states for our application. It is an object that maps filter names to their respective values: ALL
, ACTIVE
, and COMPLETED
. Similar to enums in Java or C++, FILTERS
provides a way to define a set of named values that can be used consistently throughout the code. Note that in JavaScript, there is no enum
keyword as in some other programming languages.
Step 4: Define the TodoList
Class
Next, we'll define a TodoList
class that represents a list of todos.
class TodoList {
constructor() {
this.todos = [];
}
addTodo(text) {
const todo = new Todo(text);
this.todos.push(todo);
}
// pre: todos' text are unique
toggleTodo(todoText) {
const todo = this.todos.find((t) => t.text === todoText);
if (todo) {
todo.toggle();
}
}
getVisibleTodos(filter) {
switch (filter) {
case FILTERS.ACTIVE:
return this.todos.filter((todo) => !todo.completed);
case FILTERS.COMPLETED:
return this.todos.filter((todo) => todo.completed);
default:
return this.todos;
}
}
}
Step 5: Define the TodoApp
Class
This class will encapsulate the todo application, including rendering logic and event handlers.
class TodoApp {
constructor() {
this.todoList = new TodoList();
this.filter = FILTERS.ALL;
}
renderTodos() {
const visibleTodos = this.todoList.getVisibleTodos(this.filter);
const todoListElement = document.getElementById("todo-list");
todoListElement.innerHTML = "";
visibleTodos.forEach((todo) => {
const todoElement = document.createElement("div");
todoElement.className = "p-4 todo-item";
const todoText = document.createElement("div");
todoText.className = "todo-text";
todoText.textContent = todo.text;
if (todo.completed) {
todoText.style.textDecoration = "line-through";
}
todoElement.appendChild(todoText);
todoListElement.appendChild(todoElement);
});
}
setFilter(newFilter) {
this.filter = newFilter;
}
updateFilterStyles(selectedFilter) {
const filters = document.querySelectorAll("#todo-nav a");
filters.forEach((filter) => {
filter.classList.remove(
"underline",
"underline-offset-4",
"decoration-rose-800",
"decoration-2",
);
});
selectedFilter.classList.add(
"underline",
"underline-offset-4",
"decoration-rose-800",
"decoration-2",
);
}
handleNewTodoKeyDown(event) {
if (event.key === "Enter" && event.target.value.trim() !== "") {
this.todoList.addTodo(event.target.value.trim());
event.target.value = "";
this.renderTodos();
}
}
handleTodoClick(event) {
if (event.target.classList.contains("todo-text")) {
this.todoList.toggleTodo(event.target.textContent);
this.renderTodos();
}
}
handleFilterClick(event) {
if (event.target.tagName === "A") {
const newFilter =
event.target.getAttribute("href").slice(2) || FILTERS.ALL;
this.setFilter(newFilter);
this.renderTodos();
this.updateFilterStyles(event.target);
}
}
init() {
document
.getElementById("new-todo")
.addEventListener("keydown", this.handleNewTodoKeyDown.bind(this));
document
.getElementById("todo-list")
.addEventListener("click", this.handleTodoClick.bind(this));
document
.getElementById("todo-nav")
.addEventListener("click", this.handleFilterClick.bind(this));
this.renderTodos();
}
}
In the constructor of TodoApp
, a new instance of the TodoList
class is created to represent the app's state. The renderTodos
function and various event handlers will remain largely the same but will now use methods from our TodoList
instance. The init
method binds the event listeners to the event handlers. Let's focus on one of these event handlers:
document
.getElementById("new-todo")
.addEventListener("keydown", this.handleNewTodoKeyDown.bind(this));
The handleNewTodoKeyDown
is invoked when you press a key down. The handleNewTodoKeyDown
uses the this
keyword to access the todoList
and call the renderTodos
method. However, when the handleNewTodoKeyDown
is called by addEventListener
, its execution context is not the TodoApp
anymore. Instead, it is the execution context of addEventListener
. Therefore, we need to explicitly bind its this
keyword to the TodoApp
when passing it as an argument to addEventListener
. This point is much harder to figure out on your own if I had not told you about it 😜.
Alternatively, if you declare the handleNewTodoKeyDown
as an arrow function like this:
handleNewTodoKeyDown = (event) => {
if (event.key === "Enter" && event.target.value.trim() !== "") {
this.todoList.addTodo(event.target.value.trim());
event.target.value = "";
this.renderTodos();
}
}
Then you don't need to bind its this
keyword! (Think why?)
document
.getElementById("new-todo")
.addEventListener("keydown", this.handleNewTodoKeyDown);
Step 6: Instantiate and Initialize the App
To instantiate our TodoApp
and add an event listener to the window
object, follow these steps:
const app = new TodoApp();
window.addEventListener("DOMContentLoaded", () => app.init());
Notice the use of the new
keyword to create an instance of the TodoApp
class. This will be familiar to Java or C++ programmers!
Once the DOM content is fully loaded, the init
function of the TodoApp
will be called, and the event listeners will be set in motion.
Step 7: Create Separate Files
As applications grow in size and complexity, it becomes essential to organize the code in a modular way. By splitting our code into separate modules (files), we can achieve better maintainability, reusability, and clarity. Each module should ideally have a single responsibility.
- Create three new files:
Todo.js
,TodoList.js
, andTodoApp.js
. - Move Each Class to Its File
Todo.js:
class Todo {
static nextId = 1;
constructor(text) {
this.id = Todo.nextId++;
this.text = text;
this.completed = false;
}
toggle() {
this.completed = !this.completed;
}
}
export default Todo;
TodoList.js:
import Todo from './Todo';
export const FILTERS = {
ALL: "all",
ACTIVE: "active",
COMPLETED: "completed",
};
class TodoList {
// ... (rest of the TodoList class code)
}
export default TodoList;
TodoApp.js:
import TodoList, { FILTERS } from './TodoList';
class TodoApp {
// ... (rest of the TodoApp class code)
}
export default TodoApp;
Update the main.js File
In your main.js
file, you'll now import the classes from their respective files:
import "../style.css";
import TodoApp from "./TodoApp";
const app = new TodoApp();
window.addEventListener("DOMContentLoaded", () => app.init());
ES6 Modules
Notice the import and export statements. These statements were introduced in ES6, or ECMAScript 2015, the sixth major version of the ECMAScript, a standard for scripting languages, including JavaScript, JScript, and ActionScript.
With ES6 modules, we can export and import functions, objects, or primitives from one module (file) to another. This allows for better encapsulation and separation of concerns.
export
: Theexport
statement is used to export values from a module so they can be used in other modules with theimport
statement.import
: Theimport
statement is used to bring in exports from another module. You specify the name of the exported item in curly braces{ }
and the path to the module file.- You can mark one of the exported values as the default export from a module. This makes it easy to import in another file using syntax such as
import Todo from './Todo'
.
Benefits:
- Encapsulation: By using modules, we can hide the internal details of a module and expose only what's necessary. This is a core concept in software design.
- Namespace Management: Modules help in avoiding naming collisions in the global namespace. Since each module has its own scope, the same name can exist in different modules without any conflict.
- Reusability: Modules can be reused across different parts of the application or even in different applications.
Note on Paths: When importing modules, the path can be relative or absolute. In our case, we're using relative paths. The ./
at the beginning of the path indicates that the module is in the same directory as the current module.
With these changes, we've encapsulated the entire application within the TodoApp
class. This class manages the todo list, rendering, and event handlers. The TodoList
class manages the list of todos and provides methods to manipulate them. The Todo
class represents individual todos. This structure makes our code more organized and maintainable.
By following this modular approach, not only is our codebase more organized, but it's also more scalable and maintainable. As the application grows, or if we decide to use some of these classes in another project, it becomes much easier to manage.
Step 8: Add more features
Add the following method to the TodoList
class to mark all todos as completed, to clear completed todos, and to get the count of active (uncompleted) todos.
class TodoList {
// ... (rest of the TodoList class code)
markAllAsComplete() {
this.todos.forEach((todo) => (todo.completed = true));
}
clearCompleted() {
this.todos = this.todos.filter((todo) => !todo.completed);
}
getActiveTodoCount() {
return this.todos.filter((todo) => !todo.completed).length;
}
}
Next, add the following methods to the TodoApp
class and make updates to the renderTodos
and init
methods:
class TodoApp {
// ... (rest of the TodoApp class code)
renderTodos() {
// ... (rest of the renderTodos method code)
// Update todo count
const activeTodoCount = this.todoList.getActiveTodoCount();
document.getElementById(
"todo-count",
).textContent = `${activeTodoCount} items left`;
}
handleMarkAllCompletedClick() {
this.todoList.markAllAsComplete();
this.renderTodos();
}
handleClearCompletedClick() {
this.todoList.clearCompleted();
this.renderTodos();
}
init() {
// ... (rest of the init method)
document
.getElementById("mark-all-completed")
.addEventListener("click", this.handleMarkAllCompletedClick.bind(this));
document
.getElementById("clear-completed")
.addEventListener("click", this.handleClearCompletedClick.bind(this));
this.renderTodos();
}
}
Once you run the app, you will be able to work with the "todo actions".
Note that the feature "Double-click to edit a todo" has not been implemented. You will be doing something similar to this in your homework!
Step 9. Inheritance and Polymorphism
We did not cover inheritance and polymorphism in this app. We will cover these concepts in the future. Introducing inheritance and polymorphism in this app without making it feel forced can be a challenge. However, one way to do it is by introducing different types of todos, such as:
RegularTodo
: A standard todo with basic functionality.TimedTodo
: A todo with a deadline. This can inherit fromRegularTodo
and add additional properties and methods related to the deadline.PriorityTodo
: A todo with a priority level. Again, this can inherit fromRegularTodo
and add properties and methods related to priority.
Using polymorphism, we can treat all these todo types as regular todos when rendering them, but each type can have its own unique rendering logic. For instance, a TimedTodo
might be displayed with a countdown timer, while a PriorityTodo
might have a color-coded priority level.
Here's a basic setup:
class RegularTodo extends Todo {
render() {
// Logic to render a regular todo
}
}
class TimedTodo extends Todo {
constructor(text, deadline) {
super(text);
this.deadline = deadline;
}
render() {
// Logic to render a timed todo with a deadline
}
}
class PriorityTodo extends Todo {
constructor(text, priority) {
super(text);
this.priority = priority;
}
render() {
// Logic to render a priority todo with a color-coded priority level
}
}
In the TodoApp
class, when rendering todos, you can use polymorphism:
visibleTodos.forEach((todo) => {
todo.render();
});
Each todo will use its own render
method, allowing for different types of todos to be displayed differently.
This approach introduces inheritance (with TimedTodo
and PriorityTodo
inheriting from RegularTodo
) and polymorphism (with each todo type having its own render
method). However, it does complicate the app a bit, so we’ve avoided it.