Merge pull request #58 from lordmathis/fix/theme-switching

Fix/theme switching
This commit is contained in:
2025-10-11 20:27:17 +02:00
committed by GitHub
8 changed files with 70 additions and 50 deletions

View File

@@ -1,5 +1,9 @@
import React from 'react'; import React from 'react';
import { MantineProvider, ColorSchemeScript } from '@mantine/core'; import {
MantineProvider,
ColorSchemeScript,
localStorageColorSchemeManager,
} from '@mantine/core';
import { Notifications } from '@mantine/notifications'; import { Notifications } from '@mantine/notifications';
import { ModalsProvider } from '@mantine/modals'; import { ModalsProvider } from '@mantine/modals';
import Layout from './components/layout/Layout'; import Layout from './components/layout/Layout';
@@ -39,11 +43,18 @@ const AuthenticatedContent: React.FC<AuthenticatedContentProps> = () => {
type AppProps = object; type AppProps = object;
const colorSchemeManager = localStorageColorSchemeManager({
key: 'mantine-color-scheme',
});
const App: React.FC<AppProps> = () => { const App: React.FC<AppProps> = () => {
return ( return (
<> <>
<ColorSchemeScript defaultColorScheme="light" /> <ColorSchemeScript defaultColorScheme="light" />
<MantineProvider defaultColorScheme="light"> <MantineProvider
defaultColorScheme="light"
colorSchemeManager={colorSchemeManager}
>
<Notifications /> <Notifications />
<ModalsProvider> <ModalsProvider>
<AuthProvider> <AuthProvider>

View File

@@ -110,10 +110,10 @@ const WorkspaceSwitcher: React.FC = () => {
</Center> </Center>
) : ( ) : (
workspaces.map((workspace) => { workspaces.map((workspace) => {
const isSelected = workspace.name === currentWorkspace?.name; const isSelected = workspace.id === currentWorkspace?.id;
return ( return (
<Paper <Paper
key={workspace.name} key={workspace.id}
p="xs" p="xs"
withBorder withBorder
style={(theme) => style={(theme) =>

View File

@@ -20,8 +20,6 @@ const render = (ui: React.ReactElement) => {
}; };
describe('AppearanceSettings', () => { describe('AppearanceSettings', () => {
const mockOnThemeChange = vi.fn();
beforeEach(async () => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
const { useTheme } = await import('../../../contexts/ThemeContext'); const { useTheme } = await import('../../../contexts/ThemeContext');
@@ -32,7 +30,7 @@ describe('AppearanceSettings', () => {
}); });
it('renders dark mode toggle with correct state', () => { it('renders dark mode toggle with correct state', () => {
render(<AppearanceSettings onThemeChange={mockOnThemeChange} />); render(<AppearanceSettings />);
expect(screen.getByText('Dark Mode')).toBeInTheDocument(); expect(screen.getByText('Dark Mode')).toBeInTheDocument();
const toggle = screen.getByRole('switch'); const toggle = screen.getByRole('switch');
@@ -46,20 +44,19 @@ describe('AppearanceSettings', () => {
updateColorScheme: mockUpdateColorScheme, updateColorScheme: mockUpdateColorScheme,
}); });
render(<AppearanceSettings onThemeChange={mockOnThemeChange} />); render(<AppearanceSettings />);
const toggle = screen.getByRole('switch'); const toggle = screen.getByRole('switch');
expect(toggle).toBeChecked(); expect(toggle).toBeChecked();
}); });
it('toggles theme from light to dark', () => { it('toggles theme from light to dark', () => {
render(<AppearanceSettings onThemeChange={mockOnThemeChange} />); render(<AppearanceSettings />);
const toggle = screen.getByRole('switch'); const toggle = screen.getByRole('switch');
fireEvent.click(toggle); fireEvent.click(toggle);
expect(mockUpdateColorScheme).toHaveBeenCalledWith(Theme.Dark); expect(mockUpdateColorScheme).toHaveBeenCalledWith(Theme.Dark);
expect(mockOnThemeChange).toHaveBeenCalledWith(Theme.Dark);
}); });
it('toggles theme from dark to light', async () => { it('toggles theme from dark to light', async () => {
@@ -69,12 +66,11 @@ describe('AppearanceSettings', () => {
updateColorScheme: mockUpdateColorScheme, updateColorScheme: mockUpdateColorScheme,
}); });
render(<AppearanceSettings onThemeChange={mockOnThemeChange} />); render(<AppearanceSettings />);
const toggle = screen.getByRole('switch'); const toggle = screen.getByRole('switch');
fireEvent.click(toggle); fireEvent.click(toggle);
expect(mockUpdateColorScheme).toHaveBeenCalledWith(Theme.Light); expect(mockUpdateColorScheme).toHaveBeenCalledWith(Theme.Light);
expect(mockOnThemeChange).toHaveBeenCalledWith(Theme.Light);
}); });
}); });

View File

@@ -1,21 +1,14 @@
import React from 'react'; import React from 'react';
import { Text, Switch, Group, Box } from '@mantine/core'; import { Text, Switch, Group, Box } from '@mantine/core';
import { useTheme } from '../../../contexts/ThemeContext';
import { Theme } from '@/types/models'; import { Theme } from '@/types/models';
import { useTheme } from '../../../contexts/ThemeContext';
interface AppearanceSettingsProps { const AppearanceSettings: React.FC = () => {
onThemeChange: (newTheme: Theme) => void;
}
const AppearanceSettings: React.FC<AppearanceSettingsProps> = ({
onThemeChange,
}) => {
const { colorScheme, updateColorScheme } = useTheme(); const { colorScheme, updateColorScheme } = useTheme();
const handleThemeChange = (): void => { const handleThemeChange = (): void => {
const newTheme = colorScheme === 'dark' ? Theme.Light : Theme.Dark; const newTheme = colorScheme === 'dark' ? Theme.Light : Theme.Dark;
updateColorScheme(newTheme); updateColorScheme(newTheme);
onThemeChange(newTheme);
}; };
return ( return (

View File

@@ -11,6 +11,7 @@ import WorkspaceSettings from './WorkspaceSettings';
import { Theme } from '@/types/models'; import { Theme } from '@/types/models';
const mockUpdateSettings = vi.fn(); const mockUpdateSettings = vi.fn();
const mockUpdateColorScheme = vi.fn();
vi.mock('../../../hooks/useWorkspace', () => ({ vi.mock('../../../hooks/useWorkspace', () => ({
useWorkspace: vi.fn(), useWorkspace: vi.fn(),
})); }));
@@ -48,11 +49,9 @@ vi.mock('./GeneralSettings', () => ({
})); }));
vi.mock('./AppearanceSettings', () => ({ vi.mock('./AppearanceSettings', () => ({
default: ({ onThemeChange }: { onThemeChange: (theme: string) => void }) => ( default: () => (
<div data-testid="appearance-settings"> <div data-testid="appearance-settings">
<button onClick={() => onThemeChange('dark')} data-testid="theme-toggle"> Appearance Settings
Toggle Theme
</button>
</div> </div>
), ),
})); }));
@@ -105,7 +104,7 @@ describe('WorkspaceSettings', () => {
updateSettings: mockUpdateSettings, updateSettings: mockUpdateSettings,
loading: false, loading: false,
colorScheme: 'light', colorScheme: 'light',
updateColorScheme: vi.fn(), updateColorScheme: mockUpdateColorScheme,
switchWorkspace: vi.fn(), switchWorkspace: vi.fn(),
deleteCurrentWorkspace: vi.fn(), deleteCurrentWorkspace: vi.fn(),
}); });
@@ -152,13 +151,10 @@ describe('WorkspaceSettings', () => {
}); });
}); });
it('handles theme changes', () => { it('renders appearance settings', () => {
render(<WorkspaceSettings />); render(<WorkspaceSettings />);
const themeToggle = screen.getByTestId('theme-toggle'); expect(screen.getByTestId('appearance-settings')).toBeInTheDocument();
fireEvent.click(themeToggle);
expect(screen.getByText('Unsaved Changes')).toBeInTheDocument();
}); });
it('closes modal when cancel is clicked', () => { it('closes modal when cancel is clicked', () => {
@@ -192,4 +188,16 @@ describe('WorkspaceSettings', () => {
expect(mockUpdateSettings).not.toHaveBeenCalled(); expect(mockUpdateSettings).not.toHaveBeenCalled();
}); });
it('reverts theme when canceling', () => {
render(<WorkspaceSettings />);
// Click cancel
const cancelButton = screen.getByRole('button', { name: 'Cancel' });
fireEvent.click(cancelButton);
// Theme should be reverted to saved state (Light)
expect(mockUpdateColorScheme).toHaveBeenCalledWith(Theme.Light);
expect(mockSetSettingsModalVisible).toHaveBeenCalledWith(false);
});
}); });

