Skip to content
•13 min read

Odoo JavaScript Tutorial 101 - Part 3: Adding Drag and Drop to our OWL View

Philippe L'ATTENTION
Odoo 14 OWL Tutorial, adding drag and drop feature to our View

This is the third part of our ongoing tutorial series about the Odoo 14 JavaScript Framework. In the second part, we created our new type of View in OWL that displayed a hierarchical vision of parent/child type Models. The nodes are clickable and can open the child elements.

This where we left of in the second part of the tutorial

In this follow-up, we will add the drag and drop feature that will allow us to move the items and change their hierarchy.

There are some rules though:

  • A parent item shouldn't be dragged and dropped into one of its own children.
  • Moving an item should recalculate the count of the old parent and the new target parent should be refresh.
  • An item cannot be dropped into itself.

To handle that we need to first take a look at the HTML Drag and Drop API

The HTML Drag And Drop API

In this section, I will briefly explain the HTML Drag and Drop API but if you would like a more in-depth view, take a look at this MDN Documentation.

The first step to make a DOM element draggable is adding the draggable attribute to the element and set it to true:

<div id="draggableDiv" draggable="true">This element can be dragged.</div>

The DragEvent interface

This element will then already be draggable in the browser. On top of that, it will fire multiple events. We will not cover all the events but only the ones we are interested in:

  • dragstart event when the dragging begins. After a prolonged click on the draggable element, it "pops out" and can be dragged around.
  • dragenter and dragleave events are events that happen on the element being dragged onto. You can know when the dragged element is entering or leaving a zone represented by another element, and make that zone react accordingly.
  • dragover events trigger every few hundred milliseconds when an element is being dragged over. An element that is a valid drop target should handle that event.
  • drop event when the item is dropped (mouse released). If an element is a valid drop target it should handle that event.

Handling event means that we will have to ev.preventDefault() the events coming into our handler function. Fortunately for us, OWL provides us with shortcuts for that.

The DataTransfer interface

The DataTransfer is an object holding data during the drag and drop events, it can hold multiple items.

We will use this DataTransfer to pass around the tree item being drag and dropped. To do so, the interface gives u access to 2 functions:

DataTransfer.setData()
Set the data for a given type. If data for the type does not exist, it is added at the end, such that the last item in the types list will be the new format. If data for the type already exists, the existing data is replaced in the same position.

event.dataTransfer.setData("text/plain", "hello world");

DataTransfer.getData()
Retrieves the data for a given type, or an empty string if data for that type does not exist or the data transfer contains no data.

let result = event.dataTransfer.getData("text/plain");
// result: "hello world"

Let's dive into our TreeItem Component now to make everything clearer.

Making our TreeItem draggable

Now that we know how everything works, we will update our TreeItem Component. The draggable element will be the whole <div> (without the children) and it will also be the drop zone.

Let's update static/src/components/tree_item/TreeItem.xml to reflect that:

<t t-name="owl_tutorial_views.TreeItem" owl="1">
    <div class="tree-item-wrapper">
        <div 
            draggable="true" 
            t-on-dragstart="onDragstart" 
            t-on-drop.stop.prevent="onDrop" 
            t-on-dragover.prevent="onDragover" 
            t-on-dragenter.prevent="onDragenter" 
            t-on-dragleave.prevent="onDragleave" 
            t-attf-class="list-group-item list-group-item-action d-flex justify-content-between align-items-center owl-tree-item {{ state.isDraggedOn ? 'list-group-item-warning': '' }}"
        >
            <a href="#" t-on-click.stop.prevent="toggleChildren" t-if="props.item.child_id.length > 0">
                <t t-esc="props.item.display_name"/>
                <i t-attf-class="pl-2 fa {{ state.childrenVisible ? 'fa-caret-down': 'fa-caret-right'}}" ></i>
            </a>
            <span t-else="">
                <t t-esc="props.item.display_name"/>
            </span>
            <span 
                t-if="props.countField !== '' and props.item.hasOwnProperty(props.countField)" 
                class="badge badge-primary badge-pill" 
                t-esc="props.item[props.countField]">
            </span>
        </div>
        <t t-if="props.item.child_id.length > 0">
            <div class="d-flex pl-4 py-1 flex-row treeview" t-if="props.item.children and props.item.children.length > 0 and state.childrenVisible">
                <div class="list-group">
                    <t t-foreach="props.item.children" t-as="child_item">
                        <TreeItem item="child_item"/>
                    </t>
                </div>
            </div>
        </t>
    </div>
