diff --git a/playwright/e2e/dashboards-demo/dashboard.spec.ts b/playwright/e2e/dashboards-demo/dashboard.spec.ts index cc248a3c93..854aecf4d3 100644 --- a/playwright/e2e/dashboards-demo/dashboard.spec.ts +++ b/playwright/e2e/dashboards-demo/dashboard.spec.ts @@ -218,6 +218,69 @@ test.describe('dashboard', () => { await si.runVisualAndA11yTests(stepName); }); + test(example + ' cancel restores auto-positioned layout', async ({ page, si }) => { + await si.visitExample(example, undefined); + await expect(page.getByRole('heading', { name: 'Sample Dashboard' })).toBeVisible(); + + // Record original bounding boxes of all widgets keyed by item-id + const widgets = page.locator('si-widget-host'); + const count = await widgets.count(); + const originalBoxes: Record = {}; + for (let i = 0; i < count; i++) { + const widget = widgets.nth(i); + const itemId = await widget.getAttribute('item-id'); + expect(itemId).toBeTruthy(); + const box = await widget.boundingBox(); + expect(box).toBeTruthy(); + originalBoxes[itemId!] = { x: Math.round(box!.x), y: Math.round(box!.y) }; + } + + // Enter edit mode + const editBtn = page.getByLabel('Edit'); + await editBtn.click(); + await expect(page.getByText('Cancel')).toBeVisible(); + + // Resize the Pie Chart widget by dragging the bottom-right resize handle to the left + const pieChart = page.locator('si-widget-host', { hasText: 'Pie Chart' }); + await pieChart.hover(); + const resizeHandle = pieChart.locator('.ui-resizable-se'); + const handleBox = await resizeHandle.boundingBox(); + expect(handleBox).toBeTruthy(); + const startX = handleBox!.x + handleBox!.width / 2; + const startY = handleBox!.y + handleBox!.height / 2; + await page.mouse.move(startX, startY); + await page.mouse.down(); + await page.mouse.move(startX - 100, startY); + await page.mouse.up(); + + // Move the Full Speed widget towards Pie Chart by dragging its overlay to the left + const fullSpeed = page.locator('si-widget-host', { hasText: 'Full Speed' }); + await fullSpeed.scrollIntoViewIfNeeded(); + const fullSpeedDraggable = fullSpeed.locator('.draggable-overlay'); + const dragBox = await fullSpeedDraggable.boundingBox(); + expect(dragBox).toBeTruthy(); + const dragStartX = dragBox!.x + dragBox!.width / 2; + const dragStartY = dragBox!.y + dragBox!.height / 2; + await page.mouse.move(dragStartX, dragStartY); + await page.mouse.down(); + await page.mouse.move(dragStartX - 150, dragStartY, { steps: 10 }); + await page.mouse.up(); + + // Cancel the edit + await page.getByText('Cancel', { exact: true }).click(); + await expect(page.getByLabel('Edit')).toBeVisible(); + + // Scroll the widgets container to top before verifying positions + await page.locator('.si-dashboard-content').evaluate(el => el.scrollTo(0, 0)); + + // Verify all widgets returned to their original positions + for (const [itemId, expectedBox] of Object.entries(originalBoxes)) { + await expect(page.locator(`si-widget-host[item-id="${itemId}"]`)).toHaveBoundingBox( + expectedBox + ); + } + }); + const openWidgetCatalog = async (page: Page): Promise => { await expect(page.getByLabel('Edit')).toBeVisible(); const editBtn = page.getByLabel('Edit'); diff --git a/projects/dashboards-ng/src/components/grid/si-grid.component.spec.ts b/projects/dashboards-ng/src/components/grid/si-grid.component.spec.ts index a4044beae5..c1d429b355 100644 --- a/projects/dashboards-ng/src/components/grid/si-grid.component.spec.ts +++ b/projects/dashboards-ng/src/components/grid/si-grid.component.spec.ts @@ -200,6 +200,26 @@ describe('SiGridComponent', () => { .triggerEventHandler('gridEvent', { event: { type: 'added' } }); }); + it('#handleGridEvent() should capture auto-positioned layout so cancel restores it', () => { + // Simulate persisted widgets without explicit x/y (auto-positioned by GridStack) + const widgetConfig: WidgetConfig = { id: 'w1', widgetId: 'id', x: undefined, y: undefined }; + component.persistedWidgetInstances = [widgetConfig]; + component.visibleWidgetInstances$.next([widgetConfig]); + + const spy = vi + .spyOn(component.gridStackWrapper(), 'getWidgetLayout') + .mockReturnValue({ x: 0, y: 0, width: 4, height: 2, id: 'w1' }); + + // Trigger an 'added' event while NOT in edit mode + fixture.debugElement + .query(By.css('si-gridstack-wrapper')) + .triggerEventHandler('gridEvent', { event: { type: 'added' } }); + + expect(spy).toHaveBeenCalled(); + expect(component.persistedWidgetInstances[0].x).toBe(0); + expect(component.persistedWidgetInstances[0].y).toBe(0); + }); + it('should load widgets when dashboardId changes', async () => { const widgetConfig: WidgetConfig = { id: 'myId', widgetId: 'myWidgetId' }; const myDashboardId = 'myDashboardId'; diff --git a/projects/dashboards-ng/src/components/grid/si-grid.component.ts b/projects/dashboards-ng/src/components/grid/si-grid.component.ts index 9a93f26bc0..78dd4f8dcd 100644 --- a/projects/dashboards-ng/src/components/grid/si-grid.component.ts +++ b/projects/dashboards-ng/src/components/grid/si-grid.component.ts @@ -378,6 +378,16 @@ export class SiGridComponent implements OnInit, OnChanges, OnDestroy { protected handleGridEvent(event: GridWrapperEvent): void { const relevantEventTypes = ['added', 'removed', 'dragstop', 'resizestop']; + // When widgets without explicit x/y are first added, GridStack auto-positions them. + // Capture these resolved positions so cancel() can restore the exact layout + // instead of re-triggering auto-positioning which may produce a different result. + if ( + event.event.type === 'added' && + !this.editableInternal && + this.persistedWidgetInstances.some(w => w.x === undefined || w.y === undefined) + ) { + this.persistedWidgetInstances = this.updateWidgetPositions(this.persistedWidgetInstances); + } if (this.editable() && relevantEventTypes.includes(event.event.type)) { // Make sure the widget config always holds the latest position information const widgets = this.updateWidgetPositions(this.visibleWidgetInstances$.value);