diff --git a/packages/grafana-ui/src/components/Sidebar/Sidebar.test.tsx b/packages/grafana-ui/src/components/Sidebar/Sidebar.test.tsx index 5e5116b3ada..63efd97922f 100644 --- a/packages/grafana-ui/src/components/Sidebar/Sidebar.test.tsx +++ b/packages/grafana-ui/src/components/Sidebar/Sidebar.test.tsx @@ -23,13 +23,32 @@ describe('Sidebar', () => { // Verify pane is closed expect(screen.queryByTestId('sidebar-pane-header-title')).not.toBeInTheDocument(); }); + + it('Can persist docked state', async () => { + const { unmount } = render(); + + act(() => screen.getByLabelText('Settings').click()); + act(() => screen.getByLabelText('Dock').click()); + + unmount(); + + render(); + + act(() => screen.getByLabelText('Settings').click()); + expect(screen.getByLabelText('Undock')).toBeInTheDocument(); + }); }); -function TestSetup() { +interface TestSetupProps { + persistanceKey?: string; +} + +function TestSetup({ persistanceKey }: TestSetupProps) { const [openPane, setOpenPane] = React.useState(''); const contextValue = useSidebar({ position: 'right', hasOpenPane: openPane !== '', + persistanceKey, onClosePane: () => setOpenPane(''), }); diff --git a/packages/grafana-ui/src/components/Sidebar/useSidebar.tsx b/packages/grafana-ui/src/components/Sidebar/useSidebar.tsx index 82a577bf36c..8bbb0c2ac2a 100644 --- a/packages/grafana-ui/src/components/Sidebar/useSidebar.tsx +++ b/packages/grafana-ui/src/components/Sidebar/useSidebar.tsx @@ -1,6 +1,8 @@ import { clamp } from 'lodash'; import React, { useCallback } from 'react'; +import { store } from '@grafana/data'; + import { useTheme2 } from '../../themes/ThemeContext'; export type SidebarPosition = 'left' | 'right'; @@ -30,7 +32,10 @@ export interface UseSideBarOptions { hasOpenPane?: boolean; position?: SidebarPosition; tabsMode?: boolean; - compactDefault?: boolean; + /** Initial state for compact mode */ + defaultToCompact?: boolean; + /** Initial state for docked mode */ + defaultToDocked?: boolean; /** defaults to 2 grid units (16px) */ bottomMargin?: number; /** defaults to 2 grid units (16px) */ @@ -39,6 +44,11 @@ export interface UseSideBarOptions { contentMargin?: number; /** Called when pane is closed or clicked outside of (in undocked mode) */ onClosePane?: () => void; + /** + * Optional key to use for persisting sidebar state (docked / compact / size) + * Can only be app name as the final local storag key will be `grafana.ui.sidebar.{persistanceKey}.{docked|compact|size}` + */ + persistanceKey?: string; } export const SIDE_BAR_WIDTH_ICON_ONLY = 5; @@ -48,21 +58,30 @@ export function useSidebar({ hasOpenPane, position = 'right', tabsMode, - compactDefault = true, + defaultToCompact = true, + defaultToDocked = false, bottomMargin = 2, edgeMargin = 2, contentMargin = 2, + persistanceKey, onClosePane, }: UseSideBarOptions): SidebarContextValue { const theme = useTheme2(); - const [isDocked, setIsDocked] = React.useState(false); - const [paneWidth, setPaneWidth] = React.useState(280); - const [compact, setCompact] = React.useState(compactDefault); + + const [isDocked, setIsDocked] = useSidebarSavedState(persistanceKey, 'docked', defaultToDocked); + const [compact, setCompact] = useSidebarSavedState(persistanceKey, 'compact', defaultToCompact); + const [paneWidth, setPaneWidth] = useSidebarSavedState(persistanceKey, 'size', 280); + // Used to accumulate drag distance to know when to change compact mode const [_, setCompactDrag] = React.useState(0); - const onToggleDock = useCallback(() => setIsDocked((prev) => !prev), []); + const onToggleDock = useCallback(() => { + setIsDocked((prev) => { + return !prev; + }); + }, [setIsDocked]); + // Calculate how much space the outer wrapper needs to reserve for the sidebar toolbar + pane (if docked) const prop = position === 'right' ? 'paddingRight' : 'paddingLeft'; const toolbarWidth = ((compact ? SIDE_BAR_WIDTH_ICON_ONLY : SIDE_BAR_WIDTH_WITH_TEXT) + edgeMargin + contentMargin) * @@ -82,10 +101,10 @@ export function useSidebar({ setCompactDrag((prevDrag) => { const newDrag = prevDrag + diff; if (newDrag < -20 && !compact) { - setCompact(true); + setCompact(() => true); return 0; } else if (newDrag > 20 && compact) { - setCompact(false); + setCompact(() => false); return 0; } @@ -98,7 +117,7 @@ export function useSidebar({ return clamp(prevWidth + diff, 100, 500); }); }, - [hasOpenPane, compact] + [hasOpenPane, setCompact, setPaneWidth, compact] ); return { @@ -117,3 +136,53 @@ export function useSidebar({ onClosePane, }; } + +function useSidebarSavedState( + persistanceKey: string | undefined, + subKey: string, + defaultValue: T +) { + const [state, setState] = React.useState(() => { + if (!persistanceKey) { + return defaultValue; + } + + if (typeof defaultValue === 'boolean') { + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return store.getBool(`grafana.ui.sidebar.${persistanceKey}.${subKey}`, defaultValue) as T; + } + + if (typeof defaultValue === 'number') { + const value = Number.parseInt(store.get(`grafana.ui.sidebar.${persistanceKey}.${subKey}`), 10); + if (Number.isNaN(value)) { + return defaultValue; + } + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return value as T; + } + + return defaultValue; + }); + + const setPersisted = useCallback( + (cb: (prevState: T) => T) => { + setState((prevState) => { + const newState = cb(prevState); + + if (!persistanceKey) { + return newState; + } + + if (persistanceKey) { + store.set(`grafana.ui.sidebar.${persistanceKey}.${subKey}`, String(newState)); + } + + return newState; + }); + }, + [persistanceKey, subKey] + ); + + return [state, setPersisted] as const; +} diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx index 59af972e3b0..091e4805bae 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx @@ -69,6 +69,7 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls hasOpenPane: Boolean(openPane), contentMargin: 1, position: 'right', + persistanceKey: 'dashboard', onClosePane: () => editPane.closePane(), });