If you have technical questions, please create a support ticket in the DevExpress Support Center.
import React, { useCallback, useRef, useState } from 'react';
import TreeView, { TreeViewRef } from 'devextreme-react/tree-view';
import Sortable, { SortableTypes } from 'devextreme-react/sortable';
import service from './data.ts';
const getStateFieldName = (driveName: string) => (driveName === 'driveC'
? 'itemsDriveC'
: 'itemsDriveD');
const calculateToIndex = (e: SortableTypes.DragChangeEvent) => {
if (e.fromComponent !== e.toComponent || e.dropInsideItem) {
return e.toIndex;
}
return e.fromIndex >= e.toIndex
? e.toIndex
: e.toIndex + 1;
};
const findNode = (treeView, index: string | number) => {
const nodeElement = treeView.element().querySelectorAll('.dx-treeview-node')[index];
if (nodeElement) {
return findNodeById(treeView.getNodes(), nodeElement.getAttribute('data-item-id'));
}
return null;
};
const findNodeById = (nodes, id) => {
for (let i = 0; i < nodes.length; i += 1) {
if (nodes[i].itemData.id === id) {
return nodes[i];
}
if (nodes[i].children) {
const node = findNodeById(nodes[i].children, id);
if (node != null) {
return node;
}
}
}
return null;
};
const moveNode = (fromNode, toNode, fromItems, toItems, isDropInsideItem) => {
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);
moveChildren(fromNode, fromItems, toItems);
if (isDropInsideItem) {
fromNode.itemData.parentId = toNode.itemData.id;
} else {
fromNode.itemData.parentId = toNode != null
? toNode.itemData.parentId
: undefined;
}
};
const moveChildren = (node, fromDataSource, toDataSource: any[]) => {
if (!node.itemData.isDirectory) {
return;
}
node.children.forEach((child) => {
if (child.itemData.isDirectory) {
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);
});
};
const isChildNode = (parentNode: { itemData: { id: any; }; }, childNode: { parent: any; }) => {
let { parent } = childNode;
while (parent !== null) {
if (parent.itemData.id === parentNode.itemData.id) {
return true;
}
parent = parent.parent;
}
return false;
};
const getTopVisibleNode = (component) => {
const treeViewElement = component.element();
const treeViewTopPosition = treeViewElement.getBoundingClientRect().top;
const nodes = treeViewElement.querySelectorAll('.dx-treeview-node');
for (let i = 0; i < nodes.length; i += 1) {
const nodeTopPosition = nodes[i].getBoundingClientRect().top;
if (nodeTopPosition >= treeViewTopPosition) {
return nodes[i];
}
}
return null;
};
const App = () => {
const treeViewDriveCRef = useRef<TreeViewRef>(null);
const treeViewDriveDRef = useRef<TreeViewRef>(null);
const [itemsDriveC, setItemsDriveC] = useState(service.getItemsDriveC());
const [itemsDriveD, setItemsDriveD] = useState(service.getItemsDriveD());
const getTreeView = useCallback((driveName: string) => (driveName === 'driveC'
? treeViewDriveCRef.current.instance()
: treeViewDriveDRef.current.instance()), []);
const onDragChange = useCallback((e: SortableTypes.DragChangeEvent) => {
if (e.fromComponent === e.toComponent) {
const fromNode = findNode(getTreeView(e.fromData), e.fromIndex);
const toNode = findNode(getTreeView(e.toData), calculateToIndex(e));
if (toNode !== null && isChildNode(fromNode, toNode)) {
e.cancel = true;
}
}
}, [getTreeView]);
const onDragEnd = useCallback((e: SortableTypes.DragEndEvent) => {
if (e.fromComponent === e.toComponent && e.fromIndex === e.toIndex) {
return;
}
const fromTreeView = getTreeView(e.fromData);
const toTreeView = getTreeView(e.toData);
const fromNode = findNode(fromTreeView, e.fromIndex);
const toNode = findNode(toTreeView, calculateToIndex(e));
if (e.dropInsideItem && toNode !== null && !toNode.itemData.isDirectory) {
return;
}
const fromTopVisibleNode = getTopVisibleNode(e.fromComponent);
const toTopVisibleNode = getTopVisibleNode(e.toComponent);
const fromItems = getStateFieldName(e.fromData) === 'itemsDriveC'
? itemsDriveC
: itemsDriveD;
const toItems = getStateFieldName(e.toData) === 'itemsDriveC'
? itemsDriveC
: itemsDriveD;
moveNode(fromNode, toNode, fromItems, toItems, e.dropInsideItem);
if (getStateFieldName(e.fromData) === 'itemsDriveC') {
setItemsDriveC([fromItems]);
} else {
setItemsDriveD([fromItems]);
}
if (getStateFieldName(e.toData) === 'itemsDriveC') {
setItemsDriveC([toItems]);
} else {
setItemsDriveD([toItems]);
}
fromTreeView.scrollToItem(fromTopVisibleNode);
toTreeView.scrollToItem(toTopVisibleNode);
}, [getTreeView, itemsDriveC, itemsDriveD, setItemsDriveC, setItemsDriveD]);
return (
<div className="form">
<div className="drive-panel">
<div className="drive-header dx-treeview-item"><div className="dx-treeview-item-content"><i className="dx-icon dx-icon-activefolder"></i><span>Drive C:</span></div></div>
<Sortable
filter=".dx-treeview-item"
group="shared"
data="driveC"
allowDropInsideItem={true}
allowReordering={true}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
>
<TreeView
id="treeviewDriveC"
expandNodesRecursive={false}
dataStructure="plain"
ref={treeViewDriveCRef}
items={itemsDriveC}
width={250}
height={380}
displayExpr="name"
/>
</Sortable>
</div>
<div className="drive-panel">
<div className="drive-header dx-treeview-item"><div className="dx-treeview-item-content"><i className="dx-icon dx-icon-activefolder"></i><span>Drive D:</span></div></div>
<Sortable
filter=".dx-treeview-item"
group="shared"
data="driveD"
allowDropInsideItem={true}
allowReordering={true}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
>
<TreeView
id="treeviewDriveD"
expandNodesRecursive={false}
dataStructure="plain"
ref={treeViewDriveDRef}
items={itemsDriveD}
width={250}
height={380}
displayExpr="name"
/>
</Sortable>
</div>
</div>
);
};
export default App;
xxxxxxxxxx
import React, { useCallback, useRef, useState } from 'react';
import TreeView from 'devextreme-react/tree-view';
import Sortable from 'devextreme-react/sortable';
import service from './data.js';
const getStateFieldName = (driveName) => (driveName === 'driveC' ? 'itemsDriveC' : 'itemsDriveD');
const calculateToIndex = (e) => {
if (e.fromComponent !== e.toComponent || e.dropInsideItem) {
return e.toIndex;
}
return e.fromIndex >= e.toIndex ? e.toIndex : e.toIndex + 1;
};
const findNode = (treeView, index) => {
const nodeElement = treeView.element().querySelectorAll('.dx-treeview-node')[index];
if (nodeElement) {
return findNodeById(treeView.getNodes(), nodeElement.getAttribute('data-item-id'));
}
return null;
};
const findNodeById = (nodes, id) => {
for (let i = 0; i < nodes.length; i += 1) {
if (nodes[i].itemData.id === id) {
return nodes[i];
}
if (nodes[i].children) {
const node = findNodeById(nodes[i].children, id);
if (node != null) {
return node;
}
}
}
return null;
};
const moveNode = (fromNode, toNode, fromItems, toItems, isDropInsideItem) => {
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);
moveChildren(fromNode, fromItems, toItems);
if (isDropInsideItem) {
fromNode.itemData.parentId = toNode.itemData.id;
} else {
fromNode.itemData.parentId = toNode != null ? toNode.itemData.parentId : undefined;
}
};
const moveChildren = (node, fromDataSource, toDataSource) => {
if (!node.itemData.isDirectory) {
return;
}
node.children.forEach((child) => {
if (child.itemData.isDirectory) {
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);
});
};
const isChildNode = (parentNode, childNode) => {
let { parent } = childNode;
while (parent !== null) {
if (parent.itemData.id === parentNode.itemData.id) {
return true;
}
parent = parent.parent;
}
return false;
};
const getTopVisibleNode = (component) => {
const treeViewElement = component.element();
const treeViewTopPosition = treeViewElement.getBoundingClientRect().top;
const nodes = treeViewElement.querySelectorAll('.dx-treeview-node');
for (let i = 0; i < nodes.length; i += 1) {
const nodeTopPosition = nodes[i].getBoundingClientRect().top;
if (nodeTopPosition >= treeViewTopPosition) {
return nodes[i];
}
}
return null;
};
const App = () => {
const treeViewDriveCRef = useRef(null);
const treeViewDriveDRef = useRef(null);
const [itemsDriveC, setItemsDriveC] = useState(service.getItemsDriveC());
const [itemsDriveD, setItemsDriveD] = useState(service.getItemsDriveD());
const getTreeView = useCallback(
(driveName) =>
(driveName === 'driveC'
? treeViewDriveCRef.current.instance()
: treeViewDriveDRef.current.instance()),
[],
);
const onDragChange = useCallback(
(e) => {
if (e.fromComponent === e.toComponent) {
const fromNode = findNode(getTreeView(e.fromData), e.fromIndex);
const toNode = findNode(getTreeView(e.toData), calculateToIndex(e));
if (toNode !== null && isChildNode(fromNode, toNode)) {
e.cancel = true;
}
}
},
[getTreeView],
);
const onDragEnd = useCallback(
(e) => {
if (e.fromComponent === e.toComponent && e.fromIndex === e.toIndex) {
return;
}
const fromTreeView = getTreeView(e.fromData);
const toTreeView = getTreeView(e.toData);
const fromNode = findNode(fromTreeView, e.fromIndex);
const toNode = findNode(toTreeView, calculateToIndex(e));
if (e.dropInsideItem && toNode !== null && !toNode.itemData.isDirectory) {
return;
}
const fromTopVisibleNode = getTopVisibleNode(e.fromComponent);
const toTopVisibleNode = getTopVisibleNode(e.toComponent);
const fromItems = getStateFieldName(e.fromData) === 'itemsDriveC' ? itemsDriveC : itemsDriveD;
const toItems = getStateFieldName(e.toData) === 'itemsDriveC' ? itemsDriveC : itemsDriveD;
moveNode(fromNode, toNode, fromItems, toItems, e.dropInsideItem);
if (getStateFieldName(e.fromData) === 'itemsDriveC') {
setItemsDriveC([fromItems]);
} else {
setItemsDriveD([fromItems]);
}
if (getStateFieldName(e.toData) === 'itemsDriveC') {
setItemsDriveC([toItems]);
} else {
setItemsDriveD([toItems]);
}
fromTreeView.scrollToItem(fromTopVisibleNode);
toTreeView.scrollToItem(toTopVisibleNode);
},
[getTreeView, itemsDriveC, itemsDriveD, setItemsDriveC, setItemsDriveD],
);
return (
<div className="form">
<div className="drive-panel">
<div className="drive-header dx-treeview-item">
<div className="dx-treeview-item-content">
<i className="dx-icon dx-icon-activefolder"></i>
<span>Drive C:</span>
</div>
</div>
<Sortable
filter=".dx-treeview-item"
group="shared"
data="driveC"
allowDropInsideItem={true}
allowReordering={true}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
>
<TreeView
id="treeviewDriveC"
expandNodesRecursive={false}
dataStructure="plain"
ref={treeViewDriveCRef}
items={itemsDriveC}
width={250}
height={380}
displayExpr="name"
/>
</Sortable>
</div>
<div className="drive-panel">
<div className="drive-header dx-treeview-item">
<div className="dx-treeview-item-content">
<i className="dx-icon dx-icon-activefolder"></i>
<span>Drive D:</span>
</div>
</div>
<Sortable
filter=".dx-treeview-item"
group="shared"
data="driveD"
allowDropInsideItem={true}
allowReordering={true}
onDragChange={onDragChange}
onDragEnd={onDragEnd}
>
<TreeView
id="treeviewDriveD"
expandNodesRecursive={false}
dataStructure="plain"
ref={treeViewDriveDRef}
items={itemsDriveD}
width={250}
height={380}
displayExpr="name"
/>
</Sortable>
</div>
</div>
);
};
export default App;
xxxxxxxxxx
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App.tsx';
ReactDOM.render(
<App />,
document.getElementById('app'),
);
xxxxxxxxxx
const itemsDriveD = [];
const itemsDriveC = [{
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',
icon: 'activefolder',
isDirectory: true,
expanded: true,
}, {
id: '9',
parentId: '8',
name: 'logo.png',
icon: 'file',
isDirectory: false,
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,
}];
export default {
getItemsDriveC() {
return itemsDriveC;
},
getItemsDriveD() {
return itemsDriveD;
},
};
xxxxxxxxxx
window.exports = window.exports || {};
window.config = {
transpiler: 'ts',
typescriptOptions: {
module: 'system',
emitDecoratorMetadata: true,
experimentalDecorators: true,
jsx: 'react',
},
meta: {
'react': {
'esModule': true,
},
'typescript': {
'exports': 'ts',
},
'devextreme/time_zone_utils.js': {
'esModule': true,
},
'devextreme/localization.js': {
'esModule': true,
},
'devextreme/viz/palette.js': {
'esModule': true,
},
'openai': {
'esModule': true,
},
},
paths: {
'npm:': 'https://unpkg.com/',
'bundles:': 'bundles/',
'externals:': 'bundles/externals/',
},
defaultExtension: 'js',
map: {
'ts': 'npm:plugin-typescript@8.0.0/lib/plugin.js',
'typescript': 'npm:typescript@4.2.4/lib/typescript.js',
'jszip': 'npm:jszip@3.10.1/dist/jszip.min.js',
'react': 'npm:react@17.0.2/umd/react.development.js',
'react-dom': 'npm:react-dom@17.0.2/umd/react-dom.development.js',
'prop-types': 'npm:prop-types/prop-types.js',
'rrule': 'npm:rrule@2.6.4/dist/es5/rrule.js',
'luxon': 'npm:luxon@3.4.4/build/global/luxon.min.js',
'es6-object-assign': 'npm:es6-object-assign',
'devextreme': 'npm:devextreme@link:../../packages/devextreme/artifacts/npm/devextreme/cjs',
'devextreme-react': 'npm:devextreme-react@link:../../packages/devextreme-react/npm/cjs',
'devextreme-quill': 'npm:devextreme-quill@1.7.1/dist/dx-quill.min.js',
'devexpress-diagram': 'npm:devexpress-diagram@2.2.5/dist/dx-diagram.js',
'devexpress-gantt': 'npm:devexpress-gantt@4.1.54/dist/dx-gantt.js',
'@devextreme/runtime': 'npm:@devextreme/runtime@3.0.12',
'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/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',
'devextreme-cldr-data': 'npm:devextreme-cldr-data@1.0.3',
// SystemJS plugins
'plugin-babel': 'npm:systemjs-plugin-babel@0.0.25/plugin-babel.js',
'systemjs-babel-build': 'npm:systemjs-plugin-babel@0.0.25/systemjs-babel-browser.js',
// Prettier
'prettier/standalone': 'npm:prettier@2.8.8/standalone.js',
'prettier/parser-html': 'npm:prettier@2.8.8/parser-html.js',
},
packages: {
'devextreme': {
defaultExtension: 'js',
},
'devextreme-react': {
main: 'index.js',
},
'devextreme/events/utils': {
main: 'index',
},
'devextreme/localization/messages': {
format: 'json',
defaultExtension: 'json',
},
'devextreme/events': {
main: 'index',
},
'es6-object-assign': {
main: './index.js',
defaultExtension: 'js',
},
},
packageConfigPaths: [
'npm:@devextreme/*/package.json',
'npm:@devextreme/runtime@3.0.12/inferno/package.json',
],
babelOptions: {
sourceMaps: false,
stage0: true,
react: true,
},
};
System.config(window.config);
xxxxxxxxxx
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App.js';
ReactDOM.render(<App />, document.getElementById('app'));
xxxxxxxxxx
const itemsDriveD = [];
const itemsDriveC = [
{
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',
icon: 'activefolder',
isDirectory: true,
expanded: true,
},
{
id: '9',
parentId: '8',
name: 'logo.png',
icon: 'file',
isDirectory: false,
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,
},
];
export default {
getItemsDriveC() {
return itemsDriveC;
},
getItemsDriveD() {
return itemsDriveD;
},
};
xxxxxxxxxx
<html lang="en">
<head></head>
<body class="dx-viewport">
<div class="demo-container">
<div id="app"></div>
</div>
</body>
</html>
xxxxxxxxxx
.form {
display: flex;
}
.form > div {
display: inline-block;
vertical-align: top;
}
.dx-treeview-item {
box-sizing: border-box;
}
.drive-header {
min-height: auto;
padding: 0;
cursor: default;
margin-bottom: 10px;
}
.drive-panel {
padding: 20px 30px;
font-size: 115%;
font-weight: bold;
border-right: 1px solid rgba(165, 165, 165, 0.4);
height: 100%;
}
.drive-panel:last-of-type {
border-right: none;
}
Use Sortable to implement the necessary drag and drop functionality within your web app. The following steps outline configuration requirements for our JavaScript TreeView:
-
Allow users to reorder nodes
Wrap the TreeView in a Sortable and enable the Sortable's allowReordering property. -
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. -
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 thedx-treeview-node
class, you can use this class selector as needed. -
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. -
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 themoveNode
function), and reassign the data source to the TreeView's items property. -
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". -
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.