diff --git a/frontend/src/constants/shortcuts/globalShortcuts.ts b/frontend/src/constants/shortcuts/globalShortcuts.ts index 8b68b7195e2d..4cc969504d68 100644 --- a/frontend/src/constants/shortcuts/globalShortcuts.ts +++ b/frontend/src/constants/shortcuts/globalShortcuts.ts @@ -6,6 +6,7 @@ export const GlobalShortcuts = { NavigateToAlerts: 'a+shift', NavigateToExceptions: 'e+shift', NavigateToMessagingQueues: 'm+shift', + ToggleSidebar: 'b+shift', }; export const GlobalShortcutsName = { @@ -16,6 +17,7 @@ export const GlobalShortcutsName = { NavigateToAlerts: 'shift+a', NavigateToExceptions: 'shift+e', NavigateToMessagingQueues: 'shift+m', + ToggleSidebar: 'shift+b', }; export const GlobalShortcutsDescription = { @@ -26,4 +28,5 @@ export const GlobalShortcutsDescription = { NavigateToAlerts: 'Navigate to alerts page', NavigateToExceptions: 'Navigate to Exceptions page', NavigateToMessagingQueues: 'Navigate to Messaging Queues page', + ToggleSidebar: 'Toggle sidebar visibility', }; diff --git a/frontend/src/container/AppLayout/__tests__/sidebar-toggle-shortcut.test.tsx b/frontend/src/container/AppLayout/__tests__/sidebar-toggle-shortcut.test.tsx new file mode 100644 index 000000000000..b0b2828194b1 --- /dev/null +++ b/frontend/src/container/AppLayout/__tests__/sidebar-toggle-shortcut.test.tsx @@ -0,0 +1,176 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import logEvent from 'api/common/logEvent'; +import { GlobalShortcuts } from 'constants/shortcuts/globalShortcuts'; +import { USER_PREFERENCES } from 'constants/userPreferences'; +import { + KeyboardHotkeysProvider, + useKeyboardHotkeys, +} from 'hooks/hotkeys/useKeyboardHotkeys'; +import { QueryClient, QueryClientProvider } from 'react-query'; + +// Mock dependencies +jest.mock('api/common/logEvent', () => jest.fn()); + +// Mock the AppContext +const mockUpdateUserPreferenceInContext = jest.fn(); + +const SHIFT_B_KEYBOARD_SHORTCUT = '{Shift>}b{/Shift}'; + +jest.mock('providers/App/App', () => ({ + useAppContext: jest.fn(() => ({ + userPreferences: [ + { + name: USER_PREFERENCES.SIDENAV_PINNED, + value: false, + }, + ], + updateUserPreferenceInContext: mockUpdateUserPreferenceInContext, + })), +})); + +function TestComponent({ + mockHandleShortcut, +}: { + mockHandleShortcut: () => void; +}): JSX.Element { + const { registerShortcut } = useKeyboardHotkeys(); + registerShortcut(GlobalShortcuts.ToggleSidebar, mockHandleShortcut); + return
Test
; +} + +describe('Sidebar Toggle Shortcut', () => { + let queryClient: QueryClient; + + beforeEach(() => { + queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + mutations: { + retry: false, + }, + }, + }); + + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('Global Shortcuts Constants', () => { + it('should have the correct shortcut key combination', () => { + expect(GlobalShortcuts.ToggleSidebar).toBe('b+shift'); + }); + }); + + describe('Keyboard Shortcut Registration', () => { + it('should register the sidebar toggle shortcut correctly', async () => { + const user = userEvent.setup(); + const mockHandleShortcut = jest.fn(); + + render( + + + + + , + ); + + // Trigger the shortcut + await user.keyboard(SHIFT_B_KEYBOARD_SHORTCUT); + + expect(mockHandleShortcut).toHaveBeenCalled(); + }); + + it('should not trigger shortcut in input fields', async () => { + const user = userEvent.setup(); + const mockHandleShortcut = jest.fn(); + + function TestComponent(): JSX.Element { + const { registerShortcut } = useKeyboardHotkeys(); + registerShortcut(GlobalShortcuts.ToggleSidebar, mockHandleShortcut); + return ( +
+ +
Test
+
+ ); + } + + render( + + + + + , + ); + + // Focus on input field + const inputField = screen.getByTestId('input-field'); + await user.click(inputField); + + // Try to trigger shortcut while focused on input + await user.keyboard('{Shift>}b{/Shift}'); + + // Should not trigger the shortcut + expect(mockHandleShortcut).not.toHaveBeenCalled(); + }); + }); + + describe('Sidebar Toggle Functionality', () => { + it('should log the toggle event with correct parameters', async () => { + const user = userEvent.setup(); + const mockHandleShortcut = jest.fn(() => { + logEvent('Global Shortcut: Sidebar Toggle', { + previousState: false, + newState: true, + }); + }); + + render( + + + + + , + ); + + await user.keyboard(SHIFT_B_KEYBOARD_SHORTCUT); + + expect(logEvent).toHaveBeenCalledWith('Global Shortcut: Sidebar Toggle', { + previousState: false, + newState: true, + }); + }); + + it('should update user preference in context', async () => { + const user = userEvent.setup(); + const mockHandleShortcut = jest.fn(() => { + const save = { + name: USER_PREFERENCES.SIDENAV_PINNED, + value: true, + }; + mockUpdateUserPreferenceInContext(save); + }); + + render( + + + + + , + ); + + await user.keyboard(SHIFT_B_KEYBOARD_SHORTCUT); + + expect(mockUpdateUserPreferenceInContext).toHaveBeenCalledWith({ + name: USER_PREFERENCES.SIDENAV_PINNED, + value: true, + }); + }); + }); +}); diff --git a/frontend/src/container/AppLayout/index.tsx b/frontend/src/container/AppLayout/index.tsx index 43d6b9884115..06f5ada65b93 100644 --- a/frontend/src/container/AppLayout/index.tsx +++ b/frontend/src/container/AppLayout/index.tsx @@ -10,8 +10,10 @@ import setLocalStorageApi from 'api/browser/localstorage/set'; import getChangelogByVersion from 'api/changelog/getChangelogByVersion'; import logEvent from 'api/common/logEvent'; import manageCreditCardApi from 'api/v1/portal/create'; +import updateUserPreference from 'api/v1/user/preferences/name/update'; import getUserLatestVersion from 'api/v1/version/getLatestVersion'; import getUserVersion from 'api/v1/version/getVersion'; +import { AxiosError } from 'axios'; import cx from 'classnames'; import ChangelogModal from 'components/ChangelogModal/ChangelogModal'; import ChatSupportGateway from 'components/ChatSupportGateway/ChatSupportGateway'; @@ -22,10 +24,12 @@ import { Events } from 'constants/events'; import { FeatureKeys } from 'constants/features'; import { LOCALSTORAGE } from 'constants/localStorage'; import ROUTES from 'constants/routes'; +import { GlobalShortcuts } from 'constants/shortcuts/globalShortcuts'; import { USER_PREFERENCES } from 'constants/userPreferences'; import SideNav from 'container/SideNav'; import TopNav from 'container/TopNav'; import dayjs from 'dayjs'; +import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys'; import { useIsDarkMode } from 'hooks/useDarkMode'; import { useGetTenantLicense } from 'hooks/useGetTenantLicense'; import { useNotifications } from 'hooks/useNotifications'; @@ -68,8 +72,10 @@ import { LicensePlatform, LicenseState, } from 'types/api/licensesV3/getActive'; +import { UserPreference } from 'types/api/preferences/preference'; import AppReducer from 'types/reducer/app'; import { USER_ROLES } from 'types/roles'; +import { showErrorNotification } from 'utils/error'; import { eventEmitter } from 'utils/getEventEmitter'; import { getFormattedDate, @@ -662,10 +668,85 @@ function AppLayout(props: AppLayoutProps): JSX.Element { ); - const sideNavPinned = userPreferences?.find( + const sideNavPinnedPreference = userPreferences?.find( (preference) => preference.name === USER_PREFERENCES.SIDENAV_PINNED, )?.value as boolean; + // Add loading state to prevent layout shift during initial load + const [isSidebarLoaded, setIsSidebarLoaded] = useState(false); + + // Get sidebar state from localStorage as fallback until preferences are loaded + const getSidebarStateFromLocalStorage = useCallback((): boolean => { + try { + const storedValue = getLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED); + return storedValue === 'true'; + } catch { + return false; + } + }, []); + + // Set sidebar as loaded after user preferences are fetched + useEffect(() => { + if (userPreferences !== null) { + setIsSidebarLoaded(true); + } + }, [userPreferences]); + + // Use localStorage value as fallback until preferences are loaded + const isSideNavPinned = isSidebarLoaded + ? sideNavPinnedPreference + : getSidebarStateFromLocalStorage(); + + const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys(); + const { updateUserPreferenceInContext } = useAppContext(); + + const { mutate: updateUserPreferenceMutation } = useMutation( + updateUserPreference, + { + onError: (error) => { + showErrorNotification(notifications, error as AxiosError); + }, + }, + ); + + const handleToggleSidebar = useCallback((): void => { + const newState = !isSideNavPinned; + + logEvent('Global Shortcut: Sidebar Toggle', { + previousState: isSideNavPinned, + newState, + }); + + // Save to localStorage immediately for instant feedback + setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, newState.toString()); + + // Update the context immediately + const save = { + name: USER_PREFERENCES.SIDENAV_PINNED, + value: newState, + }; + updateUserPreferenceInContext(save as UserPreference); + + // Make the API call in the background + updateUserPreferenceMutation({ + name: USER_PREFERENCES.SIDENAV_PINNED, + value: newState, + }); + }, [ + isSideNavPinned, + updateUserPreferenceInContext, + updateUserPreferenceMutation, + ]); + + // Register the sidebar toggle shortcut + useEffect(() => { + registerShortcut(GlobalShortcuts.ToggleSidebar, handleToggleSidebar); + + return (): void => { + deregisterShortcut(GlobalShortcuts.ToggleSidebar); + }; + }, [registerShortcut, deregisterShortcut, handleToggleSidebar]); + const SHOW_TRIAL_EXPIRY_BANNER = showTrialExpiryBanner && !showPaymentFailedWarning; const SHOW_WORKSPACE_RESTRICTED_BANNER = showWorkspaceRestricted; @@ -739,14 +820,14 @@ function AppLayout(props: AppLayoutProps): JSX.Element { className={cx( 'app-layout', isDarkMode ? 'darkMode dark' : 'lightMode', - sideNavPinned ? 'side-nav-pinned' : '', + isSideNavPinned ? 'side-nav-pinned' : '', SHOW_WORKSPACE_RESTRICTED_BANNER ? 'isWorkspaceRestricted' : '', SHOW_TRIAL_EXPIRY_BANNER ? 'isTrialExpired' : '', SHOW_PAYMENT_FAILED_BANNER ? 'isPaymentFailed' : '', )} > {isToDisplayLayout && !renderFullScreen && ( - + )}