Building ToDo in Real-Time

ivanzm123

Ivan Zaldivar

Posted on September 27, 2021

Building ToDo in Real-Time

I want to start with a question Have you ever wondered how applications such as Messenger, WhatsApp can update new messages without the need to refresh the page? Well, in this article we are developing a ToDo with real-time communication so that you can better understand how it works.

Preview

The end of this tutorial you will have the following result.

Preview Functionality

Prerequisites.

  1. Have Node.js installed.
  2. Have a code editor installed (in my case VSCode)

Creating project.

Create a project on my desktop with the name we want to assign it.

mkdir todo-realtime

cd todo-realtime

code .

Initialize project.

Execute the following commands.

npm init -y

tsc --init

npm init -y: This command generate a file named package.json in the root of the project, this file contains all the necessary settings for your application to work properly. For more information https://docs.npmjs.com/cli/v7/configuring-npm/package-json
tsc --init: This command generate a file name tsconfig.json This file is similar to a package.json with the difference that it sets up a project with TypeScript. For more information https://www.typescriptlang.org/docs/handbook/tsconfig-json.html

Perfect, once the above explained, we download some packages.

npm i @feathersjs/feathers @feathersjs/socketio @feathersjs/express

npm i nodemon -D

Setting server.

Now, we are going to configure our project.

Create a file nodemon.json. This file will be in charge of updating your application every time we make changes to our files that end in .ts

> nodemon.json

{
  "watch": ["src"],
  "ext": "ts,json",
  "ignore": ["src/**/*.spec.ts", "node_modules"],
  "exec": "ts-node ./src/index.ts"
}
Enter fullscreen mode Exit fullscreen mode

We update the package.json file and add the following content.

> package.json

{
  // ...
  "scripts": {
    "serve": "nodemon",
    "start": "node ./src/index.ts"
  },
  //  ...
}
Enter fullscreen mode Exit fullscreen mode

Now we create the directory src/index.ts For verify all correct add the following content and execute npm run serve

> src > index.ts

console.log("Hello world developers ♥");
Enter fullscreen mode Exit fullscreen mode

If everything is correct, we see this in the console.

Print message in the console;

Perfect, this is all to configure.

Developing the dev server.

What we are going to do is create a simple development server that can add notes, and later we will add Real-Time support to it. Copy the following content.

> src > index.ts

import feathers from "@feathersjs/feathers";
import express, { Application } from "@feathersjs/express";

const app: Application = express(feathers());

// Allows interpreting json requests.
app.use(express.json());
// Allows interpreting urlencoded requests.
app.use(express.urlencoded({ extended: true }));
// Add support REST-API.
app.configure(express.rest());

// Use error not found.
app.use(express.notFound());
// We configure the errors to send a json.
app.use(express.errorHandler({ html: false }));

app.listen(3030, () => {
  console.log("App execute in http://localhost:3030");
});

Enter fullscreen mode Exit fullscreen mode

Setting our service.

According to the official Feathers documentation. The Services are the heart of every Feathers application. Services are JavaScript objects (or instances of ES6 classes) that implement certain methods. Feathers itself will also add some additional methods and functionality to its services.

Imports modules an defined interfaces.

src > services > note.service.ts

import { Id, Params, ServiceMethods } from "@feathersjs/feathers";
import { NotFound } from "@feathersjs/errors";

export enum Status {
  COMPLETED = "completed",
  PENDING = "pending"
}

export interface Note {
  id: Id;
  name: string;
  status: Status;
  createdAt: string;
  updatedAt: string;
}
Enter fullscreen mode Exit fullscreen mode

Define class.


