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:
Yunus M 2025-07-23 01:43:07 +05:30 committed by GitHub
parent 55eadf914b
commit a576982497
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 608 additions and 14 deletions

View File

@ -3,6 +3,7 @@ import { ConfigProvider } from 'antd';
import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import AppLoading from 'components/AppLoading/AppLoading';
import NotFound from 'components/NotFound';
import Spinner from 'components/Spinner';
import UserpilotRouteTracker from 'components/UserpilotRouteTracker/UserpilotRouteTracker';
@ -342,7 +343,7 @@ function App(): JSX.Element {
if (isLoggedInState) {
// if the setup calls are loading then return a spinner
if (isFetchingActiveLicense || isFetchingUser || isFetchingFeatureFlags) {
return <Spinner tip="Loading..." />;
return <AppLoading />;
}
// if the required calls fails then return a something went wrong error

View 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
}
}
}

View 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;

View File

@ -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');
});
});

View File

@ -4,6 +4,7 @@ export enum LOCALSTORAGE {
AUTH_TOKEN = 'AUTH_TOKEN',
REFRESH_AUTH_TOKEN = 'REFRESH_AUTH_TOKEN',
THEME = 'THEME',
THEME_AUTO_SWITCH = 'THEME_AUTO_SWITCH',
LOGS_VIEW_MODE = 'LOGS_VIEW_MODE',
LOGS_LINES_PER_ROW = 'LOGS_LINES_PER_ROW',
LOGS_LIST_OPTIONS = 'LOGS_LIST_OPTIONS',

View File

@ -120,6 +120,28 @@
line-height: 20px; /* 142.857% */
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 {
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);
}
}
}
}
}
}

View File

@ -7,8 +7,11 @@ const logEventFunction = jest.fn();
jest.mock('hooks/useDarkMode', () => ({
__esModule: true,
useIsDarkMode: jest.fn(() => true),
useSystemTheme: jest.fn(() => 'dark'),
default: jest.fn(() => ({
toggleTheme: toggleThemeFunction,
autoSwitch: false,
setAutoSwitch: jest.fn(),
})),
}));
@ -45,7 +48,7 @@ describe('MySettings Flows', () => {
});
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
expect(screen.getByText('Dark')).toBeInTheDocument();
const darkThemeIcon = screen.getByTestId('dark-theme-icon');
@ -58,6 +61,12 @@ describe('MySettings Flows', () => {
expect(lightThemeIcon).toBeInTheDocument();
expect(lightThemeIcon.tagName).toBe('svg');
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 () => {

View File

@ -5,9 +5,9 @@ import logEvent from 'api/common/logEvent';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import { AxiosError } from 'axios';
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 { Moon, Sun } from 'lucide-react';
import { MonitorCog, Moon, Sun } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useEffect, useState } from 'react';
import { useMutation } from 'react-query';
@ -19,8 +19,9 @@ import UserInfo from './UserInfo';
function MySettings(): JSX.Element {
const isDarkMode = useIsDarkMode();
const { toggleTheme } = useThemeMode();
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
const { toggleTheme, autoSwitch, setAutoSwitch } = useThemeMode();
const systemTheme = useSystemTheme();
const { notifications } = useNotifications();
const [sideNavPinned, setSideNavPinned] = useState(false);
@ -68,16 +69,37 @@ function MySettings(): JSX.Element {
),
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 => {
logEvent('Account Settings: Theme Changed', {
theme: 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();
}
}
};
const handleSideNavPinnedChange = (checked: boolean): void => {
@ -150,13 +172,23 @@ function MySettings(): JSX.Element {
optionType="button"
buttonStyle="solid"
data-testid="theme-selector"
size="small"
size="middle"
/>
</div>
<div className="user-preference-section-content-item-description">
Select if SigNoz&apos;s appearance should be light or dark
Select if SigNoz&apos;s appearance should be light, dark, or
automatically follow your system preference
</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>
<TimezoneAdaptation />

View 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);
});
});
});

View File

@ -5,9 +5,12 @@ import set from 'api/browser/localstorage/set';
import { LOCALSTORAGE } from 'constants/localStorage';
import {
createContext,
Dispatch,
ReactNode,
SetStateAction,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
@ -16,13 +19,54 @@ import { THEME_MODE } from './constant';
export const ThemeContext = createContext({
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 {
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) {
setTheme(THEME_MODE.DARK);
set(LOCALSTORAGE.THEME, THEME_MODE.DARK);
@ -37,8 +81,10 @@ export function ThemeProvider({ children }: ThemeProviderProps): JSX.Element {
() => ({
theme,
toggleTheme,
autoSwitch,
setAutoSwitch,
}),
[theme, toggleTheme],
[theme, toggleTheme, autoSwitch, setAutoSwitch],
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
@ -51,12 +97,16 @@ interface ThemeProviderProps {
interface ThemeMode {
theme: string;
toggleTheme: () => void;
autoSwitch: boolean;
setAutoSwitch: Dispatch<SetStateAction<boolean>>;
}
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 => {

View File

@ -16,6 +16,25 @@ body {
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 {
max-height: 30px; // Default height for backward compatibility
overflow-y: auto;