@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.
Files changed (78) hide show
  1. package/ng-package.json +7 -0
  2. package/package.json +34 -45
  3. package/src/lib/__tests__/dashboard-component-widget-state-integration.spec.ts +537 -0
  4. package/src/lib/cell/__tests__/cell-resize.component.spec.ts +442 -0
  5. package/src/lib/cell/__tests__/cell.component.spec.ts +541 -0
  6. package/src/lib/cell/cell-context-menu.component.ts +138 -0
  7. package/src/lib/cell/cell-context-menu.service.ts +36 -0
  8. package/src/lib/cell/cell.component.html +37 -0
  9. package/src/lib/cell/cell.component.scss +198 -0
  10. package/src/lib/cell/cell.component.ts +375 -0
  11. package/src/lib/dashboard/dashboard.component.html +18 -0
  12. package/src/lib/dashboard/dashboard.component.scss +17 -0
  13. package/src/lib/dashboard/dashboard.component.ts +187 -0
  14. package/src/lib/dashboard-editor/dashboard-editor.component.html +57 -0
  15. package/src/lib/dashboard-editor/dashboard-editor.component.scss +87 -0
  16. package/src/lib/dashboard-editor/dashboard-editor.component.ts +219 -0
  17. package/src/lib/dashboard-viewer/__tests__/dashboard-viewer.component.spec.ts +258 -0
  18. package/src/lib/dashboard-viewer/dashboard-viewer.component.html +20 -0
  19. package/src/lib/dashboard-viewer/dashboard-viewer.component.scss +50 -0
  20. package/src/lib/dashboard-viewer/dashboard-viewer.component.ts +70 -0
  21. package/src/lib/drop-zone/__tests__/drop-zone.component.spec.ts +465 -0
  22. package/src/lib/drop-zone/drop-zone.component.html +20 -0
  23. package/src/lib/drop-zone/drop-zone.component.scss +67 -0
  24. package/src/lib/drop-zone/drop-zone.component.ts +122 -0
  25. package/src/lib/internal-widgets/unknown-widget/unknown-widget.component.ts +72 -0
  26. package/src/lib/models/cell-data.ts +13 -0
  27. package/src/lib/models/cell-dialog.ts +7 -0
  28. package/src/lib/models/cell-id.ts +85 -0
  29. package/src/lib/models/cell-position.ts +15 -0
  30. package/src/lib/models/dashboard-data.dto.ts +44 -0
  31. package/src/lib/models/dashboard-data.utils.ts +49 -0
  32. package/src/lib/models/drag-data.ts +6 -0
  33. package/src/lib/models/index.ts +11 -0
  34. package/src/lib/models/reserved-space.ts +24 -0
  35. package/src/lib/models/widget-factory.ts +33 -0
  36. package/src/lib/models/widget-id.ts +70 -0
  37. package/src/lib/models/widget.ts +21 -0
  38. package/src/lib/providers/cell-settings-dialog/cell-settings-dialog.component.ts +127 -0
  39. package/src/lib/providers/cell-settings-dialog/cell-settings-dialog.provider.ts +15 -0
  40. package/src/lib/providers/cell-settings-dialog/cell-settings-dialog.tokens.ts +20 -0
  41. package/src/lib/providers/cell-settings-dialog/default-cell-settings-dialog.provider.ts +32 -0
  42. package/src/lib/providers/cell-settings-dialog/index.ts +3 -0
  43. package/src/lib/providers/index.ts +1 -0
  44. package/src/lib/services/__tests__/dashboard-bridge.service.spec.ts +220 -0
  45. package/src/lib/services/__tests__/dashboard-viewport.service.spec.ts +362 -0
  46. package/src/lib/services/dashboard-bridge.service.ts +155 -0
  47. package/src/lib/services/dashboard-viewport.service.ts +148 -0
  48. package/src/lib/services/dashboard.service.ts +62 -0
  49. package/src/lib/store/__tests__/dashboard-store-collision-detection.spec.ts +756 -0
  50. package/src/lib/store/__tests__/dashboard-store-computed-properties.spec.ts +974 -0
  51. package/src/lib/store/__tests__/dashboard-store-drag-drop.spec.ts +279 -0
  52. package/src/lib/store/__tests__/dashboard-store-export-import.spec.ts +780 -0
  53. package/src/lib/store/__tests__/dashboard-store-grid-config.spec.ts +128 -0
  54. package/src/lib/store/__tests__/dashboard-store-query-methods.spec.ts +229 -0
  55. package/src/lib/store/__tests__/dashboard-store-resize-operations.spec.ts +652 -0
  56. package/src/lib/store/__tests__/dashboard-store-widget-management.spec.ts +461 -0
  57. package/src/lib/store/__tests__/dashboard-store-widget-state-preservation.spec.ts +369 -0
  58. package/src/lib/store/dashboard-store.ts +239 -0
  59. package/src/lib/store/features/drag-drop.feature.ts +140 -0
  60. package/src/lib/store/features/grid-config.feature.ts +43 -0
  61. package/src/lib/store/features/resize.feature.ts +140 -0
  62. package/src/lib/store/features/utils/collision.utils.ts +89 -0
  63. package/src/lib/store/features/utils/grid-query-internal.utils.ts +37 -0
  64. package/src/lib/store/features/utils/resize.utils.ts +165 -0
  65. package/src/lib/store/features/widget-management.feature.ts +158 -0
  66. package/src/lib/styles/_dashboard-grid-vars.scss +11 -0
  67. package/src/lib/widget-list/__tests__/widget-list-bridge-integration.spec.ts +137 -0
  68. package/src/lib/widget-list/widget-list.component.html +22 -0
  69. package/src/lib/widget-list/widget-list.component.scss +154 -0
  70. package/src/lib/widget-list/widget-list.component.ts +106 -0
  71. package/src/public-api.ts +21 -0
  72. package/src/test-setup.ts +10 -0
  73. package/tsconfig.lib.json +15 -0
  74. package/tsconfig.lib.prod.json +11 -0
  75. package/tsconfig.spec.json +14 -0
  76. package/fesm2022/dragonworks-ngx-dashboard.mjs +0 -2178
  77. package/fesm2022/dragonworks-ngx-dashboard.mjs.map +0 -1
  78. 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
+ }