Skip to content

Commit f5ef038

Browse files
authored
Merge pull request #7279 from microting/copilot/fix-nested-drag-and-drop
Fix nested drag and drop in navigation menu dropdowns
2 parents ab9ea56 + 5a5c3f8 commit f5ef038

File tree

6 files changed

+251
-7
lines changed

6 files changed

+251
-7
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './navigation-menu.service';
2+
export * from './navigation-menu-drag-drop.service';
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { TestBed } from '@angular/core/testing';
2+
import { DOCUMENT } from '@angular/common';
3+
import { NavigationMenuDragDropService } from './navigation-menu-drag-drop.service';
4+
import { CdkDragMove, CdkDragRelease } from '@angular/cdk/drag-drop';
5+
6+
describe('NavigationMenuDragDropService', () => {
7+
let service: NavigationMenuDragDropService;
8+
let mockDocument: any;
9+
10+
beforeEach(() => {
11+
mockDocument = {
12+
elementFromPoint: jest.fn()
13+
};
14+
15+
TestBed.configureTestingModule({
16+
providers: [
17+
NavigationMenuDragDropService,
18+
{ provide: DOCUMENT, useValue: mockDocument }
19+
]
20+
});
21+
service = TestBed.inject(NavigationMenuDragDropService);
22+
});
23+
24+
it('should be created', () => {
25+
expect(service).toBeTruthy();
26+
});
27+
28+
it('should register drop lists', () => {
29+
const mockDropList: any = { id: 'test-drop-list' };
30+
service.register(mockDropList);
31+
expect(service.dropLists).toContain(mockDropList);
32+
});
33+
34+
it('should set currentHoverDropListId when dragging over a drop list', () => {
35+
const mockElement = document.createElement('div');
36+
mockElement.id = 'test-drop-list';
37+
mockElement.classList.add('cdk-drop-list');
38+
mockDocument.elementFromPoint.mockReturnValue(mockElement);
39+
40+
const mockEvent: Partial<CdkDragMove<any>> = {
41+
pointerPosition: { x: 100, y: 100 }
42+
};
43+
44+
service.dragMoved(mockEvent as CdkDragMove<any>);
45+
expect(service.currentHoverDropListId).toBe('test-drop-list');
46+
});
47+
48+
it('should clear currentHoverDropListId when element is not found', () => {
49+
mockDocument.elementFromPoint.mockReturnValue(null);
50+
51+
const mockEvent: Partial<CdkDragMove<any>> = {
52+
pointerPosition: { x: 100, y: 100 }
53+
};
54+
55+
service.dragMoved(mockEvent as CdkDragMove<any>);
56+
expect(service.currentHoverDropListId).toBeUndefined();
57+
});
58+
59+
it('should clear currentHoverDropListId when drag is released', () => {
60+
service.currentHoverDropListId = 'test-id';
61+
const mockEvent: Partial<CdkDragRelease> = {};
62+
63+
service.dragReleased(mockEvent as CdkDragRelease);
64+
expect(service.currentHoverDropListId).toBeUndefined();
65+
});
66+
67+
it('should find closest drop list when element itself is not a drop list', () => {
68+
const mockParentElement = document.createElement('div');
69+
mockParentElement.id = 'parent-drop-list';
70+
mockParentElement.classList.add('cdk-drop-list');
71+
72+
const mockChildElement = document.createElement('span');
73+
mockParentElement.appendChild(mockChildElement);
74+
mockChildElement.closest = jest.fn().mockReturnValue(mockParentElement);
75+
76+
mockDocument.elementFromPoint.mockReturnValue(mockChildElement);
77+
78+
const mockEvent: Partial<CdkDragMove<any>> = {
79+
pointerPosition: { x: 100, y: 100 }
80+
};
81+
82+
service.dragMoved(mockEvent as CdkDragMove<any>);
83+
expect(service.currentHoverDropListId).toBe('parent-drop-list');
84+
});
85+
86+
it('should allow drop when currentHoverDropListId is null', () => {
87+
service.currentHoverDropListId = undefined;
88+
const mockDrag: any = {};
89+
const mockDrop: any = { id: 'test-drop-list' };
90+
91+
const result = service.isDropAllowed(mockDrag, mockDrop);
92+
expect(result).toBe(true);
93+
});
94+
95+
it('should allow drop when drop list id matches currentHoverDropListId', () => {
96+
service.currentHoverDropListId = 'test-drop-list';
97+
const mockDrag: any = {};
98+
const mockDrop: any = { id: 'test-drop-list' };
99+
100+
const result = service.isDropAllowed(mockDrag, mockDrop);
101+
expect(result).toBe(true);
102+
});
103+
104+
it('should not allow drop when drop list id does not match currentHoverDropListId', () => {
105+
service.currentHoverDropListId = 'other-drop-list';
106+
const mockDrag: any = {};
107+
const mockDrop: any = { id: 'test-drop-list' };
108+
109+
const result = service.isDropAllowed(mockDrag, mockDrop);
110+
expect(result).toBe(false);
111+
});
112+
});
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { Injectable, Inject } from '@angular/core';
2+
import { DOCUMENT } from '@angular/common';
3+
import { CdkDropList, CdkDragMove, CdkDragRelease, CdkDrag } from '@angular/cdk/drag-drop';
4+
5+
/**
6+
* Service to handle nested drag and drop operations in navigation menu.
7+
* Based on: https://stackoverflow.com/a/67337935/2144807
8+
*
9+
* This service dynamically tracks which drop list is currently being hovered
10+
* during drag operations, enabling nested drag and drop functionality that
11+
* cdkDropListGroup doesn't support out of the box.
12+
*/
13+
@Injectable()
14+
export class NavigationMenuDragDropService {
15+
dropLists: CdkDropList[] = [];
16+
currentHoverDropListId?: string;
17+
18+
constructor(@Inject(DOCUMENT) private document: Document) {}
19+
20+
/**
21+
* Register a drop list with the service
22+
*/
23+
public register(dropList: CdkDropList) {
24+
this.dropLists.push(dropList);
25+
}
26+
27+
/**
28+
* Track which drop list is currently under the pointer during drag
29+
*/
30+
dragMoved(event: CdkDragMove<any>) {
31+
const elementFromPoint = this.document.elementFromPoint(
32+
event.pointerPosition.x,
33+
event.pointerPosition.y
34+
);
35+
36+
if (!elementFromPoint) {
37+
this.currentHoverDropListId = undefined;
38+
return;
39+
}
40+
41+
const dropList = elementFromPoint.classList.contains('cdk-drop-list')
42+
? elementFromPoint
43+
: elementFromPoint.closest('.cdk-drop-list');
44+
45+
if (!dropList) {
46+
this.currentHoverDropListId = undefined;
47+
return;
48+
}
49+
50+
this.currentHoverDropListId = dropList.id;
51+
}
52+
53+
/**
54+
* Clear hover state when drag is released
55+
*/
56+
dragReleased(event: CdkDragRelease) {
57+
this.currentHoverDropListId = undefined;
58+
}
59+
60+
/**
61+
* Determine if dropping is allowed based on the current hover state
62+
*/
63+
isDropAllowed(drag: CdkDrag, drop: CdkDropList): boolean {
64+
if (this.currentHoverDropListId == null) {
65+
return true;
66+
}
67+
return drop.id === this.currentHoverDropListId;
68+
}
69+
}

