themaximehardy / typescript-drag-and-drop

Using TypeScript to build a drag and drop

Geek Repo:Geek Repo

Github PK Tool:Github PK Tool

TypeScript: Drag And Drop Project

ts project

1. DOM Element Selection & OOP Rendering

The idea here, we created the class ProjectInput where we initialised templateElement and hostElement in the constructor. We knew their types (HTMLTemplateElement and HTMLDivElement) and we were sure they will be available on the page. We can use !. We got the content from #project-input by using document.importNode and we specified the form element which is the first child. Then, we attached the form in the #app element.

class ProjectInput {
  templateElement: HTMLTemplateElement;
  hostElement: HTMLDivElement;
  element: HTMLFormElement;

  constructor() {
    this.templateElement = document.getElementById('project-input')! as HTMLTemplateElement;
    this.hostElement = document.getElementById('app')! as HTMLDivElement;

    const importedNode = document.importNode(this.templateElement.content, true);
    this.element = importedNode.firstElementChild as HTMLFormElement;
    this.element.id = 'user-input';
    this.attach();
  }

  private attach() {
    this.hostElement.insertAdjacentElement('afterbegin', this.element);
  }
}

const prjInput = new ProjectInput();

2. Interacting with DOM Elements

We added the access to all the inputs title, description and people in the constructor and then we added a listener on the form when the user submit it. We created configure and we bound the this from the class. In this case we wanted to console.log the value of title when we submitted the form.

class ProjectInput {
  templateElement: HTMLTemplateElement;
  hostElement: HTMLDivElement;
  element: HTMLFormElement;
  // INPUTS
  titleInputElement: HTMLInputElement;
  descriptionInputElement: HTMLInputElement;
  peopleInputElement: HTMLInputElement;

  constructor() {
    this.templateElement = document.getElementById('project-input')! as HTMLTemplateElement;
    this.hostElement = document.getElementById('app')! as HTMLDivElement;

    const importedNode = document.importNode(this.templateElement.content, true);
    this.element = importedNode.firstElementChild as HTMLFormElement;
    this.element.id = 'user-input';

    // GET ACCESS TO ALL THE INPUTS
    this.titleInputElement = this.element.querySelector('#title') as HTMLInputElement;
    this.descriptionInputElement = this.element.querySelector('#description') as HTMLInputElement;
    this.peopleInputElement = this.element.querySelector('#people') as HTMLInputElement;

    this.configure();
    this.attach();
  }

  private submitHandler(event: Event) {
    event.preventDefault();
    console.log(this.titleInputElement.value);
  }

  private configure() {
    this.element.addEventListener('submit', this.submitHandler.bind(this)); // we need to bind the "this" from the class
  }

  private attach() {
    this.hostElement.insertAdjacentElement('afterbegin', this.element);
  }
}

const prjInput = new ProjectInput();

3. Creating and Using an "Autobind" Decorator

We created a decorator, more precisely, a method decorator. Now we can automatically bind the this.

/**
 * Autobind decorator (method decorator)
 * @param _ (target, not used here)
 * @param _2 (methodName, not used here)
 * @param descriptor
 */
function Autobind(_: any, _2: string, descriptor: PropertyDescriptor) {
  const originalMethod = descriptor.value;
  const adjustedDescriptor: PropertyDescriptor = {
    configurable: true,
    get() {
      const boundFn = originalMethod.bind(this);
      return boundFn;
    },
  };
  return adjustedDescriptor;
}

We used it, as showed below:

@Autobind
  private submitHandler(event: Event) { ... }

4. Fetching User Input

