How to build a Todo app with Typescript

cover-picture

In this blog post, we will build a to-do app using typescript. The app allows users to add, delete, and update tasks, and users can mark completed tasks or unmark them. We will follow the Model, View, Controller (MVC) design pattern. Here is the link for the app Todo app with typescript. We will use vite to quickly set up the typescript project. Let's get started!

Setup typescript project using vite

Run the following command in the terminal

npm create vite@latest

It will ask you to enter the project name. we can say Typescript-todo-app. We will be using just typescript in this project so, in Select a framework prompt we need to choose Vanilla. In the next prompt, we need to choose TypeScript. Then, Vite will setup the project for us. To run the project run the following command in the terminal:

cd Typescript-todo-app
npm install
npm run dev

This will run the typescript-configured project. We can delete all unwanted files. I have used bootstrap and uuid to generate a unique id for each task item. So, let's install these packages and import them into the main.ts file

npm install bootstrap uuid
import "bootstrap/dist/css/bootstrap.min.css";
import { v4 as uuid } from "uuid";

Our project files and folders tree looks like the following:

Typescript-todo-app
├── public
├── src
│   ├── controller
│   │   └── TaskListController.ts
│   ├── model
│   │   ├── TaskItem.ts
│   │   └── TaskList.ts
│   ├── view
│   │   └── TaskListView.ts
│   └── main.ts
├── index.html
└── tsconfig.json

Creating model for TaskItem and TaskList

TaskItem

In the model folder create a file TaskItem.ts. In this file, we will define a model for a single task. A single task item will have the following properties

  • Unique id for that we will be using uuid library
  • task actual description of the task itself
  • completed a boolean value indicating whether the task is completed or not, which value will be false by default and can be changed latter
export interface SingleTask {
  id: string;
  task: string;
  completed: boolean;
}

export default class TaskItem implements SingleTask {
  constructor(
    private _id: string = "",
    private _task: string = "",
    private _completed: boolean = false
  ) {}
  get id(): string {
    return this._id;
  }

  set id(id: string) {
    this._id = id;
  }

  get task(): string {
    return this._task;
  }
  set task(task: string) {
    this._task = task;
  }

  get completed(): boolean {
    return this._completed;
  }
  set completed(completed: boolean) {
    this._completed = completed;
  }
}

Here, first, we have defined an interface for a SingleTask. The interface provides the syntax for classes to follow. Any classes implementing a particular interface have to follow the structure provided by that interface. Then, we have defined a class named TaskItem that implements the SingleTask interface. This means that the TaskItem class has to have all properties defined in the interface. The constructor allows us to set the value for id, task, and completed when creating a new TaskItem instance. Plus, we've also defined getter and setter methods for each property. Getters allow us to retrieve the current value of the property, while setters ensure that any changes to the property are handled appropriately. We will see these in action while editing tasks and toggling task status.

TaskList

TaskList.ts will be responsible for managing the collection of TaskItem which includes retrieving tasks, adding, deleting, updating tasks, saving collection of tasks in localStorage, loading tasks from localStorage, and filtering tasks.

import TaskItem from "./TaskItem";

interface AllTask {
 tasks: TaskItem[];
 load(): void;
 save(): void;
 clearTask(): void;
 addTask(taskObj: TaskItem): void;
 removeTask(id: string): void;
 editTask(id: string, updatedTaskText: string): void;
 toggleTaskChange(id: string): void;
 getTaskToComplete(): TaskItem[];
 getCompletedTask(): TaskItem[];
}

export default class TaskList implements AllTask {
 private _tasks: TaskItem[] = [];

 get tasks(): TaskItem[] {
   return this._tasks;
 }

 load(): void {
   const storedTasks: string | null = localStorage.getItem("myTodo");
   if (!storedTasks) return;

   const parsedTaskList: {
     _id: string;
     _task: string;
     _completed: boolean;
   }[] = JSON.parse(storedTasks);
   parsedTaskList.forEach((taskObj) => {
     const newTaskList = new TaskItem(
       taskObj._id,
       taskObj._task,
       taskObj._completed
     );

     this.addTask(newTaskList);
   });
 }

