TypeScript Section 9 – Drag & Drop Project


TypeScript

Updated Mar 14th, 2022

From Section 9 of TS Udemy Course.

Getting Started: We are building a project list: Everything in App.ts (we learn modules after this chapter.) html “template” tags. This is cool!

DOM Element Selection and OOP Rendering

The goal of the class is to grab a hold of the elements and then render what gets entered.

In a class named ProjectInput we have a property Element: HTMLTemplateElement and tell TS about element using typecasting. Also property hostElement set to a HTMLDivElement. In the constructor…

Import node()

InsertAdjacentElement

FirstElementChild

Interacting with DOM Elements: We rendered our first form and so we style here. Add an I’d programmatically.

Creating and Using an Autobind Decorator:

Fetching User Input:

Creating Re-Usable Validation Functionality: Configurable and reusable

Rendering Project Lists: Either active projects or finished projects. Used insertAdjacentHTML. private renderContent Method. uses setContent to fill out h2 content.

Managing Application State with Singletons: Build a class that manages the state of the project. Then set up listeners to listen for changes.

class ProjectState {
  private projects: any[] = []

  addProject(title: string, description: string, numOfPeople: number) {
    const newProject = {
      id: Math.random().toString(),
      title: title,
      description: description,
      people: numOfPeople
    }
    this.projects.push(newProject)
  }
}

Create a global constant for ProjectState, a global instance that can be used from within the entire file so talking to this class is now super simple.

const projectState = new ProjectState()

Now use a private constructor to guarantee this is a singleton class.

class ProjectState {
  private projects: any[] = []
  private static instance: ProjectState

  private constructor() {
  
  }

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

  addProject(title: string, description: string, numOfPeople: number) {
    const newProject = {
      id: Math.random().toString(),
      title: title,
      description: description,
      people: numOfPeople
    }
    this.projects.push(newProject)
  }


}

Now we can call “getInstance()” and we are guaranteed to always work with the exact same object

const projectState = ProjectState.getInstance()

Then set up a “subscription pattern” with a listeners property that will be an array of listener functions and add an ” add listener” method. The idea is whenever something changes, we call all listener functions. So we loop through all listeners and then since they are function we execute it and pass the state, “projectState.”

for(const listenerFn of this.listeners) {
  listenerFn(this.projects.slice())
}

Note: We call slice on it to only return a copy of that array and not the original array so that it can’t be edited from the place where the listener function is coming from. Arrays and objects are reference values are in JS, so if you would pass the original array we could edit it from outside and also on the same hand if we push something to it from inside this class it would already change everywhere else in the app but these places would not really notice that it changed so we could introduce some strange bugs if we pass around the original reference, which is why we pass around a new copy of the projects array.

projectState.addListener(projects: any[] => {
  this.assignedProjects = projects
  this.renderProjects()
})

Add an assignedProject field to the ProjectList

assignedProjects: any[] // we will specify in greater detail later

We are overriding, not adding.

Then to render the projects add a new “renderProjects” method.

private renderProjects() {
  const listEl = document.getElementById(`${this.type}-projects-list`)! as HTMLUListElement
  for (const prjItem of this.asignedProjects) {
    listEl.appendChild()
  }
}

After this chapter, it compiles without errors and renders to both “Active” and “completed” lists, also a bug where when we add a project we duplicate existing and add.

More Classes and Custom Types: Work on converting the existing “any” types to more useful types.

The downside of the object literal notation is we have to remember.

Adding a status so we can actually filter for active versus finished. We could use a union type with a literal type something like: “active” | “finished” but chose to go with the “enum” type. An “enum” is great because we have two options and we don’t need text that makes sense to humans, just some type of identifier.

Note: we use the short cut definition in the class below.

// Project type - a class bc we want to instantiate
// vs a type or an interface

enum ProjectStatus {Active, Finished}

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

  }


}

Now we can swap out the any type and instantiate a “new Project” in the “addProject” method and pass it the necessary info.

We get some good autocompletion here now.

We will also create a “Listener” custom type as well.

type Listener = (items: Project[]) => void

We can know update make some updates with this new type.

Nothing has changed at this point because we need to filter the projects.

Filtering Projects with Enums: Remove all projects and then re-render after filtering.

The best place to filter is the listener function.

