From 151cb30a6603127405874a6f2b8583a5fc844f78 Mon Sep 17 00:00:00 2001 From: MartinaeyNL Date: Wed, 7 Oct 2026 09:43:51 +0200 Subject: [PATCH 01/11] Bump Gridstack dependencies to v14, and apply breaking changes --- ui/component/or-dashboard-builder/package.json | 2 +- .../src/or-dashboard-browser.ts | 12 ++++-------- .../src/or-dashboard-preview.ts | 16 +++++++++------- yarn.lock | 10 +++++----- 4 files changed, 19 insertions(+), 21 deletions(-) diff --git a/ui/component/or-dashboard-builder/package.json b/ui/component/or-dashboard-builder/package.json index 4976306646..dc5a9c85ca 100644 --- a/ui/component/or-dashboard-builder/package.json +++ b/ui/component/or-dashboard-builder/package.json @@ -46,7 +46,7 @@ "@openremote/or-translate": "workspace:*", "@openremote/or-vaadin-components": "workspace:*", "@openremote/rest": "workspace:*", - "gridstack": "^12.4.2", + "gridstack": "^14.0.0", "lit": "^3.3.1", "lodash.debounce": "^4.0.8", "lodash.throttle": "^4.1.1", diff --git a/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts b/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts index dffbba7ffa..9f8bdfb664 100644 --- a/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts +++ b/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts @@ -74,10 +74,10 @@ export class OrDashboardBrowser extends OrElement { public itemSize = 134; @state() - protected sidebarGrid?: GridStack; + protected sidebarGrid?: GridStack | null; @state() - protected backgroundGrid?: GridStack; + protected backgroundGrid?: GridStack | null; @state() protected items: Map = new Map(widgetTypes); @@ -110,9 +110,7 @@ export class OrDashboardBrowser extends OrElement { /* --------------------------------- */ protected renderGrid() { - if (this.sidebarGrid !== undefined) { - this.sidebarGrid.destroy(false); - } + this.sidebarGrid?.destroy(false); this.sidebarGrid = GridStack.init( { acceptWidgets: false, @@ -146,9 +144,7 @@ export class OrDashboardBrowser extends OrElement { }); // Separate Static Background grid (to make it look like the items duplicate) - if (this.backgroundGrid !== undefined) { - this.backgroundGrid.destroy(false); - } + this.backgroundGrid?.destroy(false); this.backgroundGrid = GridStack.init( { acceptWidgets: false, diff --git a/ui/component/or-dashboard-builder/src/or-dashboard-preview.ts b/ui/component/or-dashboard-builder/src/or-dashboard-preview.ts index 3d3ef79786..40a0fd845e 100644 --- a/ui/component/or-dashboard-builder/src/or-dashboard-preview.ts +++ b/ui/component/or-dashboard-builder/src/or-dashboard-preview.ts @@ -246,7 +246,7 @@ export class OrDashboardPreview extends translate(i18next)(OrElement) { @state() protected isLoading: boolean = false; - protected grid?: GridStack; + protected grid?: GridStack | null; protected latestDragWidgetStart?: Date; /* ------------------------------------------- */ @@ -459,7 +459,7 @@ export class OrDashboardPreview extends translate(i18next)(OrElement) { draggable: { appendTo: "parent", // Required to work, seems to be Shadow DOM related. }, - float: true, + mode: 'float', margin: 5, resizable: { handles: "all", @@ -471,15 +471,17 @@ export class OrDashboardPreview extends translate(i18next)(OrElement) { gridElement! ); - gridElement!.style.backgroundSize = "" + this.grid.cellWidth() + "px " + this.grid.getCellHeight() + "px"; + if(this.grid) { + gridElement!.style.backgroundSize = "" + this.grid.cellWidth() + "px " + this.grid.getCellHeight() + "px"; + } gridElement!.style.height = "100%"; // When an item gets dropped ontop of the grid. GridStack docs say: // "called when an item has been dropped and accepted over a grid. If the item came from another grid, the previous widget node info will also be sent (but dom item long gone)." - this.grid.on("dropped", (ev: Event, prevWidget: any, newWidget: GridStackNode | undefined) => + this.grid?.on("dropped", (ev: Event, prevWidget: any, newWidget: GridStackNode | undefined) => this.onWidgetDrop(ev, prevWidget, newWidget as DashboardGridNode) ); - this.grid.on("change", (_event: Event, items: any) => { + this.grid?.on("change", (_event: Event, items: any) => { if (this.template != null && this.template.widgets != null) { (items as GridStackNode[]).forEach((node) => { const foundWidget: DashboardWidget | undefined = this.template?.widgets?.find((widget) => { @@ -493,10 +495,10 @@ export class OrDashboardPreview extends translate(i18next)(OrElement) { this.dispatchEvent(new CustomEvent("changed", { detail: { template: this.template } })); } }); - this.grid.on("resizestart", (_event: Event) => { + this.grid?.on("resizestart", (_event: Event) => { this.latestDragWidgetStart = new Date(); }); - this.grid.on("resizestop", (_event: Event) => { + this.grid?.on("resizestop", (_event: Event) => { setTimeout(() => { this.latestDragWidgetStart = undefined; }, 200); diff --git a/yarn.lock b/yarn.lock index 1908320a35..846d033d00 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2140,7 +2140,7 @@ __metadata: "@openremote/util": "workspace:*" "@types/lodash.debounce": "npm:^4" "@types/lodash.throttle": "npm:^4" - gridstack: "npm:^12.4.2" + gridstack: "npm:^14.0.0" lit: "npm:^3.3.1" lodash.debounce: "npm:^4.0.8" lodash.throttle: "npm:^4.1.1" @@ -7533,10 +7533,10 @@ __metadata: languageName: node linkType: hard -"gridstack@npm:^12.4.2": - version: 12.4.2 - resolution: "gridstack@npm:12.4.2" - checksum: 10c0/9b15dabf24947e3f8d529055cbfcd532449b0ea0d4b2ae3b7419eed1742ea394dd19148b023fcbd7d0d69f2b5bc9df33d0f3e01b4153e96f2a7b767e036499bc +"gridstack@npm:^14.0.0": + version: 14.0.0 + resolution: "gridstack@npm:14.0.0" + checksum: 10c0/9d74e027758b67e396e3bd3eaa3081d0ef0d37242e14d9050e12d7615a03b4a1b68954da301fcbfe5ce9223ba47ed6080013f6d606db4378ad7a4fb2c246b384 languageName: node linkType: hard From 7164c8708696da619e5e98b2e8d6c91667719057 Mon Sep 17 00:00:00 2001 From: MartinaeyNL Date: Wed, 7 Oct 2026 09:57:40 +0200 Subject: [PATCH 02/11] Apply spotless --- ui/component/or-dashboard-builder/src/or-dashboard-preview.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/component/or-dashboard-builder/src/or-dashboard-preview.ts b/ui/component/or-dashboard-builder/src/or-dashboard-preview.ts index 40a0fd845e..02276068cc 100644 --- a/ui/component/or-dashboard-builder/src/or-dashboard-preview.ts +++ b/ui/component/or-dashboard-builder/src/or-dashboard-preview.ts @@ -459,7 +459,7 @@ export class OrDashboardPreview extends translate(i18next)(OrElement) { draggable: { appendTo: "parent", // Required to work, seems to be Shadow DOM related. }, - mode: 'float', + mode: "float", margin: 5, resizable: { handles: "all", @@ -471,7 +471,7 @@ export class OrDashboardPreview extends translate(i18next)(OrElement) { gridElement! ); - if(this.grid) { + if (this.grid) { gridElement!.style.backgroundSize = "" + this.grid.cellWidth() + "px " + this.grid.getCellHeight() + "px"; } gridElement!.style.height = "100%"; From 4470b2c1a3bc9f8e7f464f0f1c690a668e94b451 Mon Sep 17 00:00:00 2001 From: MartinaeyNL Date: Fri, 9 Oct 2026 16:48:27 +0200 Subject: [PATCH 03/11] WIP: Fix for dashboard browser to prevent DOM reorder. Added regression test for it. --- .../manager/test/fixtures/pages/insights.ts | 4 ++ ui/app/manager/test/insights.test.ts | 44 +++++++++++++ .../src/or-dashboard-browser.ts | 64 ++++++++++--------- 3 files changed, 82 insertions(+), 30 deletions(-) diff --git a/ui/app/manager/test/fixtures/pages/insights.ts b/ui/app/manager/test/fixtures/pages/insights.ts index dca3b0e222..6a446f0018 100644 --- a/ui/app/manager/test/fixtures/pages/insights.ts +++ b/ui/app/manager/test/fixtures/pages/insights.ts @@ -46,6 +46,10 @@ export class InsightsPage implements BasePage { return this.page.locator("or-dashboard-browser #sidebar", options); } + getBrowserCards(options?: any) { + return this.page.locator("or-dashboard-browser #sidebarElement .grid-stack-item", options); + } + getWidgetSettings(options?: any) { return this.page.locator(".settings-container:has(or-dashboard-widgetsettings)", options); } diff --git a/ui/app/manager/test/insights.test.ts b/ui/app/manager/test/insights.test.ts index 16ee876dd6..d7f52c770b 100644 --- a/ui/app/manager/test/insights.test.ts +++ b/ui/app/manager/test/insights.test.ts @@ -297,6 +297,50 @@ test("Replace broken chart references while preserving custom colors", async ({ expect(barColors[0][1]).toBe("#ffc400"); }); +/** + * @given A dashboard with the widget browser showing its stack of cards + * @when A card is dragged from the browser onto the grid + * @then The cards respawn in their original order and grid slots + * @and The remaining cards can still be dragged onto the grid + */ +test("should keep widget cards in place and draggable after dragging one onto the grid", async ({ + manager, + shared, + page, + insightsPage, +}) => { + await shared.interceptResponse("**/dashboard", (dashboard) => { + if (dashboard) manager.dashboards.push(dashboard.id!); + }); + + await manager.setup("smartcity", { assets }); + await manager.goToRealmStartPage("smartcity"); + await manager.navigateToTab("Insights"); + await page.click(".mdi-plus >> nth=0"); + await expect(insightsPage.getDashboardListItems()).toHaveCount(1); + + // Snapshot the browser layout: card ids in DOM order plus the grid slot each card occupies + const getCardLayout = () => + insightsPage + .getBrowserCards() + .evaluateAll((cards) => + cards.map((card) => [card.getAttribute("gs-id"), card.getAttribute("gs-x"), card.getAttribute("gs-y")]) + ); + await expect(insightsPage.getBrowserCards().first()).toBeVisible(); + const layoutBefore = await getCardLayout(); + + await insightsPage.dragAndDropWidget("Image", [0, 0]); + await expect(insightsPage.getWidgets()).toHaveCount(1); + await expect(insightsPage.getBrowserCards()).toHaveCount(layoutBefore.length); + + // Cards used to reshuffle around their own slots after one had been dragged away, + // and some of them no longer responded to being picked up. + await expect.poll(() => getCardLayout()).toEqual(layoutBefore); + + await insightsPage.dragAndDropWidget("Table", [6, 0]); + await expect(insightsPage.getWidgets()).toHaveCount(2); +}); + test.afterEach(async ({ manager }) => { await manager.cleanUp(); }); diff --git a/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts b/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts index 9f8bdfb664..bdc98bcebd 100644 --- a/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts +++ b/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts @@ -16,7 +16,7 @@ * * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { GridStack, type GridStackNode } from "gridstack"; +import { GridStack } from "gridstack"; import { css, html, type PropertyValues, type TemplateResult, unsafeCSS } from "lit"; import { OrElement } from "@openremote/or-element"; import { customElement, property, query, state } from "lit/decorators.js"; @@ -25,6 +25,7 @@ import { widgetTypes } from "./index"; import type { WidgetManifest } from "./util/or-widget"; import { when } from "lit/directives/when.js"; import { repeat } from "lit/directives/repeat.js"; +import { keyed } from "lit/directives/keyed.js"; import type { DashboardGridNode } from "./or-dashboard-preview"; // TODO: Add webpack/rollup to build so consumers aren't forced to use the same tooling @@ -82,6 +83,9 @@ export class OrDashboardBrowser extends OrElement { @state() protected items: Map = new Map(widgetTypes); + @state() + protected rebuildNonce = 0; + @query("#sidebarElement") protected sidebarElem?: HTMLDivElement; @@ -128,19 +132,14 @@ export class OrDashboardBrowser extends OrElement { this.sidebarElem ); - // If an item gets dropped on the main grid, the dragged item needs to be reset to the sidebar. - // This is done by removing the Widget type from the list, waiting for a Lit lifecycle, and adding it back again. - // Unfortunately, this is required due to HTML elements having to be rendered before it can "initialize the grid") - // @ts-ignore typechecking since we assume they are not undefined - this.sidebarGrid.on("removed", (_event: Event, items: GridStackNode[]) => { - const originalItems = new Map(this.items); - const removedTypes = items.map((i) => (i as DashboardGridNode).widgetTypeId); - removedTypes.forEach((typeId) => this.items.delete(typeId)); - this.items = new Map(this.items); - this.updateComplete.then(() => { - this.items = originalItems; - this.updateComplete.then(() => this.renderGrid()); - }); + // If an item gets dropped on the main grid, the dragged card needs to be reset to the sidebar. + // Bumping the nonce rebuilds both grids from scratch (see the keyed() in render()): since gridstack 13.1.0 + // (#3329) re-orders DOM children to the visual order behind Lit's back, letting repeat()/when() remove the + // dragged card in place would leave orphaned copies behind. A clean rebuild also restores a deterministic + // DOM order, so GridStack auto-positions every card back in its original slot. + this.sidebarGrid?.on("removed", () => { + this.rebuildNonce++; + this.updateComplete.then(() => this.renderGrid()); }); // Separate Static Background grid (to make it look like the items duplicate) @@ -161,22 +160,27 @@ export class OrDashboardBrowser extends OrElement { protected render() { return html` `; } From ca47499a7e6976d8ec32459dd500dbd727cdde51 Mon Sep 17 00:00:00 2001 From: MartinaeyNL Date: Mon, 12 Oct 2026 11:36:05 +0200 Subject: [PATCH 04/11] Adjust comment to clarify the change --- .../or-dashboard-builder/src/or-dashboard-browser.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts b/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts index bdc98bcebd..9914a59d7c 100644 --- a/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts +++ b/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts @@ -133,10 +133,9 @@ export class OrDashboardBrowser extends OrElement { ); // If an item gets dropped on the main grid, the dragged card needs to be reset to the sidebar. - // Bumping the nonce rebuilds both grids from scratch (see the keyed() in render()): since gridstack 13.1.0 - // (#3329) re-orders DOM children to the visual order behind Lit's back, letting repeat()/when() remove the - // dragged card in place would leave orphaned copies behind. A clean rebuild also restores a deterministic - // DOM order, so GridStack auto-positions every card back in its original slot. + // Since gridstack 13.1.0 (#3329), the grid re-orders DOM children to the visual order behind Lit's back. + // Therefore, we bump the nonce variable, and rebuild both grids from scratch using the keyed() in render(). + // A clean rebuild restores the DOM order, so GridStack auto-positions every card back in its original slot. this.sidebarGrid?.on("removed", () => { this.rebuildNonce++; this.updateComplete.then(() => this.renderGrid()); From d720436bef064d67d377df8744a96482bbaa798f Mon Sep 17 00:00:00 2001 From: MartinaeyNL Date: Mon, 12 Oct 2026 13:38:41 +0200 Subject: [PATCH 05/11] Change gridstack version range to only accept patches, since they ship behavior changes in minor updates. --- ui/component/or-dashboard-builder/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/component/or-dashboard-builder/package.json b/ui/component/or-dashboard-builder/package.json index dc5a9c85ca..625027a51e 100644 --- a/ui/component/or-dashboard-builder/package.json +++ b/ui/component/or-dashboard-builder/package.json @@ -46,7 +46,7 @@ "@openremote/or-translate": "workspace:*", "@openremote/or-vaadin-components": "workspace:*", "@openremote/rest": "workspace:*", - "gridstack": "^14.0.0", + "gridstack": "~14.0.0", "lit": "^3.3.1", "lodash.debounce": "^4.0.8", "lodash.throttle": "^4.1.1", From f3959033f1f0246281d070e4079547198915c49e Mon Sep 17 00:00:00 2001 From: MartinaeyNL Date: Mon, 12 Oct 2026 14:03:40 +0200 Subject: [PATCH 06/11] Fix yarn.lock --- yarn.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/yarn.lock b/yarn.lock index f7fe6e1f2f..9430f466b5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2140,7 +2140,7 @@ __metadata: "@openremote/util": "workspace:*" "@types/lodash.debounce": "npm:^4" "@types/lodash.throttle": "npm:^4" - gridstack: "npm:^14.0.0" + gridstack: "npm:~14.0.0" lit: "npm:^3.3.1" lodash.debounce: "npm:^4.0.8" lodash.throttle: "npm:^4.1.1" @@ -7533,7 +7533,7 @@ __metadata: languageName: node linkType: hard -"gridstack@npm:^14.0.0": +"gridstack@npm:~14.0.0": version: 14.0.0 resolution: "gridstack@npm:14.0.0" checksum: 10c0/9d74e027758b67e396e3bd3eaa3081d0ef0d37242e14d9050e12d7615a03b4a1b68954da301fcbfe5ce9223ba47ed6080013f6d606db4378ad7a4fb2c246b384 From 2100bc3f4ec94148d865f44256b4f742c79ea02b Mon Sep 17 00:00:00 2001 From: Martin Peeters Date: Mon, 12 Oct 2026 15:28:38 +0200 Subject: [PATCH 07/11] Update ui/component/or-dashboard-builder/src/or-dashboard-browser.ts Co-authored-by: Koen <44117179+Ekhorn@users.noreply.github.com> --- ui/component/or-dashboard-builder/src/or-dashboard-browser.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts b/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts index 9914a59d7c..1e75d2cdf2 100644 --- a/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts +++ b/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts @@ -133,7 +133,8 @@ export class OrDashboardBrowser extends OrElement { ); // If an item gets dropped on the main grid, the dragged card needs to be reset to the sidebar. - // Since gridstack 13.1.0 (#3329), the grid re-orders DOM children to the visual order behind Lit's back. + // Since gridstack 13.1.0, the grid re-orders DOM children to the visual order behind Lit's back. + // See https://github.com/gridstack/gridstack.js/pull/3331 // Therefore, we bump the nonce variable, and rebuild both grids from scratch using the keyed() in render(). // A clean rebuild restores the DOM order, so GridStack auto-positions every card back in its original slot. this.sidebarGrid?.on("removed", () => { From 26d3cf7a4ab544bea94a93b0f8ff55c061506de6 Mon Sep 17 00:00:00 2001 From: MartinaeyNL Date: Mon, 26 Oct 2026 16:47:00 +0100 Subject: [PATCH 08/11] Add keepDomOrder function to prevent GridStack DOM reorder --- .../src/or-dashboard-engine.ts | 21 ++++++++++++++++++- .../src/or-dashboard-preview.ts | 4 +++- 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/ui/component/or-dashboard-builder/src/or-dashboard-engine.ts b/ui/component/or-dashboard-builder/src/or-dashboard-engine.ts index 8fe6e009a2..86cfa38490 100644 --- a/ui/component/or-dashboard-builder/src/or-dashboard-engine.ts +++ b/ui/component/or-dashboard-builder/src/or-dashboard-engine.ts @@ -16,7 +16,26 @@ * * SPDX-License-Identifier: AGPL-3.0-or-later */ -import { GridStackEngine, type GridStackNode, type GridStackMoveOpts } from "gridstack"; +import { GridStackEngine, type GridStackNode, type GridStackMoveOpts, type GridStack } from "gridstack"; + +/** + * Stops the grid from re-ordering its DOM children to match the visual layout. + * Grid items are rendered through Lit and positioned with CSS, so the order does not affect what the user sees. + * Re-ordering widgets detaches and re-attaches each HTML element, therefore this prevents widget content to be removed. + */ +export function keepDomOrder(grid?: GridStack | null) { + if (!grid) { + return; + } + const internals = grid as unknown as { _sortDom?: () => void }; + if (typeof internals._sortDom !== "function") { + throw new Error( + "GridStack._sortDom is gone, so the grid cannot be stopped from re-ordering its DOM children. " + + "Check what the supported gridstack version does on change events before raising it." + ); + } + internals._sortDom = () => {}; +} export class OrDashboardEngine extends GridStackEngine { // Cancelling move when it collides with the same widget. diff --git a/ui/component/or-dashboard-builder/src/or-dashboard-preview.ts b/ui/component/or-dashboard-builder/src/or-dashboard-preview.ts index 02276068cc..a9a09e2b86 100644 --- a/ui/component/or-dashboard-builder/src/or-dashboard-preview.ts +++ b/ui/component/or-dashboard-builder/src/or-dashboard-preview.ts @@ -37,7 +37,7 @@ import { type GridItemHTMLElement, GridStack, type GridStackElement, type GridSt import { showSnackbar } from "@openremote/or-mwc-components/or-mwc-snackbar"; import { when } from "lit/directives/when.js"; import { guard } from "lit/directives/guard.js"; -import { OrDashboardEngine } from "./or-dashboard-engine"; +import { keepDomOrder, OrDashboardEngine } from "./or-dashboard-engine"; import { WidgetService } from "./service/widget-service"; import { OrDashboardWidgetContainer } from "./or-dashboard-widgetcontainer"; import type { OrVaadinNumberField } from "@openremote/or-vaadin-components/or-vaadin-number-field"; @@ -471,6 +471,8 @@ export class OrDashboardPreview extends translate(i18next)(OrElement) { gridElement! ); + keepDomOrder(this.grid); + if (this.grid) { gridElement!.style.backgroundSize = "" + this.grid.cellWidth() + "px " + this.grid.getCellHeight() + "px"; } From bf2fdffb3271bc2484319a964740e020dfacc9b8 Mon Sep 17 00:00:00 2001 From: MartinaeyNL Date: Mon, 26 Oct 2026 16:54:38 +0100 Subject: [PATCH 09/11] Adjust comment in or-dashboard-browser after keepDomOrder function introduction --- ui/component/or-dashboard-builder/src/or-dashboard-browser.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts b/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts index 1e75d2cdf2..1e59c4b259 100644 --- a/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts +++ b/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts @@ -133,10 +133,8 @@ export class OrDashboardBrowser extends OrElement { ); // If an item gets dropped on the main grid, the dragged card needs to be reset to the sidebar. - // Since gridstack 13.1.0, the grid re-orders DOM children to the visual order behind Lit's back. - // See https://github.com/gridstack/gridstack.js/pull/3331 // Therefore, we bump the nonce variable, and rebuild both grids from scratch using the keyed() in render(). - // A clean rebuild restores the DOM order, so GridStack auto-positions every card back in its original slot. + // A clean rebuild lets GridStack auto-position every card back in its original slot. this.sidebarGrid?.on("removed", () => { this.rebuildNonce++; this.updateComplete.then(() => this.renderGrid()); From b72ad9ace48ea640e0e68076579c2b9c354d7a89 Mon Sep 17 00:00:00 2001 From: MartinaeyNL Date: Mon, 26 Oct 2026 16:57:58 +0100 Subject: [PATCH 10/11] Adjust keepDomOrder comment. Add keepDomOrder() function to the browser component as well. --- .../or-dashboard-builder/src/or-dashboard-browser.ts | 5 +++++ ui/component/or-dashboard-builder/src/or-dashboard-engine.ts | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts b/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts index 1e59c4b259..cbad71284d 100644 --- a/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts +++ b/ui/component/or-dashboard-builder/src/or-dashboard-browser.ts @@ -27,6 +27,7 @@ import { when } from "lit/directives/when.js"; import { repeat } from "lit/directives/repeat.js"; import { keyed } from "lit/directives/keyed.js"; import type { DashboardGridNode } from "./or-dashboard-preview"; +import { keepDomOrder } from "./or-dashboard-engine"; // TODO: Add webpack/rollup to build so consumers aren't forced to use the same tooling const gridcss = require("gridstack/dist/gridstack.min.css"); @@ -132,6 +133,8 @@ export class OrDashboardBrowser extends OrElement { this.sidebarElem ); + keepDomOrder(this.sidebarGrid); + // If an item gets dropped on the main grid, the dragged card needs to be reset to the sidebar. // Therefore, we bump the nonce variable, and rebuild both grids from scratch using the keyed() in render(). // A clean rebuild lets GridStack auto-position every card back in its original slot. @@ -153,6 +156,8 @@ export class OrDashboardBrowser extends OrElement { }, this.backgroundElem ); + + keepDomOrder(this.backgroundGrid); } protected render() { diff --git a/ui/component/or-dashboard-builder/src/or-dashboard-engine.ts b/ui/component/or-dashboard-builder/src/or-dashboard-engine.ts index 86cfa38490..76adf605a2 100644 --- a/ui/component/or-dashboard-builder/src/or-dashboard-engine.ts +++ b/ui/component/or-dashboard-builder/src/or-dashboard-engine.ts @@ -21,7 +21,7 @@ import { GridStackEngine, type GridStackNode, type GridStackMoveOpts, type GridS /** * Stops the grid from re-ordering its DOM children to match the visual layout. * Grid items are rendered through Lit and positioned with CSS, so the order does not affect what the user sees. - * Re-ordering widgets detaches and re-attaches each HTML element, therefore this prevents widget content to be removed. + * Re-ordering widgets detaches and re-attaches each HTML element, therefore this prevents widget content from being removed. */ export function keepDomOrder(grid?: GridStack | null) { if (!grid) { From 38090413cb9bc3530fb7ebe44a5bca25809ab05e Mon Sep 17 00:00:00 2001 From: MartinaeyNL Date: Mon, 26 Oct 2026 17:54:40 +0100 Subject: [PATCH 11/11] Add regression test for DOM order behavior. --- ui/app/manager/test/insights.test.ts | 36 ++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/ui/app/manager/test/insights.test.ts b/ui/app/manager/test/insights.test.ts index d7f52c770b..ebbb6494cc 100644 --- a/ui/app/manager/test/insights.test.ts +++ b/ui/app/manager/test/insights.test.ts @@ -341,6 +341,42 @@ test("should keep widget cards in place and draggable after dragging one onto th await expect(insightsPage.getWidgets()).toHaveCount(2); }); +/** + * @given An empty dashboard grid + * @when Two widgets are dropped with the second visually before the first + * @then The DOM order still follows insertion instead of the visual layout + */ +test("should keep grid widgets in DOM order when placed out of visual order", async ({ + manager, + shared, + page, + insightsPage, +}) => { + await shared.interceptResponse("**/dashboard", (dashboard) => { + if (dashboard) manager.dashboards.push(dashboard.id!); + }); + + await manager.setup("smartcity", { assets }); + await manager.goToRealmStartPage("smartcity"); + await manager.navigateToTab("Insights"); + await page.click(".mdi-plus >> nth=0"); + await expect(insightsPage.getDashboardListItems()).toHaveCount(1); + + // First widget on the grid in DOM order, identified by its gridstack id + const getFirstWidgetId = () => + insightsPage.getWidgetGSContainer(insightsPage.getWidgets().first()).getAttribute("gs-id"); + + // Widgets used to be re-ordered in the DOM to their visual layout on every grid change, + // detaching and re-attaching each element, which destroyed the chart and map contents. + await insightsPage.dragAndDropWidget("Image", [6, 0]); + const firstWidgetId = await getFirstWidgetId(); + + // Dropped visually before the first widget, so the visual order is the reverse of the DOM order + await insightsPage.dragAndDropWidget("Table", [0, 0]); + + await expect.poll(() => getFirstWidgetId()).toBe(firstWidgetId); +}); + test.afterEach(async ({ manager }) => { await manager.cleanUp(); });