DevExtreme v24.1 is now available.

Explore our newest features/capabilities and share your thoughts with us.

Your search did not match any results.

Angular Tree View - Drag & Drop for Plain Data Structure

This sample app demonstrates node drag and drop operations within DevExtreme TreeView when using simple data structures. You can reorder nodes within a single tree view or drag and drop nodes between two separate tree views.

Backend API
<div class="form"> <div class="drive-panel"> <div class="drive-header dx-treeview-item" ><div class="dx-treeview-item-content" ><i class="dx-icon dx-icon-activefolder"></i><span>Drive C:</span></div ></div > <dx-sortable filter=".dx-treeview-item" group="shared" data="driveC" [allowDropInsideItem]="true" [allowReordering]="true" (onDragChange)="onDragChange($event)" (onDragEnd)="onDragEnd($event)" > <dx-tree-view #treeviewDriveC id="treeviewDriveC" dataStructure="plain" displayExpr="name" [expandNodesRecursive]="false" [items]="itemsDriveC" [width]="250" [height]="380" > </dx-tree-view> </dx-sortable> </div> <div class="drive-panel"> <div class="drive-header dx-treeview-item" ><div class="dx-treeview-item-content" ><i class="dx-icon dx-icon-activefolder"></i><span>Drive D:</span></div ></div > <dx-sortable filter=".dx-treeview-item" group="shared" data="driveD" [allowDropInsideItem]="true" [allowReordering]="true" (onDragChange)="onDragChange($event)" (onDragEnd)="onDragEnd($event)" > <dx-tree-view #treeviewDriveD id="treeviewDriveD" dataStructure="plain" displayExpr="name" [expandNodesRecursive]="false" [items]="itemsDriveD" [width]="250" [height]="380" > </dx-tree-view> </dx-sortable> </div> </div>
import { NgModule, Component, enableProdMode, ViewChild, } from '@angular/core'; import { BrowserModule } from '@angular/platform-browser'; import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; import { DxSortableModule, DxSortableTypes } from 'devextreme-angular/ui/sortable'; import { DxTreeViewModule, DxTreeViewComponent, DxTreeViewTypes } from 'devextreme-angular/ui/tree-view'; import { Service, FileSystemItem } from './app.service'; if (!/localhost/.test(document.location.host)) { enableProdMode(); } type TreeView = ReturnType<AppComponent['getTreeView']>; type Node = DxTreeViewTypes.Node; type Item = DxTreeViewTypes.Item; @Component({ selector: 'demo-app', templateUrl: `app/app.component.html`, styleUrls: [`app/app.component.css`], providers: [Service], }) export class AppComponent { @ViewChild('treeviewDriveC') treeviewDriveC: DxTreeViewComponent; @ViewChild('treeviewDriveD') treeviewDriveD: DxTreeViewComponent; itemsDriveC: FileSystemItem[]; itemsDriveD: FileSystemItem[]; constructor(service: Service) { this.itemsDriveC = service.getItemsDriveC(); this.itemsDriveD = service.getItemsDriveD(); } onDragChange(e: DxSortableTypes.DragChangeEvent) { if (e.fromComponent === e.toComponent) { const fromNode = this.findNode(this.getTreeView(e.fromData), e.fromIndex); const toNode = this.findNode(this.getTreeView(e.toData), this.calculateToIndex(e)); if (toNode !== null && this.isChildNode(fromNode, toNode)) { e.cancel = true; } } } onDragEnd(e: DxSortableTypes.DragEndEvent) { if (e.fromComponent === e.toComponent && e.fromIndex === e.toIndex) { return; } const fromTreeView = this.getTreeView(e.fromData); const toTreeView = this.getTreeView(e.toData); const fromNode = this.findNode(fromTreeView, e.fromIndex); const toNode = this.findNode(toTreeView, this.calculateToIndex(e)); if (e.dropInsideItem && toNode !== null && !toNode.itemData.isDirectory) { return; } const fromTopVisibleNode = this.getTopVisibleNode(e.fromComponent); const toTopVisibleNode = this.getTopVisibleNode(e.toComponent); const fromItems = fromTreeView.option('items'); const toItems = toTreeView.option('items'); this.moveNode(fromNode, toNode, fromItems, toItems, e.dropInsideItem); fromTreeView.option('items', fromItems); toTreeView.option('items', toItems); fromTreeView.scrollToItem(fromTopVisibleNode); toTreeView.scrollToItem(toTopVisibleNode); } getTreeView(driveName: string) { return driveName === 'driveC' ? this.treeviewDriveC.instance : this.treeviewDriveD.instance; } calculateToIndex(e: DxSortableTypes.DragChangeEvent | DxSortableTypes.DragEndEvent) { if (e.fromComponent != e.toComponent || e.dropInsideItem) { return e.toIndex; } return e.fromIndex >= e.toIndex ? e.toIndex : e.toIndex + 1; } findNode(treeView: TreeView, index: number) { const nodeElement = treeView.element().querySelectorAll('.dx-treeview-node')[index]; if (nodeElement) { return this.findNodeById(treeView.getNodes(), nodeElement.getAttribute('data-item-id')); } return null; } findNodeById(nodes: Node[], id: string | number) { for (let i = 0; i < nodes.length; i++) { if (nodes[i].itemData.id == id) { return nodes[i]; } if (nodes[i].children) { const node = this.findNodeById(nodes[i].children, id); if (node != null) { return node; } } } return null; } moveNode(fromNode: Node, toNode: Node, fromItems: Item[], toItems: Item[], isDropInsideItem: boolean) { const fromIndex = fromItems.findIndex((item) => item.id == fromNode.itemData.id); fromItems.splice(fromIndex, 1); const toIndex = toNode === null || isDropInsideItem ? toItems.length : toItems.findIndex((item) => item.id == toNode.itemData.id); toItems.splice(toIndex, 0, fromNode.itemData); this.moveChildren(fromNode, fromItems, toItems); if (isDropInsideItem) { fromNode.itemData.parentId = toNode.itemData.id; } else { fromNode.itemData.parentId = toNode != null ? toNode.itemData.parentId : undefined; } } moveChildren(node: Node, fromDataSource: Item[], toDataSource: Item[]) { if (!node.itemData.isDirectory) { return; } node.children.forEach((child) => { if (child.itemData.isDirectory) { this.moveChildren(child, fromDataSource, toDataSource); } const fromIndex = fromDataSource.findIndex((item) => item.id == child.itemData.id); fromDataSource.splice(fromIndex, 1); toDataSource.splice(toDataSource.length, 0, child.itemData); }); } isChildNode(parentNode: Node, childNode: Node) { let parent = childNode.parent; while (parent !== null) { if (parent.itemData.id === parentNode.itemData.id) { return true; } parent = parent.parent; } return false; } getTopVisibleNode(component: DxSortableTypes.DragEndEvent['fromComponent']) { const treeViewElement = component.element(); const treeViewTopPosition = treeViewElement.getBoundingClientRect().top; const nodes = treeViewElement.querySelectorAll('.dx-treeview-node'); for (let i = 0; i < nodes.length; i++) { const nodeTopPosition = nodes[i].getBoundingClientRect().top; if (nodeTopPosition >= treeViewTopPosition) { return nodes[i]; } } return null; } } @NgModule({ imports: [ BrowserModule, DxTreeViewModule, DxSortableModule, ], declarations: [AppComponent], bootstrap: [AppComponent], }) export class AppModule { } platformBrowserDynamic().bootstrapModule(AppModule);
.form { display: flex; } ::ng-deep .form > div { display: inline-block; vertical-align: top; } ::ng-deep .dx-treeview-item { box-sizing: border-box; } ::ng-deep .drive-header { min-height: auto; padding: 0; cursor: default; margin-bottom: 10px; } ::ng-deep .drive-panel { padding: 20px 30px; font-size: 115%; font-weight: bold; border-right: 1px solid rgba(165, 165, 165, 0.4); height: 100%; } ::ng-deep .drive-panel:last-of-type { border-right: none; }
import { Injectable } from '@angular/core'; export class FileSystemItem { id: string; parentId?: string; name: string; icon: string; isDirectory: boolean; expanded?: boolean; } const itemsDriveD: FileSystemItem[] = []; const itemsDriveC: FileSystemItem[] = [{ id: '1', name: 'Documents', icon: 'activefolder', isDirectory: true, expanded: true, }, { id: '2', parentId: '1', name: 'Projects', icon: 'activefolder', isDirectory: true, expanded: true, }, { id: '3', parentId: '2', name: 'About.rtf', icon: 'file', isDirectory: false, expanded: true, }, { id: '4', parentId: '2', name: 'Passwords.rtf', icon: 'file', isDirectory: false, expanded: true, }, { id: '5', parentId: '2', name: 'About.xml', icon: 'file', isDirectory: false, expanded: true, }, { id: '6', parentId: '2', name: 'Managers.rtf', icon: 'file', isDirectory: false, expanded: true, }, { id: '7', parentId: '2', name: 'ToDo.txt', icon: 'file', isDirectory: false, expanded: true, }, { id: '8', name: 'Images', isDirectory: true, icon: 'activefolder', expanded: true, }, { id: '9', parentId: '8', name: 'logo.png', isDirectory: false, icon: 'file', expanded: true, }, { id: '10', parentId: '8', name: 'banner.gif', icon: 'file', isDirectory: false, expanded: true, }, { id: '11', name: 'System', icon: 'activefolder', isDirectory: true, expanded: true, }, { id: '12', parentId: '11', name: 'Employees.txt', icon: 'file', isDirectory: false, expanded: true, }, { id: '13', parentId: '11', name: 'PasswordList.txt', icon: 'file', isDirectory: false, expanded: true, }, { id: '14', name: 'Description.rtf', icon: 'file', isDirectory: false, expanded: true, }, { id: '15', name: 'Description.txt', icon: 'file', isDirectory: false, expanded: true, }]; @Injectable() export class Service { getItemsDriveC(): FileSystemItem[] { return itemsDriveC; } getItemsDriveD(): FileSystemItem[] { return itemsDriveD; } }
// In real applications, you should not transpile code in the browser. // You can see how to create your own application with Angular and DevExtreme here: // https://js.devexpress.com/Documentation/Guide/Angular_Components/Getting_Started/Create_a_DevExtreme_Application/ const componentNames = [ 'accordion', 'action-sheet', 'autocomplete', 'bar-gauge', 'box', 'bullet', 'button-group', 'button', 'calendar', 'chart', 'check-box', 'circular-gauge', 'color-box', 'context-menu', 'data-grid', 'date-box', 'date-range-box', 'defer-rendering', 'diagram', 'draggable', 'drawer', 'drop-down-box', 'drop-down-button', 'file-manager', 'file-uploader', 'filter-builder', 'form', 'funnel', 'gallery', 'gantt', 'html-editor', 'linear-gauge', 'list', 'load-indicator', 'load-panel', 'lookup', 'map', 'menu', 'multi-view', 'nested', 'number-box', 'pie-chart', 'pivot-grid-field-chooser', 'pivot-grid', 'polar-chart', 'popover', 'popup', 'progress-bar', 'radio-group', 'range-selector', 'range-slider', 'recurrence-editor', 'resizable', 'responsive-box', 'sankey', 'scheduler', 'scroll-view', 'select-box', 'slider', 'sortable', 'sparkline', 'speed-dial-action', 'splitter', 'switch', 'tab-panel', 'tabs', 'tag-box', 'text-area', 'text-box', 'tile-view', 'toast', 'toolbar', 'tooltip', 'tree-list', 'tree-map', 'tree-view', 'validation-group', 'validation-summary', 'validator', 'vector-map', ]; window.exports = window.exports || {}; window.config = { transpiler: 'ts', typescriptOptions: { module: 'system', emitDecoratorMetadata: true, experimentalDecorators: true, }, meta: { 'typescript': { 'exports': 'ts', }, 'devextreme/time_zone_utils.js': { 'esModule': true, }, 'devextreme/localization.js': { 'esModule': true, }, 'devextreme/viz/palette.js': { 'esModule': true, }, '@angular/platform-browser-dynamic': { 'esModule': true, }, '@angular/platform-browser': { 'esModule': true, }, '@angular/core': { 'esModule': true, }, '@angular/common': { 'esModule': true, }, '@angular/common/http': { 'esModule': true, }, '@angular/compiler': { 'esModule': true, }, '@angular/animations': { 'esModule': true, }, '@angular/forms': { 'esModule': true, }, }, paths: { 'npm:': 'https://unpkg.com/', 'bundles:': '../../../../bundles/', }, map: { 'ts': 'npm:plugin-typescript@4.2.4/lib/plugin.js', 'typescript': 'npm:typescript@4.2.4/lib/typescript.js', /* @angular */ '@angular/compiler': 'bundles:@angular/compiler.umd.js', '@angular/platform-browser-dynamic': 'bundles:@angular/platform-browser-dynamic.umd.js', '@angular/core': 'bundles:@angular/core.umd.js', '@angular/core/primitives/signals': 'bundles:@angular/core.primitives.signals.umd.js', '@angular/common': 'bundles:@angular/common.umd.js', '@angular/common/http': 'bundles:@angular/common-http.umd.js', '@angular/platform-browser': 'bundles:@angular/platform-browser.umd.js', '@angular/platform-browser/animations': 'bundles:@angular/platform-browser.umd.js', '@angular/forms': 'bundles:@angular/forms.umd.js', /* devextreme */ 'devextreme': 'npm:devextreme@24.1.7/cjs', '@devextreme/runtime': 'npm:@devextreme/runtime@3.0.13', 'devextreme/bundles/dx.all': 'npm:devextreme@24.1.7/bundles/dx.all.js', 'devextreme-quill': 'npm:devextreme-quill@1.7.1/dist/dx-quill.min.js', 'devexpress-diagram': 'npm:devexpress-diagram@2.2.13', 'devexpress-gantt': 'npm:devexpress-gantt@4.1.56', /* devextreme-angular umd maps */ 'devextreme-angular': 'bundles:devextreme-angular/devextreme-angular.umd.js', 'devextreme-angular/core': 'bundles:devextreme-angular/devextreme-angular-core.umd.js', 'devextreme-angular/http': 'bundles:devextreme-angular/devextreme-angular-http.umd.js', ...componentNames.reduce((acc, name) => { acc[`devextreme-angular/ui/${name}`] = `bundles:devextreme-angular/devextreme-angular-ui-${name}.umd.js`; return acc; }, {}), 'jszip': 'npm:jszip@3.10.1/dist/jszip.min.js', 'tslib': 'npm:tslib@2.6.1/tslib.js', 'rxjs': 'npm:rxjs@7.5.3/dist/bundles/rxjs.umd.js', 'rxjs/operators': 'npm:rxjs@7.5.3/dist/cjs/operators/index.js', 'rrule': 'npm:rrule@2.6.4/dist/es5/rrule.js', 'luxon': 'npm:luxon@1.28.1/build/global/luxon.min.js', 'es6-object-assign': 'npm:es6-object-assign@1.1.0', 'inferno': 'npm:inferno@7.4.11/dist/inferno.min.js', 'inferno-compat': 'npm:inferno-compat/dist/inferno-compat.min.js', 'inferno-create-element': 'npm:inferno-create-element@7.4.11/dist/inferno-create-element.min.js', 'inferno-dom': 'npm:inferno-dom/dist/inferno-dom.min.js', 'inferno-hydrate': 'npm:inferno-hydrate@7.4.11/dist/inferno-hydrate.min.js', 'inferno-clone-vnode': 'npm:inferno-clone-vnode/dist/inferno-clone-vnode.min.js', 'inferno-create-class': 'npm:inferno-create-class/dist/inferno-create-class.min.js', 'inferno-extras': 'npm:inferno-extras/dist/inferno-extras.min.js', // Prettier 'prettier/standalone': 'npm:prettier@2.8.8/standalone.js', 'prettier/parser-html': 'npm:prettier@2.8.8/parser-html.js', }, packages: { 'app': { main: './app.component.ts', defaultExtension: 'ts', }, 'devextreme': { defaultExtension: 'js', }, 'devextreme/events/utils': { main: 'index', }, 'devextreme/events': { main: 'index', }, 'es6-object-assign': { main: './index.js', defaultExtension: 'js', }, 'rxjs': { defaultExtension: 'js', }, 'rxjs/operators': { defaultExtension: 'js', }, }, packageConfigPaths: [ 'npm:@devextreme/*/package.json', 'npm:@devextreme/runtime@3.0.13/inferno/package.json', 'npm:rxjs@7.5.3/package.json', 'npm:rxjs@7.5.3/operators/package.json', 'npm:devexpress-diagram@2.2.13/package.json', 'npm:devexpress-gantt@4.1.56/package.json', ], }; System.config(window.config); // System.import('@angular/compiler').catch(console.error.bind(console));
<!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml" lang="en"> <head> <title>DevExtreme Demo</title> <meta http-equiv="X-UA-Compatible" content="IE=edge" /> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=5.0" /> <link rel="stylesheet" type="text/css" href="https://cdn3.devexpress.com/jslib/24.1.7/css/dx.light.css" /> <script src="https://unpkg.com/core-js@2.6.12/client/shim.min.js"></script> <script src="https://unpkg.com/zone.js@0.13.3/bundles/zone.umd.min.js"></script> <script src="https://unpkg.com/reflect-metadata@0.1.13/Reflect.js"></script> <script src="https://unpkg.com/systemjs@0.21.3/dist/system.js"></script> <script src="config.js"></script> <script> System.import("app").catch(console.error.bind(console)); </script> </head> <body class="dx-viewport"> <div class="demo-container"> <demo-app>Loading...</demo-app> </div> </body> </html>