We could use the “enum” but we need the concrete value in another part of the code and Max wants to show the “string literal type” example.

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()

})

We are still getting a duplicate because we are a appending to the list and are not checking if is already rendered. Running some comparison costs quite a bit of performance so instead we can just set the container to an empty string and then re-render the list. When we add a project we re-render all projects but this is fine for a simple app like ours.

listEl.innerHTML = ''

Some drag and drop functionality is missing. We are not not outputting people and descriptions. We also have some code duplication with “projectList” and “ProjectInput”

Adding Inheritance & Generics:

Base class with shared functionalities. Some changes for the types so we use generics with some constraints.

class Component<T extends HTMLElement, U extends HTMLElement> {
  template: 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

    //more shared code here as well
  }
}

We also copy in the private attach() method and call this.attach() at the end of the constructor. We also make it flexible as how it gets inserted using a new parameter in the constructor “insertAtStart.”

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

We also want to mark the class as an “abstract” class since people should never instantiate it, instead it should always be used for inheritance. If we do so then TS will yell at us.

abstract class Component<...> {...}

We also add two more methods, the “configure” method and the “renderContent” method, added as an “abstract” method which means the concrete implementation is missing here, but we no basically force any class inheriting from the “Component” class to add these two methods and to have them available. These are added so if someone else looks at our code they will get the idea of what’s going on.

abstract configure(): void
abstract renderContent(): void

Note: you cannot have private abstract methods

Now we can have the “ProjectList” class extend from the “Component” class and remove the duplicated code.

We will need to call super() and pass it information and make some other changes.

Now we can have the “ProjectInput” class extend from the “Component” class and remove the duplicated code.

We will need to call super() and pass it information and make some other changes.

Note: it is convention to have public methods defined before the private methods.

We conclude the lesson by re-factoring the project state. We don’t need it since we have one single state we manage but image we have a bigger application with multiple states, user states, projects, shopping cart, etc. Some features of the state class are always the same, (the array of listeners and the “addListener” method). We could also use a base class here.

type Listener<T> = (items: T[]) => void

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

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

We then use all of this on the “ProjectState” class to extend from the “State”

Call super() and make some other changes including changing listeners from “private” from “protected” to allow an inheriting class access.

Rendering Project Items with a Class:

We already use an object oriented approach to instantiate a class that renders the form and the project areas, we want to do the same with a “project item” class that gets instantiated and initializes in the constructor the project list items.

class ProjectItem extends Component<HTMLUListElement, HTMLLIEement>{
  
  // Store the project based on our project class
  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   
  }
}

So now we need to use the “ProjectItem” and the place to use it is the project list because that renders the list of projects. We need to make sure we render our project items by instantiating our new class. In the loop just have:

new ProjectItem(this.element.id, prjItem)

We have to remove the bullet point and we fix that by rendering inside the UL which is is currently not happening. We are using a “host element” but this is not the un-ordered list it is the section that includes a header. So we need to get access by tweaking:

new ProjectItem(this.element.querySelector("ul")!.id, prjItem)

Using a Getter: We also have to convert people from just a number. We concatenate on a string but we need to handle for “5 Persons Assigned” versus “1 Persons assigned.” We can use a getter inside the “ProjectItem” class to provide the correct text either singular or plural. Remember a “getter” helps us transform data when you retrieve it.

Note: It is convention for getters and setters to be added below your other fields but above the constructor function.

get persons() {
  if(this.project.people === 1) {
    return "1 person"
  } else {
    return `${this.project.people} persons`
  }
}

// now we can access like a normal property
// this.element.querySelector("h3")!.textContent = this.persons + " assigned"

Note: I think we are using a “getter” just to show it can be done but a ternary may have handled easier.

Utilizing Interfaces to Implement Drag and Drop

It’s not just about the visual update of the UI but also the data behind the scenes in our state management” class. So two things we need to update.

Implementing a bunch of event listeners.

Enhance with TypeScript features via interfaces to not just define structure of some objects but instead to really to set up a contract which certain classes can sign to force these classes to implement certain methods that help us with drag and drop. Using these interfaces will be optional but we can provide some code that forces a class to implement everything it needs to be draggable or a valid drop target. This cleaner code can be helpful when working on a larger project or a team.

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

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

