mirror of
https://github.com/SigNoz/signoz.git
synced 2025-12-17 23:47:12 +00:00
feat: update app loading screen and add system theme option (#8567)
* feat: update app loading screen and add system theme option * feat: update test case
This commit is contained in:
parent
55eadf914b
commit
a576982497
@ -3,6 +3,7 @@ import { ConfigProvider } from 'antd';
|
|||||||
import getLocalStorageApi from 'api/browser/localstorage/get';
|
import getLocalStorageApi from 'api/browser/localstorage/get';
|
||||||
import setLocalStorageApi from 'api/browser/localstorage/set';
|
import setLocalStorageApi from 'api/browser/localstorage/set';
|
||||||
import logEvent from 'api/common/logEvent';
|
import logEvent from 'api/common/logEvent';
|
||||||
|
import AppLoading from 'components/AppLoading/AppLoading';
|
||||||
import NotFound from 'components/NotFound';
|
import NotFound from 'components/NotFound';
|
||||||
import Spinner from 'components/Spinner';
|
import Spinner from 'components/Spinner';
|
||||||
import UserpilotRouteTracker from 'components/UserpilotRouteTracker/UserpilotRouteTracker';
|
import UserpilotRouteTracker from 'components/UserpilotRouteTracker/UserpilotRouteTracker';
|
||||||
@ -342,7 +343,7 @@ function App(): JSX.Element {
|
|||||||
if (isLoggedInState) {
|
if (isLoggedInState) {
|
||||||
// if the setup calls are loading then return a spinner
|
// if the setup calls are loading then return a spinner
|
||||||
if (isFetchingActiveLicense || isFetchingUser || isFetchingFeatureFlags) {
|
if (isFetchingActiveLicense || isFetchingUser || isFetchingFeatureFlags) {
|
||||||
return <Spinner tip="Loading..." />;
|
return <AppLoading />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// if the required calls fails then return a something went wrong error
|
// if the required calls fails then return a something went wrong error
|
||||||
|
|||||||
152
frontend/src/components/AppLoading/AppLoading.styles.scss
Normal file
152
frontend/src/components/AppLoading/AppLoading.styles.scss
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
.app-loading-container {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: var(--bg-ink-400, #121317); // Dark theme background
|
||||||
|
|
||||||
|
.app-loading-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 12px;
|
||||||
|
|
||||||
|
margin-bottom: 12px;
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bg-vanilla-100, #ffffff); // White text for dark theme
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-tagline {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
|
||||||
|
.ant-typography {
|
||||||
|
color: var(--bg-vanilla-400, #c0c1c3); // Light gray text for dark theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HTML: <div class="loader"></div> */
|
||||||
|
.loader {
|
||||||
|
width: 150px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 2px;
|
||||||
|
color: var(--bg-robin-500, #4e74f8); // Primary blue color
|
||||||
|
border: 2px solid;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.loader::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
margin: 2px;
|
||||||
|
inset: 0 100% 0 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: currentColor;
|
||||||
|
animation: l6 2s infinite;
|
||||||
|
}
|
||||||
|
@keyframes l6 {
|
||||||
|
100% {
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Light theme styles - more specific selector
|
||||||
|
.app-loading-container.lightMode {
|
||||||
|
background-color: var(
|
||||||
|
--bg-vanilla-100,
|
||||||
|
#ffffff
|
||||||
|
) !important; // White background for light theme
|
||||||
|
|
||||||
|
.app-loading-content {
|
||||||
|
.brand {
|
||||||
|
.brand-title {
|
||||||
|
color: var(--bg-ink-400, #121317) !important; // Dark text for light theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-tagline {
|
||||||
|
.ant-typography {
|
||||||
|
color: var(
|
||||||
|
--bg-ink-300,
|
||||||
|
#6b7280
|
||||||
|
) !important; // Dark gray text for light theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
color: var(
|
||||||
|
--bg-robin-500,
|
||||||
|
#4e74f8
|
||||||
|
) !important; // Keep primary blue color for consistency
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.perilin-bg {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
background: radial-gradient(circle, #fff 10%, transparent 0);
|
||||||
|
background-size: 12px 12px;
|
||||||
|
opacity: 1;
|
||||||
|
|
||||||
|
mask-image: radial-gradient(
|
||||||
|
circle at 50% 0,
|
||||||
|
rgba(11, 12, 14, 0.1) 0,
|
||||||
|
rgba(11, 12, 14, 0) 100%
|
||||||
|
);
|
||||||
|
-webkit-mask-image: radial-gradient(
|
||||||
|
circle at 50% 0,
|
||||||
|
rgba(11, 12, 14, 0.1) 0,
|
||||||
|
rgba(11, 12, 14, 0) 100%
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark theme styles - ensure dark theme is properly applied
|
||||||
|
.app-loading-container.dark {
|
||||||
|
background-color: var(--bg-ink-400, #121317) !important; // Dark background
|
||||||
|
|
||||||
|
.app-loading-content {
|
||||||
|
.brand {
|
||||||
|
.brand-title {
|
||||||
|
color: var(
|
||||||
|
--bg-vanilla-100,
|
||||||
|
#ffffff
|
||||||
|
) !important; // White text for dark theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-tagline {
|
||||||
|
.ant-typography {
|
||||||
|
color: var(
|
||||||
|
--bg-vanilla-400,
|
||||||
|
#c0c1c3
|
||||||
|
) !important; // Light gray text for dark theme
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.loader {
|
||||||
|
color: var(--bg-robin-500, #4e74f8) !important; // Primary blue color
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
frontend/src/components/AppLoading/AppLoading.tsx
Normal file
50
frontend/src/components/AppLoading/AppLoading.tsx
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import './AppLoading.styles.scss';
|
||||||
|
|
||||||
|
import { Typography } from 'antd';
|
||||||
|
import get from 'api/browser/localstorage/get';
|
||||||
|
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||||
|
import { THEME_MODE } from 'hooks/useDarkMode/constant';
|
||||||
|
|
||||||
|
function AppLoading(): JSX.Element {
|
||||||
|
// Get theme from localStorage directly to avoid context dependency
|
||||||
|
const getThemeFromStorage = (): boolean => {
|
||||||
|
try {
|
||||||
|
const theme = get(LOCALSTORAGE.THEME);
|
||||||
|
return theme !== THEME_MODE.LIGHT; // Return true for dark, false for light
|
||||||
|
} catch (error) {
|
||||||
|
// If localStorage is not available, default to dark theme
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isDarkMode = getThemeFromStorage();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`app-loading-container ${isDarkMode ? 'dark' : 'lightMode'}`}>
|
||||||
|
<div className="perilin-bg" />
|
||||||
|
<div className="app-loading-content">
|
||||||
|
<div className="brand">
|
||||||
|
<img
|
||||||
|
src="/Logos/signoz-brand-logo.svg"
|
||||||
|
alt="SigNoz"
|
||||||
|
className="brand-logo"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Typography.Title level={2} className="brand-title">
|
||||||
|
SigNoz
|
||||||
|
</Typography.Title>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="brand-tagline">
|
||||||
|
<Typography.Text>
|
||||||
|
OpenTelemetry-Native Logs, Metrics and Traces in a single pane
|
||||||
|
</Typography.Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="loader" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AppLoading;
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
|
||||||
|
import AppLoading from '../AppLoading';
|
||||||
|
|
||||||
|
// Mock the localStorage API
|
||||||
|
const mockGet = jest.fn();
|
||||||
|
jest.mock('api/browser/localstorage/get', () => ({
|
||||||
|
__esModule: true,
|
||||||
|
default: mockGet,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('AppLoading', () => {
|
||||||
|
const SIGNOZ_TEXT = 'SigNoz';
|
||||||
|
const TAGLINE_TEXT =
|
||||||
|
'OpenTelemetry-Native Logs, Metrics and Traces in a single pane';
|
||||||
|
const CONTAINER_SELECTOR = '.app-loading-container';
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should render loading screen with dark theme by default', () => {
|
||||||
|
// Mock localStorage to return dark theme (or undefined for default)
|
||||||
|
mockGet.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
render(<AppLoading />);
|
||||||
|
|
||||||
|
// Check if main elements are rendered
|
||||||
|
expect(screen.getByAltText(SIGNOZ_TEXT)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(SIGNOZ_TEXT)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(TAGLINE_TEXT)).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check if dark theme class is applied
|
||||||
|
const container = screen.getByText(SIGNOZ_TEXT).closest(CONTAINER_SELECTOR);
|
||||||
|
expect(container).toHaveClass('dark');
|
||||||
|
expect(container).not.toHaveClass('lightMode');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have proper structure and content', () => {
|
||||||
|
// Mock localStorage to return dark theme
|
||||||
|
mockGet.mockReturnValue(undefined);
|
||||||
|
|
||||||
|
render(<AppLoading />);
|
||||||
|
|
||||||
|
// Check for brand logo
|
||||||
|
const logo = screen.getByAltText(SIGNOZ_TEXT);
|
||||||
|
expect(logo).toBeInTheDocument();
|
||||||
|
expect(logo).toHaveAttribute('src', '/Logos/signoz-brand-logo.svg');
|
||||||
|
|
||||||
|
// Check for brand title
|
||||||
|
const title = screen.getByText(SIGNOZ_TEXT);
|
||||||
|
expect(title).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check for tagline
|
||||||
|
const tagline = screen.getByText(TAGLINE_TEXT);
|
||||||
|
expect(tagline).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check for loader
|
||||||
|
const loader = document.querySelector('.loader');
|
||||||
|
expect(loader).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle localStorage errors gracefully', () => {
|
||||||
|
// Mock localStorage to throw an error
|
||||||
|
mockGet.mockImplementation(() => {
|
||||||
|
throw new Error('localStorage not available');
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<AppLoading />);
|
||||||
|
|
||||||
|
// Should still render with dark theme as fallback
|
||||||
|
expect(screen.getByText(SIGNOZ_TEXT)).toBeInTheDocument();
|
||||||
|
const container = screen.getByText(SIGNOZ_TEXT).closest(CONTAINER_SELECTOR);
|
||||||
|
expect(container).toHaveClass('dark');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -4,6 +4,7 @@ export enum LOCALSTORAGE {
|
|||||||
AUTH_TOKEN = 'AUTH_TOKEN',
|
AUTH_TOKEN = 'AUTH_TOKEN',
|
||||||
REFRESH_AUTH_TOKEN = 'REFRESH_AUTH_TOKEN',
|
REFRESH_AUTH_TOKEN = 'REFRESH_AUTH_TOKEN',
|
||||||
THEME = 'THEME',
|
THEME = 'THEME',
|
||||||
|
THEME_AUTO_SWITCH = 'THEME_AUTO_SWITCH',
|
||||||
LOGS_VIEW_MODE = 'LOGS_VIEW_MODE',
|
LOGS_VIEW_MODE = 'LOGS_VIEW_MODE',
|
||||||
LOGS_LINES_PER_ROW = 'LOGS_LINES_PER_ROW',
|
LOGS_LINES_PER_ROW = 'LOGS_LINES_PER_ROW',
|
||||||
LOGS_LIST_OPTIONS = 'LOGS_LIST_OPTIONS',
|
LOGS_LIST_OPTIONS = 'LOGS_LIST_OPTIONS',
|
||||||
|
|||||||
@ -120,6 +120,28 @@
|
|||||||
line-height: 20px; /* 142.857% */
|
line-height: 20px; /* 142.857% */
|
||||||
letter-spacing: -0.07px;
|
letter-spacing: -0.07px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auto-theme-info {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-slate-400, #1d212d);
|
||||||
|
border: 1px solid var(--bg-slate-500, #161922);
|
||||||
|
|
||||||
|
.auto-theme-status {
|
||||||
|
color: var(--bg-vanilla-400, #c0c1c3);
|
||||||
|
font-family: Inter;
|
||||||
|
font-size: 11px;
|
||||||
|
font-style: normal;
|
||||||
|
line-height: 16px;
|
||||||
|
letter-spacing: -0.07px;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--bg-robin-400, #4e74f8);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -168,6 +190,19 @@
|
|||||||
.user-preference-section-content-item-description {
|
.user-preference-section-content-item-description {
|
||||||
color: var(--bg-ink-300);
|
color: var(--bg-ink-300);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auto-theme-info {
|
||||||
|
background: var(--bg-vanilla-200);
|
||||||
|
border: 1px solid var(--bg-vanilla-300);
|
||||||
|
|
||||||
|
.auto-theme-status {
|
||||||
|
color: var(--bg-ink-300);
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--bg-robin-500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,8 +7,11 @@ const logEventFunction = jest.fn();
|
|||||||
jest.mock('hooks/useDarkMode', () => ({
|
jest.mock('hooks/useDarkMode', () => ({
|
||||||
__esModule: true,
|
__esModule: true,
|
||||||
useIsDarkMode: jest.fn(() => true),
|
useIsDarkMode: jest.fn(() => true),
|
||||||
|
useSystemTheme: jest.fn(() => 'dark'),
|
||||||
default: jest.fn(() => ({
|
default: jest.fn(() => ({
|
||||||
toggleTheme: toggleThemeFunction,
|
toggleTheme: toggleThemeFunction,
|
||||||
|
autoSwitch: false,
|
||||||
|
setAutoSwitch: jest.fn(),
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@ -45,7 +48,7 @@ describe('MySettings Flows', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('Dark/Light Theme Switch', () => {
|
describe('Dark/Light Theme Switch', () => {
|
||||||
it('Should display Dark and Light theme options properly', async () => {
|
it('Should display Dark, Light, and System theme options properly', async () => {
|
||||||
// Check Dark theme option
|
// Check Dark theme option
|
||||||
expect(screen.getByText('Dark')).toBeInTheDocument();
|
expect(screen.getByText('Dark')).toBeInTheDocument();
|
||||||
const darkThemeIcon = screen.getByTestId('dark-theme-icon');
|
const darkThemeIcon = screen.getByTestId('dark-theme-icon');
|
||||||
@ -58,6 +61,12 @@ describe('MySettings Flows', () => {
|
|||||||
expect(lightThemeIcon).toBeInTheDocument();
|
expect(lightThemeIcon).toBeInTheDocument();
|
||||||
expect(lightThemeIcon.tagName).toBe('svg');
|
expect(lightThemeIcon.tagName).toBe('svg');
|
||||||
expect(screen.getByText('Beta')).toBeInTheDocument();
|
expect(screen.getByText('Beta')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Check System theme option
|
||||||
|
expect(screen.getByText('System')).toBeInTheDocument();
|
||||||
|
const autoThemeIcon = screen.getByTestId('auto-theme-icon');
|
||||||
|
expect(autoThemeIcon).toBeInTheDocument();
|
||||||
|
expect(autoThemeIcon.tagName).toBe('svg');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Should have Dark theme selected by default', async () => {
|
it('Should have Dark theme selected by default', async () => {
|
||||||
|
|||||||
@ -5,9 +5,9 @@ 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';
|
||||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||||
import useThemeMode, { useIsDarkMode } from 'hooks/useDarkMode';
|
import useThemeMode, { useIsDarkMode, useSystemTheme } from 'hooks/useDarkMode';
|
||||||
import { useNotifications } from 'hooks/useNotifications';
|
import { useNotifications } from 'hooks/useNotifications';
|
||||||
import { Moon, Sun } from 'lucide-react';
|
import { MonitorCog, Moon, Sun } from 'lucide-react';
|
||||||
import { useAppContext } from 'providers/App/App';
|
import { useAppContext } from 'providers/App/App';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useMutation } from 'react-query';
|
import { useMutation } from 'react-query';
|
||||||
@ -19,8 +19,9 @@ import UserInfo from './UserInfo';
|
|||||||
|
|
||||||
function MySettings(): JSX.Element {
|
function MySettings(): JSX.Element {
|
||||||
const isDarkMode = useIsDarkMode();
|
const isDarkMode = useIsDarkMode();
|
||||||
const { toggleTheme } = useThemeMode();
|
|
||||||
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
|
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
|
||||||
|
const { toggleTheme, autoSwitch, setAutoSwitch } = useThemeMode();
|
||||||
|
const systemTheme = useSystemTheme();
|
||||||
const { notifications } = useNotifications();
|
const { notifications } = useNotifications();
|
||||||
|
|
||||||
const [sideNavPinned, setSideNavPinned] = useState(false);
|
const [sideNavPinned, setSideNavPinned] = useState(false);
|
||||||
@ -68,16 +69,37 @@ function MySettings(): JSX.Element {
|
|||||||
),
|
),
|
||||||
value: 'light',
|
value: 'light',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: (
|
||||||
|
<div className="theme-option">
|
||||||
|
<MonitorCog size={12} data-testid="auto-theme-icon" /> System{' '}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
value: 'auto',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const [theme, setTheme] = useState(isDarkMode ? 'dark' : 'light');
|
const [theme, setTheme] = useState(() => {
|
||||||
|
if (autoSwitch) return 'auto';
|
||||||
|
return isDarkMode ? 'dark' : 'light';
|
||||||
|
});
|
||||||
|
|
||||||
const handleThemeChange = ({ target: { value } }: RadioChangeEvent): void => {
|
const handleThemeChange = ({ target: { value } }: RadioChangeEvent): void => {
|
||||||
logEvent('Account Settings: Theme Changed', {
|
logEvent('Account Settings: Theme Changed', {
|
||||||
theme: value,
|
theme: value,
|
||||||
});
|
});
|
||||||
setTheme(value);
|
setTheme(value);
|
||||||
|
|
||||||
|
if (value === 'auto') {
|
||||||
|
setAutoSwitch(true);
|
||||||
|
} else {
|
||||||
|
setAutoSwitch(false);
|
||||||
|
// Only toggle if the current theme is different from the target
|
||||||
|
const targetIsDark = value === 'dark';
|
||||||
|
if (targetIsDark !== isDarkMode) {
|
||||||
toggleTheme();
|
toggleTheme();
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSideNavPinnedChange = (checked: boolean): void => {
|
const handleSideNavPinnedChange = (checked: boolean): void => {
|
||||||
@ -150,13 +172,23 @@ function MySettings(): JSX.Element {
|
|||||||
optionType="button"
|
optionType="button"
|
||||||
buttonStyle="solid"
|
buttonStyle="solid"
|
||||||
data-testid="theme-selector"
|
data-testid="theme-selector"
|
||||||
size="small"
|
size="middle"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="user-preference-section-content-item-description">
|
<div className="user-preference-section-content-item-description">
|
||||||
Select if SigNoz's appearance should be light or dark
|
Select if SigNoz's appearance should be light, dark, or
|
||||||
|
automatically follow your system preference
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{autoSwitch && (
|
||||||
|
<div className="auto-theme-info">
|
||||||
|
<div className="auto-theme-status">
|
||||||
|
Currently following system theme:{' '}
|
||||||
|
<strong>{systemTheme === 'dark' ? 'Dark' : 'Light'}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<TimezoneAdaptation />
|
<TimezoneAdaptation />
|
||||||
|
|||||||
169
frontend/src/hooks/useDarkMode/__tests__/useDarkMode.test.tsx
Normal file
169
frontend/src/hooks/useDarkMode/__tests__/useDarkMode.test.tsx
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import { act, renderHook } from '@testing-library/react';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ThemeProvider,
|
||||||
|
useIsDarkMode,
|
||||||
|
useSystemTheme,
|
||||||
|
useThemeMode,
|
||||||
|
} from '../index';
|
||||||
|
|
||||||
|
// Mock localStorage
|
||||||
|
const localStorageMock = {
|
||||||
|
getItem: jest.fn(),
|
||||||
|
setItem: jest.fn(),
|
||||||
|
clear: jest.fn(),
|
||||||
|
};
|
||||||
|
Object.defineProperty(window, 'localStorage', {
|
||||||
|
value: localStorageMock,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to create matchMedia mock
|
||||||
|
const createMatchMediaMock = (prefersDark: boolean): jest.Mock =>
|
||||||
|
jest.fn().mockImplementation((query: string) => ({
|
||||||
|
matches:
|
||||||
|
query === '(prefers-color-scheme: dark)' ? prefersDark : !prefersDark,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: jest.fn(),
|
||||||
|
removeListener: jest.fn(),
|
||||||
|
addEventListener: jest.fn(),
|
||||||
|
removeEventListener: jest.fn(),
|
||||||
|
dispatchEvent: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Mock matchMedia
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: createMatchMediaMock(true), // Default to dark theme
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useDarkMode', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
localStorageMock.getItem.mockReturnValue(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useSystemTheme', () => {
|
||||||
|
it('should return dark theme by default', () => {
|
||||||
|
const { result } = renderHook(() => useSystemTheme());
|
||||||
|
expect(result.current).toBe('dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return light theme when system prefers light', () => {
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: createMatchMediaMock(false), // Light theme
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSystemTheme());
|
||||||
|
expect(result.current).toBe('light');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('ThemeProvider', () => {
|
||||||
|
it('should provide theme context with default values', () => {
|
||||||
|
const wrapper = ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useThemeMode(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current.theme).toBe('dark');
|
||||||
|
expect(typeof result.current.toggleTheme).toBe('function');
|
||||||
|
expect(result.current.autoSwitch).toBe(false);
|
||||||
|
expect(typeof result.current.setAutoSwitch).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should load theme from localStorage', () => {
|
||||||
|
localStorageMock.getItem.mockImplementation((key: string) => {
|
||||||
|
if (key === 'THEME') return 'light';
|
||||||
|
if (key === 'THEME_AUTO_SWITCH') return 'true';
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useThemeMode(), { wrapper });
|
||||||
|
|
||||||
|
expect(result.current.theme).toBe('light');
|
||||||
|
expect(result.current.autoSwitch).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should toggle theme correctly', () => {
|
||||||
|
const wrapper = ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useThemeMode(), { wrapper });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.toggleTheme();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.theme).toBe('light');
|
||||||
|
expect(localStorageMock.setItem).toHaveBeenCalledWith('THEME', 'light');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle auto-switch functionality', () => {
|
||||||
|
// Mock system theme as light
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: createMatchMediaMock(false), // Light theme
|
||||||
|
});
|
||||||
|
|
||||||
|
const wrapper = ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useThemeMode(), { wrapper });
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
result.current.setAutoSwitch(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.autoSwitch).toBe(true);
|
||||||
|
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||||||
|
'THEME_AUTO_SWITCH',
|
||||||
|
'true',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('useIsDarkMode', () => {
|
||||||
|
it('should return true for dark theme', () => {
|
||||||
|
localStorageMock.getItem.mockReturnValue('dark');
|
||||||
|
|
||||||
|
const wrapper = ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useIsDarkMode(), { wrapper });
|
||||||
|
expect(result.current).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return false for light theme', () => {
|
||||||
|
localStorageMock.getItem.mockReturnValue('light');
|
||||||
|
|
||||||
|
const wrapper = ({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}): JSX.Element => <ThemeProvider>{children}</ThemeProvider>;
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useIsDarkMode(), { wrapper });
|
||||||
|
expect(result.current).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -5,9 +5,12 @@ import set from 'api/browser/localstorage/set';
|
|||||||
import { LOCALSTORAGE } from 'constants/localStorage';
|
import { LOCALSTORAGE } from 'constants/localStorage';
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
|
Dispatch,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
|
SetStateAction,
|
||||||
useCallback,
|
useCallback,
|
||||||
useContext,
|
useContext,
|
||||||
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useState,
|
useState,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
@ -16,13 +19,54 @@ import { THEME_MODE } from './constant';
|
|||||||
|
|
||||||
export const ThemeContext = createContext({
|
export const ThemeContext = createContext({
|
||||||
theme: THEME_MODE.DARK,
|
theme: THEME_MODE.DARK,
|
||||||
toggleTheme: () => {},
|
toggleTheme: (): void => {},
|
||||||
|
autoSwitch: false,
|
||||||
|
setAutoSwitch: ((): void => {}) as Dispatch<SetStateAction<boolean>>,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Hook to detect system theme preference
|
||||||
|
export const useSystemTheme = (): 'light' | 'dark' => {
|
||||||
|
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>('dark');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||||
|
setSystemTheme(mediaQuery.matches ? 'dark' : 'light');
|
||||||
|
|
||||||
|
const handler = (e: MediaQueryListEvent): void => {
|
||||||
|
setSystemTheme(e.matches ? 'dark' : 'light');
|
||||||
|
};
|
||||||
|
|
||||||
|
mediaQuery.addEventListener('change', handler);
|
||||||
|
return (): void => mediaQuery.removeEventListener('change', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return systemTheme;
|
||||||
|
};
|
||||||
|
|
||||||
export function ThemeProvider({ children }: ThemeProviderProps): JSX.Element {
|
export function ThemeProvider({ children }: ThemeProviderProps): JSX.Element {
|
||||||
const [theme, setTheme] = useState(get(LOCALSTORAGE.THEME) || THEME_MODE.DARK);
|
const [theme, setTheme] = useState(get(LOCALSTORAGE.THEME) || THEME_MODE.DARK);
|
||||||
|
const [autoSwitch, setAutoSwitch] = useState(
|
||||||
|
get(LOCALSTORAGE.THEME_AUTO_SWITCH) === 'true',
|
||||||
|
);
|
||||||
|
const systemTheme = useSystemTheme();
|
||||||
|
|
||||||
const toggleTheme = useCallback(() => {
|
// Handle auto-switch functionality
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoSwitch) {
|
||||||
|
const newTheme = systemTheme === 'dark' ? THEME_MODE.DARK : THEME_MODE.LIGHT;
|
||||||
|
if (newTheme !== theme) {
|
||||||
|
setTheme(newTheme);
|
||||||
|
set(LOCALSTORAGE.THEME, newTheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [systemTheme, autoSwitch, theme]);
|
||||||
|
|
||||||
|
// Save auto-switch preference
|
||||||
|
useEffect(() => {
|
||||||
|
set(LOCALSTORAGE.THEME_AUTO_SWITCH, autoSwitch.toString());
|
||||||
|
}, [autoSwitch]);
|
||||||
|
|
||||||
|
const toggleTheme = useCallback((): void => {
|
||||||
if (theme === THEME_MODE.LIGHT) {
|
if (theme === THEME_MODE.LIGHT) {
|
||||||
setTheme(THEME_MODE.DARK);
|
setTheme(THEME_MODE.DARK);
|
||||||
set(LOCALSTORAGE.THEME, THEME_MODE.DARK);
|
set(LOCALSTORAGE.THEME, THEME_MODE.DARK);
|
||||||
@ -37,8 +81,10 @@ export function ThemeProvider({ children }: ThemeProviderProps): JSX.Element {
|
|||||||
() => ({
|
() => ({
|
||||||
theme,
|
theme,
|
||||||
toggleTheme,
|
toggleTheme,
|
||||||
|
autoSwitch,
|
||||||
|
setAutoSwitch,
|
||||||
}),
|
}),
|
||||||
[theme, toggleTheme],
|
[theme, toggleTheme, autoSwitch, setAutoSwitch],
|
||||||
);
|
);
|
||||||
|
|
||||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||||
@ -51,12 +97,16 @@ interface ThemeProviderProps {
|
|||||||
interface ThemeMode {
|
interface ThemeMode {
|
||||||
theme: string;
|
theme: string;
|
||||||
toggleTheme: () => void;
|
toggleTheme: () => void;
|
||||||
|
autoSwitch: boolean;
|
||||||
|
setAutoSwitch: Dispatch<SetStateAction<boolean>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useThemeMode = (): ThemeMode => {
|
export const useThemeMode = (): ThemeMode => {
|
||||||
const { theme, toggleTheme } = useContext(ThemeContext);
|
const { theme, toggleTheme, autoSwitch, setAutoSwitch } = useContext(
|
||||||
|
ThemeContext,
|
||||||
|
);
|
||||||
|
|
||||||
return { theme, toggleTheme };
|
return { theme, toggleTheme, autoSwitch, setAutoSwitch };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useIsDarkMode = (): boolean => {
|
export const useIsDarkMode = (): boolean => {
|
||||||
|
|||||||
@ -16,6 +16,25 @@ body {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Theme transition animations
|
||||||
|
* {
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease,
|
||||||
|
box-shadow 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For components that shouldn't transition (like loading spinners, animations)
|
||||||
|
.no-transition,
|
||||||
|
.no-transition * {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Respect user's reduced motion preference
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
* {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.u-legend {
|
.u-legend {
|
||||||
max-height: 30px; // Default height for backward compatibility
|
max-height: 30px; // Default height for backward compatibility
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user