export class NoteService implements ServiceMethods<Note> {
  private notes: Note[] = [];
  /**
   * Get list of note.
   */
  find(params?: Params): Promise<Note[]> {
    throw new Error("Method not implemented.");
  }
  /**
   * Get on note.
   */
  get(id: Id, params?: Params): Promise<Note> {
    throw new Error("Method not implemented.");
  }
  /**
   * Create a new note.
   */
  create(
    data: Partial<Note> | Partial<Note>[],
    params?: Params
  ): Promise<Note> {
    throw new Error("Method not implemented.");
  }
  /**
   * Udate note.
   */
  update(
    id: NullableId,
    data: Note,
    params?: Params
  ): Promise<Note> {
    throw new Error("Method not implemented.");
  }
  /**
   * Partially update a note.
   */
  patch(
    id: NullableId,
    data: Partial<Note>,
    params?: Params
  ): Promise<Note> {
    throw new Error("Method not implemented.");
  }
  /**
   * Delete a note.
   */
  remove(id: NullableId, params?: Params): Promise<Note> {
    throw new Error("Method not implemented.");
  }
}

Enter fullscreen mode Exit fullscreen mode

We added functionality to the methods.

NoteService.create


  async create(
    data: Pick<Note, "name">,
    _?: Params
  ): Promise<Note> {
    const note: Note = {
      id: this.notes.length + 1,
      name: data.name,
      status: Status.PENDING,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    };

    this.notes.unshift(note);
    return note;
  }

Enter fullscreen mode Exit fullscreen mode

NoteService.find


  async find(_?: Params): Promise<Note[]> {
    return this.notes;
  }
Enter fullscreen mode Exit fullscreen mode

NoteService.get

  async get(id: Id, _?: Params) {
    const note: Note | undefined = this.notes.find(
      note => Number(note.id) === Number(id)
    );
    if (!note) throw new NotFound("The note does not exist.");
    return note;
  }
Enter fullscreen mode Exit fullscreen mode

NoteService.update

  async update(id: Id, data: Note, _?: Params): Promise<Note> {
    const index: number = this.notes.findIndex(
      note => Number(note.id) === Number(id)
    );
    if (index < 0) throw new NotFound("The note does not exist");

    const { createdAt }: Note = this.notes[index];
    const note: Note = {
      id,
      name: data.name,
      status: data.status,
      createdAt,
      updatedAt: new Date().toISOString(),
    };
    this.notes.splice(index, 1, note);
    return note;
  }
Enter fullscreen mode Exit fullscreen mode

NoteService.patch

  async patch(id: Id, data: Partial<Note>, _?: Params): Promise<Note> {
    const index: number = this.notes.findIndex(
      note => Number(note.id) === Number(id)
    );
    if (index < 0) throw new NotFound("The note does not exist");

    const note: Note = this.notes[index];
    data = Object.assign({ updatedAt: new Date().toISOString() }, data);
    const values = Object.keys(data).reduce((prev, curr) => {
      return { ...prev, [curr]: { value: data[curr as keyof Note] } };
    }, {});
    const notePatched: Note = Object.defineProperties(note, values);
    this.notes.splice(index, 1, notePatched);
    return note;
  }

Enter fullscreen mode Exit fullscreen mode

NoteService.remove

  async remove(id: Id, _?: Params): Promise<Note> {
    const index: number = this.notes.findIndex(
      note => Number(note.id) === Number(id)
    );
    if (index < 0) throw new NotFound("The note does not exist");

    const note: Note = this.notes[index];
    this.notes.splice(index, 1);
    return note;
  }
Enter fullscreen mode Exit fullscreen mode

Final result.

src > note.service.ts

import { Id, Params, ServiceMethods } from "@feathersjs/feathers";
import { NotFound } from "@feathersjs/errors";

export enum Status {
  COMPLETED = "completed",
  PENDING = "pending"
}

export interface Note {
  id: Id;
  name: string;
  status: Status;
  createdAt: string;
  updatedAt: string;
}

export class NoteService implements Partial<ServiceMethods<Note>> {
  private notes: Note[] = [
    {
      id: 1,
      name: "Guns N' Roses",
      status: Status.COMPLETED,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    },
    {
      id: 2,
      name: "Motionless In White",
      status: Status.PENDING,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    },
  ];