Note: we get the “event: DragEvent” from adding “dom” and the other values to the “lib” key in the TS config file.

Use the interfaces on the “ProjectItem” class. We need to add two methods.

The listening to the “dragStart” event happnes in the “configure” method

configure() {
  this.element.addEventListener("dragstart", this.dragStartHandler)
  this.element.addEventListener("dragend", this.dragEndHandler)
}

Note: we want the “dragStartHandler” function to have the “this” keyowrd pointing to the write thing so we can add “.bind(this)” to the end of “this.dragStartHandler()” or implement the decorator we created earlier in this project.

Note Also: in order for the browser’s draggable API to work you need to add a draggable=”true” attribute to your list-item in the html code.

Drag Events & Reflecting the Current State of the UI

Add “implements DragTarget” to the “ProjectList” class. this forces us to add certain methods:

dragOverHandler() and dropHandler() and dragLeaveHandler(), all with “event: DragEvent” passed in.

Visualize a droppable area by adding class to change the background color when something is dragged over.

@autobind
dragOverHandler(event: DragEvent) {
  const listEl = this.element.querySelector("ul")!
  listEl.classList.add("droppable")
}

Now we need to make sure the “dragOverHandler” fires when necessary by updating the “configure” method by adding 3 listener to the element itself, (“dragover, dragleave, and drop”).

Note: If unused event parameter’s are throwing TS errors you can blank them out by using an underscore.

In dragLeaveHandler() remove “droppable” class.

@autobind
dragLeaveHandler(event: DragEvent) {
  const listEl = this.element.querySelector("ul")!
  listEl.classList.remove("droppable")
}

Adding a Droppable Area

Step 1: How would javascript know what is being dragged where using the “event object” that actually has a “.dataTransfer” property (special for drag events) and attach some data to the drag event using the “setData()” method and inside pass ‘text/plain’ and “this.project.id.” On “event.dataTransfer” there is also an effectAllowed = “move” to get a different cursor.

@autobind
dragStartHandler() {
  event.dataTransfer!.setData("text/plain", this.project.id)
  event.dataTransfer!.effectAllowed = "move" // how the cursor looks
}

Step 2: See if element can catch the draggable content. Is it of the valid format ‘text/plain’? Need to call “preventDefault()” as drag and drop is only allowed if inside the “dragOverHandler” we “preventDefault().”

@autobind
dragOverHandler(event: DragEvent) {
  if(event.dataTransfer && event.dataTransfer.types[0] === "text/plain") {\
    event.preventDefault()
    const listEl = this.element.querySelector("ul")!
    listEl.classList.add("droppable")
  }
}

There is also a “getData()” method on “event.dataTransfer” we can leverage inside of the “dropHandler” method

dropHandler(event:DragEvent) {
  console.log(event) // don't be surprised it it seems empty
  console.log(event.dataTransfer!.getData("text/plain"))
}

Now just update state behind the scenes and render UI.

Finishing Drag and Drop

In the “dropHandler” extract the “prjId” and update status.

dropHandler(event:DragEvent) {
  const prjId = event.dataTransfer!.getData("text/plain"))
}

In our “ProjectState” we need a “moveProject()” method to switch project status from the current list to a new list. Can’t just flip project status from “Active” to “finished” and the other way around because this would not handle the case for dropping in the same box.

moveProject(projectId: string, newStatus: projectStatus) {
  const project = this.projects.find(prj => prj.id === projectId)
  if (project && project.status !== newStatus) {
    project.status = newStatus
    this.updateListeners()
  }
}

Let all listeners know something has changed and re-render the list. but we don’t want to duplicate code so we can create a new private “updateListeners” method and call this in the “addProject” and “moveProject” methods.

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

And now we can use in the dropHandler

@autobind
dropHandler(event:DragEvent) {
  const prjId = event.dataTransfer!.getData("text/plain"))
  projectState.moveProject(prjId,
  this.type === "active" ? ProjectStatus.Active : ProjectStatus.Finished
  )
}

If it is dropped and there is no change it still empties and re-renders all projects but from a UI perspective it works. Update the “moveProject()” to prevent this by making sure “project.status !== newStatus.”

Wrap Up: Can’t edit or delete projects. There’s no user-specific data with persistence using a database.