 save(): void {
   localStorage.setItem("myTodo", JSON.stringify(this._tasks));
 }

 clearTask(): void {
   this._tasks = [];
   localStorage.removeItem("myTodo");
 }

 addTask(taskObj: TaskItem): void {
   this._tasks.push(taskObj);
   this.save();
 }

 removeTask(id: string): void {
   this._tasks = this._tasks.filter((task) => task.id !== id);
   this.save();
 }

 editTask(id: string, updatedTaskText: string): void {
   if (updatedTaskText.trim() === "") return;

   const taskToUpdate = this._tasks.find((task) => task.id === id);
   if (!taskToUpdate) return;
   taskToUpdate.task = updatedTaskText;
   this.save();
 }
 toggleTaskChange(id: string): void {
   const taskToUpdateChange = this._tasks.find((task) => task.id === id);
   if (!taskToUpdateChange) return;
   taskToUpdateChange.completed = !taskToUpdateChange.completed;
   this.save();
 }
 getCompletedTask(): TaskItem[] {
   const completedTask = this._tasks.filter((task) => task.completed);
   return completedTask;
 }

 getTaskToComplete(): TaskItem[] {
   const taskToComplete = this._tasks.filter((task) => !task.completed);
   return taskToComplete;
 }
}

In the above code, first, we have imported the TaskItem class and then defined an interface AllTask. This interface outlines the methods a TaskList class should implement to manage tasks. Inside the TaskList class, we have private property _tasks which holds an array of TaskItem. The load method attempts to retrieve a serialized list of tasks from local storage with the key "myTodo". If data is found, it parses the JSON string back into an array of objects with properties matching TaskItem's structure. It then iterates through the parsed data and creates new TaskItem objects, adding them to the internal _tasks array using the addTask method. The save method serializes the current _tasks array into a JSON string and stores it in localStorage with the key "myTodo".

clearTask: This method removes all tasks from the internal list and clears the associated data from localStorage.

addTask: This method adds a new TaskItem object to the beginning of the internal _tasks array and calls the save method to persist the change.

removeTask: This method takes an id as input and filters the _tasks array, keeping only tasks where the id property doesn't match the provided id. It then calls save to persist the change.

editTask: This method takes an id and a updatedTaskText as input. It first checks if the updatedTaskText is empty (trimmed) and returns if so. It then finds the task with the matching id using the find method. If no task is found, it returns. Finally, it updates the task property of the found task object with the provided updatedTaskText and calls save to persist the change. toggleTaskChange: This method takes an id as input. It finds the task with the matching id and flips the value of its completed property (i.e., marks it as completed if pending or vice versa). Finally, it calls save to persist the change.

getCompletedTask: This method returns an array containing only the tasks where the completed property is set to true (completed tasks).

getTaskToComplete: This method returns an array containing only the tasks where the completed property is set to false (pending tasks).

Creating a controller for TaskList

Now, let's create a file named TaskListController.ts inside the controller folder. This class acts as a bridge between view and modal as it interacts with model based on user interaction through the view component which we will create later.

First of all, let's import TaskItem and TaskList classes from the model and define an interface for TaskListController.

import TaskItem from "../model/TaskItem";
import TaskList from "../model/TaskList";

interface Controller {
  getTaskList(): TaskItem[];
  addTask(newTask: TaskItem): void;
  deleteTask(taskId: string): void;
  editTask(taskId: string, updatedTaskText: string): void;
  loadTask(): void;
  clearTask(): void;
  saveTask(): void;
  toggleTaskChange(taskId: string): void;
  getPendingTask(): TaskItem[];
  getCompletedTask(): TaskItem[];
}

Now, create a class TaskListController that implements the Controller interface. This ensures the class provides all the functionalities defined in the interface.


export default class TaskListController implements Controller {
  private _taskList: TaskList = new TaskList();

  constructor() {
    
    this.loadTask();
  }

  getTaskList(): TaskItem[] {
    return this._taskList.tasks;
  }