  async create(
    data: Pick<Note, "name">,
    _?: Params
  ): Promise<Note> {
    const note: Note = {
      id: this.notes.length + 1,
      name: data.name,
      status: Status.PENDING,
      createdAt: new Date().toISOString(),
      updatedAt: new Date().toISOString(),
    };

    this.notes.unshift(note);
    return note;
  }

  async find(_?: Params): Promise<Note[]> {
    return this.notes;
  }

  async get(id: Id, _?: Params) {
    const note: Note | undefined = this.notes.find(
      note => Number(note.id) === Number(id)
    );
    if (!note) throw new NotFound("The note does not exist.");
    return note;
  }

  async update(id: Id, data: Note, _?: Params): Promise<Note> {
    const index: number = this.notes.findIndex(
      note => Number(note.id) === Number(id)
    );
    if (index < 0) throw new NotFound("The note does not exist");

    const { createdAt }: Note = this.notes[index];
    const note: Note = {
      id,
      name: data.name,
      status: data.status,
      createdAt,
      updatedAt: new Date().toISOString(),
    };
    this.notes.splice(index, 1, note);
    return note;
  }

  async patch(id: Id, data: Partial<Note>, _?: Params): Promise<Note> {
    const index: number = this.notes.findIndex(
      note => Number(note.id) === Number(id)
    );
    if (index < 0) throw new NotFound("The note does not exist");

    const note: Note = this.notes[index];
    data = Object.assign({ updatedAt: new Date().toISOString() }, data);

    const values = Object.keys(data).reduce((prev, curr) => {
      return { ...prev, [curr]: { value: data[curr as keyof Note] } };
    }, {});
    const notePatched: Note = Object.defineProperties(note, values);

    this.notes.splice(index, 1, notePatched);
    return note;
  }

  async remove(id: Id, _?: Params): Promise<Note> {
    const index: number = this.notes.findIndex(
      note => Number(note.id) === Number(id)
    );
    if (index < 0) throw new NotFound("The note does not exist");

    const note: Note = this.notes[index];
    this.notes.splice(index, 1);
    return note;
  }
}


Enter fullscreen mode Exit fullscreen mode

Once our service is configured, it is time to use it.

src > index.ts

import { NoteService } from "./services/note.service";

// Define my service.
app.use("/notes", new NoteService());
Enter fullscreen mode Exit fullscreen mode

Note: It is very important that services are defined before error handlers.

Now, we test app. Enter to http://localhost:3030/notes

List of resources.

We setting support Real-Time

At this moment we are going to give real time support to our server.

src > index.ts

import socketio from "@feathersjs/socketio";
import "@feathersjs/transport-commons";

// Add support Real-Time
app.configure(socketio());

// My services...

// We listen connection event and join the channel.
app.on("connection", connection =>
  app.channel("everyone").join(connection)
);

// Publish all events to channel <everyone>
app.publish(() => app.channel("everyone"));
Enter fullscreen mode Exit fullscreen mode

Client development.

Now is neccessary serve the static files. We do this with the following content.

src > index.ts

import { resolve } from "path";

// Server static files.
app.use(express.static(resolve("public")));
Enter fullscreen mode Exit fullscreen mode

Note: For this to work, is neccessary added before sets errors handler, otherwise always NotFound

The directory have the following structure.

Structure Directory

Setting Frontend.

In this step we add the styles and scripts.

We added the following to the styles files.

@import url("https://fonts.googleapis.com/css2?family=Poppins&display=swap");
@import url("https://cdn.jsdelivr.net/npm/bootstrap@5.1.1/dist/css/bootstrap.min.css");
@import url("https://unpkg.com/boxicons@2.0.9/css/boxicons.min.css");

* {
  font-family: 'Poppins', sans-serif;
}
i {
  font-size: 30px;
}
.spacer {
  flex: 1 1 auto;
}
.card-body {
  max-height: 50vh;
  overflow: auto;
}

Enter fullscreen mode Exit fullscreen mode

We added the styles and scripts of project.

<head>
  <!-- Other tags -->
  <link rel="stylesheet" href="/css/style.css">
