Skip to content

Commit e5c2161

Browse files
🐛 fix: refactor the theme implement (lobehub#6844)
* refactor theme * Refactor systemStatus selectors tests to improve coverage and organization. * improve cookie issue * fix theme * Add unit tests for global store actions in general.test.ts --------- Co-authored-by: gru-agent[bot] <185149714+gru-agent[bot]@users.noreply.github.com>
1 parent 41a1f2a commit e5c2161

File tree

18 files changed

+507
-90
lines changed

18 files changed

+507
-90
lines changed

src/app/[variants]/(main)/(mobile)/me/(home)/features/Header.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@ import { Moon, Sun } from 'lucide-react';
77
import { memo } from 'react';
88

99
import { MOBILE_HEADER_ICON_SIZE } from '@/const/layoutTokens';
10-
import { useUserStore } from '@/store/user';
10+
import { useGlobalStore } from '@/store/global';
1111
import { mobileHeaderSticky } from '@/styles/mobileHeader';
1212

1313
const Header = memo(() => {
1414
const theme = useTheme();
15-
const switchThemeMode = useUserStore((s) => s.switchThemeMode);
15+
const switchThemeMode = useGlobalStore((s) => s.switchThemeMode);
1616

1717
return (
1818
<MobileNavBar

src/app/[variants]/(main)/settings/common/features/Theme/index.tsx

+5-3
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,9 @@ import { FORM_STYLE } from '@/const/layoutTokens';
1212
import { imageUrl } from '@/const/url';
1313
import { Locales, localeOptions } from '@/locales/resources';
1414
import { useGlobalStore } from '@/store/global';
15+
import { systemStatusSelectors } from '@/store/global/selectors';
1516
import { useUserStore } from '@/store/user';
16-
import { settingsSelectors, userGeneralSettingsSelectors } from '@/store/user/selectors';
17+
import { settingsSelectors } from '@/store/user/selectors';
1718

1819
import { ThemeSwatchesNeutral, ThemeSwatchesPrimary } from './ThemeSwatches';
1920

@@ -24,8 +25,9 @@ const Theme = memo(() => {
2425

2526
const [form] = Form.useForm();
2627
const settings = useUserStore(settingsSelectors.currentSettings, isEqual);
27-
const themeMode = useUserStore(userGeneralSettingsSelectors.currentThemeMode);
28-
const [setThemeMode, setSettings] = useUserStore((s) => [s.switchThemeMode, s.setSettings]);
28+
const themeMode = useGlobalStore(systemStatusSelectors.themeMode);
29+
const [setSettings] = useUserStore((s) => [s.setSettings]);
30+
const [setThemeMode] = useGlobalStore((s) => [s.switchThemeMode]);
2931

3032
useSyncSettings(form);
3133
const [switchLocale] = useGlobalStore((s) => [s.switchLocale]);

src/const/settings/common.ts

-1
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,4 @@ import { UserGeneralConfig } from '@/types/user/settings';
22

33
export const DEFAULT_COMMON_SETTINGS: UserGeneralConfig = {
44
fontSize: 14,
5-
themeMode: 'auto',
65
};

src/features/User/UserPanel/ThemeButton.tsx

+4-4
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { memo, useMemo } from 'react';
66
import { useTranslation } from 'react-i18next';
77

88
import Menu, { type MenuProps } from '@/components/Menu';
9-
import { useUserStore } from '@/store/user';
10-
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
9+
import { useGlobalStore } from '@/store/global';
10+
import { systemStatusSelectors } from '@/store/global/selectors';
1111

1212
const themeIcons = {
1313
auto: Monitor,
@@ -17,8 +17,8 @@ const themeIcons = {
1717

1818
const ThemeButton = memo<{ placement?: PopoverProps['placement'] }>(({ placement = 'right' }) => {
1919
const theme = useTheme();
20-
const [themeMode, switchThemeMode] = useUserStore((s) => [
21-
userGeneralSettingsSelectors.currentThemeMode(s),
20+
const [themeMode, switchThemeMode] = useGlobalStore((s) => [
21+
systemStatusSelectors.themeMode(s),
2222
s.switchThemeMode,
2323
]);
2424

src/layout/GlobalProvider/AppTheme.tsx

+5-9
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,9 @@ import Link from 'next/link';
1414
import { ReactNode, memo, useEffect } from 'react';
1515

1616
import AntdStaticMethods from '@/components/AntdStaticMethods';
17-
import {
18-
LOBE_THEME_APPEARANCE,
19-
LOBE_THEME_NEUTRAL_COLOR,
20-
LOBE_THEME_PRIMARY_COLOR,
21-
} from '@/const/theme';
17+
import { LOBE_THEME_NEUTRAL_COLOR, LOBE_THEME_PRIMARY_COLOR } from '@/const/theme';
18+
import { useGlobalStore } from '@/store/global';
19+
import { systemStatusSelectors } from '@/store/global/selectors';
2220
import { useUserStore } from '@/store/user';
2321
import { userGeneralSettingsSelectors } from '@/store/user/selectors';
2422
import { GlobalStyle } from '@/styles';
@@ -103,7 +101,7 @@ const AppTheme = memo<AppThemeProps>(
103101
// console.debug('server:appearance', defaultAppearance);
104102
// console.debug('server:primaryColor', defaultPrimaryColor);
105103
// console.debug('server:neutralColor', defaultNeutralColor);
106-
const themeMode = useUserStore(userGeneralSettingsSelectors.currentThemeMode);
104+
const themeMode = useGlobalStore(systemStatusSelectors.themeMode);
107105
const { styles, cx, theme } = useStyles();
108106
const [primaryColor, neutralColor] = useUserStore((s) => [
109107
userGeneralSettingsSelectors.primaryColor(s),
@@ -120,15 +118,13 @@ const AppTheme = memo<AppThemeProps>(
120118

121119
return (
122120
<ThemeProvider
121+
appearance={themeMode !== 'auto' ? themeMode : undefined}
123122
className={cx(styles.app, styles.scrollbar, styles.scrollbarPolyfill)}
124123
customTheme={{
125124
neutralColor: neutralColor ?? defaultNeutralColor,
126125
primaryColor: primaryColor ?? defaultPrimaryColor,
127126
}}
128127
defaultAppearance={defaultAppearance}
129-
onAppearanceChange={(appearance) => {
130-
setCookie(LOBE_THEME_APPEARANCE, appearance);
131-
}}
132128
theme={{
133129
cssVar: true,
134130
token: {

src/services/user/_deprecated.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ describe('ClientService', () => {
5757
});
5858

5959
it('should update user settings correctly', async () => {
60-
const settingsPatch: DeepPartial<UserSettings> = { general: { themeMode: 'dark' } };
60+
const settingsPatch: DeepPartial<UserSettings> = { general: { fontSize: 12 } };
6161
(UserModel.updateSettings as Mock).mockResolvedValue(undefined);
6262

6363
await clientService.updateUserSettings(settingsPatch);

src/services/user/client.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ describe('ClientService', () => {
5454
});
5555

5656
it('should update user settings correctly', async () => {
57-
const settingsPatch: DeepPartial<UserSettings> = { general: { themeMode: 'dark' } };
57+
const settingsPatch: DeepPartial<UserSettings> = { general: { fontSize: 12 } };
5858

5959
await clientService.updateUserSettings(settingsPatch);
6060

src/store/global/action.test.ts

+15
Original file line numberDiff line numberDiff line change
@@ -408,4 +408,19 @@ describe('createPreferenceSlice', () => {
408408
expect(result.current.status.inputHeight).toEqual(300);
409409
});
410410
});
411+
412+
describe('switchThemeMode', () => {
413+
it('should switch theme mode', async () => {
414+
const { result } = renderHook(() => useGlobalStore());
415+
416+
// Perform the action
417+
act(() => {
418+
useGlobalStore.setState({ isStatusInit: true });
419+
result.current.switchThemeMode('light');
420+
});
421+
422+
// Assert that updateUserSettings was called with the correct theme mode
423+
expect(result.current.status.themeMode).toEqual('light');
424+
});
425+
});
411426
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,221 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
import { ThemeMode } from 'antd-style';
3+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
4+
import { withSWR } from '~test-utils';
5+
6+
import { CURRENT_VERSION } from '@/const/version';
7+
import { globalService } from '@/services/global';
8+
import { useGlobalStore } from '@/store/global';
9+
import { initialState } from '@/store/global/initialState';
10+
import { switchLang } from '@/utils/client/switchLang';
11+
12+
vi.mock('@/utils/client/switchLang', () => ({
13+
switchLang: vi.fn(),
14+
}));
15+
16+
vi.mock('@/services/global', () => ({
17+
globalService: {
18+
getLatestVersion: vi.fn(),
19+
},
20+
}));
21+
22+
describe('generalActionSlice', () => {
23+
beforeEach(() => {
24+
vi.clearAllMocks();
25+
useGlobalStore.setState(initialState);
26+
});
27+
28+
afterEach(() => {
29+
vi.restoreAllMocks();
30+
});
31+
32+
describe('updateSystemStatus', () => {
33+
it('should not update status when not initialized', () => {
34+
const { result } = renderHook(() => useGlobalStore());
35+
36+
act(() => {
37+
result.current.updateSystemStatus({ inputHeight: 200 });
38+
});
39+
40+
expect(result.current.status).toEqual(initialState.status);
41+
});
42+
43+
it('should update status when initialized', () => {
44+
const { result } = renderHook(() => useGlobalStore());
45+
46+
act(() => {
47+
useGlobalStore.setState({ isStatusInit: true });
48+
result.current.updateSystemStatus({ inputHeight: 200 });
49+
});
50+
51+
expect(result.current.status.inputHeight).toBe(200);
52+
});
53+
54+
it('should not update if new status equals current status', () => {
55+
const { result } = renderHook(() => useGlobalStore());
56+
const saveToLocalStorageSpy = vi.spyOn(result.current.statusStorage, 'saveToLocalStorage');
57+
58+
act(() => {
59+
useGlobalStore.setState({ isStatusInit: true });
60+
result.current.updateSystemStatus({ inputHeight: initialState.status.inputHeight });
61+
});
62+
63+
expect(saveToLocalStorageSpy).not.toHaveBeenCalled();
64+
});
65+
66+
it('should save to localStorage when status is updated', () => {
67+
const { result } = renderHook(() => useGlobalStore());
68+
const saveToLocalStorageSpy = vi.spyOn(result.current.statusStorage, 'saveToLocalStorage');
69+
70+
act(() => {
71+
useGlobalStore.setState({ isStatusInit: true });
72+
result.current.updateSystemStatus({ inputHeight: 300 });
73+
});
74+
75+
expect(saveToLocalStorageSpy).toHaveBeenCalledWith(
76+
expect.objectContaining({ inputHeight: 300 }),
77+
);
78+
});
79+
80+
it('should merge nested objects correctly', () => {
81+
const { result } = renderHook(() => useGlobalStore());
82+
83+
act(() => {
84+
useGlobalStore.setState({ isStatusInit: true });
85+
result.current.updateSystemStatus({
86+
expandSessionGroupKeys: ['test1', 'test2'],
87+
});
88+
});
89+
90+
expect(result.current.status.expandSessionGroupKeys).toEqual(['test1', 'test2']);
91+
});
92+
});
93+
94+
describe('switchLocale', () => {
95+
it('should update language in system status and call switchLang', () => {
96+
const { result } = renderHook(() => useGlobalStore());
97+
const locale = 'zh-CN';
98+
99+
act(() => {
100+
useGlobalStore.setState({ isStatusInit: true });
101+
result.current.switchLocale(locale);
102+
});
103+
104+
expect(result.current.status.language).toBe(locale);
105+
expect(switchLang).toHaveBeenCalledWith(locale);
106+
});
107+
108+
it('should not update language if status is not initialized', () => {
109+
const { result } = renderHook(() => useGlobalStore());
110+
const locale = 'zh-CN';
111+
112+
act(() => {
113+
result.current.switchLocale(locale);
114+
});
115+
116+
expect(result.current.status.language).toBeUndefined();
117+
});
118+
});
119+
120+
describe('switchThemeMode', () => {
121+
it('should update theme mode in system status', () => {
122+
const { result } = renderHook(() => useGlobalStore());
123+
const themeMode: ThemeMode = 'dark';
124+
125+
act(() => {
126+
useGlobalStore.setState({ isStatusInit: true });
127+
result.current.switchThemeMode(themeMode);
128+
});
129+
130+
expect(result.current.status.themeMode).toBe(themeMode);
131+
});
132+
133+
it('should not update theme mode if status is not initialized', () => {
134+
const { result } = renderHook(() => useGlobalStore());
135+
const themeMode: ThemeMode = 'dark';
136+
137+
act(() => {
138+
result.current.switchThemeMode(themeMode);
139+
});
140+
141+
expect(result.current.status.themeMode).toBe(initialState.status.themeMode);
142+
});
143+
144+
it('should handle light theme mode', () => {
145+
const { result } = renderHook(() => useGlobalStore());
146+
147+
act(() => {
148+
useGlobalStore.setState({ isStatusInit: true });
149+
result.current.switchThemeMode('light');
150+
});
151+
152+
expect(result.current.status.themeMode).toBe('light');
153+
});
154+
});
155+
156+
describe('useCheckLatestVersion', () => {
157+
it('should not fetch version when check is disabled', () => {
158+
const getLatestVersionSpy = vi.spyOn(globalService, 'getLatestVersion');
159+
160+
renderHook(() => useGlobalStore().useCheckLatestVersion(false), {
161+
wrapper: withSWR,
162+
});
163+
164+
expect(getLatestVersionSpy).not.toHaveBeenCalled();
165+
});
166+
167+
it('should set hasNewVersion when major version is newer', async () => {
168+
const latestVersion = '999.0.0';
169+
vi.spyOn(globalService, 'getLatestVersion').mockResolvedValueOnce(latestVersion);
170+
171+
const { result } = renderHook(() => useGlobalStore().useCheckLatestVersion(), {
172+
wrapper: withSWR,
173+
});
174+
175+
await act(async () => {
176+
await result.current.data;
177+
});
178+
179+
expect(useGlobalStore.getState().hasNewVersion).toBe(true);
180+
expect(useGlobalStore.getState().latestVersion).toBe(latestVersion);
181+
});
182+
183+
it('should not set hasNewVersion for same major.minor version', async () => {
184+
const currentParts = CURRENT_VERSION.split('.');
185+
const latestVersion = `${currentParts[0]}.${currentParts[1]}.999`;
186+
vi.spyOn(globalService, 'getLatestVersion').mockResolvedValueOnce(latestVersion);
187+
188+
const { result } = renderHook(() => useGlobalStore().useCheckLatestVersion(), {
189+
wrapper: withSWR,
190+
});
191+
192+
await act(async () => {
193+
await result.current.data;
194+
});
195+
196+
// Reset hasNewVersion and latestVersion
197+
useGlobalStore.setState({ hasNewVersion: undefined, latestVersion: undefined });
198+
199+
expect(useGlobalStore.getState().hasNewVersion).toBeUndefined();
200+
expect(useGlobalStore.getState().latestVersion).toBeUndefined();
201+
});
202+
203+
it('should not set hasNewVersion when version is invalid', async () => {
204+
vi.spyOn(globalService, 'getLatestVersion').mockResolvedValueOnce('invalid.version');
205+
206+
const { result } = renderHook(() => useGlobalStore().useCheckLatestVersion(), {
207+
wrapper: withSWR,
208+
});
209+
210+
await act(async () => {
211+
await result.current.data;
212+
});
213+
214+
// Reset hasNewVersion and latestVersion
215+
useGlobalStore.setState({ hasNewVersion: undefined, latestVersion: undefined });
216+
217+
expect(useGlobalStore.getState().hasNewVersion).toBeUndefined();
218+
expect(useGlobalStore.getState().latestVersion).toBeUndefined();
219+
});
220+
});
221+
});

0 commit comments

Comments
 (0)