eform-client/src/app/modules/advanced/modules/navigation-menu/components/navigation-menu-page/navigation-menu-page.component.html

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
</div>
2020
</eform-new-subheader>
2121

22-
<div class="d-flex flex-row align-items-start">
22+
<div class="d-flex flex-row align-items-start" cdkDropListGroup>
2323
<mat-accordion multi="true" displayMode="flat" class="mr-2">
2424
<mat-expansion-panel *ngFor="
2525
let menuTemplate of navigationMenuModel.menuTemplates;
@@ -33,7 +33,8 @@
3333
class="dragula-template"
3434
cdkDropList
3535
[cdkDropListData]="menuTemplate.items"
36-
[cdkDropListConnectedTo]="['actualMenuList'].concat(connectedDropdownIds)"
36+
[cdkDropListConnectedTo]="connectedLists"
37+
[cdkDropListEnterPredicate]="allowDropPredicate"
3738
id="mainMenu"
3839
>
3940
<app-navigation-menu-template-item
@@ -42,6 +43,8 @@
4243
let itemIndex = index
4344
"
4445
cdkDrag
46+
(cdkDragMoved)="onDragMoved($event)"
47+
(cdkDragReleased)="onDragReleased($event)"
4548
[item]="menuTemplateItem"
4649
[itemIndex]="itemIndex"
4750
[templateIndex]="templateIndex"
@@ -61,14 +64,17 @@
6164
cdkDropList
6265
id="actualMenuList"
6366
[cdkDropListData]="navigationMenuModel.actualMenu"
64-
[cdkDropListConnectedTo]="['mainMenu']"
67+
[cdkDropListConnectedTo]="connectedLists"
68+
[cdkDropListEnterPredicate]="allowDropPredicate"
6569
(cdkDropListDropped)="dropMenuItem($event)"
6670
class="dragula-item">
6771
<mat-expansion-panel *ngFor="
6872
let menuItem of navigationMenuModel.actualMenu;
6973
let firstLevelIndex = index"
7074
[hideToggle]="menuItem.type !== menuItemTypes.Dropdown"
7175
cdkDrag
76+
(cdkDragMoved)="onDragMoved($event)"
77+
(cdkDragReleased)="onDragReleased($event)"
7278
class="menu_item"
7379
>
7480
<mat-expansion-panel-header>
@@ -100,7 +106,8 @@
100106
cdkDropList
101107
[id]="getDropdownId(firstLevelIndex)"
102108
[cdkDropListData]="menuItem.children"
103-
[cdkDropListConnectedTo]="['mainMenu']"
109+
[cdkDropListConnectedTo]="connectedLists"
110+
[cdkDropListEnterPredicate]="allowDropPredicate"
104111
(cdkDropListDropped)="dropMenuItemChild($event, firstLevelIndex)"
105112
*ngIf="menuItem.type === menuItemTypes.Dropdown"
106113
style="min-height: 50px;"
@@ -111,6 +118,8 @@
111118
>
112119
<app-navigation-menu-item
113120
cdkDrag
121+
(cdkDragMoved)="onDragMoved($event)"
122+
(cdkDragReleased)="onDragReleased($event)"
114123
(itemDelete)="onItemDelete($event, firstLevelIndex, secondLevelIndex)"
115124
(itemEdit)="onItemEdit($event, firstLevelIndex, secondLevelIndex)"
116125
[item]="menuItemChild"

