Craig Bruce

Augmenting Google Org Chart with Drag and Drop

Before digging in, check out and have a play with the complete source code for this post here.

Introduction

There are lots of libraries out there that provide organisation charts. Most are heavyweight, pricey or render in strange ways.

The Google Org Chart is a free alternative that satisfied the stakeholders of my current project by rendering nicely (read: not fancy) and costing zilch. Excellent! But what about drag and drop functionality? They want it. Now.

Out of the box, Google's charts are pretty simple with limited bells and whistles, so it's up to us to roll our sleeves up and dive into the gnarly world of HTML Drag and Drop. Yay!

I'm going to wrap the chart in a React component (but you can apply this to any front-end framework) and provide all the necessary drag/drop handlers.

First, let's list what we need the component to do.

  • get organisation data when the component mounts
  • on receipt of the data we need to draw the chart
  • we need to provide a template to the chart for rendering each node
  • when the chart has finished rendering we need to set each node to be draggable
  • we also need to attach handlers to each node for drag/drop operations
  • on drag over we need to provide some visual cue to the user to show whether the drop is allowed (e.g. you shouldn't be able to drop a node onto itself)
  • on drop we need to call our API with the updated relationship.

When the component mounts

Simple. Let's call our API from a function provided in our component's props.

componentDidMount() {  
    this.props.getOrgChart();
}

On Receipt of the Data

When data is received our component will re-render itself. If you're not familiar with React don't get bogged down with this stuff, it's an aside.

We use the component lifecycle method componentDidUpdate to draw the chart like this:

componentDidUpdate() {  
    google.charts.setOnLoadCallback(this.drawDiagram);
}

Full instructions for loading the chart can be found here. Basically, we need to provide a callback to the chart for when it has finished loading.

It is worth mentioning here that there is an initial step to loading Google Charts. You need to tell the library which charts you require in your application. I don't do this in the component we are creating here, but in a container component. The loading code looks like this:

google.charts.load("current", {  
  packages: ["orgchart" /* you can load others here too */],
});

You should have this container component strategically placed in a chunk/bundle (perhaps using webpack's code splitting) so the user only downloads it when needed and it is only loaded once.

Provide the Chart with a Template

We want our org chart nodes to display our own way, but we also want to embed some fields on each node that identify the id and parentId of the node for use in our drag/drop operations. I'm going to make use of hidden fields for this.

drawDiagram = () => {  
  const template = p =>
      `
        <hidden data-id='${p.id}' />
        <hidden data-parent-id='${p.parentId}' />
        <h6>
          ${p.title} 
        </h6>
      `;
  const orgChartDiv = document.getElementById("org-chart");
  if (orgChartDiv) {
    this.diagram = new google.visualization.OrgChart(orgChartDiv);
    // attach handlers here ... see below  

  const data = new google.visualization.DataTable();
  //  ... transform positions from API into 'view models' see source code.
  diagram.draw(data);
}

Set Each Node as Draggable and Attach Event Handlers

There is a provided function called addOneTimeListener which we can use to hook into the ready event to apply what we need:

google.visualization.events.addOneTimeListener(  
        diagram,
        "ready",
        () => {
          const nodes = window.document.getElementsByClassName(
            "org-chart-node",
          );
          each(nodes, (node: Element) => {
            node.setAttribute("draggable", "true");
            node.addEventListener("dragstart", this.dragStart);
            node.addEventListener("dragenter", this.dragEnter);
            node.addEventListener("dragover", this.dragEnter);
            node.addEventListener("dragexit", () => (this.draggedNode = null));
            node.addEventListener("dragleave", this.dragLeave);
            node.addEventListener("drop", this.drop);
          });
        },
      );

Here, when the diagram has finished rendering, we grab all the nodes and set their draggable attribute to true, then add all the drag/drop handlers we need via addEventListener. Each one is explained below.

Drag Start

This is where the process begins. It's here we want to keep a record of information on the node we are dragging, namely its id and parentId.

We could set the data we need via the drag event's dataTransfer.setData method here and retrieve it via getData on the drop event. However, getData is not available to other event handlers like dragEnter (for buggy/security reasons, full thread here) and I want those visual cues so I'm not going to waste time with that.

Instead, I am going to store data on the current node being dragged in a property on the component called draggedNode. First I need a utility function to extract the id and parentId from the dragged element. Recall we stored those in hidden fields in the template. Here's our utility:

getIds = element => {  
  if (element) {
    const id = element.querySelector("hidden[data-id]");
    const parentId = element.querySelector("hidden[data-parent-id]");
      if (id && parentId) {
        return {
          id: id.getAttribute("data-id"),
          parentId: parentId.getAttribute("data-parent-id"),
        };
      }
   }
};

giving us this structure:

{
  id,
  parentId,
}

So in dragStart

dragStart = event => {  
  const element = event.target;
  const currentNode = this.getIds(element);
  if (currentNode) {
    this.draggedNode = currentNode;
  }
};

Now, as a React developer, your first instinct may be to store the draggedNode information in the component's state by way of this.setState, but this will not work as the component will re-render and you will lose your drag operation before it starts.

Drag Enter

When the element being dragged comes into contact with another td element we want to apply a visual cue as to whether it is a legal drop target.

First, we get the drop target's id and parentId via our helper function then compare those to our draggedNode and apply the appropriate CSS class.

dragEnter = event => {  
  event.preventDefault(); // required to allow drop
  const element = event.target;
  if (element.tagName === "TD") {
    const dropNode = this.getIds(element);
    if (dropNode && this.draggedNode) {
      if (
        // don't drop into itself
        this.draggedNode.id === dropNode.id ||
        // don't drop into it's own kids
        this.draggedNode.id === dropNode.parentId
      ) {
        element.classList.add("do-not-drop");
      } else {
        element.classList.add("do-drop");
      }
    }
  }
};

Drag Leave

When our dragged node leaves a drop target, potentially to find another one, we need to reset the visual cues we added via CSS:

dragLeave = event => {  
  const element = event.target;
  setTimeout(() => {
    // timeout yields nicer UX (less flicker)
    element.classList.remove("do-not-drop");
    element.classList.remove("do-drop");
  }, 250);
};

Drop

And finally our drop event. The business end! But here be dragons.

We could potentially be dropping on a child element provided in our template and we really are only concerned with the parent td element which holds our id and parentId info.

So after we have looped up to the parent element we then extract the information we need from it. Then, ensuring you're not dropping onto yourself or immediate child nodes, perform the update by calling the provided function via this.props.update.

drop = event => {  
  // make sure we are dropping on the parent TD
  // so we can get the id/parentID
  let element = event.target;
  if (element.tagName !== "TD") {
    while (element.parentElement) {
      element = element.parentElement;
      if (element.tagName === "TD") {
        break;
      }
    }
  }
  const dropNode = this.getIds(element);
  if (dropNode && this.draggedNode) {
    if (
      // don't drop into itself
      this.draggedNode.id !== dropNode.id &&
      // don't drop into it's own kids
      this.draggedNode.id !== dropNode.parentId
    ) {
      this.props.update(
        parseInt(this.draggedNode.id, 10),
        parseInt(dropNode.id, 10),
      );
    }
  }
};

And now with all our handlers in place we can spin up our app and drag and drop positions to our heart's content:

Hope this helps someone in some way ... and again, please clone the repository and have a play around.