  addTask(newTask: TaskItem): void {
    this._taskList.addTask(newTask);
  }

  deleteTask(taskId: string): void {
    this._taskList.removeTask(taskId);
  }

  editTask(taskId: string, updatedTaskText: string): void {
    this._taskList.editTask(taskId, updatedTaskText);
  }

  getCompletedTask(): TaskItem[] {
    const completedTask = this._taskList.getCompletedTask();
    return completedTask;
  }

  getPendingTask(): TaskItem[] {
    const pendingTask = this._taskList.getTaskToComplete();
    return pendingTask;
  }

  clearTask(): void {
    this._taskList.clearTask();
  }
  loadTask(): void {
    this._taskList.load();
  }
  saveTask(): void {
    this._taskList.save();
  }

  toggleTaskChange(taskId: string): void {
    this._taskList.toggleTaskChange(taskId);
  }
}

Inside the class, a private property _taskList is declared and initialized with a new instance of the TaskList class. This _taskList object handles the actual storage and manipulation of tasks. The constructor gets called whenever a new TaskListController object is created. Inside the constructor, it calls the loadTask method, to retrieve any previously saved tasks from persistent storage and populate the internal _taskList object.

The class defines several methods each of these methods simply calls the corresponding method on the internal _taskList object. For instance, getTaskList calls this._taskList.tasks to retrieve the task list, addTask calls this._taskList. By delegating the work to the _taskList object, the TaskListController acts as a facade, providing a convenient interface for interacting with the task management functionalities.

Creating a view for TaskList

Let's create a file named TaskListView.ts inside a folder view. We will create a class HTMLTaskListView, which is responsible for rendering the tasks on the web page and managing user interactions. First, let's create an interface for this class.

import TaskItem from "../model/TaskItem";
import TaskListController from "../controller/TaskListController";

interface DOMList {
 clear(): void;
 render(allTask: TaskItem[]): void;
}

In the above interface DOMList, we only have two methods. These two methods will be public and are responsible for rendering tasks and clearing all tasks. Now let's look at HTMLTaskListView class.


export default class HTMLTaskListView implements DOMList {
 private ul: HTMLUListElement;
 private taskListController: TaskListController;
 constructor(taskListController: TaskListController) {
   this.ul = document.getElementById("taskList") as HTMLUListElement;
   this.taskListController = taskListController;

   if (!this.ul)
     throw new Error("Could not find html ul element in html document.");
 }

 clear(): void {
   this.ul.innerHTML = "";
 }

 private createTaskListElement(task: TaskItem): HTMLLIElement {
   const li = document.createElement("li") as HTMLLIElement;
   li.className = "list-group-item d-flex gap-3 align-items-center";
   li.dataset.taskId = task.id;

   const checkBox = this.createCheckBox(task);
   const label = this.createLabel(task);
   const editTaskInput = this.createEditTaskInput();
   const [saveButton, editButton] = this.createEditAndSaveButton(
     editTaskInput,
     label,
     task
   );
   const deleteButton = this.createDeleteButton(task);

   li.append(
     checkBox,
     editTaskInput,
     label,
     editButton,
     saveButton,
     deleteButton
   );
   return li;
 }

 private createCheckBox(task: TaskItem): HTMLInputElement {
   const checkBox = document.createElement("input") as HTMLInputElement;
   checkBox.type = "checkbox";
   checkBox.checked = task.completed;
   checkBox.addEventListener("change", () => {
     this.taskListController.toggleTaskChange(task.id);
   });
   return checkBox;
 }

 private createEditTaskInput(): HTMLInputElement {
   /// input field to edit task
   const editTaskInput = document.createElement("input") as HTMLInputElement;
   editTaskInput.hidden = true;
   editTaskInput.type = "text";
   editTaskInput.className = "form-control";
   return editTaskInput;
 }

 private createLabel(task: TaskItem): HTMLLabelElement {
   const label = document.createElement("label") as HTMLLabelElement;
   label.htmlFor = task.id;
   label.textContent = task.task;
   return label;
 }

