I'm working on a dynamic nested tree structure using Angular CDK Drag & Drop. The drag-and-drop feature works at the first level but fails when trying to drop elements deeper in the hierarchy. The cdkDropListDropped event is not triggered at deeper levels. (after lv12)
CdkDropList could not find connected drop list with id ...
demo: https://nested-tree-angular.netlify.app
code source: https://github.com/phamhung075/nested-tree
How can I ensure drag-and-drop works at all nested levels? Is there a limitation with CDK Drag & Drop for deep structures? Any suggestions for fixing or debugging this issue?
// tree.component.ts
import {
CdkDrag,
CdkDragDrop,
CdkDropList,
DragDropModule,
moveItemInArray,
} from '@angular/cdk/drag-drop';
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
EventEmitter,
Input,
OnInit,
Output,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TreeNode } from '@shared/interfaces/tree-node.model';
import { TreeService } from '@shared/services/tree/tree.service';
@Component({
selector: 'app-tree',
standalone: true,
imports: [CommonModule, FormsModule, DragDropModule],
templateUrl: './branch-display.component.html',
styleUrls: ['./branch-display.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TreeComponent implements OnInit {
@Input() node!: TreeNode;
@Input() isLastChild = false;
@Input() isRoot = true;
@Input() dropListIds: string[] = [];
@Output() onDelete = new EventEmitter<string>();
@Output() registerDropList = new EventEmitter<string>();
dropListId = `drop-list-${Math.random().toString(36).substring(2)}`;
private hasBeenInitialized = false;
constructor(
private treeService: TreeService,
private changeDetectionRef: ChangeDetectorRef
) {}
ngOnInit() {
if (!this.hasBeenInitialized) {
this.registerDropList.emit(this.dropListId);
console.log('Tree Component Initialized:', {
nodeId: this.node.id,
value: this.node.value,
children: this.node.children.length,
});
this.hasBeenInitialized = true;
}
}
canDrop = (drag: CdkDrag, drop: CdkDropList) => {
const dragData = drag.data as TreeNode;
const dropData = this.node; // Current node where we're trying to drop
// Prevent dropping on itself or its descendants and root
if (
dragData.id === dropData.id ||
this.isDescendant(dragData, dropData) ||
dropData.id === 'root'
) {
return false;
}
return true;
};
private isDescendant(dragNode: TreeNode, targetNode: TreeNode): boolean {
return targetNode.children.some(
(child: TreeNode) =>
child.id === dragNode.id || this.isDescendant(dragNode, child)
);
}
drop(event: CdkDragDrop<TreeNode[]>) {
const draggedNode = event.item.data as TreeNode;
if (event.previousContainer === event.container) {
// Moving within the same container
moveItemInArray(
event.container.data,
event.previousIndex,
event.currentIndex
);
} else {
// Moving to a different container
const success = this.treeService.moveNode(
draggedNode.id,
this.node.id,
'inside',
event.currentIndex // Pass the current index for position-based insertion
);
if (success) {
console.log('Node moved successfully to:', {
targetNode: this.node.value,
position: event.currentIndex,
});
}
}
}
moveUpLevel() {
const currentParent = this.treeService.getParentNode(this.node.id);
if (!currentParent) {
console.log('Cannot move up: No parent found');
return;
}
const grandParent = this.treeService.getParentNode(currentParent.id);
if (!grandParent) {
console.log('Cannot move up: No grandparent found');
return;
}
// Find the index where the current parent is in the grandparent's children
const parentIndex = grandParent.children.findIndex(
(child: TreeNode) => child.id === currentParent.id
);
if (parentIndex === -1) {
console.log('Cannot move up: Parent index not found');
return;
}
// Move the node one level up
const success = this.treeService.moveNode(
this.node.id,
grandParent.id,
'inside',
parentIndex + 1 // Insert after the current parent
);
if (success) {
console.log('Node moved up successfully:', {
nodeId: this.node.id,
newParentId: grandParent.id,
position: parentIndex + 1,
});
}
}
// Update the tree service to include better logging
removeChild(childId: string) {
const index = this.node.children.findIndex(
(child: TreeNode) => child.id === childId
);
if (index !== -1) {
const removedNode = this.node.children[index];
this.node.children.splice(index, 1);
console.log('Removed child:', {
childId,
parentId: this.node.id,
parentValue: this.node.value,
});
}
}
addChild() {
const newNode: TreeNode = {
id: Date.now().toString(),
value: 'New Node',
children: [],
};
this.treeService.updateNodeMaps(newNode, this.node.id);
this.node.children.push(newNode);
}
deleteNode() {
this.onDelete.emit(this.node.id);
}
onDragStarted() {
document.body.classList.add('dragging');
}
onDragEnded() {
document.body.classList.remove('dragging');
}
onRegisterDropList(childDropListId: string) {
if (!this.dropListIds.includes(childDropListId)) {
this.dropListIds.push(childDropListId);
this.registerDropList.emit(childDropListId);
}
}
}
// tree.service.ts
import { Injectable } from '@angular/core';
import { TreeNode } from '@shared/interfaces/tree-node.model';
@Injectable({
providedIn: 'root',
})
export class TreeService {
private nodeMap = new Map<string, TreeNode>();
private parentMap = new Map<string, string>();
getRegisteredNodes(): string[] {
return Array.from(this.nodeMap.keys());
}
updateNodeMaps(node: TreeNode, parentId?: string) {
console.log('Updating node maps:', {
nodeId: node.id,
parentId,
nodeValue: node.value,
});
this.nodeMap.set(node.id, node);
if (parentId) {
this.parentMap.set(node.id, parentId);
}
console.log('Current maps after update:', {
nodeMapSize: this.nodeMap.size,
parentMapSize: this.parentMap.size,
nodeMapKeys: Array.from(this.nodeMap.keys()),
parentMapKeys: Array.from(this.parentMap.keys()),
});
node.children.forEach((child: TreeNode) =>
this.updateNodeMaps(child, node.id)
);
}
findNodeById(id: string): TreeNode | undefined {
const node = this.nodeMap.get(id);
console.log('Finding node by id:', {
searchId: id,
found: !!node,
nodeValue: node?.value,
});
return node;
}
getParentNode(nodeId: string): TreeNode | undefined {
const parentId = this.parentMap.get(nodeId);
const parentNode = parentId ? this.nodeMap.get(parentId) : undefined;
console.log('Getting parent node:', {
childId: nodeId,
parentId,
foundParent: !!parentNode,
parentValue: parentNode?.value,
});
return parentNode;
}
private isDescendant(nodeId: string, targetId: string): boolean {
console.log('Checking if descendant:', {
nodeId,
targetId,
});
let currentNode = this.findNodeById(targetId);
let depth = 0;
while (currentNode && depth < 1000) {
console.log('Traversing up the tree:', {
currentNodeId: currentNode.id,
currentNodeValue: currentNode.value,
depth,
});
if (currentNode.id === nodeId) {
console.log('Found ancestor match - would create circular reference');
return true;
}
currentNode = this.getParentNode(currentNode.id);
depth++;
}
console.log('No circular reference found');
return false;
}
moveNode(
nodeId: string,
targetId: string,
position: 'before' | 'after' | 'inside',
insertIndex?: number
): boolean {
console.log('Starting moveNode operation:', {
nodeId,
targetId,
position,
insertIndex,
targetNodeValue: this.findNodeById(targetId)?.value,
});
const sourceNode = this.findNodeById(nodeId);
const targetNode = this.findNodeById(targetId);
if (!sourceNode || !targetNode) {
console.log('Move failed: Source or target node not found');
return false;
}
const sourceParent = this.getParentNode(nodeId);
if (!sourceParent) {
console.log('Move failed: Source parent not found');
return false;
}
// Check for circular reference
if (this.isDescendant(nodeId, targetId)) {
console.log('Move failed: Would create circular reference');
return false;
}
// Remove from old parent
sourceParent.children = sourceParent.children.filter(
(child: TreeNode) => child.id !== nodeId
);
// Add to new location
if (position === 'inside') {
if (typeof insertIndex === 'number' && insertIndex >= 0) {
// Insert at specific position
targetNode.children.splice(insertIndex, 0, sourceNode);
} else {
// Default behavior: append to end
targetNode.children.push(sourceNode);
}
this.parentMap.set(nodeId, targetId);
} else {
const targetParent = this.getParentNode(targetId);
if (!targetParent) {
console.log('Move failed: Target parent not found');
return false;
}
const targetIndex = targetParent.children.findIndex(
(child: TreeNode) => child.id === targetId
);
const insertPosition =
position === 'after' ? targetIndex + 1 : targetIndex;
targetParent.children.splice(insertPosition, 0, sourceNode);
this.parentMap.set(nodeId, targetParent.id);
}
console.log('Move completed successfully. New structure:', {
movedNodeId: nodeId,
newParentId: targetId,
newParentValue: targetNode.value,
insertPosition: insertIndex,
});
return true;
}
}
// app-tree.component.ts (Parent component)
import { CommonModule } from '@angular/common';
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
OnInit,
} from '@angular/core';
import { TreeComponent } from '../../components/branch-display/branch-display.component';
import { TreeNode } from '@shared/interfaces/tree-node.model';
import { TreeService } from '@shared/services/tree/tree.service';
import { mockTreeData } from '../../components/branch-display/mock-data';
@Component({
selector: 'app-tree-container',
standalone: true,
imports: [TreeComponent, CommonModule],
templateUrl: './branch-display-container.component.html',
styleUrls: ['./branch-display-container.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppTreeContainer implements OnInit {
treeData: TreeNode = mockTreeData;
dropListIds: string[] = [];
constructor(
private treeService: TreeService,
private changeDetectionRef: ChangeDetectorRef
) {}
ngOnInit() {
// Register all nodes in the tree with the service
this.registerNodesRecursively(this.treeData);
console.log('Tree Container Initialized');
}
private registerNodesRecursively(node: TreeNode, parentId?: string) {
// Register the current node
this.treeService.updateNodeMaps(node, parentId);
console.log('Registered node:', { nodeId: node.id, parentId });
// Register all children
node.children.forEach((child: TreeNode) => {
this.registerNodesRecursively(child, node.id);
});
}
onRegisterDropList(id: string) {
if (!this.dropListIds.includes(id)) {
this.dropListIds = [...this.dropListIds, id];
}
}
}