</head>
<body>
  <!-- My scripts -->
  <script src="//unpkg.com/@feathersjs/client@^4.3.0/dist/feathers.js"></script>
  <script src="/socket.io/socket.io.js"></script>
  <script src="/js/app.js"></script>
</body>

Enter fullscreen mode Exit fullscreen mode

We establish all the visual section of our app. Copy the following content.

  <div class="container-fluid">
    <div
      class="row justify-content-center align-items-center"
      style="min-height: 100vh;"
    >
      <div class="col-12 col-sm-8 col-md-6 col-xl-4 p-3">
        <div class="card border-0 shadow" style="max-height: 80vh;">
          <div class="card-header border-0 bg-white">
            <div class="d-flex align-items-center text-muted">
              <small class="mx-1" id="box-completed"></small>
              <small class="mx-1" id="box-pending"></small>
              <small class="mx-1" id="box-total"></small>
              <span class="spacer"></span>
              <button class="btn btn-remove rounded-pill border-0">
                <i class='bx bx-trash'></i>
              </button>
            </div>
          </div>
          <div class="card-body">
            <ul class="list-group" id="container"></ul>
          </div>
          <div class="card-footer border-0 bg-white">
            <form id="form">
              <div class="form-group py-2">
                <input
                  placeholder="Example: Learning Docker"
                  class="form-control"
                  autocomplete="off"
                  id="input"
                  name="title"
                  autofocus
                >
              </div>
            </form>
          </div>
        </div>
      </div>
    </div>
  </div>
Enter fullscreen mode Exit fullscreen mode

Now, is moment of added all logic of your project.

We capture the elements of the DOM.

const form = document.getElementById("form");
const input = document.getElementById("input");

const container = document.getElementById("container");
const boxCompleted = document.getElementById("box-completed");
const boxPending = document.getElementById("box-pending");
const boxTotal = document.getElementById("box-total");

const btnRemove = document.querySelector(".btn-remove");
Enter fullscreen mode Exit fullscreen mode

We configure Feathers.js on the client side.

// Instance my app.
const socket = io();
const app = feathers(socket);

// Configure transport with SocketIO.
app.configure(feathers.socketio(socket));

// Get note service.
const NoteService = app.service("notes");
Enter fullscreen mode Exit fullscreen mode

Sets values of some variables.

// The id of the notes are stored.
let noteIds = [];
// All notes.
let notes = [];
Enter fullscreen mode Exit fullscreen mode

We added some functions that modify the header of the card, notes and others.

/**
 * Insert id of the notes selected.
 */
async function selectNotes(noteId) {
  const index = noteIds.findIndex(id => id === noteId);
  index < 0 ? noteIds.push(noteId) : noteIds.splice(index, 1);
  btnRemove.disabled = !noteIds.length;
}

/**
 * Update stadistic of the notes.
 */
function updateHeader(items) {
  const completed = items.filter(note => note.status).length;
  const pending = items.length - completed;

  boxCompleted.textContent = `Completed: ${ completed }`;
  boxPending.textContent = `Pending: ${ pending }`;
  boxTotal.textContent = `Total: ${ items.length }`;
}

/**
 * Update note by Id
 */
function updateElement(noteId) {
  const note = notes.find(note => note.id === noteId);
  NoteService.patch(note.id, { status: !note.status });
}

Enter fullscreen mode Exit fullscreen mode

We create a class that will be responsible of the creation of the elements

/**
 * This class is responsible for the creation,
 * removal and rendering of the component interfaces.
 */
class NoteUI {
  /**
   * Create element of the note.
   */
  createElement(note) {
    const element = document.createElement("li");
    element.className = "list-group-item border-0";
    element.id = note.id;
    element.innerHTML = `
    <div class="d-flex align-items-center">
      <div>
        <h6>
          <strong>${ note.name }</strong>
        </h6>
        <small class="m-0 text-muted">${ note.createdAt }</small>
      </div>
      <span class="spacer"></span>
      <div onclick="updateElement(${note.id})" class="mx-2 text-center text-${ note.status ? 'success' : 'danger' }">
        <i class='bx bx-${ note.status ? 'check-circle' : 'error' }'></i>
      </div>
      <div class="ms-2">
        <div class="form-check">
          <input
            class="form-check-input"
            type="checkbox"
            value=""
            id="flexCheckDefault"
            onclick="selectNotes(${ note.id })"
          >
        </div>
      </div>
    </div>
    `;
    return element;
  }