 private createEditAndSaveButton(
   editTaskInput: HTMLInputElement,
   label: HTMLLabelElement,
   task: TaskItem
 ): HTMLButtonElement[] {
   const saveButton = document.createElement("button") as HTMLButtonElement;
   saveButton.hidden = true;
   saveButton.className = "btn btn-warning btn-sm";
   saveButton.textContent = "Save";

   const editButton = document.createElement("button") as HTMLButtonElement;
   editButton.className = "btn btn-success btn-sm";
   editButton.textContent = "Edit";

   saveButton.addEventListener("click", () => {
     const updatedTaskText = editTaskInput.value;
     task.task = updatedTaskText;
     this.taskListController.editTask(task.id, updatedTaskText);
     saveButton.hidden = true;
     editButton.hidden = false;
     editTaskInput.hidden = true;
     this.render(this.taskListController.getTaskList());
   });

   editButton.addEventListener("click", () => {
     saveButton.hidden = false;
     editTaskInput.hidden = false;
     editTaskInput.value = task.task;
     label.innerText = "";
     editButton.hidden = true;
   });

   return [saveButton, editButton];
 }

 private createDeleteButton(task: TaskItem): HTMLButtonElement {
   const deleteButton = document.createElement("button") as HTMLButtonElement;
   deleteButton.className = "btn btn-primary btn-sm";
   deleteButton.textContent = "Delete";

   deleteButton.addEventListener("click", () => {
     this.taskListController.deleteTask(task.id);
     this.render(this.taskListController.getTaskList());
   });
   return deleteButton;
 }

 render(allTask: TaskItem[]): void {
   this.clear();

   allTask.forEach((task) => {
     const li = this.createTaskListElement(task);
     this.ul.append(li);
   });
 }
}

Constructor and Initialization

The constructor initializes the HTMLTaskListView class. It sets up essential properties and ensures that the necessary DOM elements are available.

constructor(taskListController: TaskListController) {
  this.ul = document.getElementById("taskList") as HTMLUListElement;
  this.taskListController = taskListController;

  if (!this.ul)
    throw new Error("Could not find html ul element in html document.");
}

The properties taskListController is an instance of TaskListController that manager the tasks and ul is a reference to the HTML unordered list element where tasks will be displayed.

Clearing the TaskList

The clear method removes all child elements from the ul element, effectively clearing the task list displayed on the webpage.

clear(): void {
  this.ul.innerHTML = "";
}

Creating Task List Elements

  • createTaskListElement(task: TaskItem): HTMLLIElement creates an individual task item element (li) and appends various components such as checkboxes, labels, edit inputs, and buttons and we have methods for each of these components.

  • createEditTaskInput(): HTMLInputElement generates an input field used for editing the task description, initially hides the input field, and will show when the user clicks on Edit button.

  • createLabel(task: TaskItem): HTMLLabelElement creates a label element to display the task description.

  • createEditAndSaveButton(editTaskInput: HTMLInputElement, label: HTMLLabelElement, task: TaskItem): HTMLButtonElement[] generates buttons for editing and saving task descriptions and manages their visibility and actions.

  • createDeleteButton(task: TaskItem): HTMLButtonElement creates a delete button and when clicked removes tasks from the list.

Rendering the Task List

render method takes an array of TaskItem representing all tasks to be displayed, clears the current task list, and populates it with the provided tasks.

Creating an add task form

So far, we have created a Model, View, and Controller for our Todo app. Now, it's time to look into index.html page. Where we will create a form where users can write a task and add it to our to-do app plus we will create buttons to show completed tasks, tasks to complete, and clear all tasks.

<body>
    <div class="container" style="max-width: 800px;">
      
<h2>Todo list app with TypeScript</h2>
<form class="m-3" id="todo-form">
  <div class="form-group p-2 d-flex">
    <input name="new-todo" class="form-control" type="text" id="new-todo" placeholder="Add new todo"/>
    <button type="submit" class="btn btn-primary mx-2">Add</button>
  </div>
