mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-21 17:36:37 +00:00
fix: use localstorage value to avoid waiting for pref api to set the toggle state, add shortcut (#8751)
This commit is contained in:
parent
5412e7f70b
commit
6d97db1d9d
@ -6,6 +6,7 @@ export const GlobalShortcuts = {
|
|||||||
NavigateToAlerts: 'a+shift',
|
NavigateToAlerts: 'a+shift',
|
||||||
NavigateToExceptions: 'e+shift',
|
NavigateToExceptions: 'e+shift',
|
||||||
NavigateToMessagingQueues: 'm+shift',
|
NavigateToMessagingQueues: 'm+shift',
|
||||||
|
ToggleSidebar: 'b+shift',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GlobalShortcutsName = {
|
export const GlobalShortcutsName = {
|
||||||
@ -16,6 +17,7 @@ export const GlobalShortcutsName = {
|
|||||||
NavigateToAlerts: 'shift+a',
|
NavigateToAlerts: 'shift+a',
|
||||||
NavigateToExceptions: 'shift+e',
|
NavigateToExceptions: 'shift+e',
|
||||||
NavigateToMessagingQueues: 'shift+m',
|
NavigateToMessagingQueues: 'shift+m',
|
||||||
|
ToggleSidebar: 'shift+b',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const GlobalShortcutsDescription = {
|
export const GlobalShortcutsDescription = {
|
||||||
@ -26,4 +28,5 @@ export const GlobalShortcutsDescription = {
|
|||||||
NavigateToAlerts: 'Navigate to alerts page',
|
NavigateToAlerts: 'Navigate to alerts page',
|
||||||
NavigateToExceptions: 'Navigate to Exceptions page',
|
NavigateToExceptions: 'Navigate to Exceptions page',
|
||||||
NavigateToMessagingQueues: 'Navigate to Messaging Queues page',
|
NavigateToMessagingQueues: 'Navigate to Messaging Queues page',
|
||||||
|
ToggleSidebar: 'Toggle sidebar visibility',
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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 <div data-testid="test">Test</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<KeyboardHotkeysProvider>
|
||||||
|
<TestComponent mockHandleShortcut={mockHandleShortcut} />
|
||||||
|
</KeyboardHotkeysProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 (
|
||||||
|
<div>
|
||||||
|
<input data-testid="input-field" />
|
||||||
|
<div data-testid="test">Test</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
render(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<KeyboardHotkeysProvider>
|
||||||
|
<TestComponent />
|
||||||
|
</KeyboardHotkeysProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<KeyboardHotkeysProvider>
|
||||||
|
<TestComponent mockHandleShortcut={mockHandleShortcut} />
|
||||||
|
</KeyboardHotkeysProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<KeyboardHotkeysProvider>
|
||||||
|
<TestComponent mockHandleShortcut={mockHandleShortcut} />
|
||||||
|
</KeyboardHotkeysProvider>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.keyboard(SHIFT_B_KEYBOARD_SHORTCUT);
|
||||||
|
|
||||||
|
expect(mockUpdateUserPreferenceInContext).toHaveBeenCalledWith({
|
||||||
|
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -10,8 +10,10 @@ import setLocalStorageApi from 'api/browser/localstorage/set';
|
|||||||
import getChangelogByVersion from 'api/changelog/getChangelogByVersion';
|
import getChangelogByVersion from 'api/changelog/getChangelogByVersion';
|
||||||
import logEvent from 'api/common/logEvent';
|
import logEvent from 'api/common/logEvent';
|
||||||
import manageCreditCardApi from 'api/v1/portal/create';
|
import manageCreditCardApi from 'api/v1/portal/create';
|
||||||
|
import updateUserPreference from 'api/v1/user/preferences/name/update';
|
||||||
import getUserLatestVersion from 'api/v1/version/getLatestVersion';
|
import getUserLatestVersion from 'api/v1/version/getLatestVersion';
|
||||||
import getUserVersion from 'api/v1/version/getVersion';
|
import getUserVersion from 'api/v1/version/getVersion';
|
||||||
|
import { AxiosError } from 'axios';
|
||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import ChangelogModal from 'components/ChangelogModal/ChangelogModal';
|
import ChangelogModal from 'components/ChangelogModal/ChangelogModal';
|
||||||
import ChatSupportGateway from 'components/ChatSupportGateway/ChatSupportGateway';
|
import ChatSupportGateway from 'components/ChatSupportGateway/ChatSupportGateway';
|
||||||
@ -22,10 +24,12 @@ import { Events } from 'constants/events';
|
|||||||
import { FeatureKeys } from 'constants/features';
|
import { FeatureKeys } from 'constants/features';
|
||||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||||
import ROUTES from 'constants/routes';
|
import ROUTES from 'constants/routes';
|
||||||
|
import { GlobalShortcuts } from 'constants/shortcuts/globalShortcuts';
|
||||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||||
import SideNav from 'container/SideNav';
|
import SideNav from 'container/SideNav';
|
||||||
import TopNav from 'container/TopNav';
|
import TopNav from 'container/TopNav';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||||
import { useNotifications } from 'hooks/useNotifications';
|
import { useNotifications } from 'hooks/useNotifications';
|
||||||
@ -68,8 +72,10 @@ import {
|
|||||||
LicensePlatform,
|
LicensePlatform,
|
||||||
LicenseState,
|
LicenseState,
|
||||||
} from 'types/api/licensesV3/getActive';
|
} from 'types/api/licensesV3/getActive';
|
||||||
|
import { UserPreference } from 'types/api/preferences/preference';
|
||||||
import AppReducer from 'types/reducer/app';
|
import AppReducer from 'types/reducer/app';
|
||||||
import { USER_ROLES } from 'types/roles';
|
import { USER_ROLES } from 'types/roles';
|
||||||
|
import { showErrorNotification } from 'utils/error';
|
||||||
import { eventEmitter } from 'utils/getEventEmitter';
|
import { eventEmitter } from 'utils/getEventEmitter';
|
||||||
import {
|
import {
|
||||||
getFormattedDate,
|
getFormattedDate,
|
||||||
@ -662,10 +668,85 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const sideNavPinned = userPreferences?.find(
|
const sideNavPinnedPreference = userPreferences?.find(
|
||||||
(preference) => preference.name === USER_PREFERENCES.SIDENAV_PINNED,
|
(preference) => preference.name === USER_PREFERENCES.SIDENAV_PINNED,
|
||||||
)?.value as boolean;
|
)?.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 =
|
const SHOW_TRIAL_EXPIRY_BANNER =
|
||||||
showTrialExpiryBanner && !showPaymentFailedWarning;
|
showTrialExpiryBanner && !showPaymentFailedWarning;
|
||||||
const SHOW_WORKSPACE_RESTRICTED_BANNER = showWorkspaceRestricted;
|
const SHOW_WORKSPACE_RESTRICTED_BANNER = showWorkspaceRestricted;
|
||||||
@ -739,14 +820,14 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
|||||||
className={cx(
|
className={cx(
|
||||||
'app-layout',
|
'app-layout',
|
||||||
isDarkMode ? 'darkMode dark' : 'lightMode',
|
isDarkMode ? 'darkMode dark' : 'lightMode',
|
||||||
sideNavPinned ? 'side-nav-pinned' : '',
|
isSideNavPinned ? 'side-nav-pinned' : '',
|
||||||
SHOW_WORKSPACE_RESTRICTED_BANNER ? 'isWorkspaceRestricted' : '',
|
SHOW_WORKSPACE_RESTRICTED_BANNER ? 'isWorkspaceRestricted' : '',
|
||||||
SHOW_TRIAL_EXPIRY_BANNER ? 'isTrialExpired' : '',
|
SHOW_TRIAL_EXPIRY_BANNER ? 'isTrialExpired' : '',
|
||||||
SHOW_PAYMENT_FAILED_BANNER ? 'isPaymentFailed' : '',
|
SHOW_PAYMENT_FAILED_BANNER ? 'isPaymentFailed' : '',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isToDisplayLayout && !renderFullScreen && (
|
{isToDisplayLayout && !renderFullScreen && (
|
||||||
<SideNav isPinned={sideNavPinned} />
|
<SideNav isPinned={isSideNavPinned} />
|
||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
className={cx('app-content', {
|
className={cx('app-content', {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import './MySettings.styles.scss';
|
import './MySettings.styles.scss';
|
||||||
|
|
||||||
import { Radio, RadioChangeEvent, Switch, Tag } from 'antd';
|
import { Radio, RadioChangeEvent, Switch, Tag } from 'antd';
|
||||||
|
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||||
import logEvent from 'api/common/logEvent';
|
import logEvent from 'api/common/logEvent';
|
||||||
import updateUserPreference from 'api/v1/user/preferences/name/update';
|
import updateUserPreference from 'api/v1/user/preferences/name/update';
|
||||||
import { AxiosError } from 'axios';
|
import { AxiosError } from 'axios';
|
||||||
@ -109,6 +110,9 @@ function MySettings(): JSX.Element {
|
|||||||
// Optimistically update the UI
|
// Optimistically update the UI
|
||||||
setSideNavPinned(checked);
|
setSideNavPinned(checked);
|
||||||
|
|
||||||
|
// Save to localStorage immediately for instant feedback
|
||||||
|
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, checked.toString());
|
||||||
|
|
||||||
// Update the context immediately
|
// Update the context immediately
|
||||||
const save = {
|
const save = {
|
||||||
name: USER_PREFERENCES.SIDENAV_PINNED,
|
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||||
@ -130,6 +134,8 @@ function MySettings(): JSX.Element {
|
|||||||
name: USER_PREFERENCES.SIDENAV_PINNED,
|
name: USER_PREFERENCES.SIDENAV_PINNED,
|
||||||
value: !checked,
|
value: !checked,
|
||||||
} as UserPreference);
|
} as UserPreference);
|
||||||
|
// Also revert localStorage
|
||||||
|
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, (!checked).toString());
|
||||||
showErrorNotification(notifications, error as AxiosError);
|
showErrorNotification(notifications, error as AxiosError);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user