  /**
   * Insert the element at the beginning of the container.
   * @param {HTMLElement} container 
   * @param {HTMLElement} element 
   */
  insertElement(container, element) {
    container.insertAdjacentElement("afterbegin", element);
  }

  /**
   * Remove element by tag id.
   */
  removeElement(id) {
    const element = document.getElementById(id);
    element.remove();
  }
}

// Instance UI
const ui = new NoteUI();

Enter fullscreen mode Exit fullscreen mode

We listen the events CRUD operations.

// Listening events CRUD.
NoteService.on("created", note => {
  const element = ui.createElement(note);
  ui.insertElement(container, element);

  notes.push(note);
  updateHeader(notes);
});

NoteService.on("updated", note => {
  // I leave this method for you as homework.
  console.log("Updated: ",  note);
  updateHeader(notes);
});

NoteService.on("patched", note => {
  // Remove old element.
  ui.removeElement(note.id);
  // Create element updated.
  const element = ui.createElement(note);
  ui.insertElement(container, element);
  // Update header.
  const index = notes.findIndex(item => item.id === note.id);
  notes.splice(index, 1, note);
  updateHeader(notes);
});

NoteService.on("removed", note => {
  ui.removeElement(note.id);

  const index = notes.findIndex(note => note.id === note.id);
  notes.splice(index, 1);
  updateHeader(notes);
});

Enter fullscreen mode Exit fullscreen mode

Initialize some values and get list of notes.

// Initialize values.
(async () => {
  // Get lits of note.
  notes = await NoteService.find();
  notes.forEach(note => {
    const element = ui.createElement(note);
    ui.insertElement(container, element);
  });
  // Update header.
  updateHeader(notes);
  // Button for remove is disable.
  btnRemove.disabled = true;
})();
Enter fullscreen mode Exit fullscreen mode

We listen the events of DOM elements.

// Listen event of the DOM elements.
btnRemove.addEventListener("click", () => {
  if (confirm(`Se eliminaran ${ noteIds.length } notas ¿estas seguro?`)) {
    noteIds.forEach(id => NoteService.remove(id));
    btnRemove.disabled = true;
    noteIds = [];
  }
});

form.addEventListener("submit", e => {
  e.preventDefault();

  const formdata = new FormData(form);
  const title = formdata.get("title");
  if (!title) return false;

  NoteService.create({ name: title });
  form.reset();
});

Enter fullscreen mode Exit fullscreen mode

The final result.

// Get elements DOM.
const form = document.getElementById("form");
const input = document.getElementById("input");

const container = document.getElementById("container");
const boxCompleted = document.getElementById("box-completed");
const boxPending = document.getElementById("box-pending");
const boxTotal = document.getElementById("box-total");

const btnRemove = document.querySelector(".btn-remove");

// Instance my app.
const socket = io();
const app = feathers(socket);

// Configure transport with SocketIO.
app.configure(feathers.socketio(socket));

// Get note service.
const NoteService = app.service("notes");

// Sets values.
let noteIds = [];
let notes = [];

/**
 * Insert id of the notes selected.
 */
async function selectNotes(noteId) {
  const index = noteIds.findIndex(id => id === noteId);
  index < 0 ? noteIds.push(noteId) : noteIds.splice(index, 1);
  btnRemove.disabled = !noteIds.length;
}

/**
 * Update stadistic of the notes.
 */
function updateHeader(items) {
  const completed = items.filter(note => note.status).length;
  const pending = items.length - completed;

  boxCompleted.textContent = `Completed: ${ completed }`;
  boxPending.textContent = `Pending: ${ pending }`;
  boxTotal.textContent = `Total: ${ items.length }`;
}