View File

@@ -1,4 +1,4 @@
import React, { useReducer, useEffect, useCallback, useRef } from 'react'; import React, { useReducer, useEffect, useCallback } from 'react';
import { import {
Modal, Modal,
Badge, Badge,
@@ -72,14 +72,13 @@ function settingsReducer(
} }
const WorkspaceSettings: React.FC = () => { const WorkspaceSettings: React.FC = () => {
const { currentWorkspace, updateSettings } = useWorkspace(); const { currentWorkspace, updateSettings, updateColorScheme, colorScheme } =
useWorkspace();
const { settingsModalVisible, setSettingsModalVisible } = useModalContext(); const { settingsModalVisible, setSettingsModalVisible } = useModalContext();
const [state, dispatch] = useReducer(settingsReducer, initialState); const [state, dispatch] = useReducer(settingsReducer, initialState);
const isInitialMount = useRef<boolean>(true);
useEffect(() => { useEffect(() => {
if (isInitialMount.current && currentWorkspace) { if (currentWorkspace && settingsModalVisible) {
isInitialMount.current = false;
const settings: Partial<Workspace> = { const settings: Partial<Workspace> = {
name: currentWorkspace.name, name: currentWorkspace.name,
theme: currentWorkspace.theme, theme: currentWorkspace.theme,
@@ -96,7 +95,7 @@ const WorkspaceSettings: React.FC = () => {
}; };
dispatch({ type: SettingsActionType.INIT_SETTINGS, payload: settings }); dispatch({ type: SettingsActionType.INIT_SETTINGS, payload: settings });
} }
}, [currentWorkspace]); }, [currentWorkspace, settingsModalVisible]);
const handleInputChange = useCallback( const handleInputChange = useCallback(
<K extends keyof Workspace>(key: K, value: Workspace[K]): void => { <K extends keyof Workspace>(key: K, value: Workspace[K]): void => {
@@ -118,7 +117,13 @@ const WorkspaceSettings: React.FC = () => {
return; return;
} }
await updateSettings(state.localSettings); // Save with current Mantine theme
const settingsToSave = {
...state.localSettings,
theme: colorScheme as Theme,
};
await updateSettings(settingsToSave);
dispatch({ type: SettingsActionType.MARK_SAVED }); dispatch({ type: SettingsActionType.MARK_SAVED });
notifications.show({ notifications.show({
message: 'Settings saved successfully', message: 'Settings saved successfully',
@@ -137,8 +142,12 @@ const WorkspaceSettings: React.FC = () => {
}; };
const handleClose = useCallback(() => { const handleClose = useCallback(() => {
// Revert theme to saved state
if (state.initialSettings.theme) {
updateColorScheme(state.initialSettings.theme);
}
setSettingsModalVisible(false); setSettingsModalVisible(false);
}, [setSettingsModalVisible]); }, [setSettingsModalVisible, state.initialSettings.theme, updateColorScheme]);
return ( return (
<Modal <Modal
@@ -180,11 +189,7 @@ const WorkspaceSettings: React.FC = () => {
<Accordion.Item value="appearance"> <Accordion.Item value="appearance">
<AccordionControl>Appearance</AccordionControl> <AccordionControl>Appearance</AccordionControl>
<Accordion.Panel> <Accordion.Panel>
<AppearanceSettings <AppearanceSettings />
onThemeChange={(newTheme: string) =>
handleInputChange('theme', newTheme as Theme)
}
/>
</Accordion.Panel> </Accordion.Panel>
</Accordion.Item> </Accordion.Item>

View File

@@ -2,6 +2,7 @@ import React, {
createContext, createContext,
useContext, useContext,
useCallback, useCallback,
useMemo,
type ReactNode, type ReactNode,
} from 'react'; } from 'react';
import { useMantineColorScheme, type MantineColorScheme } from '@mantine/core'; import { useMantineColorScheme, type MantineColorScheme } from '@mantine/core';
@@ -32,11 +33,16 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
); );
// Ensure colorScheme is never undefined by falling back to light theme // Ensure colorScheme is never undefined by falling back to light theme
const value: ThemeContextType = { const normalizedColorScheme =
colorScheme: colorScheme === 'light' || colorScheme === 'dark' ? colorScheme : 'light';
colorScheme === 'light' || colorScheme === 'dark' ? colorScheme : 'light',
updateColorScheme, const value: ThemeContextType = useMemo(
}; () => ({
colorScheme: normalizedColorScheme,
updateColorScheme,
}),
[normalizedColorScheme, updateColorScheme]
);
return ( return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider> <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>

View File

@@ -74,7 +74,8 @@ export const WorkspaceDataProvider: React.FC<WorkspaceDataProviderProps> = ({
}); });
} }
}, },
[updateColorScheme] // eslint-disable-next-line react-hooks/exhaustive-deps
[]
); );
const loadFirstAvailableWorkspace = useCallback(async (): Promise<void> => { const loadFirstAvailableWorkspace = useCallback(async (): Promise<void> => {