@dragonworks/ngx-dashboard 20.0.5 → 20.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ng-package.json +7 -0
- package/package.json +34 -45
- package/src/lib/__tests__/dashboard-component-widget-state-integration.spec.ts +537 -0
- package/src/lib/cell/__tests__/cell-resize.component.spec.ts +442 -0
- package/src/lib/cell/__tests__/cell.component.spec.ts +541 -0
- package/src/lib/cell/cell-context-menu.component.ts +138 -0
- package/src/lib/cell/cell-context-menu.service.ts +36 -0
- package/src/lib/cell/cell.component.html +37 -0
- package/src/lib/cell/cell.component.scss +198 -0
- package/src/lib/cell/cell.component.ts +375 -0
- package/src/lib/dashboard/dashboard.component.html +18 -0
- package/src/lib/dashboard/dashboard.component.scss +17 -0
- package/src/lib/dashboard/dashboard.component.ts +187 -0
- package/src/lib/dashboard-editor/dashboard-editor.component.html +57 -0
- package/src/lib/dashboard-editor/dashboard-editor.component.scss +87 -0
- package/src/lib/dashboard-editor/dashboard-editor.component.ts +219 -0
- package/src/lib/dashboard-viewer/__tests__/dashboard-viewer.component.spec.ts +258 -0
- package/src/lib/dashboard-viewer/dashboard-viewer.component.html +20 -0
- package/src/lib/dashboard-viewer/dashboard-viewer.component.scss +50 -0
- package/src/lib/dashboard-viewer/dashboard-viewer.component.ts +70 -0
- package/src/lib/drop-zone/__tests__/drop-zone.component.spec.ts +465 -0
- package/src/lib/drop-zone/drop-zone.component.html +20 -0
- package/src/lib/drop-zone/drop-zone.component.scss +67 -0
- package/src/lib/drop-zone/drop-zone.component.ts +122 -0
- package/src/lib/internal-widgets/unknown-widget/unknown-widget.component.ts +72 -0
- package/src/lib/models/cell-data.ts +13 -0
- package/src/lib/models/cell-dialog.ts +7 -0
- package/src/lib/models/cell-id.ts +85 -0
- package/src/lib/models/cell-position.ts +15 -0
- package/src/lib/models/dashboard-data.dto.ts +44 -0
- package/src/lib/models/dashboard-data.utils.ts +49 -0
- package/src/lib/models/drag-data.ts +6 -0
- package/src/lib/models/index.ts +11 -0
- package/src/lib/models/reserved-space.ts +24 -0
- package/src/lib/models/widget-factory.ts +33 -0
- package/src/lib/models/widget-id.ts +70 -0
- package/src/lib/models/widget.ts +21 -0
- package/src/lib/providers/cell-settings-dialog/cell-settings-dialog.component.ts +127 -0
- package/src/lib/providers/cell-settings-dialog/cell-settings-dialog.provider.ts +15 -0
- package/src/lib/providers/cell-settings-dialog/cell-settings-dialog.tokens.ts +20 -0
- package/src/lib/providers/cell-settings-dialog/default-cell-settings-dialog.provider.ts +32 -0
- package/src/lib/providers/cell-settings-dialog/index.ts +3 -0
- package/src/lib/providers/index.ts +1 -0
- package/src/lib/services/__tests__/dashboard-bridge.service.spec.ts +220 -0
- package/src/lib/services/__tests__/dashboard-viewport.service.spec.ts +362 -0
- package/src/lib/services/dashboard-bridge.service.ts +155 -0
- package/src/lib/services/dashboard-viewport.service.ts +148 -0
- package/src/lib/services/dashboard.service.ts +62 -0
- package/src/lib/store/__tests__/dashboard-store-collision-detection.spec.ts +756 -0
- package/src/lib/store/__tests__/dashboard-store-computed-properties.spec.ts +974 -0
- package/src/lib/store/__tests__/dashboard-store-drag-drop.spec.ts +279 -0
- package/src/lib/store/__tests__/dashboard-store-export-import.spec.ts +780 -0
- package/src/lib/store/__tests__/dashboard-store-grid-config.spec.ts +128 -0
- package/src/lib/store/__tests__/dashboard-store-query-methods.spec.ts +229 -0
- package/src/lib/store/__tests__/dashboard-store-resize-operations.spec.ts +652 -0
- package/src/lib/store/__tests__/dashboard-store-widget-management.spec.ts +461 -0
- package/src/lib/store/__tests__/dashboard-store-widget-state-preservation.spec.ts +369 -0
- package/src/lib/store/dashboard-store.ts +239 -0
- package/src/lib/store/features/drag-drop.feature.ts +140 -0
- package/src/lib/store/features/grid-config.feature.ts +43 -0
- package/src/lib/store/features/resize.feature.ts +140 -0
- package/src/lib/store/features/utils/collision.utils.ts +89 -0
- package/src/lib/store/features/utils/grid-query-internal.utils.ts +37 -0
- package/src/lib/store/features/utils/resize.utils.ts +165 -0
- package/src/lib/store/features/widget-management.feature.ts +158 -0
- package/src/lib/styles/_dashboard-grid-vars.scss +11 -0
- package/src/lib/widget-list/__tests__/widget-list-bridge-integration.spec.ts +137 -0
- package/src/lib/widget-list/widget-list.component.html +22 -0
- package/src/lib/widget-list/widget-list.component.scss +154 -0
- package/src/lib/widget-list/widget-list.component.ts +106 -0
- package/src/public-api.ts +21 -0
- package/src/test-setup.ts +10 -0
- package/tsconfig.lib.json +15 -0
- package/tsconfig.lib.prod.json +11 -0
- package/tsconfig.spec.json +14 -0
- package/fesm2022/dragonworks-ngx-dashboard.mjs +0 -2178
- package/fesm2022/dragonworks-ngx-dashboard.mjs.map +0 -1
- package/index.d.ts +0 -678
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
|
2
|
+
import { ViewContainerRef, Renderer2 } from '@angular/core';
|
|
3
|
+
import { CellComponent } from '../cell.component';
|
|
4
|
+
import { DashboardStore } from '../../store/dashboard-store';
|
|
5
|
+
import { DashboardService } from '../../services/dashboard.service';
|
|
6
|
+
import { CellContextMenuService } from '../cell-context-menu.service';
|
|
7
|
+
import { CELL_SETTINGS_DIALOG_PROVIDER } from '../../providers/cell-settings-dialog';
|
|
8
|
+
import {
|
|
9
|
+
CellId,
|
|
10
|
+
CellIdUtils,
|
|
11
|
+
WidgetId,
|
|
12
|
+
WidgetIdUtils,
|
|
13
|
+
WidgetFactory,
|
|
14
|
+
Widget,
|
|
15
|
+
} from '../../models';
|
|
16
|
+
import { Component, signal } from '@angular/core';
|
|
17
|
+
|
|
18
|
+
// Mock test widget component
|
|
19
|
+
@Component({
|
|
20
|
+
selector: 'lib-test-widget',
|
|
21
|
+
template: '<div>Test Widget</div>',
|
|
22
|
+
standalone: true,
|
|
23
|
+
})
|
|
24
|
+
class TestWidgetComponent implements Widget {
|
|
25
|
+
private state = signal<any>({ value: 'test' });
|
|
26
|
+
|
|
27
|
+
dashboardGetState(): any {
|
|
28
|
+
return this.state();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
dashboardSetState(state: any): void {
|
|
32
|
+
this.state.set(state);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
dashboardEditState(): void {
|
|
36
|
+
// Mock edit state method
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe('CellComponent - User Scenarios', () => {
|
|
41
|
+
let component: CellComponent;
|
|
42
|
+
let fixture: ComponentFixture<CellComponent>;
|
|
43
|
+
let store: InstanceType<typeof DashboardStore>;
|
|
44
|
+
let mockDashboardService: jasmine.SpyObj<DashboardService>;
|
|
45
|
+
let mockContextMenuService: jasmine.SpyObj<CellContextMenuService>;
|
|
46
|
+
let mockDialogProvider: jasmine.SpyObj<any>;
|
|
47
|
+
let mockRenderer: jasmine.SpyObj<Renderer2>;
|
|
48
|
+
|
|
49
|
+
const mockCellId: CellId = CellIdUtils.create(1, 1);
|
|
50
|
+
const mockWidgetId: WidgetId = WidgetIdUtils.generate();
|
|
51
|
+
|
|
52
|
+
const mockWidgetFactory: WidgetFactory = {
|
|
53
|
+
widgetTypeid: 'test-widget',
|
|
54
|
+
name: 'Test Widget',
|
|
55
|
+
description: 'A test widget',
|
|
56
|
+
svgIcon: '<svg><rect width="10" height="10"/></svg>',
|
|
57
|
+
createInstance: jasmine
|
|
58
|
+
.createSpy('createInstance')
|
|
59
|
+
.and.callFake((container: ViewContainerRef, state?: unknown) => {
|
|
60
|
+
const componentRef = container.createComponent(TestWidgetComponent);
|
|
61
|
+
if (state) {
|
|
62
|
+
componentRef.instance.dashboardSetState(state);
|
|
63
|
+
}
|
|
64
|
+
return componentRef;
|
|
65
|
+
}),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
beforeEach(async () => {
|
|
69
|
+
mockDashboardService = jasmine.createSpyObj('DashboardService', ['getFactory']);
|
|
70
|
+
mockContextMenuService = jasmine.createSpyObj('CellContextMenuService', ['show']);
|
|
71
|
+
mockDialogProvider = jasmine.createSpyObj('CellSettingsDialogProvider', ['openCellSettings']);
|
|
72
|
+
mockRenderer = jasmine.createSpyObj('Renderer2', ['listen']);
|
|
73
|
+
|
|
74
|
+
await TestBed.configureTestingModule({
|
|
75
|
+
imports: [CellComponent, TestWidgetComponent],
|
|
76
|
+
providers: [
|
|
77
|
+
DashboardStore,
|
|
78
|
+
{ provide: DashboardService, useValue: mockDashboardService },
|
|
79
|
+
{ provide: CellContextMenuService, useValue: mockContextMenuService },
|
|
80
|
+
{ provide: CELL_SETTINGS_DIALOG_PROVIDER, useValue: mockDialogProvider },
|
|
81
|
+
{ provide: Renderer2, useValue: mockRenderer },
|
|
82
|
+
],
|
|
83
|
+
}).compileComponents();
|
|
84
|
+
|
|
85
|
+
store = TestBed.inject(DashboardStore);
|
|
86
|
+
fixture = TestBed.createComponent(CellComponent);
|
|
87
|
+
component = fixture.componentInstance;
|
|
88
|
+
|
|
89
|
+
mockRenderer.listen.and.returnValue(() => {});
|
|
90
|
+
mockDashboardService.getFactory.and.returnValue(mockWidgetFactory);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
describe('Component Creation', () => {
|
|
94
|
+
it('should create and initialize with required inputs', () => {
|
|
95
|
+
fixture.componentRef.setInput('widgetId', mockWidgetId);
|
|
96
|
+
fixture.componentRef.setInput('cellId', mockCellId);
|
|
97
|
+
fixture.componentRef.setInput('row', 1);
|
|
98
|
+
fixture.componentRef.setInput('column', 1);
|
|
99
|
+
fixture.detectChanges();
|
|
100
|
+
|
|
101
|
+
expect(component).toBeTruthy();
|
|
102
|
+
expect(component.cellId()).toEqual(mockCellId);
|
|
103
|
+
expect(component.row()).toBe(1);
|
|
104
|
+
expect(component.column()).toBe(1);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('Widget Creation Workflow', () => {
|
|
109
|
+
beforeEach(() => {
|
|
110
|
+
fixture.componentRef.setInput('widgetId', mockWidgetId);
|
|
111
|
+
fixture.componentRef.setInput('cellId', mockCellId);
|
|
112
|
+
fixture.componentRef.setInput('row', 1);
|
|
113
|
+
fixture.componentRef.setInput('column', 1);
|
|
114
|
+
fixture.detectChanges();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('should create widget when user adds widget to cell', async () => {
|
|
118
|
+
const mockState = { value: 'test-state' };
|
|
119
|
+
|
|
120
|
+
// User adds widget to cell
|
|
121
|
+
fixture.componentRef.setInput('widgetFactory', mockWidgetFactory);
|
|
122
|
+
fixture.componentRef.setInput('widgetState', mockState);
|
|
123
|
+
fixture.detectChanges();
|
|
124
|
+
await fixture.whenStable();
|
|
125
|
+
|
|
126
|
+
// Widget should be created with provided state
|
|
127
|
+
expect(mockWidgetFactory.createInstance).toHaveBeenCalledWith(
|
|
128
|
+
jasmine.any(ViewContainerRef),
|
|
129
|
+
mockState
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('should handle widget creation failure gracefully', async () => {
|
|
134
|
+
const failingFactory = {
|
|
135
|
+
...mockWidgetFactory,
|
|
136
|
+
createInstance: jasmine.createSpy('createInstance').and.throwError('Creation failed'),
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
spyOn(console, 'error');
|
|
140
|
+
|
|
141
|
+
// User attempts to add failing widget
|
|
142
|
+
fixture.componentRef.setInput('widgetFactory', failingFactory);
|
|
143
|
+
fixture.detectChanges();
|
|
144
|
+
await fixture.whenStable();
|
|
145
|
+
|
|
146
|
+
// Should not throw error to user
|
|
147
|
+
expect(failingFactory.createInstance).toHaveBeenCalled();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('Widget Deletion Workflow', () => {
|
|
152
|
+
beforeEach(() => {
|
|
153
|
+
fixture.componentRef.setInput('widgetId', mockWidgetId);
|
|
154
|
+
fixture.componentRef.setInput('cellId', mockCellId);
|
|
155
|
+
fixture.componentRef.setInput('row', 1);
|
|
156
|
+
fixture.componentRef.setInput('column', 1);
|
|
157
|
+
fixture.detectChanges();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should emit delete event when user deletes widget', () => {
|
|
161
|
+
spyOn(component.delete, 'emit');
|
|
162
|
+
|
|
163
|
+
// User deletes widget
|
|
164
|
+
component.onDelete();
|
|
165
|
+
|
|
166
|
+
// Delete event should be emitted
|
|
167
|
+
expect(component.delete.emit).toHaveBeenCalledWith(mockWidgetId);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
describe('Widget Edit Workflow', () => {
|
|
172
|
+
beforeEach(() => {
|
|
173
|
+
fixture.componentRef.setInput('widgetId', mockWidgetId);
|
|
174
|
+
fixture.componentRef.setInput('cellId', mockCellId);
|
|
175
|
+
fixture.componentRef.setInput('row', 1);
|
|
176
|
+
fixture.componentRef.setInput('column', 1);
|
|
177
|
+
fixture.componentRef.setInput('widgetFactory', mockWidgetFactory);
|
|
178
|
+
fixture.detectChanges();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should emit edit event when user edits widget', async () => {
|
|
182
|
+
spyOn(component.edit, 'emit');
|
|
183
|
+
await fixture.whenStable();
|
|
184
|
+
|
|
185
|
+
// User edits widget
|
|
186
|
+
component.onEdit();
|
|
187
|
+
|
|
188
|
+
// Edit event should be emitted
|
|
189
|
+
expect(component.edit.emit).toHaveBeenCalledWith(mockWidgetId);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should report if widget can be edited', async () => {
|
|
193
|
+
await fixture.whenStable();
|
|
194
|
+
expect(component.canEdit()).toBe(true);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should report false for widgets without edit capability', async () => {
|
|
198
|
+
const factoryWithoutEdit = {
|
|
199
|
+
...mockWidgetFactory,
|
|
200
|
+
createInstance: jasmine.createSpy('createInstance').and.returnValue({
|
|
201
|
+
destroy: jasmine.createSpy('destroy'),
|
|
202
|
+
}),
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
fixture.componentRef.setInput('widgetFactory', factoryWithoutEdit);
|
|
206
|
+
fixture.detectChanges();
|
|
207
|
+
await fixture.whenStable();
|
|
208
|
+
|
|
209
|
+
expect(component.canEdit()).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('Settings Dialog Workflow', () => {
|
|
214
|
+
beforeEach(() => {
|
|
215
|
+
fixture.componentRef.setInput('widgetId', mockWidgetId);
|
|
216
|
+
fixture.componentRef.setInput('cellId', mockCellId);
|
|
217
|
+
fixture.componentRef.setInput('row', 1);
|
|
218
|
+
fixture.componentRef.setInput('column', 1);
|
|
219
|
+
fixture.componentRef.setInput('flat', true);
|
|
220
|
+
fixture.detectChanges();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('should open settings dialog and emit changes when user saves', async () => {
|
|
224
|
+
spyOn(component.settings, 'emit');
|
|
225
|
+
mockDialogProvider.openCellSettings.and.returnValue(
|
|
226
|
+
Promise.resolve({ id: CellIdUtils.toString(mockCellId), flat: false })
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
// User opens settings
|
|
230
|
+
await component.onSettings();
|
|
231
|
+
|
|
232
|
+
// Dialog should open with current values
|
|
233
|
+
expect(mockDialogProvider.openCellSettings).toHaveBeenCalledWith({
|
|
234
|
+
id: CellIdUtils.toString(mockCellId),
|
|
235
|
+
flat: true,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Settings event should be emitted with new values
|
|
239
|
+
expect(component.settings.emit).toHaveBeenCalledWith({
|
|
240
|
+
id: mockWidgetId,
|
|
241
|
+
flat: false,
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('should handle user canceling settings dialog', async () => {
|
|
246
|
+
spyOn(component.settings, 'emit');
|
|
247
|
+
mockDialogProvider.openCellSettings.and.returnValue(Promise.resolve(null));
|
|
248
|
+
|
|
249
|
+
// User cancels settings
|
|
250
|
+
await component.onSettings();
|
|
251
|
+
|
|
252
|
+
// No settings event should be emitted
|
|
253
|
+
expect(component.settings.emit).not.toHaveBeenCalled();
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
describe('Drag and Drop Workflow', () => {
|
|
258
|
+
let mockDragEvent: any;
|
|
259
|
+
|
|
260
|
+
beforeEach(() => {
|
|
261
|
+
fixture.componentRef.setInput('widgetId', mockWidgetId);
|
|
262
|
+
fixture.componentRef.setInput('cellId', mockCellId);
|
|
263
|
+
fixture.componentRef.setInput('row', 2);
|
|
264
|
+
fixture.componentRef.setInput('column', 3);
|
|
265
|
+
fixture.componentRef.setInput('rowSpan', 2);
|
|
266
|
+
fixture.componentRef.setInput('colSpan', 3);
|
|
267
|
+
fixture.detectChanges();
|
|
268
|
+
|
|
269
|
+
mockDragEvent = {
|
|
270
|
+
dataTransfer: {
|
|
271
|
+
effectAllowed: 'move' as const,
|
|
272
|
+
setDragImage: jasmine.createSpy('setDragImage'),
|
|
273
|
+
} as Partial<DataTransfer>,
|
|
274
|
+
} as Partial<DragEvent>;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
it('should complete drag and drop workflow', () => {
|
|
278
|
+
spyOn(component.dragStart, 'emit');
|
|
279
|
+
spyOn(component.dragEnd, 'emit');
|
|
280
|
+
|
|
281
|
+
// User starts drag
|
|
282
|
+
component.onDragStart(mockDragEvent as DragEvent);
|
|
283
|
+
|
|
284
|
+
// Drag should start with correct data
|
|
285
|
+
expect(component.isDragging()).toBe(true);
|
|
286
|
+
expect(component.dragStart.emit).toHaveBeenCalledWith({
|
|
287
|
+
kind: 'cell',
|
|
288
|
+
content: {
|
|
289
|
+
cellId: mockCellId,
|
|
290
|
+
widgetId: mockWidgetId,
|
|
291
|
+
row: 2,
|
|
292
|
+
col: 3,
|
|
293
|
+
rowSpan: 2,
|
|
294
|
+
colSpan: 3,
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// User ends drag
|
|
299
|
+
component.onDragEnd();
|
|
300
|
+
|
|
301
|
+
// Drag should end properly
|
|
302
|
+
expect(component.isDragging()).toBe(false);
|
|
303
|
+
expect(component.dragEnd.emit).toHaveBeenCalled();
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it('should handle invalid drag event', () => {
|
|
307
|
+
spyOn(component.dragStart, 'emit');
|
|
308
|
+
const invalidEvent = { dataTransfer: null };
|
|
309
|
+
|
|
310
|
+
// User attempts drag with invalid event
|
|
311
|
+
component.onDragStart(invalidEvent as DragEvent);
|
|
312
|
+
|
|
313
|
+
// Drag should not start
|
|
314
|
+
expect(component.dragStart.emit).not.toHaveBeenCalled();
|
|
315
|
+
expect(component.isDragging()).toBe(false);
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe('Context Menu Workflow', () => {
|
|
320
|
+
let mockMouseEvent: any;
|
|
321
|
+
|
|
322
|
+
beforeEach(() => {
|
|
323
|
+
fixture.componentRef.setInput('widgetId', mockWidgetId);
|
|
324
|
+
fixture.componentRef.setInput('cellId', mockCellId);
|
|
325
|
+
fixture.componentRef.setInput('row', 1);
|
|
326
|
+
fixture.componentRef.setInput('column', 1);
|
|
327
|
+
fixture.componentRef.setInput('isEditMode', true);
|
|
328
|
+
fixture.detectChanges();
|
|
329
|
+
|
|
330
|
+
mockMouseEvent = {
|
|
331
|
+
clientX: 100,
|
|
332
|
+
clientY: 200,
|
|
333
|
+
preventDefault: jasmine.createSpy('preventDefault'),
|
|
334
|
+
stopPropagation: jasmine.createSpy('stopPropagation'),
|
|
335
|
+
} as Partial<MouseEvent>;
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('should show context menu when user right-clicks in edit mode', () => {
|
|
339
|
+
// User right-clicks cell
|
|
340
|
+
component.onContextMenu(mockMouseEvent as MouseEvent);
|
|
341
|
+
|
|
342
|
+
// Context menu should appear with correct options
|
|
343
|
+
expect(mockMouseEvent.preventDefault).toHaveBeenCalled();
|
|
344
|
+
expect(mockMouseEvent.stopPropagation).toHaveBeenCalled();
|
|
345
|
+
expect(mockContextMenuService.show).toHaveBeenCalledWith(
|
|
346
|
+
100,
|
|
347
|
+
200,
|
|
348
|
+
jasmine.any(Array)
|
|
349
|
+
);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('should not show context menu when not in edit mode', () => {
|
|
353
|
+
fixture.componentRef.setInput('isEditMode', false);
|
|
354
|
+
fixture.detectChanges();
|
|
355
|
+
|
|
356
|
+
// User right-clicks cell in view mode
|
|
357
|
+
component.onContextMenu(mockMouseEvent as MouseEvent);
|
|
358
|
+
|
|
359
|
+
// No context menu should appear
|
|
360
|
+
expect(mockContextMenuService.show).not.toHaveBeenCalled();
|
|
361
|
+
});
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
describe('Position Management', () => {
|
|
365
|
+
beforeEach(() => {
|
|
366
|
+
fixture.componentRef.setInput('widgetId', mockWidgetId);
|
|
367
|
+
fixture.componentRef.setInput('cellId', mockCellId);
|
|
368
|
+
fixture.componentRef.setInput('row', 1);
|
|
369
|
+
fixture.componentRef.setInput('column', 1);
|
|
370
|
+
fixture.detectChanges();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it('should update position when programmatically set', () => {
|
|
374
|
+
// Position is updated programmatically
|
|
375
|
+
component.setPosition(5, 3);
|
|
376
|
+
|
|
377
|
+
// Position should be updated
|
|
378
|
+
expect(component.row()).toBe(5);
|
|
379
|
+
expect(component.column()).toBe(3);
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
describe('Resize Integration', () => {
|
|
384
|
+
beforeEach(() => {
|
|
385
|
+
fixture.componentRef.setInput('widgetId', mockWidgetId);
|
|
386
|
+
fixture.componentRef.setInput('cellId', mockCellId);
|
|
387
|
+
fixture.componentRef.setInput('row', 1);
|
|
388
|
+
fixture.componentRef.setInput('column', 1);
|
|
389
|
+
fixture.detectChanges();
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it('should respond to resize state changes from store', async () => {
|
|
393
|
+
// Add cell to store first
|
|
394
|
+
store.addWidget({
|
|
395
|
+
widgetId: mockWidgetId,
|
|
396
|
+
cellId: mockCellId,
|
|
397
|
+
row: 1,
|
|
398
|
+
col: 1,
|
|
399
|
+
rowSpan: 1,
|
|
400
|
+
colSpan: 1,
|
|
401
|
+
widgetFactory: mockWidgetFactory,
|
|
402
|
+
widgetState: {},
|
|
403
|
+
flat: false
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// Initially not resizing
|
|
407
|
+
expect(component.isResizing()).toBe(false);
|
|
408
|
+
|
|
409
|
+
// Store starts resize
|
|
410
|
+
store.startResize(mockCellId);
|
|
411
|
+
expect(component.isResizing()).toBe(true);
|
|
412
|
+
|
|
413
|
+
// Store ends resize
|
|
414
|
+
store.endResize(false);
|
|
415
|
+
expect(component.isResizing()).toBe(false);
|
|
416
|
+
});
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
describe('Widget State Management', () => {
|
|
420
|
+
beforeEach(() => {
|
|
421
|
+
fixture.componentRef.setInput('widgetId', mockWidgetId);
|
|
422
|
+
fixture.componentRef.setInput('cellId', mockCellId);
|
|
423
|
+
fixture.componentRef.setInput('row', 1);
|
|
424
|
+
fixture.componentRef.setInput('column', 1);
|
|
425
|
+
fixture.detectChanges();
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
it('should get current widget state', async () => {
|
|
429
|
+
const mockState = { value: 'current-state' };
|
|
430
|
+
fixture.componentRef.setInput('widgetFactory', mockWidgetFactory);
|
|
431
|
+
fixture.componentRef.setInput('widgetState', mockState);
|
|
432
|
+
fixture.detectChanges();
|
|
433
|
+
await fixture.whenStable();
|
|
434
|
+
|
|
435
|
+
const result = component.getCurrentWidgetState();
|
|
436
|
+
expect(result).toEqual({ value: 'current-state' });
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it('should return undefined when no widget exists', () => {
|
|
440
|
+
const result = component.getCurrentWidgetState();
|
|
441
|
+
expect(result).toBeUndefined();
|
|
442
|
+
});
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
describe('Error Handling', () => {
|
|
446
|
+
beforeEach(() => {
|
|
447
|
+
fixture.componentRef.setInput('widgetId', mockWidgetId);
|
|
448
|
+
fixture.componentRef.setInput('cellId', mockCellId);
|
|
449
|
+
fixture.componentRef.setInput('row', 1);
|
|
450
|
+
fixture.componentRef.setInput('column', 1);
|
|
451
|
+
fixture.detectChanges();
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it('should handle settings dialog errors gracefully', async () => {
|
|
455
|
+
spyOn(console, 'error');
|
|
456
|
+
mockDialogProvider.openCellSettings.and.returnValue(
|
|
457
|
+
Promise.reject(new Error('Dialog error'))
|
|
458
|
+
);
|
|
459
|
+
|
|
460
|
+
// User attempts to open settings but error occurs
|
|
461
|
+
await component.onSettings();
|
|
462
|
+
|
|
463
|
+
// Error should be logged, not thrown
|
|
464
|
+
expect(console.error).toHaveBeenCalledWith(
|
|
465
|
+
'Error opening cell settings dialog:',
|
|
466
|
+
jasmine.any(Error)
|
|
467
|
+
);
|
|
468
|
+
});
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
// Test suite for testing without CellContextMenuService
|
|
476
|
+
describe('CellComponent - Without Context Menu Service', () => {
|
|
477
|
+
let component: CellComponent;
|
|
478
|
+
let fixture: ComponentFixture<CellComponent>;
|
|
479
|
+
let mockDashboardService: jasmine.SpyObj<DashboardService>;
|
|
480
|
+
let mockDialogProvider: jasmine.SpyObj<any>;
|
|
481
|
+
let mockRenderer: jasmine.SpyObj<Renderer2>;
|
|
482
|
+
|
|
483
|
+
const mockCellId: CellId = CellIdUtils.create(1, 1);
|
|
484
|
+
const mockWidgetId: WidgetId = WidgetIdUtils.generate();
|
|
485
|
+
const mockWidgetFactory: WidgetFactory = {
|
|
486
|
+
widgetTypeid: 'test-widget',
|
|
487
|
+
name: 'Test Widget',
|
|
488
|
+
description: 'A test widget',
|
|
489
|
+
svgIcon: '<svg><rect width="10" height="10"/></svg>',
|
|
490
|
+
createInstance: jasmine.createSpy('createInstance').and.returnValue({
|
|
491
|
+
destroy: jasmine.createSpy('destroy'),
|
|
492
|
+
}),
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
beforeEach(async () => {
|
|
496
|
+
mockDashboardService = jasmine.createSpyObj('DashboardService', ['getFactory']);
|
|
497
|
+
mockDialogProvider = jasmine.createSpyObj('CellSettingsDialogProvider', ['openCellSettings']);
|
|
498
|
+
mockRenderer = jasmine.createSpyObj('Renderer2', ['listen']);
|
|
499
|
+
|
|
500
|
+
// Configure TestBed WITHOUT CellContextMenuService
|
|
501
|
+
await TestBed.configureTestingModule({
|
|
502
|
+
imports: [CellComponent, TestWidgetComponent],
|
|
503
|
+
providers: [
|
|
504
|
+
DashboardStore,
|
|
505
|
+
{ provide: DashboardService, useValue: mockDashboardService },
|
|
506
|
+
// Notice: CellContextMenuService is NOT provided
|
|
507
|
+
{ provide: CELL_SETTINGS_DIALOG_PROVIDER, useValue: mockDialogProvider },
|
|
508
|
+
{ provide: Renderer2, useValue: mockRenderer },
|
|
509
|
+
],
|
|
510
|
+
}).compileComponents();
|
|
511
|
+
|
|
512
|
+
fixture = TestBed.createComponent(CellComponent);
|
|
513
|
+
component = fixture.componentInstance;
|
|
514
|
+
|
|
515
|
+
mockRenderer.listen.and.returnValue(() => {});
|
|
516
|
+
mockDashboardService.getFactory.and.returnValue(mockWidgetFactory);
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
it('should handle missing context menu service gracefully', () => {
|
|
520
|
+
fixture.componentRef.setInput('widgetId', mockWidgetId);
|
|
521
|
+
fixture.componentRef.setInput('cellId', mockCellId);
|
|
522
|
+
fixture.componentRef.setInput('row', 1);
|
|
523
|
+
fixture.componentRef.setInput('column', 1);
|
|
524
|
+
fixture.componentRef.setInput('isEditMode', true);
|
|
525
|
+
fixture.detectChanges();
|
|
526
|
+
|
|
527
|
+
const mockMouseEvent = {
|
|
528
|
+
clientX: 100,
|
|
529
|
+
clientY: 200,
|
|
530
|
+
preventDefault: jasmine.createSpy('preventDefault'),
|
|
531
|
+
stopPropagation: jasmine.createSpy('stopPropagation'),
|
|
532
|
+
} as Partial<MouseEvent>;
|
|
533
|
+
|
|
534
|
+
// User right-clicks when service is not available
|
|
535
|
+
component.onContextMenu(mockMouseEvent as MouseEvent);
|
|
536
|
+
|
|
537
|
+
// Should not process the event
|
|
538
|
+
expect(mockMouseEvent.preventDefault).not.toHaveBeenCalled();
|
|
539
|
+
expect(mockMouseEvent.stopPropagation).not.toHaveBeenCalled();
|
|
540
|
+
});
|
|
541
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { Component, viewChild, inject, ChangeDetectionStrategy, effect, computed, ViewContainerRef, TemplateRef, AfterViewInit } from '@angular/core';
|
|
2
|
+
import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu';
|
|
3
|
+
import { MatIconModule } from '@angular/material/icon';
|
|
4
|
+
import { MatDividerModule } from '@angular/material/divider';
|
|
5
|
+
import { MatButtonModule } from '@angular/material/button';
|
|
6
|
+
import {
|
|
7
|
+
CellContextMenuService,
|
|
8
|
+
CellContextMenuItem,
|
|
9
|
+
} from './cell-context-menu.service';
|
|
10
|
+
|
|
11
|
+
@Component({
|
|
12
|
+
selector: 'lib-cell-context-menu',
|
|
13
|
+
standalone: true,
|
|
14
|
+
imports: [MatMenuModule, MatIconModule, MatDividerModule, MatButtonModule],
|
|
15
|
+
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
16
|
+
template: `
|
|
17
|
+
<!-- Hidden trigger for menu positioned at exact mouse coordinates
|
|
18
|
+
|
|
19
|
+
IMPORTANT: Angular Material applies its own positioning logic to menus,
|
|
20
|
+
which by default offsets the menu from the trigger element to avoid overlap.
|
|
21
|
+
To achieve precise positioning at mouse coordinates, we use these workarounds:
|
|
22
|
+
|
|
23
|
+
1. The trigger container is 1x1px (not 0x0) because Material needs a physical
|
|
24
|
+
element to calculate position against. Zero-sized elements cause unpredictable
|
|
25
|
+
positioning.
|
|
26
|
+
|
|
27
|
+
2. We use opacity:0 instead of visibility:hidden to keep the element in the
|
|
28
|
+
layout flow while making it invisible.
|
|
29
|
+
|
|
30
|
+
3. The button itself is styled to 1x1px with no padding to serve as a precise
|
|
31
|
+
anchor point for the menu.
|
|
32
|
+
|
|
33
|
+
4. The mat-menu uses [overlapTrigger]="true" to allow the menu to appear
|
|
34
|
+
directly at the trigger position rather than offset from it.
|
|
35
|
+
|
|
36
|
+
This approach ensures the menu appears at the exact mouse coordinates passed
|
|
37
|
+
from the cell component's right-click handler.
|
|
38
|
+
-->
|
|
39
|
+
<div
|
|
40
|
+
style="position: fixed; width: 1px; height: 1px; opacity: 0; pointer-events: none;"
|
|
41
|
+
[style]="menuPosition()">
|
|
42
|
+
<button
|
|
43
|
+
mat-button
|
|
44
|
+
#menuTrigger="matMenuTrigger"
|
|
45
|
+
[matMenuTriggerFor]="contextMenu"
|
|
46
|
+
style="width: 1px; height: 1px; padding: 0; min-width: 0; line-height: 0;">
|
|
47
|
+
</button>
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
<!-- Context menu -->
|
|
51
|
+
<mat-menu #contextMenu="matMenu" [overlapTrigger]="true">
|
|
52
|
+
@for (item of menuItems(); track $index) {
|
|
53
|
+
@if (item.divider) {
|
|
54
|
+
<mat-divider></mat-divider>
|
|
55
|
+
} @else {
|
|
56
|
+
<button
|
|
57
|
+
mat-menu-item
|
|
58
|
+
(click)="executeAction(item)"
|
|
59
|
+
[disabled]="item.disabled">
|
|
60
|
+
@if (item.icon) {
|
|
61
|
+
<mat-icon>{{ item.icon }}</mat-icon>
|
|
62
|
+
}
|
|
63
|
+
<span>{{ item.label }}</span>
|
|
64
|
+
</button>
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
</mat-menu>
|
|
68
|
+
`,
|
|
69
|
+
styles: [`
|
|
70
|
+
:host {
|
|
71
|
+
display: contents;
|
|
72
|
+
}
|
|
73
|
+
`]
|
|
74
|
+
})
|
|
75
|
+
export class CellContextMenuComponent implements AfterViewInit {
|
|
76
|
+
menuTrigger = viewChild.required('menuTrigger', { read: MatMenuTrigger });
|
|
77
|
+
|
|
78
|
+
menuService = inject(CellContextMenuService);
|
|
79
|
+
|
|
80
|
+
menuPosition = computed(() => {
|
|
81
|
+
const menu = this.menuService.activeMenu();
|
|
82
|
+
return menu ? { left: `${menu.x}px`, top: `${menu.y}px` } : { left: '0px', top: '0px' };
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
menuItems = computed(() => {
|
|
86
|
+
const menu = this.menuService.activeMenu();
|
|
87
|
+
return menu?.items || [];
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
constructor() {
|
|
91
|
+
effect(() => {
|
|
92
|
+
const menu = this.menuService.activeMenu();
|
|
93
|
+
if (menu) {
|
|
94
|
+
// Use queueMicrotask to ensure the view is fully initialized
|
|
95
|
+
// This fixes the issue where the menu disappears on first right-click
|
|
96
|
+
queueMicrotask(() => {
|
|
97
|
+
const trigger = this.menuTrigger();
|
|
98
|
+
if (trigger) {
|
|
99
|
+
// Close any existing menu first
|
|
100
|
+
if (trigger.menuOpen) {
|
|
101
|
+
trigger.closeMenu();
|
|
102
|
+
}
|
|
103
|
+
// Open menu - position is handled by signal
|
|
104
|
+
trigger.openMenu();
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
} else {
|
|
108
|
+
const trigger = this.menuTrigger();
|
|
109
|
+
if (trigger) {
|
|
110
|
+
trigger.closeMenu();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// Hide service menu when Material menu closes
|
|
116
|
+
effect(() => {
|
|
117
|
+
const trigger = this.menuTrigger();
|
|
118
|
+
if (trigger) {
|
|
119
|
+
trigger.menuClosed.subscribe(() => {
|
|
120
|
+
if (this.menuService.activeMenu()) {
|
|
121
|
+
this.menuService.hide();
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
ngAfterViewInit(): void {
|
|
129
|
+
// Effects moved to constructor to be within injection context
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
executeAction(item: CellContextMenuItem) {
|
|
133
|
+
if (!item.divider && item.action) {
|
|
134
|
+
item.action();
|
|
135
|
+
this.menuService.hide();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|