Rows can be rearranged interactively when using Tree Data by dragging with the mouse.
Enabling Row Dragging Copy Link
To enable row dragging, set rowDrag: true on the group column (usually via autoGroupColumnDef).
const gridOptions = {
treeData: true,
autoGroupColumnDef: {
field: 'name',
rowDrag: true // Enable row dragging on the group column
},
// other grid options ...
}See the Row Dragging documentation for more information about row dragging options, APIs, and advanced usage.
There are two approaches to enable Row Dragging:
- Managed Row Dragging: The grid handles row dragging automatically.
- Unmanaged Row Dragging: Customized application-specific logic for row dragging.
Enabling Managed Row Dragging Copy Link
This is the simplest way to enable row dragging with Tree Data. The grid will automatically handle the dragging of rows and updating the data structure. It supports reordering, moving parents and children, and converting a leaf node into a group. Moving a parent to be a child of itself is not allowed, as this would create a cycle. The grid will prevent this automatically.
To enable managed row dragging, set the following options:
rowDragManaged: trueâ Enables managed row dragging, so the grid handles row movement automatically.autoGroupColumnDef.rowDrag: trueâ Enables the drag handle in the group column.suppressMoveWhenRowDragging: trueâ Prevents the grid from moving rows while dragging, showing a highlight over the row instead.
It is recommended to enable suppressMoveWhenRowDragging when using managed row dragging with Tree Data. Without this option, moving subtrees can cause the grid to jump or scroll unexpectedly as rows are repositioned during the drag. Enabling it provides a smoother and more predictable user experience by only highlighting the drop target without moving rows until the drop is complete.
import {
ClientSideRowModelModule,
GridApi,
GridOptions,
ModuleRegistry,
RowDragModule,
createGrid,
enableDevValidations,
} from "ag-grid-community";
import { TreeDataModule } from "ag-grid-enterprise";
import { Task, getData } from "./data";
// Enable extended validations only for development
if (process.env.NODE_ENV !== "production") {
enableDevValidations();
}
ModuleRegistry.registerModules([
ClientSideRowModelModule,
TreeDataModule,
RowDragModule,
]);
const gridOptions: GridOptions<Task> = {
columnDefs: [{ field: "assignee" }],
autoGroupColumnDef: {
headerName: "Task",
field: "title",
rowDrag: true,
flex: 2,
minWidth: 200,
},
rowData: getData(),
getRowId: (params) => params.data.id,
treeData: true,
treeDataParentIdField: "parentId",
groupDefaultExpanded: -1,
rowDragManaged: true,
suppressMoveWhenRowDragging: true,
};
const eGridDiv = document.getElementById("myGrid");
let gridApi: GridApi<Task>;
gridApi = createGrid(eGridDiv!, gridOptions) as GridApi<Task>;
export type Task = {
id: string;
parentId?: string;
title: string;
assignee?: string;
};
export function getData(): Task[] {
return [
{ id: '1', title: 'Launch Website', assignee: 'Alice' },
{ id: '2', parentId: '1', title: 'Design Landing Page', assignee: 'Bob' },
{ id: '3', parentId: '1', title: 'Implement Backend', assignee: 'Carol' },
{ id: '4', parentId: '3', title: 'Set Up Database', assignee: 'David' },
{ id: '5', parentId: '3', title: 'API Endpoints', assignee: 'Eve' },
{ id: '6', parentId: '5', title: 'User Auth', assignee: 'Frank' },
{ id: '7', parentId: '5', title: 'Payment Integration', assignee: 'Grace' },
{ id: '8', parentId: '1', title: 'Testing', assignee: 'Heidi' },
{ id: '9', title: 'Mobile App', assignee: 'Ivan' },
{ id: '10', parentId: '9', title: 'UI Design', assignee: 'Judy' },
{ id: '11', parentId: '9', title: 'Push Notifications', assignee: 'Mallory' },
{ id: '12', title: 'Marketing Campaign', assignee: 'Oscar' },
{ id: '13', parentId: '12', title: 'Social Media', assignee: 'Peggy' },
{ id: '14', parentId: '12', title: 'Email Outreach', assignee: 'Sybil' },
{ id: '15', parentId: '1', title: 'SEO Optimization', assignee: 'Trent' },
{ id: '16', parentId: '15', title: 'Keyword Research', assignee: 'Victor' },
{ id: '17', parentId: '15', title: 'On-Page SEO', assignee: 'Walter' },
{ id: '18', parentId: '3', title: 'Server Deployment', assignee: 'Yvonne' },
{ id: '19', parentId: '9', title: 'App Store Submission', assignee: 'Zara' },
{ id: '20', parentId: '12', title: 'Content Creation', assignee: 'Uma' },
];
}
<div id="myGrid" style="height: 100%"></div>
Other relevant options used in the example above include:
getRowIdâ Provides a unique ID for each row, required for row movement.treeData: trueâ Enables tree data mode, allowing hierarchical data structures.treeDataParentIdField: 'parentId'â Specifies the field that defines parent-child relationships.groupDefaultExpanded: -1â Expands all groups by default.
const gridOptions = {
treeData: true,
getRowId: params => params.data.id,
treeDataParentIdField: 'parentId',
rowDragManaged: true,
groupDefaultExpanded: -1,
suppressMoveWhenRowDragging: true,
autoGroupColumnDef: {
field: 'name',
rowDrag: true
},
// other grid options ...
} Managed Row Dragging with getDataPath Copy Link
This next examples shows how to use the getDataPath callback to define the hierarchical structure of the data.
This example uses filler nodes (where some intermediate path segments do not exist as explicit nodes in the data). Empty filler nodes cannot exist in the grid; if all their children are moved out, the filler node will be deleted and disappear. It is instead recommended to provide a full grid without filler nodes to avoid this. See the Providing Data Paths for details about filler nodes and getDataPath.
import {
ClientSideRowModelModule,
GridApi,
GridOptions,
ModuleRegistry,
RowDragModule,
createGrid,
enableDevValidations,
} from "ag-grid-community";
import { TreeDataModule } from "ag-grid-enterprise";
import { Task, getData } from "./data";
// Enable extended validations only for development
if (process.env.NODE_ENV !== "production") {
enableDevValidations();
}
ModuleRegistry.registerModules([
ClientSideRowModelModule,
TreeDataModule,
RowDragModule,
]);
const gridOptions: GridOptions<Task> = {
columnDefs: [{ field: "assignee" }],
autoGroupColumnDef: {
headerName: "Task",
rowDrag: true,
flex: 2,
minWidth: 200,
},
rowData: getData(),
getRowId: (params) => params.data.id,
treeData: true,
getDataPath: (data) => data.path,
groupDefaultExpanded: -1,
rowDragManaged: true,
suppressMoveWhenRowDragging: true,
};
const eGridDiv = document.getElementById("myGrid");
let gridApi: GridApi<Task>;
gridApi = createGrid(eGridDiv!, gridOptions) as GridApi<Task>;
export type Task = {
id: string;
path: string[];
title: string;
assignee?: string;
};
export function getData(): Task[] {
return [
{ id: '1', path: ['Launch Website'], title: 'Launch Website', assignee: 'Alice' },
{ id: '2', path: ['Launch Website', 'Design Landing Page'], title: 'Design Landing Page', assignee: 'Bob' },
{
id: '3',
path: ['Launch Website', 'Implement Backend', 'Set Up Database'],
title: 'Set Up Database',
assignee: 'David',
},
{
id: '4',
path: ['Launch Website', 'Implement Backend', 'API Endpoints'],
title: 'API Endpoints',
assignee: 'Eve',
},
{
id: '5',
path: ['Launch Website', 'Implement Backend', 'API Endpoints', 'User Auth'],
title: 'User Auth',
assignee: 'Frank',
},
{
id: '6',
path: ['Launch Website', 'Implement Backend', 'API Endpoints', 'Payment Integration'],
title: 'Payment Integration',
assignee: 'Grace',
},
{ id: '7', path: ['Launch Website', 'Testing'], title: 'Testing', assignee: 'Heidi' },
{ id: '8', path: ['Launch Website', 'SEO Optimization'], title: 'SEO Optimization', assignee: 'Trent' },
{
id: '9',
path: ['Launch Website', 'SEO Optimization', 'Keyword Research'],
title: 'Keyword Research',
assignee: 'Victor',
},
{
id: '10',
path: ['Launch Website', 'SEO Optimization', 'On-Page SEO'],
title: 'On-Page SEO',
assignee: 'Walter',
},
{
id: '11',
path: ['Launch Website', 'Implement Backend', 'Server Deployment'],
title: 'Server Deployment',
assignee: 'Yvonne',
},
{ id: '12', path: ['Mobile App'], title: 'Mobile App', assignee: 'Ivan' },
{ id: '13', path: ['Mobile App', 'UI Design'], title: 'UI Design', assignee: 'Judy' },
{ id: '14', path: ['Mobile App', 'Push Notifications'], title: 'Push Notifications', assignee: 'Mallory' },
{ id: '15', path: ['Mobile App', 'App Store Submission'], title: 'App Store Submission', assignee: 'Zara' },
{ id: '16', path: ['Marketing Campaign'], title: 'Marketing Campaign', assignee: 'Oscar' },
{ id: '17', path: ['Marketing Campaign', 'Social Media'], title: 'Social Media', assignee: 'Peggy' },
{ id: '18', path: ['Marketing Campaign', 'Email Outreach'], title: 'Email Outreach', assignee: 'Sybil' },
{ id: '19', path: ['Marketing Campaign', 'Content Creation'], title: 'Content Creation', assignee: 'Uma' },
{
id: '20',
path: ['Launch Website', 'Design Landing Page', 'Mobile', 'Wireframes'],
title: 'Wireframes',
assignee: 'Fay',
},
{
id: '21',
path: ['Launch Website', 'Design Landing Page', 'Mobile', 'Mockups'],
title: 'Mockups',
assignee: 'Nina',
},
{ id: '22', path: ['Mobile App', 'UI Design', 'Dark Mode', 'Contrast'], title: 'Contrast', assignee: 'Omar' },
{
id: '23',
path: ['Mobile App', 'UI Design', 'Accessibility', 'Screen Reader'],
title: 'Screen Reader',
assignee: 'Liam',
},
{
id: '24',
path: ['Marketing Campaign', 'Social Media', 'PhotoShare', 'Stories'],
title: 'Stories',
assignee: 'Mona',
},
{
id: '25',
path: ['Marketing Campaign', 'Social Media', 'QuickPost', 'Threads'],
title: 'Threads',
assignee: 'Ned',
},
];
}
<div id="myGrid" style="height: 100%"></div>
Multi-Row Dragging Copy Link
Managed row dragging supports multi-row dragging, allowing users to select multiple rows and drag them together, including rows in different levels.
To enable this, set the grid options rowDragMultiRow = true together with rowSelection.mode = 'multiRow'.
For this example note the following:
- When you select multiple items and drag one of them, all items in the selection will be dragged.
- When you drag an item that is not selected while other items are selected, only the unselected item will be dragged.
import {
ClientSideRowModelModule,
GridApi,
GridOptions,
ModuleRegistry,
RowDragModule,
RowSelectionModule,
createGrid,
enableDevValidations,
} from "ag-grid-community";
import { TreeDataModule } from "ag-grid-enterprise";
import { Task, getData } from "./data";
// Enable extended validations only for development
if (process.env.NODE_ENV !== "production") {
enableDevValidations();
}
ModuleRegistry.registerModules([
ClientSideRowModelModule,
TreeDataModule,
RowDragModule,
RowSelectionModule,
]);
const gridOptions: GridOptions<Task> = {
columnDefs: [{ field: "assignee" }],
autoGroupColumnDef: {
headerName: "Task",
field: "title",
rowDrag: true,
flex: 2,
minWidth: 200,
},
rowData: getData(),
getRowId: (params) => params.data.id,
treeData: true,
treeDataChildrenField: "children",
groupDefaultExpanded: -1,
rowDragManaged: true,
rowDragMultiRow: true,
rowSelection: {
mode: "multiRow",
},
suppressMoveWhenRowDragging: true,
};
const eGridDiv = document.getElementById("myGrid");
let gridApi: GridApi<Task>;
gridApi = createGrid(eGridDiv!, gridOptions) as GridApi<Task>;
export type Task = {
id: string;
title: string;
assignee?: string;
children?: Task[];
};
export function getData(): Task[] {
return [
{
id: '1',
title: 'Launch Website',
assignee: 'Alice',
children: [
{
id: '2',
title: 'Design Landing Page',
assignee: 'Bob',
},
{
id: '3',
title: 'Implement Backend',
assignee: 'Carol',
children: [
{
id: '4',
title: 'Set Up Database',
assignee: 'David',
},
{
id: '5',
title: 'API Endpoints',
assignee: 'Eve',
children: [
{
id: '6',
title: 'User Auth',
assignee: 'Frank',
},
{
id: '7',
title: 'Payment Integration',
assignee: 'Grace',
},
],
},
{
id: '18',
title: 'Server Deployment',
assignee: 'Yvonne',
},
],
},
{
id: '8',
title: 'Testing',
assignee: 'Heidi',
},
{
id: '15',
title: 'SEO Optimization',
assignee: 'Trent',
children: [
{
id: '16',
title: 'Keyword Research',
assignee: 'Victor',
},
{
id: '17',
title: 'On-Page SEO',
assignee: 'Walter',
},
],
},
],
},
{
id: '9',
title: 'Mobile App',
assignee: 'Ivan',
children: [
{
id: '10',
title: 'UI Design',
assignee: 'Judy',
},
{
id: '11',
title: 'Push Notifications',
assignee: 'Mallory',
},
{
id: '19',
title: 'App Store Submission',
assignee: 'Zara',
},
],
},
{
id: '12',
title: 'Marketing Campaign',
assignee: 'Oscar',
children: [
{
id: '13',
title: 'Social Media',
assignee: 'Peggy',
},
{
id: '14',
title: 'Email Outreach',
assignee: 'Sybil',
},
{
id: '20',
title: 'Content Creation',
assignee: 'Uma',
},
],
},
];
}
<div id="myGrid" style="height: 100%"></div>
Row Drag Insert Delay Copy Link
When using Tree Data with Managed Row Dragging, the rowDragInsertDelay grid option sets a delay (in milliseconds) before a dragged row is inserted into a new parent node. The default value is 500 milliseconds. This delay helps prevent accidental moves when hovering over potential drop targets. If the target is a collapsed parent or a leaf node, the grid will expand the parent or convert the leaf into a parent after this delay, allowing the dragged row to be inserted as a child.
Preventing Dropping on Certain Rows Copy Link
The isRowValidDropPosition callback allows you to control whether a row drop is allowed during managed or unmanaged row dragging, and optionally override the rows, parent or position for the drop. This is useful for restricting where rows can be dropped or customizing drop behaviour. Returning an object allows instead to filter the rows to drop, or change the parent or the position of the drop.
This affects also the icon and label shown when dragging a row for both managed and unmanaged row dragging.
Called by drag and drop when rows are dragged over another row to conditionally prevent dropping the dragged row on the hovered row.
The user can cancel the drop by returning false or customize the operation by returning a IsRowValidDropPositionResult. |
In the example below, note that:
- A file cannot be converted to a folder, dropping a file or a folder into a file is blocked.
- The
READONLYfolder cannot change parent, and drag and drop into or from it is not allowed.
import {
ClientSideRowModelModule,
GridApi,
GridOptions,
IRowNode,
ModuleRegistry,
RowDragModule,
ValueFormatterParams,
createGrid,
enableDevValidations,
} from "ag-grid-community";
import { TreeDataModule } from "ag-grid-enterprise";
import { IFile, getData } from "./data";
// Enable extended validations only for development
if (process.env.NODE_ENV !== "production") {
enableDevValidations();
}
ModuleRegistry.registerModules([
ClientSideRowModelModule,
TreeDataModule,
RowDragModule,
]);
const gridOptions: GridOptions<IFile> = {
columnDefs: [
{
field: "type",
headerName: "Type",
width: 90,
},
{
field: "dateModified",
headerName: "Modified",
width: 130,
},
{
field: "size",
aggFunc: "sum",
width: 140,
valueFormatter: (params: ValueFormatterParams<IFile, number>) =>
params.value ? params.value.toFixed(1) + " MB" : "",
},
],
autoGroupColumnDef: {
headerName: "Task",
field: "name",
rowDrag: true,
flex: 2,
minWidth: 200,
},
rowData: getData(),
getRowId: (params) => params.data.id,
treeData: true,
treeDataChildrenField: "children",
groupDefaultExpanded: -1,
rowDragManaged: true,
suppressMoveWhenRowDragging: true,
isRowValidDropPosition: (params) => {
let { newParent, rows, moved } = params;
if (!moved) {
return { allowed: false };
}
if (isReadonlyFolder(newParent) || isInsideReadonlyFolder(newParent)) {
return { allowed: false }; // Prevent dropping into a readonly folder
}
// Filter out anything that is a readonly folder or inside a readonly folder
rows = rows.filter(
(row) => !isReadonlyFolder(row) && !isInsideReadonlyFolder(row),
);
if (newParent && newParent.data && newParent.data.type !== "folder") {
// Block changing parents on anything that is not of type 'folder'
return { newParent: null, rows };
}
return { rows };
},
};
/** Returns true if the row is a readonly folder, false if it is a file or a normal folder */
function isReadonlyFolder(row: IRowNode<IFile> | null) {
return !!row && row.data?.type === "readonly-folder";
}
/** Returns true if the row is a file or folder inside a readonly folder */
function isInsideReadonlyFolder(row: IRowNode<IFile> | null): boolean {
if (!row || !row.parent) {
return false; // Root level
}
if (isReadonlyFolder(row.parent)) {
return true;
}
return isInsideReadonlyFolder(row.parent);
}
const eGridDiv = document.getElementById("myGrid");
let gridApi: GridApi<IFile>;
gridApi = createGrid(eGridDiv!, gridOptions) as GridApi<IFile>;
export interface IFile {
id: string;
name: string;
type: 'folder' | 'file' | 'readonly-folder';
dateModified?: string; // ISO date string
size?: number; // Size in MB
children?: IFile[];
}
export function getData(): IFile[] {
return [
{
id: '1',
name: 'Documents',
type: 'folder',
children: [
{
id: '2',
name: 'notes.txt',
type: 'file',
dateModified: '2017-05-21',
size: 14.7,
},
{
id: '3',
name: 'accounts.xls',
type: 'file',
dateModified: '2016-08-12',
size: 4.3,
},
{
id: '4',
name: 'xyz.txt',
type: 'file',
dateModified: '2016-01-17',
size: 1.1,
},
{
id: '5',
name: 'var',
type: 'folder',
},
],
},
{
id: '100',
name: 'READONLY',
type: 'readonly-folder',
children: [
{
id: '101',
name: 'subfolder',
type: 'folder',
children: [
{
id: '102',
name: 'temp.txt',
type: 'file',
dateModified: '2016-08-12',
size: 101,
},
],
},
{
id: '103',
name: 'notes.txt',
type: 'file',
dateModified: '2016-08-12',
size: 400,
},
],
},
{
id: '11',
name: 'Music',
type: 'folder',
children: [
{
id: '12',
name: 'mp3',
type: 'folder',
children: [
{
id: '13',
name: 'theme.mp3',
type: 'file',
dateModified: '2016-09-11',
size: 14.3,
},
],
},
],
},
];
}
<div id="myGrid" style="height: 100%"></div>
Persisting Row Order Copy Link
These three examples below show how to persist the row order from the grid on to the server after a row drag operation has been completed.
Example with Parent IDs:
import {
ClientSideRowModelApiModule,
ClientSideRowModelModule,
GridApi,
GridOptions,
ModuleRegistry,
RowDragModule,
createGrid,
enableDevValidations,
} from "ag-grid-community";
import { TreeDataModule } from "ag-grid-enterprise";
import { Task, getData } from "./data";
// Enable extended validations only for development
if (process.env.NODE_ENV !== "production") {
enableDevValidations();
}
ModuleRegistry.registerModules([
ClientSideRowModelModule,
ClientSideRowModelApiModule,
TreeDataModule,
RowDragModule,
]);
function extractRowData(api: GridApi<Task>) {
const extractedData: Task[] = [];
api.forEachLeafNode((node) => {
let data = node.data!;
const parentId = node.parent?.data?.id;
if (data.parentId !== parentId) {
// We create a new object only if the parentId has changed
data = { ...data, parentId };
}
extractedData.push(data);
});
return extractedData;
}
function showExtractedRowData(api: GridApi<Task>) {
const extractedRowData = extractRowData(api);
const json = JSON.stringify(extractedRowData, null, 2);
document.getElementById("extracted-data-content")!.textContent = json;
}
const gridOptions: GridOptions<Task> = {
columnDefs: [{ field: "assignee" }],
autoGroupColumnDef: {
headerName: "Task",
field: "title",
rowDrag: true,
flex: 2,
minWidth: 200,
},
rowData: getData(),
getRowId: (params) => params.data.id,
treeData: true,
treeDataParentIdField: "parentId",
groupDefaultExpanded: -1,
rowDragManaged: true,
suppressMoveWhenRowDragging: true,
onRowDragEnd: (event) => {
showExtractedRowData(event.api);
},
};
const eGridDiv = document.getElementById("myGrid");
let gridApi: GridApi<Task>;
gridApi = createGrid(eGridDiv!, gridOptions) as GridApi<Task>;
/* Simple responsive side-by-side layout */
.container {
display: flex;
width: 100%;
height: 100%;
gap: 8px;
}
#myGrid {
flex: 1;
}
#extracted-data-content {
margin: 0;
padding: 5px;
}
#extracted-data-content-container {
min-width: 350px;
overflow: auto;
border: 1px solid #aaa;
}
export type Task = {
id: string;
parentId?: string;
title: string;
assignee?: string;
};
export function getData(): Task[] {
return [
{ id: '1', title: 'Launch Website', assignee: 'Alice' },
{ id: '2', parentId: '1', title: 'Design Landing Page', assignee: 'Bob' },
{ id: '3', parentId: '1', title: 'Implement Backend', assignee: 'Carol' },
{ id: '4', parentId: '3', title: 'Set Up Database', assignee: 'David' },
{ id: '5', parentId: '3', title: 'API Endpoints', assignee: 'Eve' },
{ id: '6', parentId: '5', title: 'User Auth', assignee: 'Frank' },
{ id: '7', parentId: '5', title: 'Payment Integration', assignee: 'Grace' },
{ id: '8', parentId: '1', title: 'Testing', assignee: 'Heidi' },
{ id: '9', title: 'Mobile App', assignee: 'Ivan' },
{ id: '10', parentId: '9', title: 'UI Design', assignee: 'Judy' },
{ id: '11', parentId: '9', title: 'Push Notifications', assignee: 'Mallory' },
{ id: '12', title: 'Marketing Campaign', assignee: 'Oscar' },
{ id: '13', parentId: '12', title: 'Social Media', assignee: 'Peggy' },
{ id: '14', parentId: '12', title: 'Email Outreach', assignee: 'Sybil' },
{ id: '15', parentId: '1', title: 'SEO Optimization', assignee: 'Trent' },
{ id: '16', parentId: '15', title: 'Keyword Research', assignee: 'Victor' },
{ id: '17', parentId: '15', title: 'On-Page SEO', assignee: 'Walter' },
{ id: '18', parentId: '3', title: 'Server Deployment', assignee: 'Yvonne' },
{ id: '19', parentId: '9', title: 'App Store Submission', assignee: 'Zara' },
{ id: '20', parentId: '12', title: 'Content Creation', assignee: 'Uma' },
];
}
<div class="container">
<div id="myGrid"></div>
<div id="extracted-data-content-container">
<pre id="extracted-data-content">output</pre>
</div>
</div>
Example with Children arrays:
import {
ClientSideRowModelApiModule,
ClientSideRowModelModule,
GridApi,
GridOptions,
IRowNode,
ModuleRegistry,
RowApiModule,
RowDragModule,
createGrid,
enableDevValidations,
} from "ag-grid-community";
import { TreeDataModule } from "ag-grid-enterprise";
import { Task, getData } from "./data";
// Enable extended validations only for development
if (process.env.NODE_ENV !== "production") {
enableDevValidations();
}
ModuleRegistry.registerModules([
ClientSideRowModelModule,
ClientSideRowModelApiModule,
RowApiModule,
TreeDataModule,
RowDragModule,
]);
function arrayEquals<T>(a: T[], b: T[]) {
return a === b || (a.length === b.length && a.every((v, i) => v === b[i]));
}
/** Recursively build the tree structure from a node */
function buildTree(node: IRowNode<Task>): Task {
const data = node.data!;
const oldChildren = data.children ?? [];
const children = node.childrenAfterGroup?.map(buildTree) ?? [];
if (!arrayEquals(oldChildren, children)) {
// We create a new object only if the children have changed
return { ...data, children: children.length > 0 ? children : undefined };
}
return data; // unchanged
}
/** Extract children for each node in the tree */
function extractRowData(rootNode: IRowNode<Task> | undefined) {
return rootNode?.childrenAfterGroup?.map(buildTree) ?? [];
}
function showExtractedRowData(rootNode: IRowNode<Task> | undefined) {
const extractedRowData = extractRowData(rootNode);
const json = JSON.stringify(extractedRowData, null, 2);
document.getElementById("extracted-data-content")!.textContent = json;
}
const gridOptions: GridOptions<Task> = {
columnDefs: [{ field: "assignee" }],
autoGroupColumnDef: {
headerName: "Task",
field: "title",
rowDrag: true,
flex: 2,
minWidth: 200,
},
rowData: getData(),
getRowId: (params) => params.data.id,
treeData: true,
treeDataChildrenField: "children",
groupDefaultExpanded: -1,
rowDragManaged: true,
suppressMoveWhenRowDragging: true,
onRowDragEnd: (event) => {
showExtractedRowData(event.rowsDrop?.rootNode);
},
};
const eGridDiv = document.getElementById("myGrid");
let gridApi: GridApi<Task>;
gridApi = createGrid(eGridDiv!, gridOptions) as GridApi<Task>;
/* Simple responsive side-by-side layout */
.container {
display: flex;
width: 100%;
height: 100%;
gap: 8px;
}
#myGrid {
flex: 1;
}
#extracted-data-content {
margin: 0;
padding: 5px;
}
#extracted-data-content-container {
min-width: 350px;
overflow: auto;
border: 1px solid #aaa;
}
export type Task = {
id: string;
title: string;
assignee?: string;
children?: Task[];
};
export function getData(): Task[] {
return [
{
id: '1',
title: 'Launch Website',
assignee: 'Alice',
children: [
{
id: '2',
title: 'Design Landing Page',
assignee: 'Bob',
},
{
id: '3',
title: 'Implement Backend',
assignee: 'Carol',
children: [
{
id: '4',
title: 'Set Up Database',
assignee: 'David',
},
{
id: '5',
title: 'API Endpoints',
assignee: 'Eve',
children: [
{
id: '6',
title: 'User Auth',
assignee: 'Frank',
},
{
id: '7',
title: 'Payment Integration',
assignee: 'Grace',
},
],
},
{
id: '18',
title: 'Server Deployment',
assignee: 'Yvonne',
},
],
},
{
id: '8',
title: 'Testing',
assignee: 'Heidi',
},
{
id: '15',
title: 'SEO Optimization',
assignee: 'Trent',
children: [
{
id: '16',
title: 'Keyword Research',
assignee: 'Victor',
},
{
id: '17',
title: 'On-Page SEO',
assignee: 'Walter',
},
],
},
],
},
{
id: '9',
title: 'Mobile App',
assignee: 'Ivan',
children: [
{
id: '10',
title: 'UI Design',
assignee: 'Judy',
},
{
id: '11',
title: 'Push Notifications',
assignee: 'Mallory',
},
{
id: '19',
title: 'App Store Submission',
assignee: 'Zara',
},
],
},
{
id: '12',
title: 'Marketing Campaign',
assignee: 'Oscar',
children: [
{
id: '13',
title: 'Social Media',
assignee: 'Peggy',
},
{
id: '14',
title: 'Email Outreach',
assignee: 'Sybil',
},
{
id: '20',
title: 'Content Creation',
assignee: 'Uma',
},
],
},
];
}
<div class="container">
<div id="myGrid"></div>
<div id="extracted-data-content-container">
<pre id="extracted-data-content">output</pre>
</div>
</div>
Example with Data Paths:
import {
ClientSideRowModelApiModule,
ClientSideRowModelModule,
GridApi,
GridOptions,
IRowNode,
ModuleRegistry,
RowApiModule,
RowDragModule,
createGrid,
enableDevValidations,
} from "ag-grid-community";
import { TreeDataModule } from "ag-grid-enterprise";
import { Task, getData } from "./data";
// Enable extended validations only for development
if (process.env.NODE_ENV !== "production") {
enableDevValidations();
}
ModuleRegistry.registerModules([
ClientSideRowModelModule,
ClientSideRowModelApiModule,
RowApiModule,
TreeDataModule,
RowDragModule,
]);
function arrayEquals<T>(a: T[], b: T[]) {
return a === b || (a.length === b.length && a.every((v, i) => v === b[i]));
}
// Rebuild the data array, updating the path for each node if changed
function extractRowData(api: GridApi<Task>) {
const extractedData: Task[] = [];
api.forEachLeafNode((node) => {
const data = node.data;
if (data) {
// Use getRoute() to rebuild the path
const path = node.getRoute() ?? [];
if (!arrayEquals(data.path, path)) {
// Create a new object only if the path has changed
extractedData.push({ ...data, path });
} else {
extractedData.push(data);
}
}
});
return extractedData;
}
function showExtractedRowData(api: GridApi<Task>) {
const extractedRowData = extractRowData(api);
const json = JSON.stringify(extractedRowData, null, 2);
document.getElementById("extracted-data-content")!.textContent = json;
}
const gridOptions: GridOptions<Task> = {
columnDefs: [{ field: "assignee" }],
autoGroupColumnDef: {
headerName: "Task",
rowDrag: true,
flex: 2,
minWidth: 200,
},
rowData: getData(),
getRowId: (params) => params.data.id,
treeData: true,
getDataPath: (data) => data.path,
groupDefaultExpanded: -1,
rowDragManaged: true,
suppressMoveWhenRowDragging: true,
onRowDragEnd: (event) => {
showExtractedRowData(event.api);
},
};
const eGridDiv = document.getElementById("myGrid");
let gridApi: GridApi<Task>;
gridApi = createGrid(eGridDiv!, gridOptions) as GridApi<Task>;
/* Simple responsive side-by-side layout */
.container {
display: flex;
width: 100%;
height: 100%;
gap: 8px;
}
#myGrid {
flex: 1;
}
#extracted-data-content {
margin: 0;
padding: 5px;
}
#extracted-data-content-container {
min-width: 350px;
overflow: auto;
border: 1px solid #aaa;
}
export type Task = {
id: string;
path: string[];
assignee?: string;
};
export function getData(): Task[] {
return [
{ id: '1', path: ['Launch Website'], assignee: 'Alice' },
{ id: '2', path: ['Launch Website', 'Design Landing Page'], assignee: 'Bob' },
{ id: '3', path: ['Launch Website', 'Implement Backend'], assignee: 'Carol' },
{
id: '4',
path: ['Launch Website', 'Implement Backend', 'Set Up Database'],
assignee: 'David',
},
{
id: '5',
path: ['Launch Website', 'Implement Backend', 'API Endpoints'],
assignee: 'Eve',
},
{
id: '6',
path: ['Launch Website', 'Implement Backend', 'API Endpoints', 'User Auth'],
assignee: 'Frank',
},
{
id: '7',
path: ['Launch Website', 'Implement Backend', 'API Endpoints', 'Payment Integration'],
assignee: 'Grace',
},
{ id: '8', path: ['Launch Website', 'Testing'], assignee: 'Heidi' },
{ id: '9', path: ['Mobile App'], assignee: 'Ivan' },
{ id: '10', path: ['Mobile App', 'UI Design'], assignee: 'Judy' },
{ id: '11', path: ['Mobile App', 'Push Notifications'], assignee: 'Mallory' },
{ id: '12', path: ['Marketing Campaign'], assignee: 'Oscar' },
{ id: '13', path: ['Marketing Campaign', 'Social Media'], assignee: 'Peggy' },
{ id: '14', path: ['Marketing Campaign', 'Email Outreach'], assignee: 'Sybil' },
{ id: '15', path: ['Launch Website', 'SEO Optimization'], assignee: 'Trent' },
{
id: '16',
path: ['Launch Website', 'SEO Optimization', 'Keyword Research'],
assignee: 'Victor',
},
{
id: '17',
path: ['Launch Website', 'SEO Optimization', 'On-Page SEO'],
assignee: 'Walter',
},
{
id: '18',
path: ['Launch Website', 'Implement Backend', 'Server Deployment'],
assignee: 'Yvonne',
},
{ id: '19', path: ['Mobile App', 'App Store Submission'], assignee: 'Zara' },
{ id: '20', path: ['Marketing Campaign', 'Content Creation'], assignee: 'Uma' },
];
}
<div class="container">
<div id="myGrid"></div>
<div id="extracted-data-content-container">
<pre id="extracted-data-content">output</pre>
</div>
</div>
Unmanaged Row Dragging Copy Link
In order to have full control over row dragging, it is possible to provide a customized implementation of row dragging using Unmanaged Row Dragging. In this case, the application is responsible for maintaining the rowData state, handling the dragging events and updating the rowData based on the drag events fired by the grid.
Tree Data with getDataPath Copy Link
The example below shows Tree Data and row dragging with getDataPath where the following can be noted:
- The Auto-Group Column has row drag
truefor all rows. - The application moves the rows in the row data while the row drag is happening in the
onRowDragMoveevent handler. - While row dragging, the row move operation can be reverted by pressing â Escape key.
- Is possible to reorder a row only inside its current parent by holding the â§ Shift key and dragging it.
- The expanded/contracted state of a folder and all of its child folders is preserved when the folder is moved to a new parent.
import {
ClientSideRowModelApiModule,
ClientSideRowModelModule,
GetRowIdParams,
GridApi,
GridOptions,
ModuleRegistry,
RowDragCancelEvent,
RowDragEndEvent,
RowDragEnterEvent,
RowDragModule,
RowDragMoveEvent,
ValueFormatterParams,
createGrid,
enableDevValidations,
} from "ag-grid-community";
import { TreeDataModule } from "ag-grid-enterprise";
import { getData } from "./data";
import { FileCellRenderer } from "./fileCellRenderer";
import { IFile, moveFiles } from "./fileUtils";
/** Custom user data attached to the grid */
interface MyGridContext {
/** The original row data before dragging started */
rowDataDragging: IFile[] | null | undefined;
}
let gridApi: GridApi<IFile>;
// Enable extended validations only for development
if (process.env.NODE_ENV !== "production") {
enableDevValidations();
}
ModuleRegistry.registerModules([
RowDragModule,
ClientSideRowModelApiModule,
ClientSideRowModelModule,
TreeDataModule,
]);
/** Called when row dragging start */
function onRowDragEnter(event: RowDragEnterEvent<IFile, MyGridContext>): void {
// Store the original row data to restore it the drag is cancelled in a custom property in the context
event.context.rowDataDragging = event.api.getGridOption("rowData");
}
/** Called both when dragging and dropping (drag end) */
function rowDragOrDrop(
event:
| RowDragMoveEvent<IFile, MyGridContext>
| RowDragEndEvent<IFile, MyGridContext>,
): void {
let target = event.overNode?.data;
const source = event.node.data;
const rowData = event.api.getGridOption("rowData");
if (rowData && source && source !== target) {
const reorderOnly = event.event?.shiftKey;
const newRowData = moveFiles(rowData, source, target, reorderOnly);
if (newRowData !== rowData) {
event.api.setGridOption("rowData", newRowData);
}
}
}
/** Called both when dragging and dropping (drag end) */
function onRowDragMove(
event:
| RowDragMoveEvent<IFile, MyGridContext>
| RowDragEndEvent<IFile, MyGridContext>,
): void {
rowDragOrDrop(event);
}
/** Called when row dragging end, and the operation need to be committed */
function onRowDragEnd(event: RowDragEndEvent<IFile, MyGridContext>): void {
rowDragOrDrop(event);
event.api.clearFocusedCell();
event.context.rowDataDragging = null;
}
/** Called when row dragging is cancelled, for example, ESC key is pressed */
function onRowDragCancel(
event: RowDragCancelEvent<IFile, MyGridContext>,
): void {
if (event.context.rowDataDragging) {
// Restore the original row data before the drag started
event.api.setGridOption("rowData", event.context.rowDataDragging);
event.context.rowDataDragging = null;
}
}
const gridOptions: GridOptions<IFile> = {
columnDefs: [
{ field: "dateModified" },
{
field: "size",
aggFunc: "sum",
valueFormatter: (params: ValueFormatterParams<IFile, number>) =>
params.value ? params.value.toFixed(1) + " MB" : "",
},
],
autoGroupColumnDef: {
rowDrag: true,
headerName: "Files",
minWidth: 300,
cellRendererParams: {
suppressCount: true,
innerRenderer: FileCellRenderer,
},
},
defaultColDef: { flex: 1 },
treeData: true,
groupDefaultExpanded: -1,
rowData: getData(),
getDataPath: (data: IFile) => data.filePath,
getRowId: (params: GetRowIdParams) => params.data.id,
context: { rowDataDragging: null },
onRowDragEnter: onRowDragEnter,
onRowDragMove: onRowDragMove,
onRowDragEnd: onRowDragEnd,
onRowDragCancel: onRowDragCancel,
};
// wait for the document to be loaded, otherwise
// AG Grid will not find the div in the document.
// lookup the container we want the Grid to use
const eGridDiv = document.querySelector<HTMLElement>("#myGrid")!;
// create the grid passing in the div to use together with the columns & data we want to use
gridApi = createGrid(eGridDiv, gridOptions);
.myGrid {
height: 100%;
}
.fa-folder {
color: darkorange;
}
.fa-file-pdf {
color: red;
}
.fa-file-excel {
color: green;
}
.fa-file-audio {
color: blue;
}
.filename {
color: var(--ag-foreground-color);
font-size: 16px;
font-family: 'Courier New', Courier, monospace;
}
.filename > i {
margin-right: 5px;
}
import type { IFile } from './fileUtils';
export function getData(): IFile[] {
return [
{ id: '1', filePath: ['Documents'], type: 'folder' },
{ id: '2', filePath: ['Documents', 'txt'], type: 'folder' },
{
id: '3',
filePath: ['Documents', 'txt', 'notes.txt'],
type: 'file',
dateModified: 'May 21 2017 01:50:00 PM',
size: 14.7,
},
{ id: '4', filePath: ['Documents', 'pdf'], type: 'folder' },
{
id: '5',
filePath: ['Documents', 'pdf', 'book.pdf'],
type: 'file',
dateModified: 'May 20 2017 01:50:00 PM',
size: 2.1,
},
{
id: '6',
filePath: ['Documents', 'pdf', 'cv.pdf'],
type: 'file',
dateModified: 'May 20 2016 11:50:00 PM',
size: 2.4,
},
{ id: '7', filePath: ['Documents', 'xls'], type: 'folder' },
{
id: '8',
filePath: ['Documents', 'xls', 'accounts.xls'],
type: 'file',
dateModified: 'Aug 12 2016 10:50:00 AM',
size: 4.3,
},
{ id: '9', filePath: ['Documents', 'stuff'], type: 'folder' },
{
id: '10',
filePath: ['Documents', 'stuff', 'xyz.txt'],
type: 'file',
dateModified: 'Jan 17 2016 08:03:00 PM',
size: 1.1,
},
{ id: '11', filePath: ['Music'], type: 'folder' },
{ id: '12', filePath: ['Music', 'mp3'], type: 'folder' },
{
id: '13',
filePath: ['Music', 'mp3', 'theme.mp3'],
type: 'file',
dateModified: 'Sep 11 2016 08:03:00 PM',
size: 14.3,
},
{ id: '14', filePath: ['Misc'], type: 'folder' },
{
id: '15',
filePath: ['Misc', 'temp.txt'],
type: 'file',
dateModified: 'Aug 12 2016 10:50:00 PM',
size: 101,
},
];
}
export interface IFile {
id: string;
filePath: string[];
type: 'file' | 'folder';
dateModified?: string;
size?: number;
}
/**
* Move a file or a folder. This is a pure function, it does not modify the original data.
* @param files the list of files
* @param source the file or folder to move
* @param target the target file or folder to move to
* @param reorderOnly if true, the move is a reorder only operation, not a move to a different folder
* @returns the new list of files
*/
export function moveFiles(
files: IFile[],
source: IFile,
target: IFile | null | undefined,
reorderOnly: boolean
): IFile[] {
if (source === target) {
return files; // invalid move - no-op
}
const sourcePath = source.filePath; // folder or file to move
let newParentPath: string[] | undefined; // folder to drop into is where we are going to move the file/folder to
if (reorderOnly) {
newParentPath = pathParent(sourcePath);
if (target && !pathInSameFolder(sourcePath, target.filePath)) {
return files; // invalid move - we are moving to a different folder
}
} else if (target) {
newParentPath = target.filePath;
if (target.type !== 'folder') {
newParentPath = pathParent(newParentPath); // if over a file, we take the parent folder
}
}
if (pathStartsWith(newParentPath, sourcePath)) {
return files; // invalid move - we are moving a parent folder into one of its child folders
}
let splitIndex: number;
if (target) {
splitIndex = files.indexOf(target);
if (splitIndex > files.indexOf(source)) {
++splitIndex; // If we are moving to the top, we move after the target
}
} else {
splitIndex = files.length; // we move at the end
}
// All the rows before the split index not starting with the source path
const rowsBefore = files.slice(0, splitIndex).filter((item) => !pathStartsWith(item.filePath, sourcePath));
// All the rows starting with the source path, with the path updated
const rowsMiddle = files
.filter((item) => pathStartsWith(item.filePath, sourcePath))
.map((item) => ({ ...item, filePath: pathReplaceBase(item.filePath, sourcePath, newParentPath) }));
// All the rows after the split index not starting with the source path
const rowsAfter = files.slice(splitIndex).filter((item) => !pathStartsWith(item.filePath, sourcePath));
// Merge the three parts
return [...rowsBefore, ...rowsMiddle, ...rowsAfter];
}
/** Get the parent path of a path */
function pathParent(path: string[]): string[] {
return path.slice(0, -1);
}
/** Check the given path is a subpath or equal to the given base path */
function pathStartsWith(path: string[] | undefined, base: string[]): boolean {
return !!path && path.length >= base.length && base.every((part, i) => path[i] === part);
}
/** Check if two entries are exactly in the same folder. e.g. pathInSameFolder([a,b], [a,c]) => true */
function pathInSameFolder(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((part, i) => i === a.length - 1 || part === b[i]);
}
/** Replace the base of a path. e.g. pathReplaceBase([a,b,c], [a,b], [x,y]) => [x,y,c] */
function pathReplaceBase(path: string[], oldBase: string[], newBase: string[] = []): string[] {
return newBase.concat(path.slice(oldBase.length - 1));
}
/** Gets the file extension from a filename */
function fileExtension(filename: string): string {
const i = filename.lastIndexOf('.');
return i === -1 ? '' : filename.slice(i + 1);
}
/** Get the CSS icon class for a file or folder */
export function getFileCssIcon(type: 'file' | 'folder' | undefined, filename: string): string {
if (type !== 'file') {
return 'far fa-folder';
}
switch (fileExtension(filename)) {
case 'xls':
return 'far fa-file-excel';
case 'pdf':
return 'far fa-file-pdf';
case 'mp3':
case 'wav':
return 'far fa-file-audio';
}
return 'far fa-file-alt';
}
import type { ICellRendererParams } from 'ag-grid-community';
import { getFileCssIcon } from './fileUtils';
import type { IFile } from './fileUtils';
export class FileCellRenderer {
private eGui!: any;
init(params: ICellRendererParams<IFile>) {
const cell = document.createElement('span');
cell.className = 'filename';
const icon = document.createElement('i');
icon.className = getFileCssIcon(params.data?.type, params.value);
cell.appendChild(icon);
cell.appendChild(document.createTextNode(params.value));
this.eGui = cell;
}
getGui() {
return this.eGui;
}
}
<div id="myGrid" class="myGrid"></div>
Tree Data with getDataPath, Highlighting the Drop Parent Row Copy Link
The example above works, however it is not intuitive as the user is given no visual hint what folder will be the destination folder. The example below continues with the example above by providing hints to the user while the drag is in progress. From the example the following can be observed:
- The example registers for
onRowDragMoveevents and works out which folder the mouse is over as the drag is happening. - While the row is dragging, the application highlights the folder that is currently selected as the destination folder (called
potentialParentin the example code). - The application does NOT rearrange the rows as the drag is happening. As with the previous example, it waits for the
onRowDragEndevent before updating the data. - The example uses Cell Class Rules to highlight the destination folder. The example adds a CSS class
hover-overto all the cells of the destination folder. - The example uses Refresh Cells to get the grid to execute the Cell Class Rules again over the destination folder when the destination folder changes.
import {
CellClassParams,
CellStyleModule,
ClientSideRowModelApiModule,
ClientSideRowModelModule,
GridApi,
GridOptions,
ICellRendererParams,
IRowNode,
ModuleRegistry,
RefreshCellsParams,
RenderApiModule,
RowDragEndEvent,
RowDragLeaveEvent,
RowDragModule,
RowDragMoveEvent,
ValueFormatterParams,
createGrid,
enableDevValidations,
} from "ag-grid-community";
import { TreeDataModule } from "ag-grid-enterprise";
import { getData } from "./data";
import { IFile, getFileCssIcon, moveFiles } from "./fileUtils";
// Enable extended validations only for development
if (process.env.NODE_ENV !== "production") {
enableDevValidations();
}
ModuleRegistry.registerModules([
RowDragModule,
ClientSideRowModelApiModule,
RenderApiModule,
CellStyleModule,
ClientSideRowModelModule,
TreeDataModule,
]);
class FileCellRenderer {
private eGui!: any;
init(params: ICellRendererParams<IFile>) {
const eGui = document.createElement("div");
const eIcon = document.createElement("i");
eIcon.className = getFileCssIcon(params.data?.type, params.value);
const eFilename = document.createElement("span");
eFilename.className = "filename";
eFilename.innerText = params.value;
eGui.appendChild(eIcon);
eGui.appendChild(eFilename);
this.eGui = eGui;
}
getGui() {
return this.eGui;
}
}
const valueFormatter = function (params: ValueFormatterParams<IFile, number>) {
return params.value ? params.value.toFixed(1) + " MB" : "";
};
const cellClassRules = {
"hover-over": (params: CellClassParams) => {
return params.node === potentialParent;
},
};
let gridApi: GridApi;
const gridOptions: GridOptions<IFile> = {
columnDefs: [
{
field: "dateModified",
cellClassRules: cellClassRules,
},
{
field: "size",
aggFunc: "sum",
valueFormatter: valueFormatter,
cellClassRules: cellClassRules,
},
],
defaultColDef: {
flex: 1,
},
rowData: getData(),
treeData: true,
groupDefaultExpanded: -1,
getDataPath: (data: IFile) => data.filePath,
getRowId: ({ data }) => data.id,
autoGroupColumnDef: {
rowDrag: true,
headerName: "Files",
minWidth: 300,
cellRendererParams: {
suppressCount: true,
innerRenderer: FileCellRenderer,
},
cellClassRules: {
"hover-over": (params) => {
return params.node === potentialParent;
},
},
},
onRowDragEnd: onRowDragEnd,
onRowDragMove: onRowDragMove,
onRowDragLeave: onRowDragLeave,
};
var potentialParent: any = null;
function onRowDragMove(event: RowDragMoveEvent) {
setPotentialParentForNode(event.api, event.overNode);
}
function onRowDragLeave(event: RowDragLeaveEvent) {
// clear node to highlight
setPotentialParentForNode(event.api, null);
}
function onRowDragEnd(event: RowDragEndEvent) {
let target = event.overNode?.data;
if (!potentialParent && target) {
return; // no move
}
const source = event.node.data;
const rowData = event.api.getGridOption("rowData");
if (rowData && source && source !== target) {
const newRowData = moveFiles(rowData, source, target);
if (!newRowData) {
console.log("invalid move");
} else if (newRowData !== rowData) {
event.api.setGridOption("rowData", newRowData);
}
gridApi!.clearFocusedCell();
}
// clear node to highlight
setPotentialParentForNode(event.api, null);
}
function setPotentialParentForNode(
api: GridApi<IFile>,
overNode: IRowNode<IFile> | undefined | null,
) {
let newPotentialParent: IRowNode<IFile> | null = null;
if (overNode) {
if (overNode.data?.type === "folder") {
// over a folder, we take the immediate row
newPotentialParent = overNode;
} else if (overNode.parent) {
// over a file, we take the parent row (which will be a folder)
newPotentialParent = overNode.parent;
}
}
const alreadySelected = potentialParent === newPotentialParent;
if (alreadySelected) {
return; // no change
}
// we refresh the previous selection (if it exists) to clear
// the highlighted and then the new selection.
const rowsToRefresh = [];
if (potentialParent) {
rowsToRefresh.push(potentialParent);
}
if (newPotentialParent) {
rowsToRefresh.push(newPotentialParent);
}
potentialParent = newPotentialParent;
refreshRows(api, rowsToRefresh);
}
function refreshRows(api: GridApi, rowsToRefresh: IRowNode<IFile>[]) {
const params: RefreshCellsParams<IFile> = {
// refresh these rows only.
rowNodes: rowsToRefresh,
// because the grid does change detection, the refresh
// will not happen because the underlying value has not
// changed. to get around this, we force the refresh,
// which skips change detection.
force: true,
};
api.refreshCells(params);
}
// wait for the document to be loaded, otherwise
// AG Grid will not find the div in the document.
// lookup the container we want the Grid to use
const eGridDiv = document.querySelector<HTMLElement>("#myGrid")!;
// create the grid passing in the div to use together with the columns & data we want to use
gridApi = createGrid(eGridDiv, gridOptions);
.fa-folder {
color: darkorange;
}
.fa-file-pdf {
color: red;
}
.fa-file-excel {
color: green;
}
.fa-file-audio {
color: blue;
}
.filename {
padding: 5px;
color: var(--ag-foreground-color);
font-size: 16px;
font-family: 'Courier New', Courier, monospace;
font-weight: normal;
}
.hover-over {
background-color: var(--ag-accent-color);
}
import type { IFile } from './fileUtils';
export function getData(): IFile[] {
return [
{ id: '1', filePath: ['Documents'], type: 'folder' },
{ id: '2', filePath: ['Documents', 'txt'], type: 'folder' },
{
id: '3',
filePath: ['Documents', 'txt', 'notes.txt'],
type: 'file',
dateModified: 'May 21 2017 01:50:00 PM',
size: 14.7,
},
{ id: '4', filePath: ['Documents', 'pdf'], type: 'folder' },
{
id: '5',
filePath: ['Documents', 'pdf', 'book.pdf'],
type: 'file',
dateModified: 'May 20 2017 01:50:00 PM',
size: 2.1,
},
{
id: '6',
filePath: ['Documents', 'pdf', 'cv.pdf'],
type: 'file',
dateModified: 'May 20 2016 11:50:00 PM',
size: 2.4,
},
{ id: '7', filePath: ['Documents', 'xls'], type: 'folder' },
{
id: '8',
filePath: ['Documents', 'xls', 'accounts.xls'],
type: 'file',
dateModified: 'Aug 12 2016 10:50:00 AM',
size: 4.3,
},
{ id: '9', filePath: ['Documents', 'stuff'], type: 'folder' },
{
id: '10',
filePath: ['Documents', 'stuff', 'xyz.txt'],
type: 'file',
dateModified: 'Jan 17 2016 08:03:00 PM',
size: 1.1,
},
{ id: '11', filePath: ['Music'], type: 'folder' },
{ id: '12', filePath: ['Music', 'mp3'], type: 'folder' },
{
id: '13',
filePath: ['Music', 'mp3', 'theme.mp3'],
type: 'file',
dateModified: 'Sep 11 2016 08:03:00 PM',
size: 14.3,
},
{ id: '14', filePath: ['Misc'], type: 'folder' },
{
id: '15',
filePath: ['Misc', 'temp.txt'],
type: 'file',
dateModified: 'Aug 12 2016 10:50:00 PM',
size: 101,
},
];
}
export interface IFile {
id: string;
filePath: string[];
type: 'file' | 'folder';
dateModified?: string;
size?: number;
}
/**
* Move a file or a folder. This is a pure function, it does not modify the original data.
* @param files the list of files
* @param source the file or folder to move
* @param target the target file or folder to move to
* @returns the new list of files, or null if the move is invalid
*/
export function moveFiles(files: IFile[], source: IFile, target: IFile | null | undefined): IFile[] | null {
if (source === target) {
return null; // invalid move - no-op
}
const sourcePath = source.filePath; // folder or file to move
let newParentPath: string[] | undefined; // folder to drop into is where we are going to move the file/folder to
if (target) {
newParentPath = target.filePath;
if (target.type !== 'folder') {
newParentPath = pathParent(newParentPath); // if over a file, we take the parent folder
}
}
if (pathStartsWith(newParentPath, sourcePath)) {
return null; // invalid move - we are moving a parent folder into one of its child folders
}
let splitIndex: number;
if (target) {
splitIndex = files.indexOf(target);
if (splitIndex > files.indexOf(source)) {
++splitIndex; // If we are moving to the top, we move after the target
}
} else {
splitIndex = files.length; // we move at the end
}
// All the rows before the split index not starting with the source path
const rowsBefore = files.slice(0, splitIndex).filter((item) => !pathStartsWith(item.filePath, sourcePath));
// All the rows starting with the source path, with the path updated
const rowsMiddle = files
.filter((item) => pathStartsWith(item.filePath, sourcePath))
.map((item) => ({ ...item, filePath: pathReplaceBase(item.filePath, sourcePath, newParentPath) }));
// All the rows after the split index not starting with the source path
const rowsAfter = files.slice(splitIndex).filter((item) => !pathStartsWith(item.filePath, sourcePath));
// Merge the three parts
return [...rowsBefore, ...rowsMiddle, ...rowsAfter];
}
/** Get the parent path of a path */
function pathParent(path: string[]): string[] {
return path.slice(0, -1);
}
/** Check the given path is a subpath or equal to the given base path */
function pathStartsWith(path: string[] | undefined, base: string[]): boolean {
return !!path && path.length >= base.length && base.every((part, i) => path[i] === part);
}
/** Check if two entries are exactly in the same folder. e.g. pathInSameFolder([a,b], [a,c]) => true */
function pathInSameFolder(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((part, i) => i === a.length - 1 || part === b[i]);
}
/** Replace the base of a path. e.g. pathReplaceBase([a,b,c], [a,b], [x,y]) => [x,y,c] */
function pathReplaceBase(path: string[], oldBase: string[], newBase: string[] = []): string[] {
return newBase.concat(path.slice(oldBase.length - 1));
}
/** Gets the file extension from a filename */
function fileExtension(filename: string): string {
const i = filename.lastIndexOf('.');
return i === -1 ? '' : filename.slice(i + 1);
}
/** Get the CSS icon class for a file or folder */
export function getFileCssIcon(type: 'file' | 'folder' | undefined, filename: string): string {
if (type !== 'file') {
return 'far fa-folder';
}
switch (fileExtension(filename)) {
case 'xls':
return 'far fa-file-excel';
case 'pdf':
return 'far fa-file-pdf';
case 'mp3':
case 'wav':
return 'far fa-file-audio';
}
return 'far fa-file-alt';
}
<div id="myGrid" style="height: 100%"></div>
Tree Data with Parent ID Copy Link
The following example shows how to implement unmanaged row dragging using the parentId approach, which is simpler and more direct than using getDataPath. The grid uses the treeDataParentIdField property, and utility functions are provided to move rows and update the tree structure. This approach is recommended for most use cases where your data is already structured with parent IDs.
This example also demonstrates how to provide custom drop indicators using the setRowDropPositionIndicator API.
import {
ClientSideRowModelModule,
GetRowIdParams,
GridApi,
GridOptions,
ModuleRegistry,
RowApiModule,
RowDragCancelEvent,
RowDragEndEvent,
RowDragLeaveEvent,
RowDragModule,
ValueFormatterParams,
createGrid,
enableDevValidations,
} from "ag-grid-community";
import { TreeDataModule } from "ag-grid-enterprise";
import { getData } from "./data";
import { IFile, getFileDropPosition, moveFiles } from "./fileUtils";
// Enable extended validations only for development
if (process.env.NODE_ENV !== "production") {
enableDevValidations();
}
ModuleRegistry.registerModules([
ClientSideRowModelModule,
RowApiModule,
TreeDataModule,
RowDragModule,
]);
let gridApi: GridApi<IFile>;
function getRowId(params: GetRowIdParams<IFile>) {
return params.data.id;
}
function onRowDragMove(event: any) {
const source = event.node.data;
const target = event.overNode?.data;
const reorderOnly = event.event?.shiftKey;
const rowData = gridApi.getGridOption("rowData") ?? [];
const indicator = getFileDropPosition(rowData, source, target, !!reorderOnly);
if (indicator) {
// Find the row node by file reference
const rowNode = gridApi.getRowNode(indicator.target.id);
if (rowNode) {
// Update the position indicator
gridApi.setRowDropPositionIndicator({
row: rowNode,
dropIndicatorPosition: indicator.position,
});
return;
}
}
gridApi.setRowDropPositionIndicator(null);
}
function onRowDragEnd(event: RowDragEndEvent<IFile>) {
const source = event.node.data;
const target = event.overNode?.data;
if (!source || source === target) {
gridApi.setRowDropPositionIndicator(null);
return;
}
const reorderOnly = event.event?.shiftKey;
const rowData = gridApi.getGridOption("rowData") ?? [];
const indicator = getFileDropPosition(rowData, source, target, !!reorderOnly);
if (indicator) {
const newRowData = moveFiles(rowData, indicator);
if (newRowData !== rowData) {
gridApi.setGridOption("rowData", newRowData);
}
}
event.api.setRowDropPositionIndicator(null);
}
function onRowDragLeave(event: RowDragLeaveEvent<IFile>) {
event.api.setRowDropPositionIndicator(null);
}
function onRowDragCancel(event: RowDragCancelEvent<IFile>) {
event.api.setRowDropPositionIndicator(null);
}
const gridOptions: GridOptions<IFile> = {
columnDefs: [
{
field: "type",
headerName: "Type",
width: 90,
},
{
field: "dateModified",
headerName: "Modified",
width: 130,
},
{
field: "size",
aggFunc: "sum",
width: 140,
valueFormatter: (params: ValueFormatterParams<IFile, number>) =>
params.value ? params.value.toFixed(1) + " MB" : "",
},
],
autoGroupColumnDef: {
rowDrag: true,
field: "name",
headerName: "Files",
minWidth: 400,
cellRendererParams: { suppressCount: true },
},
treeData: true,
getRowId,
treeDataParentIdField: "parentId",
rowData: getData(),
animateRows: true,
onRowDragMove,
onRowDragEnd,
onRowDragLeave,
onRowDragCancel,
groupDefaultExpanded: -1,
};
gridApi = createGrid(document.getElementById("myGrid")!, gridOptions);
import type { IFile } from './fileUtils';
export function getData(): IFile[] {
return [
{ id: '1', name: 'Documents', type: 'folder' },
{ id: '2', parentId: '1', name: 'txt', type: 'folder' },
{
id: '3',
parentId: '2',
name: 'notes.txt',
type: 'file',
dateModified: '2017-05-21',
size: 14.7,
},
{ id: '4', parentId: '1', name: 'pdf', type: 'folder' },
{ id: '5', parentId: '4', name: 'book.pdf', type: 'file', dateModified: '2017-05-20', size: 2.1 },
{ id: '6', parentId: '4', name: 'cv.pdf', type: 'file', dateModified: '2016-05-20', size: 2.4 },
{ id: '7', parentId: '1', name: 'xls', type: 'folder' },
{
id: '8',
parentId: '7',
name: 'accounts.xls',
type: 'file',
dateModified: '2016-08-12',
size: 4.3,
},
{ id: '9', parentId: '1', name: 'stuff', type: 'folder' },
{ id: '10', parentId: '9', name: 'xyz.txt', type: 'file', dateModified: '2016-01-17', size: 1.1 },
{ id: '11', name: 'Music', type: 'folder' },
{ id: '12', parentId: '11', name: 'mp3', type: 'folder' },
{
id: '13',
parentId: '12',
name: 'theme.mp3',
type: 'file',
dateModified: '2016-09-11',
size: 14.3,
},
{ id: '14', name: 'Misc', type: 'folder' },
{
id: '15',
parentId: '14',
name: 'temp.txt',
type: 'file',
dateModified: '2016-08-12',
size: 101,
},
{
id: '16',
parentId: '14',
name: 'temp2.txt',
type: 'file',
dateModified: '2016-08-12',
size: 200,
},
{
id: '17',
parentId: '14',
name: 'temp3.txt',
type: 'file',
dateModified: '2016-08-12',
size: 200,
},
];
}
import type { DropIndicatorPosition } from 'ag-grid-community';
export interface IFile {
id: string;
parentId?: string;
name: string;
type: 'file' | 'folder';
dateModified?: string;
size?: number;
}
export interface FileDropPosition {
parentId: string | undefined;
source: IFile;
target: IFile;
position: DropIndicatorPosition;
}
const indexOfFile = (files: IFile[], file: IFile): number => files.findIndex((f) => f.id === file.id);
export function getFileDropPosition(
files: IFile[],
source: IFile | null | undefined,
target: IFile | null | undefined,
reorderOnly: boolean
): FileDropPosition | null {
if (!source) {
return null;
}
if (!target) {
target = files.findLast((f) => f.parentId === undefined) ?? source;
}
if (target === source) {
return null;
}
let parentId = getNewParentId(source, target, reorderOnly);
let dropIndicatorPosition: DropIndicatorPosition = 'inside';
if (parentId === undefined || target.id !== parentId) {
let indexOfTarget = indexOfFile(files, target);
const indexOfSource = indexOfFile(files, source);
const direction = indexOfSource > indexOfTarget ? 1 : -1;
dropIndicatorPosition = direction === 1 ? 'above' : 'below';
for (let i = 0; i < files.length; i++) {
const index = Math.abs(indexOfTarget + direction * i) % files.length;
const item = files[index];
if (item !== source && item.parentId === parentId) {
indexOfTarget = index;
target = item;
break;
}
}
}
return { parentId, source, target, position: dropIndicatorPosition };
}
/**
* Moves a file or folder in a flat tree structure using parentId.
* - Prevents moving a folder into itself or its descendants.
* - Handles reordering among siblings and moving to a new parent.
* - Returns a new array, does not mutate the input.
*/
export function moveFiles(files: IFile[], { source, target, parentId, position }: FileDropPosition): IFile[] {
if (target && isDescendant(source, target, files)) {
return files; // Prevent moving a folder into itself or its descendants
}
if (source.parentId !== parentId) {
source = { ...source, parentId }; // Update parentId if it has changed
}
const above = position === 'above';
const result: IFile[] = [];
let inserted = false;
for (const file of files) {
const shouldInsert = !inserted && file.id === target.id;
if (shouldInsert && above) {
result.push(source);
inserted = true;
}
if (file.id !== source.id) {
result.push(file);
}
if (shouldInsert && !above) {
result.push(source);
inserted = true;
}
}
if (!inserted) {
result.push(source);
}
return result;
}
/**
* Returns true if target is a descendant of source (or the same node).
* Used to prevent invalid moves.
*/
function isDescendant(source: IFile, target: IFile, files: IFile[]): boolean {
if (source.id === target.id) return true;
let parent = target.parentId;
while (parent) {
if (parent === source.id) return true;
parent = files.find((f) => f.id === parent)?.parentId;
}
return false;
}
/**
* Returns the new parentId for a move operation.
*/
function getNewParentId(source: IFile, target: IFile | null | undefined, reorderOnly: boolean): string | undefined {
if (reorderOnly) {
return source.parentId;
}
if (!target) {
return reorderOnly ? source.parentId : undefined;
}
if (target.type === 'folder') {
return target.id;
}
return target.parentId;
}
<div id="myGrid" style="height: 100%"></div>
See Also Copy Link
- Aggregation for aggregating values in tree data
- Editing Groups for editing aggregated values with cascading updates to children