mirror of
				https://github.com/inventree/InvenTree.git
				synced 2025-10-31 05:05:42 +00:00 
			
		
		
		
	[FR] Save widget state per user (#9567)
* [FR] Save widget state per user Fixes #9562 * cleanup * fix doc strings * add reset stage
This commit is contained in:
		| @@ -6,102 +6,28 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; | |||||||
| import { type Layout, Responsive, WidthProvider } from 'react-grid-layout'; | import { type Layout, Responsive, WidthProvider } from 'react-grid-layout'; | ||||||
|  |  | ||||||
| import { useDashboardItems } from '../../hooks/UseDashboardItems'; | import { useDashboardItems } from '../../hooks/UseDashboardItems'; | ||||||
| import { useUserState } from '../../states/UserState'; | import { useLocalState } from '../../states/LocalState'; | ||||||
| import DashboardMenu from './DashboardMenu'; | import DashboardMenu from './DashboardMenu'; | ||||||
| import DashboardWidget, { type DashboardWidgetProps } from './DashboardWidget'; | import DashboardWidget, { type DashboardWidgetProps } from './DashboardWidget'; | ||||||
| import DashboardWidgetDrawer from './DashboardWidgetDrawer'; | import DashboardWidgetDrawer from './DashboardWidgetDrawer'; | ||||||
|  |  | ||||||
| const ReactGridLayout = WidthProvider(Responsive); | 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<string, Layout[]> { |  | ||||||
|   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() { | export default function DashboardLayout() { | ||||||
|   const user = useUserState(); |  | ||||||
|  |  | ||||||
|   // Dashboard layout definition |   // Dashboard layout definition | ||||||
|   const [layouts, setLayouts] = useState({}); |   const [layouts, setLayouts] = useState({}); | ||||||
|  |  | ||||||
|   // Dashboard widget selection |   // Dashboard widget selection | ||||||
|   const [widgets, setWidgets] = useState<DashboardWidgetProps[]>([]); |   const [widgets, setWidgets] = useState<DashboardWidgetProps[]>([]); | ||||||
|  |  | ||||||
|  |   // 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 [editing, setEditing] = useDisclosure(false); | ||||||
|   const [removing, setRemoving] = 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 |   // Save the selected widgets to local storage when the selection changes | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     if (loaded) { |     if (loaded) { | ||||||
|       saveDashboardWidgets(widgetLabels, user.userId()); |       setRemoteWidgets(widgetLabels); | ||||||
|     } |     } | ||||||
|   }, [widgetLabels]); |   }, [widgetLabels]); | ||||||
|  |  | ||||||
| @@ -232,7 +158,18 @@ export default function DashboardLayout() { | |||||||
|       }); |       }); | ||||||
|  |  | ||||||
|       if (layouts && loaded && availableWidgets.loaded) { |       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); |         setLayouts(newLayouts); | ||||||
|       } |       } | ||||||
|     }, |     }, | ||||||
| @@ -242,13 +179,10 @@ export default function DashboardLayout() { | |||||||
|   // Load the dashboard layout from local storage |   // Load the dashboard layout from local storage | ||||||
|   useEffect(() => { |   useEffect(() => { | ||||||
|     if (availableWidgets.loaded) { |     if (availableWidgets.loaded) { | ||||||
|       const initialLayouts = loadDashboardLayout(user.userId()); |       setLayouts(remoteLayouts); | ||||||
|       const initialWidgetLabels = loadDashboardWidgets(user.userId()); |  | ||||||
|  |  | ||||||
|       setLayouts(initialLayouts); |  | ||||||
|       setWidgets( |       setWidgets( | ||||||
|         availableWidgets.items.filter((widget) => |         availableWidgets.items.filter((widget) => | ||||||
|           initialWidgetLabels.includes(widget.label) |           remoteWidgets.includes(widget.label) | ||||||
|         ) |         ) | ||||||
|       ); |       ); | ||||||
|  |  | ||||||
| @@ -256,6 +190,12 @@ export default function DashboardLayout() { | |||||||
|     } |     } | ||||||
|   }, [availableWidgets.loaded]); |   }, [availableWidgets.loaded]); | ||||||
|  |  | ||||||
|  |   // Clear all widgets from the dashboard | ||||||
|  |   const clearWidgets = useCallback(() => { | ||||||
|  |     setWidgets([]); | ||||||
|  |     setLayouts({}); | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <> |     <> | ||||||
|       <DashboardWidgetDrawer |       <DashboardWidgetDrawer | ||||||
| @@ -267,6 +207,7 @@ export default function DashboardLayout() { | |||||||
|       <DashboardMenu |       <DashboardMenu | ||||||
|         onAddWidget={openWidgetDrawer} |         onAddWidget={openWidgetDrawer} | ||||||
|         onStartEdit={setEditing.open} |         onStartEdit={setEditing.open} | ||||||
|  |         onClear={clearWidgets} | ||||||
|         onStartRemove={setRemoving.open} |         onStartRemove={setRemoving.open} | ||||||
|         onAcceptLayout={() => { |         onAcceptLayout={() => { | ||||||
|           setEditing.close(); |           setEditing.close(); | ||||||
|   | |||||||
| @@ -30,6 +30,7 @@ export default function DashboardMenu({ | |||||||
|   onAddWidget, |   onAddWidget, | ||||||
|   onStartEdit, |   onStartEdit, | ||||||
|   onStartRemove, |   onStartRemove, | ||||||
|  |   onClear, | ||||||
|   onAcceptLayout |   onAcceptLayout | ||||||
| }: Readonly<{ | }: Readonly<{ | ||||||
|   editing: boolean; |   editing: boolean; | ||||||
| @@ -37,6 +38,7 @@ export default function DashboardMenu({ | |||||||
|   onAddWidget: () => void; |   onAddWidget: () => void; | ||||||
|   onStartEdit: () => void; |   onStartEdit: () => void; | ||||||
|   onStartRemove: () => void; |   onStartRemove: () => void; | ||||||
|  |   onClear: () => void; | ||||||
|   onAcceptLayout: () => void; |   onAcceptLayout: () => void; | ||||||
| }>) { | }>) { | ||||||
|   const user = useUserState(); |   const user = useUserState(); | ||||||
| @@ -119,6 +121,15 @@ export default function DashboardMenu({ | |||||||
|                 </Menu.Item> |                 </Menu.Item> | ||||||
|               )} |               )} | ||||||
|  |  | ||||||
|  |               {!editing && !removing && ( | ||||||
|  |                 <Menu.Item | ||||||
|  |                   leftSection={<IconLayoutGridRemove color='red' size={14} />} | ||||||
|  |                   onClick={onClear} | ||||||
|  |                 > | ||||||
|  |                   <Trans>Clear Widgets</Trans> | ||||||
|  |                 </Menu.Item> | ||||||
|  |               )} | ||||||
|  |  | ||||||
|               {(editing || removing) && ( |               {(editing || removing) && ( | ||||||
|                 <Menu.Item |                 <Menu.Item | ||||||
|                   leftSection={<IconCircleCheck color='green' size={14} />} |                   leftSection={<IconCircleCheck color='green' size={14} />} | ||||||
|   | |||||||
| @@ -183,7 +183,7 @@ function observeProfile() { | |||||||
|   // overwrite language and theme info in session with profile info |   // overwrite language and theme info in session with profile info | ||||||
|  |  | ||||||
|   const user = useUserState.getState().getUser(); |   const user = useUserState.getState().getUser(); | ||||||
|   const { language, setLanguage, userTheme, setTheme } = |   const { language, setLanguage, userTheme, setTheme, setWidgets, setLayouts } = | ||||||
|     useLocalState.getState(); |     useLocalState.getState(); | ||||||
|   if (user) { |   if (user) { | ||||||
|     if (user.profile?.language && language != user.profile.language) { |     if (user.profile?.language && language != user.profile.language) { | ||||||
| @@ -216,6 +216,15 @@ function observeProfile() { | |||||||
|         setTheme(newTheme); |         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); | ||||||
|  |     } | ||||||
|   } |   } | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -28,6 +28,10 @@ interface LocalStateProps { | |||||||
|     }[], |     }[], | ||||||
|     noPatch?: boolean |     noPatch?: boolean | ||||||
|   ) => void; |   ) => void; | ||||||
|  |   widgets: string[]; | ||||||
|  |   setWidgets: (widgets: string[], noPatch?: boolean) => void; | ||||||
|  |   layouts: any; | ||||||
|  |   setLayouts: (layouts: any, noPatch?: boolean) => void; | ||||||
|   // panels |   // panels | ||||||
|   lastUsedPanels: Record<string, string>; |   lastUsedPanels: Record<string, string>; | ||||||
|   setLastUsedPanel: (panelKey: string) => (value: string) => void; |   setLastUsedPanel: (panelKey: string) => (value: string) => void; | ||||||
| @@ -104,6 +108,28 @@ export const useLocalState = create<LocalStateProps>()( | |||||||
|         set({ userTheme: newTheme }); |         set({ userTheme: newTheme }); | ||||||
|         if (!noPatch) patchUser('theme', 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 |       // panels | ||||||
|       lastUsedPanels: {}, |       lastUsedPanels: {}, | ||||||
|       setLastUsedPanel: (panelKey) => (value) => { |       setLastUsedPanel: (panelKey) => (value) => { | ||||||
| @@ -171,7 +197,7 @@ export const useLocalState = create<LocalStateProps>()( | |||||||
| /* | /* | ||||||
| pushes changes in user profile to backend | 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(); |   const uid = useUserState.getState().userId(); | ||||||
|   if (uid) { |   if (uid) { | ||||||
|     api.patch(apiUrl(ApiEndpoints.user_profile), { [key]: val }); |     api.patch(apiUrl(ApiEndpoints.user_profile), { [key]: val }); | ||||||
|   | |||||||
| @@ -78,7 +78,7 @@ export const doCachedLogin = async ( | |||||||
|   await page.waitForURL('**/web/**'); |   await page.waitForURL('**/web/**'); | ||||||
|  |  | ||||||
|   // Wait for the dashboard to load |   // Wait for the dashboard to load | ||||||
|   await page.getByText('No widgets selected').waitFor(); |   //await page.getByText('No widgets selected').waitFor() | ||||||
|   await page.waitForLoadState('load'); |   await page.waitForLoadState('load'); | ||||||
|  |  | ||||||
|   // Cache the login state |   // Cache the login state | ||||||
|   | |||||||
| @@ -5,6 +5,10 @@ import { setPluginState } from '../settings.js'; | |||||||
| test('Dashboard - Basic', async ({ browser }) => { | test('Dashboard - Basic', async ({ browser }) => { | ||||||
|   const page = await doCachedLogin(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(); |   await page.getByText('Use the menu to add widgets').waitFor(); | ||||||
|  |  | ||||||
|   // Let's add some widgets |   // Let's add some widgets | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user