</form>


<section  style="max-width: 600px;">


  <div class="btn-group my-2" role="group" aria-label="Basic mixed styles example">
    <button type="button" id="all-task" class="btn btn-danger">All Tasks</button>
    <button type="button" id="completed-task" class="btn btn-warning">Completed Task</button>
    <button type="button" id="task-to-complete" class="btn btn-success">Task To Complete</button>
    <buttonc type="button" id="clear-btn" class="btn btn-secondary">Clear All</button>
  </div>

<ul id="taskList" class="list-group">

</ul>

</section>

    </div>
  
  </body>

todo-app

Connecting Model, View, Controller

In, MVC architecture, View captures user interaction such as new task is added, the delete button is clicked, and sends them to the Controller. Then, the Controller acts as an intermediary between the Model and the View. It updates the Model such as adding a new task to the task list, deleting a task from the list, or updating a single task. We have already created a Model, View, and Controller. Now let's connect them together. In main.ts file let's import the required classes and initialize Controller and View.

Importing and Initializing the Controller and View

import TaskItem from "./model/TaskItem";
import TaskListController from "./controller/TaskListController";
import HTMLTaskListView from "./view/TaskListView";

const taskListController = new TaskListController();
const taskListView = new HTMLTaskListView(taskListController);
  • taskListController: An instance of TaskListController that manages the task data and business logic.

  • taskListView: An instance of HTMLTaskListView that takes the taskListController as an argument. This view will handle rendering tasks on the webpage.

Accessing DOM Elements

const todoForm = document.getElementById("todo-form") as HTMLFormElement;
const clearBtn = document.getElementById("clear-btn") as HTMLButtonElement;
const showCompletedTask = document.getElementById("completed-task") as HTMLButtonElement;
const showTaskToComplete = document.getElementById("task-to-complete") as HTMLButtonElement;
const showAllTask = document.getElementById("all-task") as HTMLButtonElement;
  • todoForm: The form where new tasks are added.
  • clearBtn: The button to clear all tasks.
  • showCompletedTask: The button to filter and display completed tasks.
  • showTaskToComplete: The button to filter and display pending tasks.
  • showAllTask: The button to display all tasks.

Initializing the Application

const initApp = () => {
  const allTask = taskListController.getTaskList();
  taskListView.render(allTask);
};

initApp()
  • initApp: A function that initializes the application by fetching all tasks from the controller and rendering them using the view. We have called the initApp function right away to ensure that the task list is rendered when the application loads.

Adding a New Task

if (todoForm) {
  todoForm.addEventListener("submit", (e) => {
    e.preventDefault();
    const formData = new FormData(todoForm);
    const todoValue = formData.get("new-todo") as string;
    if (todoValue === null || todoValue?.toString().trim() === "") return;
    const newTask = new TaskItem(uuid(), todoValue.trim());

    taskListController.addTask(newTask);

    initApp();

    todoForm.reset();
  });
}

On submission, todoForm will do the following:

  1. Prevent the default form submission behavior.
  2. Extract the new task description from the form.
  3. Validate the task description (non-empty and non-null).
  4. Create a new TaskItem with a unique ID and the trimmed task description.
  5. Add the new task to the controller.
  6. Reinitialize the application to render the updated task list.
  7. Reset the form.

Clearing All Tasks

clearBtn.addEventListener("click", () => {
  taskListController.clearTask();
  taskListView.clear();
});

Showing Completed Tasks

showCompletedTask.addEventListener("click", () => {
  const completedTask = taskListController.getCompletedTask();
  taskListView.render(completedTask);
});

Showing Tasks to Complete

showTaskToComplete.addEventListener("click", () => {
  const taskToComplete = taskListController.getPendingTask();
  taskListView.render(taskToComplete);
});

Conclusion

In Conclusion, our todo application built using the MVC architecture demonstrates a robust and maintainable structure for managing tasks. By breaking down the responsibilities into distinct components—Model, View, and Controller—the application achieves a clear separation of concerns, which enhances both scalability and maintainability.

Comments

No comments