Use Sortable to implement the necessary drag and drop functionality within your web app. The following steps outline configuration requirements for our TreeView:

  1. Allow users to reorder nodes
    Wrap the TreeView in a Sortable and enable the Sortable's allowReordering property.

  2. Allow users to change node hierarchy
    Enable the allowDropInsideItem property so that users can drop one node onto another. This adds it as the target node's child. If this property is disabled, users can only drop nodes between other nodes.

  3. Allow users to drag only tree view nodes
    To specify tree view nodes as drag targets, set the filter property to a class selector. Since all tree view nodes use the dx-treeview-node class, you can use this class selector as needed.

  4. Prevent a node from being moved into its child node
    When a user moves a parent node into its own child node, it breaks the hierarchy. To prevent this outcome, implement the onDragChange function and traverse up the node tree. If the target is a child of the dragged node, cancel the ability to drop the node.

  5. Reorder nodes in code
    Implement the onDragEnd function. In this function, you must gather information about nodes being moved. With this information, you can reorder the nodes in the data source (see the moveNode function), and reassign the data source to the TreeView's items property.

  6. Specify tree view identifiers (for drag and drop between multiple tree views only)
    Identifiers help distinguish between multiple tree views. Save them in the Sortable's data property. The tree views below include the following identifiers: "driveC" and "driveD".

  7. Combine tree views into one drag and drop group (for drag and drop between multiple tree views only)
    Set the Sortable's group property to the same value for all tree views. This allows users to move nodes between the tree views.