eform-client/src/app/modules/advanced/modules/navigation-menu/components/navigation-menu-page/navigation-menu-page.component.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { Component, OnDestroy, OnInit, ViewChild, inject } from '@angular/core';
2-
import { CdkDragDrop, moveItemInArray, copyArrayItem, transferArrayItem } from '@angular/cdk/drag-drop';
1+
import { Component, OnDestroy, OnInit, ViewChild, inject, AfterViewInit, ViewChildren, QueryList } from '@angular/core';
2+
import { CdkDragDrop, moveItemInArray, copyArrayItem, transferArrayItem, CdkDragMove, CdkDragRelease, CdkDropList, CdkDrag } from '@angular/cdk/drag-drop';
33
import {
44
NavigationMenuItemIndexedModel,
55
NavigationMenuItemModel,
@@ -10,6 +10,7 @@ import {
1010
import {
1111
NavigationMenuService,
1212
SecurityGroupsService,
13+
NavigationMenuDragDropService,
1314
} from 'src/app/common/services';
1415
import {Subscription, take} from 'rxjs';
1516
import { AutoUnsubscribe } from 'ngx-auto-unsubscribe';
@@ -34,7 +35,7 @@ import {loadAppMenu, selectCurrentUserLocale} from 'src/app/state';
3435
styleUrls: ['./navigation-menu-page.component.scss'],
3536
standalone: false
3637
})
37-
export class NavigationMenuPageComponent implements OnInit, OnDestroy {
38+
export class NavigationMenuPageComponent implements OnInit, OnDestroy, AfterViewInit {
3839
private authStore = inject(Store);
3940
private navigationMenuService = inject(NavigationMenuService);
4041
private securityGroupsService = inject(SecurityGroupsService);
@@ -43,10 +44,12 @@ export class NavigationMenuPageComponent implements OnInit, OnDestroy {
4344
private dialog = inject(MatDialog);
4445
private overlay = inject(Overlay);
4546
private store = inject(Store);
47+
private dragDropService = inject(NavigationMenuDragDropService);
4648

4749

4850
@ViewChild('resetMenuModal')
4951
resetMenuModal: NavigationMenuResetComponent;
52+
@ViewChildren(CdkDropList) dropLists?: QueryList<CdkDropList>;
5053
securityGroups: CommonDictionaryModel[] = [];
5154
navigationMenuModel: NavigationMenuModel = new NavigationMenuModel();
5255

@@ -73,6 +76,20 @@ export class NavigationMenuPageComponent implements OnInit, OnDestroy {
7376
.filter(id => id !== null) as string[];
7477
}
7578

79+
/**
80+
* Predicate function to determine if dropping is allowed
81+
*/
82+
allowDropPredicate = (drag: CdkDrag, drop: CdkDropList): boolean => {
83+
return this.dragDropService.isDropAllowed(drag, drop);
84+
};
85+
86+
/**
87+
* Get all connected drop lists from the service
88+
*/
89+
get connectedLists(): CdkDropList[] {
90+
return this.dragDropService.dropLists;
91+
}
92+
7693
constructor() {
7794
}
7895

@@ -81,6 +98,24 @@ export class NavigationMenuPageComponent implements OnInit, OnDestroy {
8198
this.getSecurityGroups();
8299
}
83100

101+
ngAfterViewInit(): void {
102+
// Register all drop lists with the service after view initialization
103+
if (this.dropLists) {
104+
this.dropLists.forEach(dropList => {
105+
this.dragDropService.register(dropList);
106+
});
107+
108+
// Subscribe to changes in drop lists (for dynamically added dropdowns)
109+
this.dropLists.changes.subscribe(() => {
110+
// Clear and re-register all drop lists when the list changes
111+
this.dragDropService.dropLists = [];
112+
this.dropLists?.forEach(dropList => {
113+
this.dragDropService.register(dropList);
114+
});
115+
});
116+
}
117+
}
118+
84119
getSecurityGroups() {
85120
this.securityGroupsSub$ = this.securityGroupsService
86121
.getSecurityGroupsDictionary()
@@ -246,4 +281,18 @@ export class NavigationMenuPageComponent implements OnInit, OnDestroy {
246281
}).filter(name => name !== '');
247282
}
248283

284+
/**
285+
* Handle drag move events to track hover state
286+
*/
287+
onDragMoved(event: CdkDragMove<any>) {
288+
this.dragDropService.dragMoved(event);
289+
}
290+
291+
/**
292+
* Handle drag release events to clear hover state
293+
*/
294+
onDragReleased(event: CdkDragRelease) {
295+
this.dragDropService.dragReleased(event);
296+
}
297+
249298
}

eform-client/src/app/modules/advanced/modules/navigation-menu/navigation-menu.module.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {MatDialogModule} from '@angular/material/dialog';
2727
import {MatFormFieldModule} from '@angular/material/form-field';
2828
import {MtxSelectModule} from '@ng-matero/extensions/select';
2929
import {MatInputModule} from '@angular/material/input';
30+
import { NavigationMenuDragDropService } from 'src/app/common/services';
3031

3132
@NgModule({
3233
declarations: [
@@ -60,5 +61,8 @@ import {MatInputModule} from '@angular/material/input';
6061
MtxSelectModule,
6162
MatInputModule,
6263
],
64+
providers: [
65+
NavigationMenuDragDropService
66+
],
6367
})
6468
export class NavigationMenuModule {}

0 commit comments

Comments
 (0)