Reputation: 19
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];
}
}
}
Upvotes: 1
Views: 42
Reputation: 19
I have identified the issue: when the tree exceeds the original screen width, the tree's div extends beyond the screen container's div, causing the drag-and-drop functionality to stop working. To resolve this, I need to add the w-fit
property to automatically adjust the size of my container.
<!-- Tree node template -->
<div class="relative pl-8 mt-1 ml-6 w-fit" [class.ml-0]="isRoot" [class.pl-3]="isRoot">
<!-- Node Content -->
<div
class="flex items-center gap-2 relative cursor-move bg-white rounded p-1.5 transition-all duration-200 w-fit !cursor-default"
cdkDrag
[cdkDragDisabled]="isRoot"
[cdkDragData]="node"
(cdkDragStarted)="onDragStarted()"
(cdkDragEnded)="onDragEnded()"
[ngClass]="{
'bg-red-50 border-4 border-red-500 p-2.5 rounded-lg': isRoot,
'bg-gray-50 border-3 border-blue-200 rounded': node.children.length > 0,
'bg-green-50 border border-green-200 rounded': node.children.length === 0
}"
>
<!-- Vertical and Horizontal Connector Lines -->
<div *ngIf="!isRoot" class="absolute -left-8 top-1/2 flex items-center">
<div class="absolute -left-11 w-19 h-0.5 bg-gray-300"></div>
</div>
<!-- Expand/Collapse Button - Add this new button -->
<button
*ngIf="node.children.length > 0"
(click)="toggleExpand()"
class="absolute -left-2.5 w-4 h-4 rounded-full bg-gray-400 text-white flex items-center justify-center text-sm hover:bg-gray-300"
title="{{ isExpanded ? 'Collapse' : 'Expand' }}"
>
{{ isExpanded ? '▼' : '▶' }}
</button>
<!-- Expand/Collapse Button - Add this new button -->
<div
*ngIf="node.children.length > 0 && !isExpanded"
class="absolute -right-2.5 w-4 h-4 rounded-full bg-gray-400 text-white flex items-center justify-center text-sm hover:bg-gray-300"
>
{{ node.children.length }}
</div>
<!-- Node Icon -->
<div *ngIf="!isRoot" class="w-5 text-center text-base">
<span class="text-yellow-500">{{ node.children.length > 0 ? '📁' : '📄' }}</span>
</div>
<!-- Root Icon -->
<div *ngIf="isRoot" class="text-xl mr-2 text-yellow-500">📁</div>
<!-- Input Field -->
<input
[(ngModel)]="node.value"
[placeholder]="isRoot ? 'Root Node' : 'Enter value'"
class="px-1.5 w-[200px] min-w-[150px] max-w-[300px]"
[ngClass]="{ 'font-bold text-base text-blue-700 bg-white': isRoot }"
/>
<!-- Action Buttons -->
<div class="flex gap-1.5 ml-2">
<!-- Delete Button -->
<button
*ngIf="!isRoot"
(click)="deleteNode()"
class="w-6 h-6 rounded-full bg-red-500 text-white flex items-center justify-center text-sm hover:bg-red-600"
title="Delete Node"
>
×
</button>
<!-- Add Child Button -->
<button
(click)="addChild()"
class="w-6 h-6 rounded-full bg-green-500 text-white flex items-center justify-center text-sm hover:bg-green-600"
[ngClass]="{ 'bg-blue-500 hover:bg-blue-600': isRoot }"
title="Add Child Node"
>
+
</button>
<!-- Move Up Button -->
<button
*ngIf="!isRoot"
(click)="moveUpLevel()"
class="w-6 h-6 rounded-full bg-blue-500 text-white flex items-center justify-center text-sm hover:bg-blue-600"
title="Move to upper level"
>
↑
</button>
<!-- Drag Handle -->
<button
*ngIf="!isRoot"
class="w-6 h-6 rounded-full bg-gray-200 text-gray-600 flex items-center justify-center text-sm hover:bg-gray-300 !cursor-move z-100"
cdkDragHandle
title="Drag to reorder"
>
☰
</button>
</div>
</div>
<!-- Children Container -->
<div
*ngIf="node.children.length > 0 && isExpanded"
[@expandCollapse]="isExpanded ? 'expanded' : 'collapsed'"
class="relative ml-8"
cdkDropList
[id]="dropListId"
[cdkDropListData]="node.children"
[cdkDropListConnectedTo]="dropListIds"
(cdkDropListDropped)="drop($event)"
[cdkDropListEnterPredicate]="canDrop"
[cdkDropListSortingDisabled]="false"
[cdkDropListAutoScrollDisabled]="false"
[cdkDropListAutoScrollStep]="5"
>
<!-- Vertical Line for Children -->
<div class="absolute -left-5 -top-1 w-0.5 h-[calc(100%-1rem)] bg-gray-300 z-0" [ngClass]="{ 'bg-blue-500': isRoot }"></div>
<!-- Child Nodes -->
<app-tree
*ngFor="let child of node.children; let last = last"
[node]="child"
[isLastChild]="last"
[isRoot]="false"
[dropListIds]="dropListIds"
(onDelete)="removeChild($event)"
(registerDropList)="onRegisterDropList($event)"
>
</app-tree>
</div>
</div>
Upvotes: 0