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 (
+
+ );
+ }
+
+ 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 && (
-
+
)}