</t>

We can directly use .prevent shortcuts to not have to write event.preventDefault() inside the handler function.

Notice that we also added a CSS class that triggers by checking the state of the TreeItem here {{ state.isDraggedOn ? 'list-group-item-warning': '' }}.

This will make the item become yellow-ish when dragged over. Let's now check each function we have to implement.

Handling the dragstart event

The dragstart represents the first step of the whole drag and drop operation. This is where we will set our TreeItem Object as the data to be transferred during the drag and drop.

We want to transfer the full object but the DataTransfer API setData only let us transfer strings so we will have to JSON.stringify our object first. So inside our src/components/tree_item/TreeItem.js:

onDragstart(event) {
	event.dataTransfer.setData("TreeItem", JSON.stringify(this.props.item));
}
We transfer a custom format "TreeItem"

We transfer a custom format "TreeItem"

As you can see, we can transfer custom formats of data, ie "TreeItem" in our example. With that done, we will be able to get the item being moved at the end of the chain of operations.

Handling dragenter and dragleave to give visual feedback to the user.

These two events only trigger once and they will toggle the list-group-item-warning CSS class to give the user visual feedback on the component he is dragging over.

onDragenter() {
  Object.assign(this.state, { isDraggedOn: true });
}

onDragleave() {
  Object.assign(this.state, { isDraggedOn: false });
}

The code is self-explanatory on this one, let's move on to the main event.

Handling the drop event

The first thing to do in the drop handle will be to toggle the state isDraggedOn to false so the yellow highlight disappears. Then we have to retrieve the current item being drop by parsing the JSON string into an object:

onDrop(event) {
  Object.assign(this.state, { isDraggedOn: false });
  let droppedItem = JSON.parse(event.dataTransfer.getData("TreeItem"));
}

Now that we have the item, we will check these 2 conditions:

  • An item cannot be dropped on itself.
  • Dropping an item into the same parent as origin should do nothing.
  • The item cannot be dropped inside one of his children

Applying the restrictions

The first 2 requirements are easy to check by comparing the drop target inside the this.props.item and the dropped item inside droppedItem

onDrop(event) {
  Object.assign(this.state, { isDraggedOn: false });
  let droppedItem = JSON.parse(event.dataTransfer.getData("TreeItem"));
  if (
    droppedItem.id == this.props.item.id ||
    droppedItem.parent_id[0] == this.props.item.id
  ) {
    console.log("Drop inside itself or same parent has no effect");
    return;
  }
}

For the second condition "The item cannot be dropped inside its children" we will use the useful parent_path property available:

if (this.props.item.parent_path.startsWith(droppedItem.parent_path)) {
    console.log("Oops, drop inside child item is forbidden.");
    return;
}

Here we checked if the current target (this.props.item) has a "parent_path" containing the dropped item "parent_path". If yes, then it means that we are dropping inside a child element and we directly return the function.

Triggering an event to change the tree.

A component should never modify its own props, so as a good practice we will fire an event called change_item_tree with a payload containing the item being moved and its new parent:

this.trigger("change_item_tree", {
    itemMoved: droppedItem,
    newParent: this.props.item,
});

This event will be captured by our OWLTreeController, it is the responsibility of the Controller to listen to the event and call the Model to update the data.

Full onDrop function code

