From d6033582051a346d6de1fe84c4c81aa33bf2249f Mon Sep 17 00:00:00 2001 From: tigger2000ttfn Date: Thu, 30 Apr 2026 23:58:54 -0400 Subject: [PATCH] Fix: preserve existing widget sizes when adding a dashboard widget (#11834) * Fix: preserve existing widget sizes when adding a dashboard widget When adding a new widget from the drawer, addWidget called updateLayoutForWidget with overrideSize=true on every existing layout key. That flag forces w/h on every layout entry to the widget's minWidth/minHeight, which silently reset all user-customised widget sizes back to their minimums. Pass overrideSize=false instead. Existing widgets retain their layout entries (and therefore their user-set sizes); the newly added widget has no entry yet, so react-grid-layout auto-places it at default size. Adds a Playwright regression test that resizes a widget, adds a second widget, and asserts the first widget's height is preserved. * Make widget-resize regression test reliable Replace the previous drag-based test with a deterministic check that manipulates the persisted layout directly. The resize handles only exist when isResizable={editing} is true, so the previous test's attempt to grab .react-resizable-handle returned null in CI. The new test inflates the first widget's layout entry in localStorage, reloads to rehydrate zustand, then adds a second widget via the menu and asserts the first widget retained its enlarged dimensions. This directly exercises the addWidget code path without needing edit mode or mouse simulation. * Apply biome formatting * Patch backend profile to inflate widget; fix unused non-null assertion Updating localStorage was wiped out by observeProfile() on reload, so inflate the widget on the backend user profile instead. --------- Co-authored-by: tigger2000ttfn --- .../components/dashboard/DashboardLayout.tsx | 7 +- .../tests/pages/pui_dashboard.spec.ts | 77 +++++++++++++++++++ 2 files changed, 82 insertions(+), 2 deletions(-) diff --git a/src/frontend/src/components/dashboard/DashboardLayout.tsx b/src/frontend/src/components/dashboard/DashboardLayout.tsx index 5edcf5cc26..ddccb3034c 100644 --- a/src/frontend/src/components/dashboard/DashboardLayout.tsx +++ b/src/frontend/src/components/dashboard/DashboardLayout.tsx @@ -97,11 +97,14 @@ export default function DashboardLayout() { setWidgets([...widgets, newWidget]); } - // Update the layouts to include the new widget (and enforce initial size) + // Update the layouts to include the new widget. + // Pass overrideSize=false so existing widgets keep their user-set + // dimensions; only the newly added widget will receive default sizing + // via react-grid-layout's auto-placement. const _layouts: any = { ...layouts }; Object.keys(_layouts).forEach((key) => { - _layouts[key] = updateLayoutForWidget(_layouts[key], widgets, true); + _layouts[key] = updateLayoutForWidget(_layouts[key], widgets, false); }); setLayouts(_layouts); diff --git a/src/frontend/tests/pages/pui_dashboard.spec.ts b/src/frontend/tests/pages/pui_dashboard.spec.ts index acc3547e4b..dd9ef7ea05 100644 --- a/src/frontend/tests/pages/pui_dashboard.spec.ts +++ b/src/frontend/tests/pages/pui_dashboard.spec.ts @@ -1,4 +1,5 @@ import type { Page } from '@playwright/test'; +import { expect } from '@playwright/test'; import { test } from '../baseFixtures.js'; import { doCachedLogin } from '../login.js'; import { setPluginState } from '../settings.js'; @@ -94,3 +95,79 @@ test('Dashboard - Plugins', async ({ browser }) => { await page.getByRole('heading', { name: 'Sample Dashboard Item' }).waitFor(); await page.getByText('Hello world! This is a sample').waitFor(); }); + +test('Dashboard - Preserve widget sizes when adding new widget', async ({ + browser +}) => { + // Regression: addWidget previously snapped every existing widget back to + // its minW/minH. Fix is in DashboardLayout.tsx::addWidget (overrideSize=false). + const TARGET_W = 10; + const TARGET_H = 6; + + const readLayouts = (page: Page) => + page.evaluate(() => { + const raw = localStorage.getItem('session-settings'); + return raw ? (JSON.parse(raw)?.state?.layouts ?? {}) : {}; + }); + + const page = await doCachedLogin(browser); + await resetDashboard(page); + + // Add widget A; this also persists to the backend user profile. + await page.getByLabel('dashboard-menu').click(); + await page.getByRole('menuitem', { name: 'Add Widget' }).click(); + await page.getByLabel('dashboard-widgets-filter-input').fill('overdue order'); + await page.getByLabel('add-widget-ovr-so').click(); + await page.getByRole('banner').getByRole('button').click(); + await page.getByText('Overdue Sales Orders').waitFor(); + await page.waitForTimeout(500); + + // Inflate widget A on the backend profile and reload. The auth flow on + // page load rehydrates layouts from the profile, not localStorage, so a + // localStorage-only edit would be wiped out on reload. + const current = await readLayouts(page); + const inflated: Record = {}; + for (const bp of Object.keys(current)) { + inflated[bp] = current[bp].map((it: any) => + it?.i === 'ovr-so' ? { ...it, w: TARGET_W, h: TARGET_H } : it + ); + } + await page.evaluate(async (layouts) => { + const csrf = document.cookie.match(/csrftoken=([^;]+)/)?.[1] ?? ''; + await fetch('/api/user/profile/', { + method: 'PATCH', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrf + }, + body: JSON.stringify({ widgets: { widgets: ['ovr-so'], layouts } }) + }); + }, inflated); + + await page.reload(); + await page.getByText('Overdue Sales Orders').waitFor(); + await page.waitForTimeout(500); + + // Sanity: profile rehydration produced the inflated values. + for (const [bp, items] of Object.entries(await readLayouts(page))) { + const entry = (items as any[]).find((i) => i?.i === 'ovr-so'); + expect(entry?.w, `${bp}: ovr-so missing or wrong w`).toBe(TARGET_W); + expect(entry?.h, `${bp}: ovr-so missing or wrong h`).toBe(TARGET_H); + } + + // Add widget B. With the bug, this clobbered widget A back to minW/minH. + await page.getByLabel('dashboard-menu').click(); + await page.getByRole('menuitem', { name: 'Add Widget' }).click(); + await page.getByLabel('dashboard-widgets-filter-input').fill('overdue order'); + await page.getByLabel('add-widget-ovr-po').click(); + await page.getByRole('banner').getByRole('button').click(); + await page.getByText('Overdue Purchase Orders').waitFor(); + await page.waitForTimeout(800); + + for (const [bp, items] of Object.entries(await readLayouts(page))) { + const entry = (items as any[]).find((i) => i?.i === 'ovr-so'); + expect(entry?.w, `${bp}: ovr-so width was reset`).toBe(TARGET_W); + expect(entry?.h, `${bp}: ovr-so height was reset`).toBe(TARGET_H); + } +});