/**
 * Update note by Id
 */
function updateElement(noteId) {
  const note = notes.find(note => note.id === noteId);
  NoteService.patch(note.id, { status: !note.status });
}

/**
 * This class is responsible for the creation,
 * removal and rendering of the component interfaces.
 */
class NoteUI {
  /**
   * Create element of the note.
   */
  createElement(note) {
    const element = document.createElement("li");
    element.className = "list-group-item border-0";
    element.id = note.id;
    element.innerHTML = `
    <div class="d-flex align-items-center">
      <div>
        <h6>
          <strong>${ note.name }</strong>
        </h6>
        <small class="m-0 text-muted">${ note.createdAt }</small>
      </div>
      <span class="spacer"></span>
      <div onclick="updateElement(${note.id})" class="mx-2 text-center text-${ note.status ? 'success' : 'danger' }">
        <i class='bx bx-${ note.status ? 'check-circle' : 'error' }'></i>
      </div>
      <div class="ms-2">
        <div class="form-check">
          <input
            class="form-check-input"
            type="checkbox"
            value=""
            id="flexCheckDefault"
            onclick="selectNotes(${ note.id })"
          >
        </div>
      </div>
    </div>
    `;
    return element;
  }

  /**
   * Insert the element at the beginning of the container.
   * @param {HTMLElement} container 
   * @param {HTMLElement} element 
   */
  insertElement(container, element) {
    container.insertAdjacentElement("afterbegin", element);
  }

  /**
   * Remove element by tag id.
   */
  removeElement(id) {
    const element = document.getElementById(id);
    element.remove();
  }
}

// Instance UI
const ui = new NoteUI();

// Listening events CRUD.
NoteService.on("created", note => {
  const element = ui.createElement(note);
  ui.insertElement(container, element);

  notes.push(note);
  updateHeader(notes);
});

NoteService.on("updated", note => {
  // I leave this method for you as homework.
  console.log("Updated: ",  note);
  updateHeader(notes);
});

NoteService.on("patched", note => {
  // Remove old element.
  ui.removeElement(note.id);
  // Create element updated.
  const element = ui.createElement(note);
  ui.insertElement(container, element);
  // Update header.
  const index = notes.findIndex(item => item.id === note.id);
  notes.splice(index, 1, note);
  updateHeader(notes);
});

NoteService.on("removed", note => {
  ui.removeElement(note.id);

  const index = notes.findIndex(note => note.id === note.id);
  notes.splice(index, 1);
  updateHeader(notes);
});

// Initialize values.
(async () => {
  // Get lits of note.
  notes = await NoteService.find();
  notes.forEach(note => {
    const element = ui.createElement(note);
    ui.insertElement(container, element);
  });
  // Update header.
  updateHeader(notes);
  // Button for remove is disable.
  btnRemove.disabled = true;
})();

// Listen event of the DOM elements.
btnRemove.addEventListener("click", () => {
  if (confirm(`Se eliminaran ${ noteIds.length } notas ¿estas seguro?`)) {
    noteIds.forEach(id => NoteService.remove(id));
    btnRemove.disabled = true;
    noteIds = [];
  }
});

form.addEventListener("submit", e => {
  e.preventDefault();

  const formdata = new FormData(form);
  const title = formdata.get("title");
  if (!title) return false;

  NoteService.create({ name: title });
  form.reset();
});

Enter fullscreen mode Exit fullscreen mode

Preview

Preview Functionality

Perfect, with this we have finished with the construction of our ToDo Real-Time. Well, more or less because you have your homework to complete the update of the notes.

Remember that if you have a question you can read the official documentation: https://docs.feathersjs.com/guides

Good developers, any questions, simplification of the code or improvement, do not hesitate to comment. Until next time...

Repository: https://github.com/IvanZM123/todo-realtime

Follow me on social networks.

💖 💪 🙅 🚩
ivanzm123
Ivan Zaldivar

Posted on September 27, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related