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 the self parameter instead of this 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.

  1. Create three new files: Todo.js, TodoList.js, and TodoApp.js.
  2. 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: The export statement is used to export values from a module so they can be used in other modules with the import statement.
  • import: The import 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 from RegularTodo and add additional properties and methods related to the deadline.
  • PriorityTodo: A todo with a priority level. Again, this can inherit from RegularTodo 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.