From 71cf9f5452b23dcd3ccc96773dceab080fc36837 Mon Sep 17 00:00:00 2001 From: Matthias Mair Date: Thu, 24 Apr 2025 09:29:28 +0200 Subject: [PATCH] [FR] Save widget state per user (#9567) * [FR] Save widget state per user Fixes #9562 * cleanup * fix doc strings * add reset stage --- .../components/dashboard/DashboardLayout.tsx | 123 +++++------------- .../components/dashboard/DashboardMenu.tsx | 11 ++ src/frontend/src/functions/auth.tsx | 11 +- src/frontend/src/states/LocalState.tsx | 28 +++- src/frontend/tests/login.ts | 2 +- .../tests/pages/pui_dashboard.spec.ts | 4 + 6 files changed, 85 insertions(+), 94 deletions(-) diff --git a/src/frontend/src/components/dashboard/DashboardLayout.tsx b/src/frontend/src/components/dashboard/DashboardLayout.tsx index 427c0e2025..8b7be0a732 100644 --- a/src/frontend/src/components/dashboard/DashboardLayout.tsx +++ b/src/frontend/src/components/dashboard/DashboardLayout.tsx @@ -6,102 +6,28 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { type Layout, Responsive, WidthProvider } from 'react-grid-layout'; import { useDashboardItems } from '../../hooks/UseDashboardItems'; -import { useUserState } from '../../states/UserState'; +import { useLocalState } from '../../states/LocalState'; import DashboardMenu from './DashboardMenu'; import DashboardWidget, { type DashboardWidgetProps } from './DashboardWidget'; import DashboardWidgetDrawer from './DashboardWidgetDrawer'; const ReactGridLayout = WidthProvider(Responsive); -/** - * Save the dashboard layout to local storage - */ -function saveDashboardLayout(layouts: any, userId: number | undefined): void { - const reducedLayouts: any = {}; - - // Reduce the layouts to exclude default attributes from the dataset - Object.keys(layouts).forEach((key) => { - reducedLayouts[key] = layouts[key].map((item: Layout) => { - return { - ...item, - moved: item.moved ? true : undefined, - static: item.static ? true : undefined - }; - }); - }); - - const data = JSON.stringify(reducedLayouts); - - if (userId) { - localStorage?.setItem(`dashboard-layout-${userId}`, data); - } - - localStorage?.setItem('dashboard-layout', data); -} - -/** - * Load the dashboard layout from local storage - */ -function loadDashboardLayout( - userId: number | undefined -): Record { - let layout = userId && localStorage?.getItem(`dashboard-layout-${userId}`); - - if (!layout) { - // Fallback to global layout - layout = localStorage?.getItem('dashboard-layout'); - } - - if (layout) { - return JSON.parse(layout); - } else { - return {}; - } -} - -/** - * Save the list of selected widgets to local storage - */ -function saveDashboardWidgets( - widgets: string[], - userId: number | undefined -): void { - const data = JSON.stringify(widgets); - - if (userId) { - localStorage?.setItem(`dashboard-widgets-${userId}`, data); - } - - localStorage?.setItem('dashboard-widgets', data); -} - -/** - * Load the list of selected widgets from local storage - */ -function loadDashboardWidgets(userId: number | undefined): string[] { - let widgets = userId && localStorage?.getItem(`dashboard-widgets-${userId}`); - - if (!widgets) { - // Fallback to global widget list - widgets = localStorage?.getItem('dashboard-widgets'); - } - - if (widgets) { - return JSON.parse(widgets); - } else { - return []; - } -} - export default function DashboardLayout() { - const user = useUserState(); - // Dashboard layout definition const [layouts, setLayouts] = useState({}); - // Dashboard widget selection const [widgets, setWidgets] = useState([]); + // local/remote storage values for widget / layout + const [remoteWidgets, setRemoteWidgets, remoteLayouts, setRemoteLayouts] = + useLocalState((state) => [ + state.widgets, + state.setWidgets, + state.layouts, + state.setLayouts + ]); + const [editing, setEditing] = useDisclosure(false); const [removing, setRemoving] = useDisclosure(false); @@ -132,7 +58,7 @@ export default function DashboardLayout() { // Save the selected widgets to local storage when the selection changes useEffect(() => { if (loaded) { - saveDashboardWidgets(widgetLabels, user.userId()); + setRemoteWidgets(widgetLabels); } }, [widgetLabels]); @@ -232,7 +158,18 @@ export default function DashboardLayout() { }); if (layouts && loaded && availableWidgets.loaded) { - saveDashboardLayout(newLayouts, user.userId()); + const reducedLayouts: any = {}; + // Reduce the layouts to exclude default attributes from the dataset + Object.keys(newLayouts).forEach((key) => { + reducedLayouts[key] = newLayouts[key].map((item: Layout) => { + return { + ...item, + moved: item.moved ? true : undefined, + static: item.static ? true : undefined + }; + }); + }); + setRemoteLayouts(reducedLayouts); setLayouts(newLayouts); } }, @@ -242,13 +179,10 @@ export default function DashboardLayout() { // Load the dashboard layout from local storage useEffect(() => { if (availableWidgets.loaded) { - const initialLayouts = loadDashboardLayout(user.userId()); - const initialWidgetLabels = loadDashboardWidgets(user.userId()); - - setLayouts(initialLayouts); + setLayouts(remoteLayouts); setWidgets( availableWidgets.items.filter((widget) => - initialWidgetLabels.includes(widget.label) + remoteWidgets.includes(widget.label) ) ); @@ -256,6 +190,12 @@ export default function DashboardLayout() { } }, [availableWidgets.loaded]); + // Clear all widgets from the dashboard + const clearWidgets = useCallback(() => { + setWidgets([]); + setLayouts({}); + }, []); + return ( <> { setEditing.close(); diff --git a/src/frontend/src/components/dashboard/DashboardMenu.tsx b/src/frontend/src/components/dashboard/DashboardMenu.tsx index fdd31cb41a..04dd8780d6 100644 --- a/src/frontend/src/components/dashboard/DashboardMenu.tsx +++ b/src/frontend/src/components/dashboard/DashboardMenu.tsx @@ -30,6 +30,7 @@ export default function DashboardMenu({ onAddWidget, onStartEdit, onStartRemove, + onClear, onAcceptLayout }: Readonly<{ editing: boolean; @@ -37,6 +38,7 @@ export default function DashboardMenu({ onAddWidget: () => void; onStartEdit: () => void; onStartRemove: () => void; + onClear: () => void; onAcceptLayout: () => void; }>) { const user = useUserState(); @@ -119,6 +121,15 @@ export default function DashboardMenu({ )} + {!editing && !removing && ( + } + onClick={onClear} + > + Clear Widgets + + )} + {(editing || removing) && ( } diff --git a/src/frontend/src/functions/auth.tsx b/src/frontend/src/functions/auth.tsx index f2e11f7434..6e4b376ab4 100644 --- a/src/frontend/src/functions/auth.tsx +++ b/src/frontend/src/functions/auth.tsx @@ -183,7 +183,7 @@ function observeProfile() { // overwrite language and theme info in session with profile info const user = useUserState.getState().getUser(); - const { language, setLanguage, userTheme, setTheme } = + const { language, setLanguage, userTheme, setTheme, setWidgets, setLayouts } = useLocalState.getState(); if (user) { if (user.profile?.language && language != user.profile.language) { @@ -216,6 +216,15 @@ function observeProfile() { setTheme(newTheme); } } + + if (user.profile?.widgets) { + const data = user.profile.widgets; + // split data into widgets and layouts (either might be undefined) + const widgets = data.widgets ?? []; + const layouts = data.layouts ?? {}; + setWidgets(widgets, true); + setLayouts(layouts, true); + } } } diff --git a/src/frontend/src/states/LocalState.tsx b/src/frontend/src/states/LocalState.tsx index 9c263700cd..c798f275b9 100644 --- a/src/frontend/src/states/LocalState.tsx +++ b/src/frontend/src/states/LocalState.tsx @@ -28,6 +28,10 @@ interface LocalStateProps { }[], noPatch?: boolean ) => void; + widgets: string[]; + setWidgets: (widgets: string[], noPatch?: boolean) => void; + layouts: any; + setLayouts: (layouts: any, noPatch?: boolean) => void; // panels lastUsedPanels: Record; setLastUsedPanel: (panelKey: string) => (value: string) => void; @@ -104,6 +108,28 @@ export const useLocalState = create()( set({ userTheme: newTheme }); if (!noPatch) patchUser('theme', newTheme); }, + widgets: [], + setWidgets: (newWidgets, noPatch = false) => { + // check for difference + if (JSON.stringify(newWidgets) === JSON.stringify(get().widgets)) { + return; + } + + set({ widgets: newWidgets }); + if (!noPatch) + patchUser('widgets', { widgets: newWidgets, layouts: get().layouts }); + }, + layouts: {}, + setLayouts: (newLayouts, noPatch) => { + // check for difference + if (JSON.stringify(newLayouts) === JSON.stringify(get().layouts)) { + return; + } + + set({ layouts: newLayouts }); + if (!noPatch) + patchUser('widgets', { widgets: get().widgets, layouts: newLayouts }); + }, // panels lastUsedPanels: {}, setLastUsedPanel: (panelKey) => (value) => { @@ -171,7 +197,7 @@ export const useLocalState = create()( /* pushes changes in user profile to backend */ -function patchUser(key: 'language' | 'theme', val: any) { +function patchUser(key: 'language' | 'theme' | 'widgets', val: any) { const uid = useUserState.getState().userId(); if (uid) { api.patch(apiUrl(ApiEndpoints.user_profile), { [key]: val }); diff --git a/src/frontend/tests/login.ts b/src/frontend/tests/login.ts index f51bfd4dfb..f69bbcd80b 100644 --- a/src/frontend/tests/login.ts +++ b/src/frontend/tests/login.ts @@ -78,7 +78,7 @@ export const doCachedLogin = async ( await page.waitForURL('**/web/**'); // Wait for the dashboard to load - await page.getByText('No widgets selected').waitFor(); + //await page.getByText('No widgets selected').waitFor() await page.waitForLoadState('load'); // Cache the login state diff --git a/src/frontend/tests/pages/pui_dashboard.spec.ts b/src/frontend/tests/pages/pui_dashboard.spec.ts index 43af8998bf..78e70e5150 100644 --- a/src/frontend/tests/pages/pui_dashboard.spec.ts +++ b/src/frontend/tests/pages/pui_dashboard.spec.ts @@ -5,6 +5,10 @@ import { setPluginState } from '../settings.js'; test('Dashboard - Basic', async ({ browser }) => { const page = await doCachedLogin(browser); + // Reset wizards + await page.getByLabel('dashboard-menu').click(); + await page.getByRole('menuitem', { name: 'Clear Widgets' }).click(); + await page.getByText('Use the menu to add widgets').waitFor(); // Let's add some widgets