Here, we have created a new private function gatherUserInput which return a tuple (or void). We have created a simple data validation but very naive and not scalable (we're going to improve it later). In submitHandler, we're returning the tuple in a constant userInput but need to verify if it is a tuple. Because JS doesn't know what is a tuple we need to check if it is an array via Array.isArray(value). We also wanted to clear the inputs after submission, we created clearInputs.

//...
  private gatherUserInput(): [string, string, number] | void {
    const enteredTitle = this.titleInputElement.value;
    const enteredDescription = this.descriptionInputElement.value;
    const enteredPeople = this.peopleInputElement.value;

    if (
      enteredTitle.trim().length === 0 ||
      enteredDescription.trim().length === 0 ||
      enteredPeople.trim().length === 0
    ) {
      alert('Invalid input, please try again!');
      return;
    }

    return [enteredTitle, enteredDescription, +enteredPeople];
  }

  private clearInputs() {
    this.titleInputElement.value = '';
    this.descriptionInputElement.value = '';
    this.peopleInputElement.value = '';
  }

  @Autobind
  private submitHandler(event: Event) {
    event.preventDefault();
    const userInput = this.gatherUserInput();
    if (Array.isArray(userInput)) {
      const [title, desc, people] = userInput;
      console.log(title, desc, people);
      this.clearInputs();
    }
  }
//...

5. Creating a Re-Usable Validation Functionality

We've improved our validation functionality. We created a Validatable interface with a value (required) and all the validation rules (optional, we added the ?). Then, we implemented the validate function which receive a Validatable object in param.

In gatherUserInput, we created three Validatable objects (titleValidatable, descriptionValidatable, peopleValidatable) which we validated before the "send" the data.

// VALIDATION
interface Validatable {
  value: string | number;
  required?: boolean;
  minLength?: number;
  maxLength?: number;
  min?: number;
  max?: number;
}

function validate(validatableInput: Validatable) {
  let isValid = true;
  if (validatableInput.required) {
    isValid = isValid && validatableInput.value.toString().trim().length !== 0;
  }

  if (validatableInput.minLength != null && typeof validatableInput.value === 'string') {
    isValid = isValid && validatableInput.value.trim().length >= validatableInput.minLength;
  }

  if (validatableInput.maxLength != null && typeof validatableInput.value === 'string') {
    isValid = isValid && validatableInput.value.trim().length <= validatableInput.maxLength;
  }

  if (validatableInput.min != null && typeof validatableInput.value === 'number') {
    isValid = isValid && validatableInput.value >= validatableInput.min;
  }

  if (validatableInput.max != null && typeof validatableInput.value === 'number') {
    isValid = isValid && validatableInput.value <= validatableInput.max;
  }

  return isValid;
}

//...
class ProjectInput {
  //...
  private gatherUserInput(): [string, string, number] | void {
    const enteredTitle = this.titleInputElement.value;
    const enteredDescription = this.descriptionInputElement.value;
    const enteredPeople = this.peopleInputElement.value;

    const titleValidatable: Validatable = {
      value: enteredTitle,
      required: true,
    };

    const descriptionValidatable: Validatable = {
      value: enteredDescription,
      required: true,
      minLength: 5,
    };

    const peopleValidatable: Validatable = {
      value: +enteredPeople, // + to transform the value in number
      required: true,
      min: 1,
      max: 5,
    };

    if (!validate(titleValidatable) || !validate(descriptionValidatable) || !validate(peopleValidatable)) {
      alert('Invalid input, please try again!');
      return;
    }

    return [enteredTitle, enteredDescription, +enteredPeople];
  }
  //...
}

6. Rendering Project Lists

We have implemented a new class ProjectList. It is very similar to the ProjectInput class. The main differences are the element we select on the HTML (e.g. #project-list).

class ProjectList {
  templateElement: HTMLTemplateElement;
  hostElement: HTMLDivElement;
  element: HTMLElement;

  constructor(private type: 'active' | 'finished') {
    this.templateElement = document.getElementById('project-list')! as HTMLTemplateElement;
    this.hostElement = document.getElementById('app')! as HTMLDivElement;

    const importedNode = document.importNode(this.templateElement.content, true);
    this.element = importedNode.firstElementChild as HTMLElement;
    this.element.id = `${this.type}-projects`;
    this.attach();
    this.renderContent();
  }

  private renderContent() {
    const listId = `${this.type}-projects-list`;
    this.element.querySelector('ul')!.id = listId; // we add an id to the `ul` element (based on the list type, active or finished)
    this.element.querySelector('h2')!.textContent = this.type.toUpperCase() + ' PROJECTS'; // we fill the h2 title (based on the list type, active or finished)
  }

  private attach() {
    this.hostElement.insertAdjacentElement('beforeend', this.element); // we want to add the element before to close the tag
  }
}

//...
const prjInput = new ProjectInput();
const activePrjList = new ProjectList('active'); // we call the active list here
const finishedPrjList = new ProjectList('finished'); // we call the finished list here

7. Managing Application State with Singleton

We have create a class - ProjectState - to manage our state. We have an array of projects (which is an object with an id, a title, a description and a number of people). We also have an array of listeners which is an array of function, it will help us to share the change of the state "reactively" with our others classes. We decided to create a singleton via a private constructor, a private static instance field and a static method getInstance. If the instance already exists, we return it, otherwise we call the private constructor to instantiate it.

class ProjectState {
  private listeners: any[] = []; // we'll change any later
  private projects: any[] = []; // we'll change any later
  private static instance: ProjectState;

  private constructor() {}

  static getInstance() {
    if (this.instance) {
      return this.instance;
    }
    this.instance = new ProjectState();
    return this.instance;
  }

  addListener(listenerFn: Function) {
    this.listeners.push(listenerFn);
  }

  addProject(title: string, description: string, numOfPeople: number) {
    const newProject = {
      id: Math.random().toString(), // Not a good practice but ok for our purpose here
      title,
      description,
      people: numOfPeople,
    };
    this.projects.push(newProject);
    for (const listenerFn of this.listeners) {
      // slice allow us to return a copy of the array and not the reference
      listenerFn(this.projects.slice());
    }
  }
}

We called the method addListener on the projectState instance. We passed a function, the projects are returned and I can assign them to the assignedProjects (a field which is a array of all the current projects created). We render them via renderProjects.

//...
class ProjectList {
  //...
  assignedProjects: any[]; // we'll change any later
  //...
  constructor(private type: 'active' | 'finished') {
    //...
    projectState.addListener((projects: any[]) => {
      this.assignedProjects = projects;
      this.renderProjects();
    });
    this.attach();
    this.renderContent();
  }

  private renderProjects() {
    const listEl = document.getElementById(`${this.type}-projects-list`)! as HTMLUListElement;
    for (const prjItem of this.assignedProjects) {
      const listItem = document.createElement('li');
      listItem.textContent = prjItem.title;
      listEl.appendChild(listItem);
    }
  }
}
//...

8. More Classes & Custom Types

We have created a Project class to enforce the same project structure everywhere we want to use it. We added a status which is an enum (ProjectStatus). We've also created a new type Listener, which is a function which takes Project array in arg and return void. We now create a new project by const newProject = new Project(Math.random().toString(), title, description, numOfPeople, ProjectStatus.Active);.

// PROJECT TYPE
enum ProjectStatus {
  Active,
  Finished,
}

class Project {
  constructor(
    public id: string,
    public title: string,
    public description: string,
    public people: number,
    public status: ProjectStatus,
  ) {}
}

// PROJECT STATE MANAGEMENT
type Listener = (items: Project[]) => void; // Listener type added

class ProjectState {
  private listeners: Listener[] = []; // we replaced any
  private projects: Project[] = []; // we replaced any
  private static instance: ProjectState;

  //...
  addProject(title: string, description: string, numOfPeople: number) {
    const newProject = new Project(Math.random().toString(), title, description, numOfPeople, ProjectStatus.Active);
    this.projects.push(newProject);
    for (const listenerFn of this.listeners) {
      // slice allow us to return a copy of the array and not the reference
      listenerFn(this.projects.slice());
    }
  }
  //...

9. Filtering Projects with Enums

The idea here is to filter on the projects returned by the listener – Active or Finished.

//...
class ProjectList {
  //...
  assignedProjects: Project[];

  constructor(private type: 'active' | 'finished') {
    //...
    projectState.addListener((projects: Project[]) => {
      const relevantProjects = projects.filter((prj) => {
        if (this.type === 'active') {
          return prj.status === ProjectStatus.Active;
        }
        return prj.status === ProjectStatus.Finished;
      });
      this.assignedProjects = relevantProjects;
      this.renderProjects();
    });
    //...
  }
  //...

10. Adding Inheritance & Generics

We created a Component abstract class (we can't instantiate it, because the class is incomplete in the sense it contains abstract methods without body and output) where we use generics <T extends HTMLElement, U extends HTMLElement> because we could get ≠ types for the hostElement and the element. We added two abstract methods configure and renderContent which has to be defined in the concrete subclass.

abstract class Component<T extends HTMLElement, U extends HTMLElement> {
  templateElement: HTMLTemplateElement;
  hostElement: T;
  element: U;

  constructor(templateId: string, hostElementId: string, insertAtStart: boolean, newElementId?: string) {
    this.templateElement = document.getElementById(templateId)! as HTMLTemplateElement;
    this.hostElement = document.getElementById(hostElementId)! as T;

    const importedNode = document.importNode(this.templateElement.content, true);
    this.element = importedNode.firstElementChild as U;
    if (newElementId) {
      this.element.id = newElementId;
    }

    this.attach(insertAtStart);
  }

  private attach(insertAtBeginning: boolean) {
    this.hostElement.insertAdjacentElement(insertAtBeginning ? 'afterbegin' : 'beforeend', this.element);
  }

  abstract configure(): void;
  abstract renderContent(): void;
}

Then we can extends ProjectList with Component. Same for ProjectInput. We take advantage of code reusage (thanks to inheritance).

class ProjectList extends Component<HTMLDivElement, HTMLElement> {
  assignedProjects: Project[];

  constructor(private type: 'active' | 'finished') {
    super('project-list', 'app', false, `${type}-projects`);
    this.assignedProjects = [];

    this.configure();
    this.renderContent();
  }

  configure() {
    projectState.addListener((projects: Project[]) => {
      //...
      this.renderProjects();
    });
  }
  //...

We can also improve our "state management" by creating a "general" State class. With a generic type pass to the Listener, ProjectState now extends State (with Listeners of type Project).

class State<T> {
  protected listeners: Listener<T>[] = [];

  addListener(listenerFn: Listener<T>) {
    this.listeners.push(listenerFn);
  }
}

class ProjectState extends State<Project> {
  private projects: Project[] = [];
  private static instance: ProjectState;

  private constructor() {
    super(); // we need to add super here
  }

  static getInstance() {
    if (this.instance) {
      return this.instance;
    }
    this.instance = new ProjectState();
    return this.instance;
  }

  addProject(title: string, description: string, numOfPeople: number) {
    const newProject = new Project(Math.random().toString(), title, description, numOfPeople, ProjectStatus.Active);
    this.projects.push(newProject);
    for (const listenerFn of this.listeners) {
      // slice allow us to return a copy of the array and not the reference
      listenerFn(this.projects.slice());
    }
  }
}

11. Rendering Project Items with a Class

We've created a ProjectItem class which extends Component.

//...
class ProjectItem extends Component<HTMLUListElement, HTMLLIElement> {
  private project: Project;

  constructor(hostId: string, project: Project) {
    super('single-project', hostId, false, project.id);
    this.project = project;
    this.configure();
    this.renderContent();
  }

  configure() {}
  renderContent() {
    this.element.querySelector('h2')!.textContent = this.project.title;
    this.element.querySelector('h3')!.textContent = this.project.people.toString();
    this.element.querySelector('p')!.textContent = this.project.description;
  }
}

class ProjectList extends Component<HTMLDivElement, HTMLElement> {
  //...

  renderContent() {
    const listId = `${this.type}-projects-list`;
    this.element.querySelector('ul')!.id = listId;
    this.element.querySelector('h2')!.textContent = this.type.toUpperCase() + ' PROJECTS';
  }

  private renderProjects() {
    for (const prjItem of this.assignedProjects) {
      new ProjectItem(this.element.querySelector('ul')!.id, prjItem); // we created a new instance of ProjectItem for every project
    }
  }
}
//...

12. Using a Getter

Using a getter is a good idea here. We want to display 1 person or X (multiple) perons before "assigned".

class ProjectItem extends Component<HTMLUListElement, HTMLLIElement> {
  //...
  get persons() {
    if (this.project.people === 1) {
      return '1 person';
    } else {
      return `${this.project.people} persons`;
    }
  }
  //...
  renderContent() {
    //...
    this.element.querySelector('h3')!.textContent = this.persons + ' assigned'; // we call it like a property (not like a method/function)
    //...
  }
}

13. Utilizing Interfaces to Implement Drag & Drop

We created two new interfaces Draggable (which will be the ProjectItem) and DragTarget (which will be the ProjectList).

Note: we need to add draggable="true" to the html element which will be draggable – <li draggable="true">.

interface Draggable {
  dragStartHandler(event: DragEvent): void;
  dragEndHandler(event: DragEvent): void;
}

interface DragTarget {
  dragOverHandler(event: DragEvent): void;
  dragHandler(event: DragEvent): void;
  dragLeaveHandler(event: DragEvent): void;
}

We have to implement the Draggable interface to ProjectItem. And we have to implement the methods we have created in the interface.

class ProjectItem extends Component<HTMLUListElement, HTMLLIElement> implements Draggable {
  //...
  @Autobind
  dragStartHandler(event: DragEvent) {
    console.log(event);
  }

  @Autobind
  dragEndHandler(_: DragEvent) {
    console.log('dragend');
  }

  configure() {
    this.element.addEventListener('dragstart', this.dragStartHandler);
    this.element.addEventListener('dragend', this.dragEndHandler);
  }
  //...
}

14. Drag Events & Reflecting the Current State in the UI

We have to implement the DragTarget interface to ProjectList. And we have to implement the methods we have created in the interface.

class ProjectList extends Component<HTMLDivElement, HTMLElement> implements DragTarget {
  //...
  @Autobind
  dragOverHandler(_: DragEvent) {
    const listEl = this.element.querySelector('ul');
    listEl?.classList.add('droppable');
  }

  @Autobind
  dropHandler(_: DragEvent) {}

  @Autobind
  dragLeaveHandler(_: DragEvent) {
    const listEl = this.element.querySelector('ul');
    listEl?.classList.remove('droppable');
  }

  configure() {
    this.element.addEventListener('dragover', this.dragOverHandler);
    this.element.addEventListener('drop', this.dropHandler);
    this.element.addEventListener('dragleave', this.dragLeaveHandler);

    //...
  }
  //...
}

15. Adding a Droppable Area

We use DragEvent with dataTransfer to pass the id of the current project.

class ProjectItem extends Component<HTMLUListElement, HTMLLIElement> implements Draggable {
  //...
  @Autobind
  dragStartHandler(event: DragEvent) {
    event.dataTransfer!.setData('text/plain', this.project.id);
    event.dataTransfer!.effectAllowed = 'move';
  }

  @Autobind
  dragEndHandler(_: DragEvent) {
    console.log('dragend');
  }
  //...
}

class ProjectList extends Component<HTMLDivElement, HTMLElement> implements DragTarget {
  //...
  @Autobind
  dragOverHandler(event: DragEvent) {
    if (event?.dataTransfer?.types[0] === 'text/plain') {
      event.preventDefault();
      const listEl = this.element.querySelector('ul');
      listEl?.classList.add('droppable');
    }
  }

  @Autobind
  dropHandler(event: DragEvent) {
    console.log(event.dataTransfer!.getData('text/plain')); // we get the id of the project we dragged
  }

  @Autobind
  dragLeaveHandler(_: DragEvent) {
    const listEl = this.element.querySelector('ul');
    listEl?.classList.remove('droppable');
  }
  //...
}

16. Finishing Drag & Drop

We created moveProject in ProjectState which we call in ProjectList.

Note: more on Drag & Drop.

class ProjectState extends State<Project> {
  //...
  moveProject(projectId: string, newStatus: ProjectStatus) {
    const project = this.projects.find((prj) => prj.id === projectId);
    if (project && project.status !== newStatus) {
      // we check the status to not render if we drag in the same project list
      project.status = newStatus;
      this.updateListeners();
    }
  }

  private updateListeners() {
    for (const listenerFn of this.listeners) {
      listenerFn(this.projects.slice());
    }
  }
  //...
}

class ProjectList extends Component<HTMLDivElement, HTMLElement> implements DragTarget {
  //...
  @Autobind
  dropHandler(event: DragEvent) {
    const prjId = event.dataTransfer!.getData('text/plain');
    projectState.moveProject(prjId, this.type === 'active' ? ProjectStatus.Active : ProjectStatus.Finished);
  }
  //...
}

class ProjectItem extends Component<HTMLUListElement, HTMLLIElement> implements Draggable {
  //...
  @Autobind
  dragStartHandler(event: DragEvent) {
    event.dataTransfer!.setData('text/plain', this.project.id);
    event.dataTransfer!.effectAllowed = 'move';
  }

  @Autobind
  dragEndHandler(_: DragEvent) {}

  configure() {
    this.element.addEventListener('dragstart', this.dragStartHandler);
    this.element.addEventListener('dragend', this.dragEndHandler);
  }
  //...
}

17. Modules & Namespaces

About

Using TypeScript to build a drag and drop


Languages

Language:TypeScript 73.5%Language:CSS 10.7%Language:HTML 9.1%Language:JavaScript 6.7%