Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions ui/app/manager/test/fixtures/pages/insights.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
80 changes: 80 additions & 0 deletions ui/app/manager/test/insights.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,86 @@ 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", (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);
});

/**
* @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", (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();
});
2 changes: 1 addition & 1 deletion ui/component/or-dashboard-builder/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
79 changes: 41 additions & 38 deletions ui/component/or-dashboard-builder/src/or-dashboard-browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -25,7 +25,9 @@ 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";
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");
Expand Down Expand Up @@ -74,14 +76,17 @@ 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<string, WidgetManifest> = new Map(widgetTypes);

@state()
protected rebuildNonce = 0;

@query("#sidebarElement")
protected sidebarElem?: HTMLDivElement;

Expand Down Expand Up @@ -110,9 +115,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,
Expand All @@ -130,25 +133,18 @@ 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());
});
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.
this.sidebarGrid?.on("removed", () => {
this.rebuildNonce++;
this.updateComplete.then(() => this.renderGrid());
});

// 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,
Expand All @@ -160,27 +156,34 @@ export class OrDashboardBrowser extends OrElement {
},
this.backgroundElem
);

keepDomOrder(this.backgroundGrid);
}

protected render() {
return html`
<div id="sidebar">
<div id="sidebarElement" class="grid-stack" style="width: 100%; z-index: 3;">
${repeat(
this.items,
([type]) => type,
([type, manifest]) => this._getSidebarItemTemplate(type, type, manifest)
)}
${when(this.items.size % 2 !== 0, () => this._getEmptyItemTemplate())}
</div>
<div id="sidebarBgElement" class="grid-stack" style="width: 100%; z-index: 2">
${repeat(
this.items,
([type]) => type,
([type, manifest]) => this._getSidebarItemTemplate(`bg-${type}`, type, manifest)
)}
${when(this.items.size % 2 !== 0, () => this._getEmptyItemTemplate())}
</div>
${keyed(
this.rebuildNonce,
html`
<div id="sidebarElement" class="grid-stack" style="width: 100%; z-index: 3;">
${repeat(
this.items,
([type]) => type,
([type, manifest]) => this._getSidebarItemTemplate(type, type, manifest)
)}
${when(this.items.size % 2 !== 0, () => this._getEmptyItemTemplate())}
</div>
<div id="sidebarBgElement" class="grid-stack" style="width: 100%; z-index: 2">
${repeat(
this.items,
([type]) => type,
([type, manifest]) => this._getSidebarItemTemplate(`bg-${type}`, type, manifest)
)}
${when(this.items.size % 2 !== 0, () => this._getEmptyItemTemplate())}
</div>
`
)}
</div>
`;
}
Expand Down
21 changes: 20 additions & 1 deletion ui/component/or-dashboard-builder/src/or-dashboard-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 from being 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.
Expand Down
20 changes: 12 additions & 8 deletions ui/component/or-dashboard-builder/src/or-dashboard-preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;

/* ------------------------------------------- */
Expand Down Expand Up @@ -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",
Expand All @@ -471,15 +471,19 @@ export class OrDashboardPreview extends translate(i18next)(OrElement) {
gridElement!
);

gridElement!.style.backgroundSize = "" + this.grid.cellWidth() + "px " + this.grid.getCellHeight() + "px";
keepDomOrder(this.grid);

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) => {
Expand All @@ -493,10 +497,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);
Expand Down
10 changes: 5 additions & 5 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -2149,7 +2149,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"
Expand Down Expand Up @@ -7540,10 +7540,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

Expand Down
Loading