onDrop(event) {
  Object.assign(this.state, { isDraggedOn: false });
  let droppedItem = JSON.parse(event.dataTransfer.getData("TreeItem"));
  if (
    droppedItem.id == this.props.item.id ||
    droppedItem.parent_id[0] == this.props.item.id
  ) {
    console.log("Drop inside itself or same parent has no effect");
    return;
  }
  if (this.props.item.parent_path.startsWith(droppedItem.parent_path)) {
    console.log("Oops, drop inside child item is forbidden.");
    return;
  }
  this.trigger("change_item_tree", {
    itemMoved: droppedItem,
    newParent: this.props.item,
  });
}

The code for the TreeItem and the drag and drop interface is now complete, you can already test it in your browser but, for now, the change_item_tree event is not listened to, so nothing will happen.

Handling the Tree change in the Controller

Inside our OWLTreeController we will begin by registering the new event change_item_tree and associate a handler for it:

var OWLTreeController = AbstractController.extend({
  custom_events: _.extend({}, AbstractController.prototype.custom_events, {
    tree_item_clicked: "_onTreeItemClicked",
    change_item_tree: "_onChangeItemTree",
  }),
  // ...
}
The new handler is _onChangeItemTree

The new handler is _onChangeItemTree

What should happen in the event handler?

Let's do a quick summary of everything we need to do to handle this change of parent/children:

  • First, an RPC call to the server should be made setting the new parent_id of the current item to the "dropped on" item ID.
  • Then we should refresh the data of the old parent. Now that it misses one child, the count has probably changed and if it was expanded it should be expanded again with fresh data coming from the server. And if the step before went fine, the refresh expanded children should not contain the old item.
  • Finally, we should expand the new target drop zone. This is now the new parent so we should expand its node and assess that the dropped item is inside.

The expandChildrenOf Model function is already coded so we will use that one multiple times to expand old and new parents, but we miss the changeParentfunction and the refreshNodefunction.

Anyway, let's write the Controller _onChangeItemTree handler function as we wish it to be, and then we will handle these missing Model functions in the next section:

/**
 * @param {Object} ev
 * @param {Object} ev.data.itemMoved is the item that has been dragged
 * @param {Object} ev.data.newParent is the new target parent of the dropped item
 */
_onChangeItemTree: async function (ev) {
  ev.stopPropagation();
  let itemMoved = ev.data.itemMoved;
  let newParent = ev.data.newParent;
  await this.model.changeParent(itemMoved.id, newParent.id);

  // Refresh old parent
  let oldParent = await this.model.refreshNode(itemMoved.parent_id[0]);
  await this.model.expandChildrenOf(oldParent.id, oldParent.parent_path);

  // Refresh new parent
  await this.model.expandChildrenOf(
    ev.data.newParent.id,
    ev.data.newParent.parent_path
  );
  this.update({}, { reload: false });
},
OWLTreeController new event handler for "change_item_tree"

OWLTreeController new event handler for "change_item_tree"

Notice that this event handler is once again async, some of the steps need to be waited for before going onto the next. For example, we need to wait for the server response of the changeParent function before refreshing the old parent item.

New Model functions to handle the hierarchical changes.

Let's update our OWLTreeModel inside /src/owl_tree_view/owl_tree_model.js and add the new required functions.

The changeParent function

Nothing tricky in this feature, we will just write on the model and change the parent_id:

/**
 * Make an RPC 'write' method call to update the parent_id of 
 * an existing record.
 *
 * @param {integer} id ID Of the item we want to update
 * @param {integer} parent_id The parent item ID
 */
changeParent: async function (id, parent_id) {
  await this._rpc({
    model: this.modelName,
    method: "write",
    args: [
      [id],
      {
        parent_id: parent_id,
      },
    ],
  });
},

The "refreshNode" function

This function's goal is to make an RPC call to have fresh data of a given item. The item data will then be used to replace directly the item in the this.data hierarchical tree of the Model.

To target the item in the hierarchical tree and replace it, we will use a function we already wrote in the 2nd part of this tutorial called __target_parent_node_with_path:

/**
 * Refresh a node get fresh data from the server for a given item.
 * A search_read is executed via RPC Call and then the item is modified
 * in place in the hierarchical tree structure.
 *
 * @param {integer} id ID Of the item we want to refresh
 */
refreshNode: async function (id) {
  var self = this;
  var result = null;
  await this._rpc({
    model: this.modelName,
    method: "search_read",
    kwargs: {
      domain: [["id", "=", id]],
    },
  }).then(function (itemUpdated) {
    let path = itemUpdated[0].parent_path;
    let target_node = self.__target_parent_node_with_path(
      path.split("/").filter((i) => i),
      self.data.items
    );
    target_node = itemUpdated[0];
    result = itemUpdated[0];
  });
  return result;
},

With this refreshNode we will be able to fetch new data from the server for the old parent, especially to have the new child_id ids.

After that, calling expandChildrenOf on the old parent will open the node with the new children.

Conclusion

That's it for the drag and drop functionality, to go again over what we did:

  • Drag and Drop Interface DragEvents and DataTransfer are implemented on our TreeItem Component. The data transferred is the actual item object (props of the TreeItem Component).
  • When a TreeItem is dropped on another correct target TreeItem, we trigger an event change_item_tree with a payload containing the item being moved and the new parent.
  • The Controller listens to the change_item_tree event will call different Model methods to reorganize the items hierarchy.
  • The Model assigns the new parent_id to the item moved. The old parent is refreshed and re-expanded. And the new parent is also expanded.

Optional: Improving our module to answer the search panel.

This part is an optional, non-fully-functional feature. You can follow the concepts but the feature is not mature enough and can only return one result. Making our view respond to the search panel correctly would take more effort but this is a starting point.

The last improvement we will add to our module is the response to the search panel. In our example, if a user would search for a category name, we would like the tree to open directly to the target found item, without any other item shown.

Refactoring our TreeItem to have childrenVisible as props.

For now childrenVisible, the boolean corresponding to the fact if the child items are visible (expanded) or not, is stored inside the state of the TreeItem Component.

This is problematic when the Component has to answer to the reload function triggered by the Search Panel. The full Component Tree has to be rendered with childrenVisible as true.

To fix that, we will put childrenVisible as a property of the item props, so it will be handled by the Model. First, let's update the QWeb template where we will change the state.childrenVisible into props.item.childrenVisible:

<div class="d-flex pl-4 py-1 flex-row treeview" t-if="props.item.children and props.item.children.length > 0 and props.item.childrenVisible">
    <div class="list-group">
        <t t-foreach="props.item.children" t-as="child_item" t-key="child_item.id">
            <TreeItem item="child_item"/>
        </t>
    </div>
</div>

Update also the caret toggle's class, to use the props.item:

<i t-attf-class="pl-2 fa {{ props.item.childrenVisible ? 'fa-caret-down': 'fa-caret-right'}}" ></i>

Removing the childrenVisible from the state in the TreeItem.js

Inside our Component file, let's update the state in the constructor and the toggleChildren will now only trigger an event, the Controller will call the correct Model methods to handle the childrenVisible flag:

class TreeItem extends Component {
  /**
   * @override
   */
  constructor(...args) {
    super(...args);
    this.state = useState({
      isDraggedOn: false,
    });
  }

  toggleChildren() {
    if (this.props.item.child_id.length > 0) {
      this.trigger("tree_item_clicked", { data: this.props.item });
    }
  }
  // ...
  // Rest of the Component unchanged
}

If it is the first time we open the item the Controller will call the Model method expandChildrenOf (RPC Call to fetch children and setting childrenVisible property). If the item already has children, it will call a Model method that will just update the childrenVisible property on the item.

Updating the Controller handler accordingly

Let's update the handler _onTreeItemClicked so it will now call a different function if the item already have children but just want to toggle the childrenVisible property:

/**
 * @param {Object} ev
 * @param {Object} ev.data contains the payload
 */
_onTreeItemClicked: async function (ev) {
  ev.stopPropagation();
  if (ev.data.data.children === undefined) {
    await this.model.expandChildrenOf(
      ev.data.data.id,
      ev.data.data.parent_path
    );
  } else {
    this.model.toggleChildrenVisibleForItem(ev.data.data);
  }
  this.update({}, { reload: false });
},

Updating the OWLTreeModel to handle the childrenVisible flag

Let's update our Model method expandChildrenOf to set the childrenVisible property and add a new function toggleChildrenVisibleForItem:

expandChildrenOf: async function (parentId, path) {
  var self = this;
  await this._rpc({
    model: this.modelName,
    method: "search_read",
    kwargs: {
      domain: [["parent_id", "=", parentId]],
    },
  }).then(function (children) {
    var target_node = self.__target_parent_node_with_path(
      path.split("/").filter((i) => i),
      self.data.items
    );
    target_node.children = children;
    target_node.child_id = children.map((i) => i.id);
    target_node.childrenVisible = true;
  });
},

toggleChildrenVisibleForItem: async function (item) {
  var target_node = this.__target_parent_node_with_path(
    item.parent_path.split("/").filter((i) => i),
    this.data.items
  );
  target_node.childrenVisible = !target_node.childrenVisible;
},

Okay, that's it for the refactoring, now we can move on to our new feature.

Handling the Search Panel params in the reload Model function.

When a user searches for a term in the search panel, the reload function of the Model will be triggered down the line.

Don't forget that it's the Controller that is actually responding to the Search Panel and then calling the Model reload function.

When the reload function is called, new params are passed to the function, and inside these params is the new search domain. Let's update the content of this function so that if the domain is different than [] we will call some different functions than fetchData to create a hierarchical tree containing only search results.

__reload: function (handle, params) {
  this.domain = [["parent_id", "=", false]];
  if (params && params.domain && params.domain.length > 0) {
    return this._fetchFilteredData(params.domain);
  } else {
    return this._fetchData();
  }
},
We call a new function "_fetchFilteredData" with the domain

We call a new function "_fetchFilteredData" with the domain

This is the content of the _fetchFilteredData inside the OWLTreeModel:

_fetchFilteredData: async function (domain) {
  var self = this;
  await this._rpc({
    model: this.modelName,
    method: "search_read",
    kwargs: {
      domain: domain,
    },
  }).then(async function (result) {
    for (const item of result) {
      return await self.recursivelyOpenParents(item);
    }
  });
},

As you can see, we also do a search_read but with the new domain. Then with the found result, we will create a hierarchical tree containing only this result item and all its parents. This is the expected result:

Searching for the "Software" category opens the whole Tree directly to that item. Without other items.
Searching for the "Software" category opens the whole Tree directly to that item. Without other items.

The 2 requirements are:

  • Hierarchical Tree should not show all the items. Only the search result and its direct parent
  • The hierarchical Tree should be directly open to the found item. So each item should have the childrenVisible property set to true.

Recursively create a tree from a given item

For this tutorial, let's say that the result of the search_read with the given domain gives us one result. From this result item, we will recursively check its parent to create a filtered tree:

for (const item of result) {
    return await self.recursivelyOpenParents(item);
}

This is the content of the recursivelyOpenParents function:

recursivelyOpenParents: async function (item) {
  var self = this;
  await this._rpc({
    model: this.modelName,
    method: "read",
    args: [[item.parent_id[0]], []],
  }).then(async function (parent) {
    if (parent[0] && parent[0].id) {
      const directParent = parent[0];
      directParent.children = [item];
      directParent.childrenVisible = true;
      return await self.recursivelyOpenParents(directParent);
    } else {
      self.data.items = [item];
      return true;
    }
  });
},

In this recursive function, we make an RPC call to read the parent given the parent_id. We will go up a parent each time until we find an item without parent_id (root item).

The RPC call with a false "parent_id" won't return any result, that's our cue to return from our recursive loop, and set the self.data.items with the root parent.

Conclusion

That's it for our module that now reacts to the Search Panel input.

Search feature demo of the hierarchical tree
Search feature demo of the hierarchical tree

You may have noticed that this search feature can only return and open one item hierarchical tree.

It is more complicated to have multiple items and merge multiple trees, and this is not in the scope of this tutorial for now.

Feel free to improve it and Tweet at me to show your work, I will share it! ?

As always, thank you for reading and your support!

You might also like