@openedx/frontend-base 1.0.0-alpha.1 → 1.0.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/eslint/base.eslint.config.js +1 -1
- package/config/jest/jest.config.js +1 -0
- package/config/types.js +0 -2
- package/config/webpack/common-config/all/getStylesheetRule.js +1 -1
- package/config/webpack/webpack.config.build.js +1 -11
- package/config/webpack/webpack.config.dev.js +5 -11
- package/config/webpack/webpack.config.dev.shell.js +5 -11
- package/package.json +4 -3
- package/runtime/config/index.ts +2 -3
- package/runtime/index.ts +5 -0
- package/runtime/jest.config.js +1 -0
- package/runtime/react/SiteProvider.tsx +26 -3
- package/runtime/react/constants.ts +3 -0
- package/runtime/react/hooks/index.ts +8 -0
- package/runtime/react/hooks/theme/index.ts +2 -0
- package/runtime/react/hooks/theme/useTheme.test.ts +221 -0
- package/runtime/react/hooks/theme/useTheme.ts +179 -0
- package/runtime/react/hooks/theme/useThemeConfig.test.ts +107 -0
- package/runtime/react/hooks/theme/useThemeConfig.ts +34 -0
- package/runtime/react/hooks/theme/useThemeCore.test.ts +65 -0
- package/runtime/react/hooks/theme/useThemeCore.ts +52 -0
- package/runtime/react/hooks/theme/useThemeVariants.test.ts +97 -0
- package/runtime/react/hooks/theme/useThemeVariants.ts +116 -0
- package/runtime/react/hooks/theme/useTrackColorSchemeChoice.test.ts +54 -0
- package/runtime/react/hooks/theme/useTrackColorSchemeChoice.ts +30 -0
- package/runtime/react/hooks/theme/utils.ts +11 -0
- package/runtime/react/hooks/useActiveRoles.ts +15 -0
- package/runtime/react/hooks/useActiveRouteRoleWatcher.ts +31 -0
- package/runtime/react/hooks/useAppConfig.ts +9 -0
- package/runtime/react/hooks/useAuthenticatedUser.test.tsx +41 -0
- package/runtime/react/hooks/useAuthenticatedUser.ts +9 -0
- package/runtime/react/hooks/useSiteConfig.test.tsx +13 -0
- package/runtime/react/hooks/useSiteConfig.ts +9 -0
- package/runtime/react/hooks/useSiteEvent.ts +24 -0
- package/runtime/react/reducers.ts +40 -0
- package/runtime/setupTest.js +0 -35
- package/runtime/slots/widget/iframe/hooks.ts +1 -1
- package/runtime/testing/initializeMockApp.ts +5 -0
- package/shell/app.scss +2 -1
- package/shell/jest.config.js +1 -0
- package/shell/setupTest.js +0 -35
- package/shell/site.tsx +1 -1
- package/tools/dist/cli/openedx.js +1 -15
- package/tools/dist/cli/utils/printUsage.js +0 -9
- package/tools/dist/eslint/base.eslint.config.js +1 -1
- package/tools/dist/jest/jest.config.js +1 -0
- package/tools/dist/types.js +0 -2
- package/tools/dist/webpack/common-config/all/getStylesheetRule.js +1 -1
- package/tools/dist/webpack/webpack.config.build.js +1 -11
- package/tools/dist/webpack/webpack.config.dev.js +5 -11
- package/tools/dist/webpack/webpack.config.dev.shell.js +5 -11
- package/types.ts +20 -0
- package/config/webpack/plugins/paragon-webpack-plugin/ParagonWebpackPlugin.js +0 -108
- package/config/webpack/plugins/paragon-webpack-plugin/index.js +0 -7
- package/config/webpack/plugins/paragon-webpack-plugin/utils/assetUtils.js +0 -64
- package/config/webpack/plugins/paragon-webpack-plugin/utils/htmlUtils.js +0 -53
- package/config/webpack/plugins/paragon-webpack-plugin/utils/index.js +0 -9
- package/config/webpack/plugins/paragon-webpack-plugin/utils/paragonStylesheetUtils.js +0 -114
- package/config/webpack/plugins/paragon-webpack-plugin/utils/scriptUtils.js +0 -146
- package/config/webpack/plugins/paragon-webpack-plugin/utils/stylesheetUtils.js +0 -126
- package/config/webpack/plugins/paragon-webpack-plugin/utils/tagUtils.js +0 -57
- package/config/webpack/types.js +0 -2
- package/config/webpack/utils/paragonUtils.js +0 -138
- package/runtime/react/hooks.test.jsx +0 -104
- package/runtime/react/hooks.ts +0 -106
- package/tools/dist/cli/commands/pack.js +0 -14
- package/tools/dist/cli/commands/release.js +0 -28
- package/tools/dist/webpack/plugins/paragon-webpack-plugin/ParagonWebpackPlugin.js +0 -108
- package/tools/dist/webpack/plugins/paragon-webpack-plugin/index.js +0 -7
- package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/assetUtils.js +0 -64
- package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/htmlUtils.js +0 -53
- package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/index.js +0 -9
- package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/paragonStylesheetUtils.js +0 -114
- package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/scriptUtils.js +0 -146
- package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/stylesheetUtils.js +0 -126
- package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/tagUtils.js +0 -57
- package/tools/dist/webpack/types.js +0 -2
- package/tools/dist/webpack/utils/paragonUtils.js +0 -138
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback, useEffect, useReducer, useState,
|
|
3
|
+
} from 'react';
|
|
4
|
+
|
|
5
|
+
import { SELECTED_THEME_VARIANT_KEY } from '../../constants';
|
|
6
|
+
import { logError } from '../../../logging';
|
|
7
|
+
import { themeActions, themeReducer } from '../../reducers';
|
|
8
|
+
import { ThemeVariants, ThemeDefaults } from '../../../../types';
|
|
9
|
+
import { isEmptyObject } from './utils';
|
|
10
|
+
|
|
11
|
+
import useThemeCore from './useThemeCore';
|
|
12
|
+
import useThemeConfig from './useThemeConfig';
|
|
13
|
+
import useThemeVariants from './useThemeVariants';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Finds the default theme variant from the given theme variants object. If no default theme exists, the light theme
|
|
17
|
+
* variant is returned as a fallback.
|
|
18
|
+
*
|
|
19
|
+
* It prioritizes:
|
|
20
|
+
* 1. A persisted theme variant from localStorage.
|
|
21
|
+
* 2. A system preference (`prefers-color-scheme`).
|
|
22
|
+
* 3. The configured default theme variant.
|
|
23
|
+
*
|
|
24
|
+
* Returns the default theme variant, or `undefined` if no valid theme variant is found.
|
|
25
|
+
*/
|
|
26
|
+
export const getDefaultThemeVariant = (themeVariants: ThemeVariants | undefined, themeDefaults: ThemeDefaults = {}) => {
|
|
27
|
+
if (!themeVariants) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const themeVariantKeys = Object.keys(themeVariants);
|
|
32
|
+
|
|
33
|
+
// If there is only one theme variant, return it since it's the only one that may be used.
|
|
34
|
+
if (themeVariantKeys.length === 1) {
|
|
35
|
+
const themeVariantKey = themeVariantKeys[0];
|
|
36
|
+
return {
|
|
37
|
+
name: themeVariantKey,
|
|
38
|
+
metadata: themeVariants[themeVariantKey],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Prioritize persisted localStorage theme variant preference.
|
|
43
|
+
const persistedSelectedThemeVariant = localStorage.getItem(SELECTED_THEME_VARIANT_KEY);
|
|
44
|
+
if (persistedSelectedThemeVariant && themeVariants[persistedSelectedThemeVariant]) {
|
|
45
|
+
return {
|
|
46
|
+
name: persistedSelectedThemeVariant,
|
|
47
|
+
metadata: themeVariants[persistedSelectedThemeVariant],
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Then, detect system preference via `prefers-color-scheme` media query and use
|
|
52
|
+
// the default dark theme variant, if one exists.
|
|
53
|
+
const hasDarkSystemPreference = !!window.matchMedia?.('(prefers-color-scheme: dark)')?.matches;
|
|
54
|
+
const defaultDarkThemeVariant = themeDefaults.dark ?? 'dark';
|
|
55
|
+
const darkThemeVariantMetadata = themeVariants[defaultDarkThemeVariant];
|
|
56
|
+
|
|
57
|
+
if (hasDarkSystemPreference && defaultDarkThemeVariant && darkThemeVariantMetadata) {
|
|
58
|
+
return {
|
|
59
|
+
name: defaultDarkThemeVariant,
|
|
60
|
+
metadata: darkThemeVariantMetadata,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const defaultLightThemeVariant = themeDefaults.light ?? 'light';
|
|
65
|
+
const lightThemeVariantMetadata = themeVariants[defaultLightThemeVariant];
|
|
66
|
+
|
|
67
|
+
// Handle edge case where the default light theme variant is not configured or provided.
|
|
68
|
+
if (!lightThemeVariantMetadata) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Otherwise, fallback to using the default light theme variant as configured.
|
|
73
|
+
return {
|
|
74
|
+
name: defaultLightThemeVariant,
|
|
75
|
+
metadata: lightThemeVariantMetadata,
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A custom React hook that manages the application's theme state and injects the appropriate CSS for the theme core
|
|
81
|
+
* and theme variants (e.g., light and dark modes) into the HTML document. It handles dynamically loading the theme
|
|
82
|
+
* CSS based on the current theme variant, and ensures that the theme variant's CSS is preloaded for runtime theme
|
|
83
|
+
* switching. This is done using "alternate" stylesheets. That is, the browser will download the CSS for the
|
|
84
|
+
* non-current theme variants with a lower priority than the current one.
|
|
85
|
+
*
|
|
86
|
+
* The hook also responds to system theme preference changes (e.g., via the `prefers-color-scheme` media query),
|
|
87
|
+
* and can automatically switch the theme based on the system's dark mode or light mode preference.
|
|
88
|
+
*
|
|
89
|
+
* * @example
|
|
90
|
+
* const [themeState, dispatch] = useTheme();
|
|
91
|
+
* console.log(themeState.isThemeLoaded); // true when the theme has been successfully loaded.
|
|
92
|
+
*
|
|
93
|
+
* // Dispatch an action to change the theme variant
|
|
94
|
+
* dispatch(themeActions.setThemeVariant('dark'));
|
|
95
|
+
*/
|
|
96
|
+
const useTheme = () => {
|
|
97
|
+
const themeConfig = useThemeConfig();
|
|
98
|
+
const {
|
|
99
|
+
core: themeCore,
|
|
100
|
+
defaults: themeVariantDefaults,
|
|
101
|
+
variants: themeVariants,
|
|
102
|
+
} = themeConfig;
|
|
103
|
+
const initialThemeState = {
|
|
104
|
+
isThemeLoaded: false,
|
|
105
|
+
themeVariant: getDefaultThemeVariant(themeVariants, themeVariantDefaults)?.name,
|
|
106
|
+
};
|
|
107
|
+
const [themeState, dispatch] = useReducer(themeReducer, initialThemeState);
|
|
108
|
+
|
|
109
|
+
const [isThemeCoreLoaded, setIsThemeCoreLoaded] = useState(false);
|
|
110
|
+
const onLoadThemeCore = useCallback(() => {
|
|
111
|
+
setIsThemeCoreLoaded(true);
|
|
112
|
+
}, []);
|
|
113
|
+
|
|
114
|
+
const [hasLoadedThemeVariants, setHasLoadedThemeVariants] = useState(false);
|
|
115
|
+
const onLoadThemeVariants = useCallback(() => {
|
|
116
|
+
setHasLoadedThemeVariants(true);
|
|
117
|
+
}, []);
|
|
118
|
+
|
|
119
|
+
// load the theme's core CSS
|
|
120
|
+
useThemeCore({
|
|
121
|
+
themeCore,
|
|
122
|
+
onComplete: onLoadThemeCore,
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
// respond to system preference changes with regard to `prefers-color-scheme: dark`.
|
|
126
|
+
const handleDarkModeSystemPreferenceChange = useCallback((prefersDarkMode: boolean) => {
|
|
127
|
+
// Ignore system preference change if the theme variant is already set in localStorage.
|
|
128
|
+
if (localStorage.getItem(SELECTED_THEME_VARIANT_KEY)) {
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (prefersDarkMode && themeVariantDefaults?.dark) {
|
|
133
|
+
dispatch(themeActions.setThemeVariant(themeVariantDefaults.dark));
|
|
134
|
+
} else if (!prefersDarkMode && themeVariantDefaults?.light) {
|
|
135
|
+
dispatch(themeActions.setThemeVariant(themeVariantDefaults.light));
|
|
136
|
+
} else {
|
|
137
|
+
logError(`Could not set theme variant based on system preference (prefers dark mode: ${prefersDarkMode})`);
|
|
138
|
+
}
|
|
139
|
+
}, [themeVariantDefaults]);
|
|
140
|
+
|
|
141
|
+
// load the theme variant(s) CSS
|
|
142
|
+
useThemeVariants({
|
|
143
|
+
themeVariants,
|
|
144
|
+
onComplete: onLoadThemeVariants,
|
|
145
|
+
currentThemeVariant: themeState.themeVariant,
|
|
146
|
+
onDarkModeSystemPreferenceChange: handleDarkModeSystemPreferenceChange,
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
// theme is already loaded, do nothing
|
|
151
|
+
if (themeState.isThemeLoaded) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const hasThemeConfig = (themeCore?.url && !isEmptyObject(themeVariants));
|
|
156
|
+
if (!hasThemeConfig) {
|
|
157
|
+
// no theme URLs to load, set loading to false.
|
|
158
|
+
dispatch(themeActions.setThemeLoaded(true));
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Return early if neither the core theme CSS nor any theme variant CSS is loaded.
|
|
162
|
+
if (!isThemeCoreLoaded || !hasLoadedThemeVariants) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// All application theme URLs are loaded
|
|
167
|
+
dispatch(themeActions.setThemeLoaded(true));
|
|
168
|
+
}, [
|
|
169
|
+
themeState.isThemeLoaded,
|
|
170
|
+
isThemeCoreLoaded,
|
|
171
|
+
hasLoadedThemeVariants,
|
|
172
|
+
themeCore?.url,
|
|
173
|
+
themeVariants,
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
return [themeState, dispatch];
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export default useTheme;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { renderHook } from '@testing-library/react';
|
|
2
|
+
|
|
3
|
+
import useThemeConfig from './useThemeConfig';
|
|
4
|
+
import * as config from '../../../config';
|
|
5
|
+
|
|
6
|
+
const baseSiteConfig = config.getSiteConfig();
|
|
7
|
+
|
|
8
|
+
describe('useThemeConfig', () => {
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
jest.spyOn(config, 'getSiteConfig').mockRestore();
|
|
11
|
+
jest.resetAllMocks();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it.each([
|
|
15
|
+
[undefined, {}],
|
|
16
|
+
[{}, {}],
|
|
17
|
+
])('handles when `siteConfig.theme` is not present (%s)', (theme, expectedURLConfig) => {
|
|
18
|
+
jest.spyOn(config, 'getSiteConfig').mockReturnValue({
|
|
19
|
+
...baseSiteConfig,
|
|
20
|
+
theme
|
|
21
|
+
});
|
|
22
|
+
const { result } = renderHook(() => useThemeConfig());
|
|
23
|
+
expect(result.current).toEqual(expectedURLConfig);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe('when `siteConfig.theme` is present', () => {
|
|
27
|
+
it('returns empty object if core is not defined and no variants are defined', () => {
|
|
28
|
+
jest.spyOn(config, 'getSiteConfig').mockReturnValue({
|
|
29
|
+
...baseSiteConfig,
|
|
30
|
+
theme: {
|
|
31
|
+
core: undefined,
|
|
32
|
+
variants: {}
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
const { result } = renderHook(() => useThemeConfig());
|
|
36
|
+
expect(result.current).toStrictEqual({});
|
|
37
|
+
});
|
|
38
|
+
it('returns expected object when only core is defined', () => {
|
|
39
|
+
jest.spyOn(config, 'getSiteConfig').mockReturnValue({
|
|
40
|
+
...baseSiteConfig,
|
|
41
|
+
theme: {
|
|
42
|
+
core: {
|
|
43
|
+
url: 'core.css',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
const { result } = renderHook(() => useThemeConfig());
|
|
48
|
+
expect(result.current).toStrictEqual({
|
|
49
|
+
core: {
|
|
50
|
+
url: 'core.css',
|
|
51
|
+
},
|
|
52
|
+
defaults: undefined,
|
|
53
|
+
variants: undefined,
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
it('returns expected object when only a light variant is defined', () => {
|
|
57
|
+
jest.spyOn(config, 'getSiteConfig').mockReturnValue({
|
|
58
|
+
...baseSiteConfig,
|
|
59
|
+
theme: {
|
|
60
|
+
variants: {
|
|
61
|
+
light: { url: 'light.css' },
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
const { result } = renderHook(() => useThemeConfig());
|
|
66
|
+
expect(result.current).toStrictEqual({
|
|
67
|
+
core: undefined,
|
|
68
|
+
defaults: undefined,
|
|
69
|
+
variants: {
|
|
70
|
+
light: { url: 'light.css' },
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
it('returns expected object when configuration is valid', () => {
|
|
75
|
+
jest.spyOn(config, 'getSiteConfig').mockReturnValue({
|
|
76
|
+
...baseSiteConfig,
|
|
77
|
+
theme: {
|
|
78
|
+
core: {
|
|
79
|
+
url: 'core.css',
|
|
80
|
+
},
|
|
81
|
+
defaults: {
|
|
82
|
+
light: 'light',
|
|
83
|
+
},
|
|
84
|
+
variants: {
|
|
85
|
+
light: {
|
|
86
|
+
url: 'light.css',
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
const { result } = renderHook(() => useThemeConfig());
|
|
92
|
+
expect(result.current).toStrictEqual({
|
|
93
|
+
core: {
|
|
94
|
+
url: 'core.css',
|
|
95
|
+
},
|
|
96
|
+
defaults: {
|
|
97
|
+
light: 'light',
|
|
98
|
+
},
|
|
99
|
+
variants: {
|
|
100
|
+
light: {
|
|
101
|
+
url: 'light.css',
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
|
|
3
|
+
import { Theme } from '../../../../types';
|
|
4
|
+
import { getSiteConfig } from '../../../config';
|
|
5
|
+
import { isEmptyObject } from './utils';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Custom React hook that retrieves the theme configuration.
|
|
9
|
+
*
|
|
10
|
+
* Configuration is considered entirely invalid if it doesn't define at least one variant, including core.
|
|
11
|
+
*
|
|
12
|
+
* Example:
|
|
13
|
+
*
|
|
14
|
+
* const themeConfig = useThemeConfig();
|
|
15
|
+
* if (themeConfig) {
|
|
16
|
+
* console.log(themeConfig.core.url); // Outputs the URL of theme's core CSS
|
|
17
|
+
* console.log(themeConfig.variants['dark'].url); // Outputs the URL of the theme's dark variant CSS
|
|
18
|
+
* }
|
|
19
|
+
*/
|
|
20
|
+
const useThemeConfig = (): Theme => useMemo(() => {
|
|
21
|
+
const { theme } = getSiteConfig();
|
|
22
|
+
|
|
23
|
+
if (!theme || (isEmptyObject(theme.core) && isEmptyObject(theme.variants))) {
|
|
24
|
+
return {};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
core: theme.core,
|
|
29
|
+
defaults: theme.defaults,
|
|
30
|
+
variants: theme.variants,
|
|
31
|
+
};
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
export default useThemeConfig;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { act, renderHook, fireEvent } from '@testing-library/react';
|
|
2
|
+
|
|
3
|
+
import { logError } from '../../../logging';
|
|
4
|
+
|
|
5
|
+
import useThemeCore from './useThemeCore';
|
|
6
|
+
|
|
7
|
+
jest.mock('../../../logging');
|
|
8
|
+
|
|
9
|
+
describe('useThemeCore', () => {
|
|
10
|
+
const onComplete = jest.fn();
|
|
11
|
+
let testParams: any;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
document.head.innerHTML = '';
|
|
15
|
+
testParams = {
|
|
16
|
+
themeCore: {
|
|
17
|
+
url: 'https://cdn.jsdelivr.net/npm/@openedx/brand@1.0.0/dist/core.min.css',
|
|
18
|
+
},
|
|
19
|
+
onComplete,
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
jest.clearAllMocks();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('should load the core url and change the loading state to true', () => {
|
|
28
|
+
renderHook(() => useThemeCore(testParams));
|
|
29
|
+
|
|
30
|
+
const createdLinkTag: HTMLAnchorElement | null = document.head.querySelector('link[data-theme-core="true"]');
|
|
31
|
+
act(() => {
|
|
32
|
+
if (createdLinkTag) {
|
|
33
|
+
fireEvent.load(createdLinkTag);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
expect(createdLinkTag?.href).toBe(testParams.themeCore.url);
|
|
37
|
+
expect(onComplete).toHaveBeenCalledTimes(1);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should dispatch a log error if the theme link cannot be loaded', () => {
|
|
41
|
+
renderHook(() => useThemeCore(testParams));
|
|
42
|
+
|
|
43
|
+
const createdLinkTag: HTMLAnchorElement | null = document.head.querySelector('link[data-theme-core="true"]');
|
|
44
|
+
act(() => {
|
|
45
|
+
if (createdLinkTag) {
|
|
46
|
+
fireEvent.error(createdLinkTag);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
expect(logError).toHaveBeenCalledTimes(1);
|
|
50
|
+
expect(onComplete).toHaveBeenCalledTimes(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should not create a link if there is no configured theme core URL', () => {
|
|
54
|
+
testParams = {
|
|
55
|
+
themeCore: {
|
|
56
|
+
foo: 'bar',
|
|
57
|
+
},
|
|
58
|
+
onComplete,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
renderHook(() => useThemeCore(testParams));
|
|
62
|
+
expect(document.head.querySelectorAll('link').length).toBe(0);
|
|
63
|
+
expect(onComplete).toHaveBeenCalledTimes(1);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { logError } from '../../../logging';
|
|
4
|
+
import { removeExistingLinks } from './utils';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Custom React hook that manages the loading and updating of a theme's core
|
|
8
|
+
* CSS, ensuring the theme's core CSS is added to the document `<head>` as a
|
|
9
|
+
* `<link>` element.
|
|
10
|
+
*/
|
|
11
|
+
const useThemeCore = ({
|
|
12
|
+
themeCore,
|
|
13
|
+
onComplete,
|
|
14
|
+
}) => {
|
|
15
|
+
const [isThemeCoreComplete, setIsThemeCoreComplete] = useState(false);
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (isThemeCoreComplete) {
|
|
19
|
+
onComplete();
|
|
20
|
+
}
|
|
21
|
+
}, [isThemeCoreComplete, onComplete]);
|
|
22
|
+
|
|
23
|
+
useEffect(() => {
|
|
24
|
+
// If there is no theme core config, do nothing.
|
|
25
|
+
if (!themeCore?.url) {
|
|
26
|
+
setIsThemeCoreComplete(true);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const themeCoreLink = document.createElement('link');
|
|
31
|
+
themeCoreLink.href = themeCore.url;
|
|
32
|
+
themeCoreLink.rel = 'stylesheet';
|
|
33
|
+
themeCoreLink.dataset.themeCore = 'true';
|
|
34
|
+
themeCoreLink.onload = () => {
|
|
35
|
+
setIsThemeCoreComplete(true);
|
|
36
|
+
};
|
|
37
|
+
themeCoreLink.onerror = () => {
|
|
38
|
+
setIsThemeCoreComplete(true);
|
|
39
|
+
const otherExistingLinks = document.head.querySelectorAll('link[data-theme-core="true"]');
|
|
40
|
+
removeExistingLinks(otherExistingLinks);
|
|
41
|
+
logError(`Failed to load core theme CSS from ${themeCore.url}. Aborting.`);
|
|
42
|
+
return;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
document.head.insertAdjacentElement(
|
|
46
|
+
'beforeend',
|
|
47
|
+
themeCoreLink,
|
|
48
|
+
);
|
|
49
|
+
}, [themeCore?.url, onComplete]);
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export default useThemeCore;
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { act, fireEvent, renderHook } from '@testing-library/react';
|
|
2
|
+
|
|
3
|
+
import { getSiteConfig } from '../../../config';
|
|
4
|
+
import { logError } from '../../../logging';
|
|
5
|
+
|
|
6
|
+
import useThemeVariants from './useThemeVariants';
|
|
7
|
+
|
|
8
|
+
jest.mock('../../../logging');
|
|
9
|
+
|
|
10
|
+
describe('useThemeVariants', () => {
|
|
11
|
+
const onComplete = jest.fn();
|
|
12
|
+
const onDarkModeSystemPreferenceChange = jest.fn();
|
|
13
|
+
const originalWindowLocation = window.location;
|
|
14
|
+
const mockWindowLocationOrigin = jest.fn();
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
Object.defineProperty(window, 'location', {
|
|
18
|
+
value: {
|
|
19
|
+
get origin() {
|
|
20
|
+
return mockWindowLocationOrigin();
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
mockWindowLocationOrigin.mockReturnValue(getSiteConfig().baseUrl);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
document.head.innerHTML = '';
|
|
29
|
+
jest.clearAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
afterAll(() => {
|
|
33
|
+
Object.defineProperty(window, 'location', originalWindowLocation);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should do nothing if themeVariants is not configured', () => {
|
|
37
|
+
const themeVariants = undefined;
|
|
38
|
+
const currentThemeVariant = 'light';
|
|
39
|
+
|
|
40
|
+
renderHook(() => useThemeVariants({ themeVariants, currentThemeVariant, onComplete, onDarkModeSystemPreferenceChange }));
|
|
41
|
+
expect(document.head.querySelectorAll('link').length).toBe(0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('should create the links tags for each theme variant', () => {
|
|
45
|
+
const themeVariants = {
|
|
46
|
+
light: {
|
|
47
|
+
url: 'https://cdn.jsdelivr.net/npm/@openedx/brand@1.0.0/dist/light.min.css',
|
|
48
|
+
},
|
|
49
|
+
dark: {
|
|
50
|
+
url: 'https://cdn.jsdelivr.net/npm/@openedx/brand@1.0.0/dist/dark.min.css',
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
const currentThemeVariant = 'light';
|
|
54
|
+
|
|
55
|
+
renderHook(() => useThemeVariants({ themeVariants, currentThemeVariant, onComplete, onDarkModeSystemPreferenceChange }));
|
|
56
|
+
const themeLinks = document.head.querySelectorAll('link');
|
|
57
|
+
expect(themeLinks.length).toBe(2);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('should dispatch a log error if the variant theme link cannot be loaded', () => {
|
|
61
|
+
const themeVariants = {
|
|
62
|
+
light: {
|
|
63
|
+
url: 'https://cdn.jsdelivr.net/npm/@edx/brand@1.0.0/dist/light.min.css',
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
const currentThemeVariant = 'light';
|
|
67
|
+
|
|
68
|
+
renderHook(() => useThemeVariants({ themeVariants, currentThemeVariant, onComplete, onDarkModeSystemPreferenceChange }));
|
|
69
|
+
const themeLinks = document.head.querySelectorAll('link');
|
|
70
|
+
|
|
71
|
+
act(() => {
|
|
72
|
+
themeLinks.forEach((link) => fireEvent.error(link));
|
|
73
|
+
});
|
|
74
|
+
expect(logError).toHaveBeenCalledTimes(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('shoud not create a new link if it already exists', () => {
|
|
78
|
+
document.head.innerHTML = '<link rel="alternate stylesheet" href="https://cdn.jsdelivr.net/npm/@openedx/brand@1.0.0/dist/light.min.css">';
|
|
79
|
+
|
|
80
|
+
const themeVariants = {
|
|
81
|
+
light: {
|
|
82
|
+
url: 'https://cdn.jsdelivr.net/npm/@openedx/brand@1.0.0/dist/light.min.css',
|
|
83
|
+
},
|
|
84
|
+
dark: {
|
|
85
|
+
url: 'https://cdn.jsdelivr.net/npm/@openedx/brand@1.0.0/dist/dark.min.css',
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const currentThemeVariant = 'light';
|
|
90
|
+
renderHook(() => useThemeVariants({ themeVariants, currentThemeVariant, onComplete, onDarkModeSystemPreferenceChange }));
|
|
91
|
+
const themeLinks = document.head.querySelectorAll('link');
|
|
92
|
+
const lightThemeBrandLink: HTMLAnchorElement | null = document.head.querySelector('link[data-theme-variant="light"]');
|
|
93
|
+
|
|
94
|
+
expect(themeLinks.length).toBe(2);
|
|
95
|
+
expect(lightThemeBrandLink?.rel).toContain('stylesheet');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import { logError } from '../../../logging';
|
|
4
|
+
|
|
5
|
+
import { ThemeVariants } from '../../../../types';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A custom React hook that manages the loading of theme variant CSS files dynamically.
|
|
9
|
+
* Adds/updates a `<link>` element in the HTML document to load each theme variant's CSS, setting the
|
|
10
|
+
* non-current theme variants as "alternate" stylesheets. That is, the browser will download
|
|
11
|
+
* the CSS for the non-current theme variants, but at a lower priority than the current one.
|
|
12
|
+
* This ensures that if the theme variant is changed at runtime, the new theme's CSS will already be loaded.
|
|
13
|
+
*
|
|
14
|
+
* The hook also listens for changes in the system's preference and triggers the provided callback accordingly.
|
|
15
|
+
*/
|
|
16
|
+
const useThemeVariants = ({
|
|
17
|
+
themeVariants,
|
|
18
|
+
currentThemeVariant,
|
|
19
|
+
onComplete,
|
|
20
|
+
onDarkModeSystemPreferenceChange,
|
|
21
|
+
}: {
|
|
22
|
+
themeVariants: ThemeVariants | undefined,
|
|
23
|
+
currentThemeVariant: string,
|
|
24
|
+
onComplete: () => void,
|
|
25
|
+
onDarkModeSystemPreferenceChange: (prefersDarkMode: boolean) => void,
|
|
26
|
+
}) => {
|
|
27
|
+
const [isThemeVariantComplete, setIsThemeVariantComplete] = useState(false);
|
|
28
|
+
|
|
29
|
+
// Effect hook that listens for changes in the system's dark mode preference.
|
|
30
|
+
useEffect(() => {
|
|
31
|
+
const changeColorScheme = (colorSchemeQuery) => {
|
|
32
|
+
onDarkModeSystemPreferenceChange(colorSchemeQuery.matches);
|
|
33
|
+
};
|
|
34
|
+
const colorSchemeQuery = window.matchMedia?.('(prefers-color-scheme: dark)');
|
|
35
|
+
if (colorSchemeQuery) {
|
|
36
|
+
colorSchemeQuery.addEventListener('change', changeColorScheme);
|
|
37
|
+
}
|
|
38
|
+
return () => {
|
|
39
|
+
if (colorSchemeQuery) {
|
|
40
|
+
colorSchemeQuery.removeEventListener('change', changeColorScheme);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
}, [onDarkModeSystemPreferenceChange]);
|
|
44
|
+
|
|
45
|
+
// Effect hook to set the theme current variant on the HTML element.
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (currentThemeVariant && themeVariants?.[currentThemeVariant]) {
|
|
48
|
+
const htmlDataThemeVariantAttr = 'data-theme-variant';
|
|
49
|
+
document.querySelector('html')?.setAttribute(htmlDataThemeVariantAttr, currentThemeVariant);
|
|
50
|
+
return () => {
|
|
51
|
+
document.querySelector('html')?.removeAttribute(htmlDataThemeVariantAttr);
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
return () => { }; // Cleanup: no action needed when theme variant is not set
|
|
55
|
+
}, [themeVariants, currentThemeVariant]);
|
|
56
|
+
|
|
57
|
+
// Effect hook that calls `onComplete` when brand theme variant processing is complete.
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (isThemeVariantComplete) {
|
|
60
|
+
onComplete();
|
|
61
|
+
}
|
|
62
|
+
}, [isThemeVariantComplete, onComplete]);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
if (!themeVariants) {
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Determines the value for the `rel` attribute for a given theme variant based
|
|
71
|
+
* on if its the currently applied variant.
|
|
72
|
+
*/
|
|
73
|
+
const getActiveOrAlternateRel = (themeVariant: string): string => (currentThemeVariant === themeVariant ? 'stylesheet' : 'alternate stylesheet');
|
|
74
|
+
|
|
75
|
+
// Iterate over each theme variant URLs and inject them into the HTML document, if each doesn't already exist.
|
|
76
|
+
Object.entries(themeVariants).forEach(([themeVariant, { url }]) => {
|
|
77
|
+
// If the config for the theme variant does not have a URL, set the variant to complete and continue.
|
|
78
|
+
if (!url) {
|
|
79
|
+
setIsThemeVariantComplete(true);
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const existingThemeVariantLink: HTMLAnchorElement | null = document.head.querySelector(`link[href='${url}']`);
|
|
84
|
+
if (existingThemeVariantLink) {
|
|
85
|
+
existingThemeVariantLink.rel = getActiveOrAlternateRel(themeVariant);
|
|
86
|
+
existingThemeVariantLink.dataset.themeVariant = themeVariant;
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const themeVariantLink = document.createElement('link');
|
|
91
|
+
themeVariantLink.href = url;
|
|
92
|
+
themeVariantLink.rel = getActiveOrAlternateRel(themeVariant);
|
|
93
|
+
themeVariantLink.dataset.themeVariant = themeVariant;
|
|
94
|
+
|
|
95
|
+
themeVariantLink.onload = () => {
|
|
96
|
+
if (themeVariant === currentThemeVariant) {
|
|
97
|
+
setIsThemeVariantComplete(true);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
themeVariantLink.onerror = () => {
|
|
102
|
+
logError(`Failed to load theme variant (${themeVariant}) CSS from ${url}. Aborting.`);
|
|
103
|
+
setIsThemeVariantComplete(true);
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
document.head.insertAdjacentElement(
|
|
107
|
+
'beforeend',
|
|
108
|
+
themeVariantLink,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
setIsThemeVariantComplete(true);
|
|
112
|
+
});
|
|
113
|
+
}, [themeVariants, currentThemeVariant, onComplete]);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export default useThemeVariants;
|