@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
|
@@ -31,7 +31,6 @@ module.exports = tseslint.config(eslint.configs.recommended, ...tseslint.configs
|
|
|
31
31
|
...globals.browser,
|
|
32
32
|
...globals.node,
|
|
33
33
|
...globals.jest,
|
|
34
|
-
PARAGON_THEME: 'readonly',
|
|
35
34
|
newrelic: 'readonly',
|
|
36
35
|
},
|
|
37
36
|
},
|
|
@@ -79,6 +78,7 @@ module.exports = tseslint.config(eslint.configs.recommended, ...tseslint.configs
|
|
|
79
78
|
caughtErrors: 'none',
|
|
80
79
|
}],
|
|
81
80
|
'@typescript-eslint/no-empty-function': 'off',
|
|
81
|
+
'@typescript-eslint/prefer-nullish-coalescing': 'off',
|
|
82
82
|
'@stylistic/semi': ['error', 'always', { omitLastInOneLineBlock: true, omitLastInOneLineClassBody: true }],
|
|
83
83
|
'@stylistic/quotes': ['error', 'single', {
|
|
84
84
|
avoidEscape: true,
|
|
@@ -9,6 +9,7 @@ module.exports = {
|
|
|
9
9
|
moduleNameMapper: {
|
|
10
10
|
'\\.(css|scss)$': require.resolve('identity-obj-proxy'),
|
|
11
11
|
'site.config': path.resolve(process.cwd(), './site.config.test.tsx'),
|
|
12
|
+
'^@src/(.*)$': '<rootDir>/src/$1',
|
|
12
13
|
},
|
|
13
14
|
collectCoverageFrom: [
|
|
14
15
|
'src/**/*.{js,jsx,ts,tsx}',
|
package/config/types.js
CHANGED
|
@@ -12,8 +12,6 @@ var ConfigTypes;
|
|
|
12
12
|
})(ConfigTypes || (exports.ConfigTypes = ConfigTypes = {}));
|
|
13
13
|
var CommandTypes;
|
|
14
14
|
(function (CommandTypes) {
|
|
15
|
-
CommandTypes["RELEASE"] = "release";
|
|
16
|
-
CommandTypes["PACK"] = "pack";
|
|
17
15
|
CommandTypes["LINT"] = "lint";
|
|
18
16
|
CommandTypes["TEST"] = "test";
|
|
19
17
|
CommandTypes["BUILD"] = "build";
|
|
@@ -87,7 +87,7 @@ function getStyleUseConfig(mode) {
|
|
|
87
87
|
],
|
|
88
88
|
// Silences compiler deprecation warnings. They mostly come from bootstrap and/or paragon.
|
|
89
89
|
quietDeps: true,
|
|
90
|
-
silenceDeprecations: ['abs-percent', 'color-functions', 'import', '
|
|
90
|
+
silenceDeprecations: ['abs-percent', 'color-functions', 'import', 'global-builtin', 'legacy-js-api'],
|
|
91
91
|
},
|
|
92
92
|
},
|
|
93
93
|
},
|
|
@@ -9,13 +9,9 @@ const path_1 = __importDefault(require("path"));
|
|
|
9
9
|
const webpack_bundle_analyzer_1 = require("webpack-bundle-analyzer");
|
|
10
10
|
const webpack_remove_empty_scripts_1 = __importDefault(require("webpack-remove-empty-scripts"));
|
|
11
11
|
const common_config_1 = require("./common-config");
|
|
12
|
-
const ParagonWebpackPlugin_1 = __importDefault(require("./plugins/paragon-webpack-plugin/ParagonWebpackPlugin"));
|
|
13
12
|
const getLocalAliases_1 = __importDefault(require("./utils/getLocalAliases"));
|
|
14
13
|
const getPublicPath_1 = __importDefault(require("./utils/getPublicPath"));
|
|
15
14
|
const getResolvedSiteConfigPath_1 = __importDefault(require("./utils/getResolvedSiteConfigPath"));
|
|
16
|
-
const paragonUtils_1 = require("./utils/paragonUtils");
|
|
17
|
-
const paragonThemeCss = (0, paragonUtils_1.getParagonThemeCss)(process.cwd());
|
|
18
|
-
const brandThemeCss = (0, paragonUtils_1.getParagonThemeCss)(process.cwd(), { isBrandOverride: true });
|
|
19
15
|
const aliases = (0, getLocalAliases_1.default)();
|
|
20
16
|
const resolvedSiteConfigPath = (0, getResolvedSiteConfigPath_1.default)('site.config.build.tsx');
|
|
21
17
|
const config = {
|
|
@@ -23,8 +19,6 @@ const config = {
|
|
|
23
19
|
devtool: 'source-map',
|
|
24
20
|
entry: {
|
|
25
21
|
app: path_1.default.resolve(process.cwd(), 'node_modules/@openedx/frontend-base/shell/site'),
|
|
26
|
-
...(0, paragonUtils_1.getParagonEntryPoints)(paragonThemeCss),
|
|
27
|
-
...(0, paragonUtils_1.getParagonEntryPoints)(brandThemeCss),
|
|
28
22
|
},
|
|
29
23
|
output: {
|
|
30
24
|
filename: '[name].[chunkhash].js',
|
|
@@ -36,6 +30,7 @@ const config = {
|
|
|
36
30
|
alias: {
|
|
37
31
|
...aliases,
|
|
38
32
|
'site.config': resolvedSiteConfigPath,
|
|
33
|
+
'@src': path_1.default.resolve(process.cwd(), 'src'),
|
|
39
34
|
},
|
|
40
35
|
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
|
41
36
|
},
|
|
@@ -51,10 +46,6 @@ const config = {
|
|
|
51
46
|
runtimeChunk: 'single',
|
|
52
47
|
splitChunks: {
|
|
53
48
|
chunks: 'all',
|
|
54
|
-
cacheGroups: {
|
|
55
|
-
...(0, paragonUtils_1.getParagonCacheGroups)(paragonThemeCss),
|
|
56
|
-
...(0, paragonUtils_1.getParagonCacheGroups)(brandThemeCss),
|
|
57
|
-
},
|
|
58
49
|
},
|
|
59
50
|
minimizer: (0, common_config_1.getImageMinimizer)(),
|
|
60
51
|
},
|
|
@@ -65,7 +56,6 @@ const config = {
|
|
|
65
56
|
// See: https://www.npmjs.com/package/webpack-remove-empty-scripts#usage-with-mini-css-extract-plugin
|
|
66
57
|
new webpack_remove_empty_scripts_1.default(),
|
|
67
58
|
// Writes the extracted CSS from each entry to a file in the output directory.
|
|
68
|
-
new ParagonWebpackPlugin_1.default(),
|
|
69
59
|
new mini_css_extract_plugin_1.default({
|
|
70
60
|
filename: '[name].[chunkhash].css',
|
|
71
61
|
}),
|
|
@@ -9,20 +9,14 @@ const mini_css_extract_plugin_1 = __importDefault(require("mini-css-extract-plug
|
|
|
9
9
|
const path_1 = __importDefault(require("path"));
|
|
10
10
|
const webpack_remove_empty_scripts_1 = __importDefault(require("webpack-remove-empty-scripts"));
|
|
11
11
|
const common_config_1 = require("./common-config");
|
|
12
|
-
const ParagonWebpackPlugin_1 = __importDefault(require("./plugins/paragon-webpack-plugin/ParagonWebpackPlugin"));
|
|
13
12
|
const getLocalAliases_1 = __importDefault(require("./utils/getLocalAliases"));
|
|
14
13
|
const getPublicPath_1 = __importDefault(require("./utils/getPublicPath"));
|
|
15
14
|
const getResolvedSiteConfigPath_1 = __importDefault(require("./utils/getResolvedSiteConfigPath"));
|
|
16
|
-
const paragonUtils_1 = require("./utils/paragonUtils");
|
|
17
|
-
const paragonThemeCss = (0, paragonUtils_1.getParagonThemeCss)(process.cwd());
|
|
18
|
-
const brandThemeCss = (0, paragonUtils_1.getParagonThemeCss)(process.cwd(), { isBrandOverride: true });
|
|
19
15
|
const aliases = (0, getLocalAliases_1.default)();
|
|
20
16
|
const resolvedSiteConfigPath = (0, getResolvedSiteConfigPath_1.default)('site.config.dev.tsx');
|
|
21
17
|
const config = {
|
|
22
18
|
entry: {
|
|
23
19
|
app: path_1.default.resolve(process.cwd(), 'node_modules/@openedx/frontend-base/shell/site'),
|
|
24
|
-
...(0, paragonUtils_1.getParagonEntryPoints)(paragonThemeCss),
|
|
25
|
-
...(0, paragonUtils_1.getParagonEntryPoints)(brandThemeCss),
|
|
26
20
|
},
|
|
27
21
|
output: {
|
|
28
22
|
path: path_1.default.resolve(process.cwd(), './dist'),
|
|
@@ -32,6 +26,7 @@ const config = {
|
|
|
32
26
|
alias: {
|
|
33
27
|
...aliases,
|
|
34
28
|
'site.config': resolvedSiteConfigPath,
|
|
29
|
+
'@src': path_1.default.resolve(process.cwd(), 'src'),
|
|
35
30
|
},
|
|
36
31
|
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
|
37
32
|
},
|
|
@@ -47,10 +42,6 @@ const config = {
|
|
|
47
42
|
optimization: {
|
|
48
43
|
splitChunks: {
|
|
49
44
|
chunks: 'all',
|
|
50
|
-
cacheGroups: {
|
|
51
|
-
...(0, paragonUtils_1.getParagonCacheGroups)(paragonThemeCss),
|
|
52
|
-
...(0, paragonUtils_1.getParagonCacheGroups)(brandThemeCss),
|
|
53
|
-
},
|
|
54
45
|
},
|
|
55
46
|
minimizer: (0, common_config_1.getImageMinimizer)(),
|
|
56
47
|
},
|
|
@@ -60,7 +51,6 @@ const config = {
|
|
|
60
51
|
// This helps to clean up the final bundle application
|
|
61
52
|
// See: https://www.npmjs.com/package/webpack-remove-empty-scripts#usage-with-mini-css-extract-plugin
|
|
62
53
|
new webpack_remove_empty_scripts_1.default(),
|
|
63
|
-
new ParagonWebpackPlugin_1.default(),
|
|
64
54
|
// Writes the extracted CSS from each entry to a file in the output directory.
|
|
65
55
|
new mini_css_extract_plugin_1.default({
|
|
66
56
|
filename: '[name].css',
|
|
@@ -72,5 +62,9 @@ const config = {
|
|
|
72
62
|
// This configures webpack-dev-server which serves bundles from memory and provides live
|
|
73
63
|
// reloading.
|
|
74
64
|
devServer: (0, common_config_1.getDevServer)(),
|
|
65
|
+
// Limit the number of watched files to avoid `inotify` resource starvation.
|
|
66
|
+
watchOptions: {
|
|
67
|
+
ignored: /node_modules/
|
|
68
|
+
}
|
|
75
69
|
};
|
|
76
70
|
exports.default = config;
|
|
@@ -11,21 +11,15 @@ const path_1 = __importDefault(require("path"));
|
|
|
11
11
|
const react_refresh_typescript_1 = __importDefault(require("react-refresh-typescript"));
|
|
12
12
|
const webpack_remove_empty_scripts_1 = __importDefault(require("webpack-remove-empty-scripts"));
|
|
13
13
|
const common_config_1 = require("./common-config");
|
|
14
|
-
const ParagonWebpackPlugin_1 = __importDefault(require("./plugins/paragon-webpack-plugin/ParagonWebpackPlugin"));
|
|
15
14
|
const html_webpack_plugin_1 = __importDefault(require("html-webpack-plugin"));
|
|
16
15
|
const getLocalAliases_1 = __importDefault(require("./utils/getLocalAliases"));
|
|
17
16
|
const getPublicPath_1 = __importDefault(require("./utils/getPublicPath"));
|
|
18
17
|
const getResolvedSiteConfigPath_1 = __importDefault(require("./utils/getResolvedSiteConfigPath"));
|
|
19
|
-
const paragonUtils_1 = require("./utils/paragonUtils");
|
|
20
|
-
const paragonThemeCss = (0, paragonUtils_1.getParagonThemeCss)(process.cwd());
|
|
21
|
-
const brandThemeCss = (0, paragonUtils_1.getParagonThemeCss)(process.cwd(), { isBrandOverride: true });
|
|
22
18
|
const aliases = (0, getLocalAliases_1.default)();
|
|
23
19
|
const resolvedSiteConfigPath = (0, getResolvedSiteConfigPath_1.default)('shell/site.config.dev.tsx');
|
|
24
20
|
const config = {
|
|
25
21
|
entry: {
|
|
26
22
|
app: path_1.default.resolve(process.cwd(), 'shell/site'),
|
|
27
|
-
...(0, paragonUtils_1.getParagonEntryPoints)(paragonThemeCss),
|
|
28
|
-
...(0, paragonUtils_1.getParagonEntryPoints)(brandThemeCss),
|
|
29
23
|
},
|
|
30
24
|
output: {
|
|
31
25
|
path: path_1.default.resolve(process.cwd(), './dist'),
|
|
@@ -35,6 +29,7 @@ const config = {
|
|
|
35
29
|
alias: {
|
|
36
30
|
...aliases,
|
|
37
31
|
'site.config': resolvedSiteConfigPath,
|
|
32
|
+
'@src': path_1.default.resolve(process.cwd(), 'src'),
|
|
38
33
|
},
|
|
39
34
|
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
|
40
35
|
},
|
|
@@ -77,10 +72,6 @@ const config = {
|
|
|
77
72
|
optimization: {
|
|
78
73
|
splitChunks: {
|
|
79
74
|
chunks: 'all',
|
|
80
|
-
cacheGroups: {
|
|
81
|
-
...(0, paragonUtils_1.getParagonCacheGroups)(paragonThemeCss),
|
|
82
|
-
...(0, paragonUtils_1.getParagonCacheGroups)(brandThemeCss),
|
|
83
|
-
},
|
|
84
75
|
},
|
|
85
76
|
minimizer: (0, common_config_1.getImageMinimizer)(),
|
|
86
77
|
},
|
|
@@ -90,7 +81,6 @@ const config = {
|
|
|
90
81
|
// This helps to clean up the final bundle application
|
|
91
82
|
// See: https://www.npmjs.com/package/webpack-remove-empty-scripts#usage-with-mini-css-extract-plugin
|
|
92
83
|
new webpack_remove_empty_scripts_1.default(),
|
|
93
|
-
new ParagonWebpackPlugin_1.default(),
|
|
94
84
|
// Writes the extracted CSS from each entry to a file in the output directory.
|
|
95
85
|
new mini_css_extract_plugin_1.default({
|
|
96
86
|
filename: '[name].css',
|
|
@@ -106,5 +96,9 @@ const config = {
|
|
|
106
96
|
// This configures webpack-dev-server which serves bundles from memory and provides live
|
|
107
97
|
// reloading.
|
|
108
98
|
devServer: (0, common_config_1.getDevServer)(),
|
|
99
|
+
// Limit the number of watched files to avoid `inotify` resource starvation.
|
|
100
|
+
watchOptions: {
|
|
101
|
+
ignored: /node_modules/
|
|
102
|
+
}
|
|
109
103
|
};
|
|
110
104
|
exports.default = config;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openedx/frontend-base",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.10",
|
|
4
4
|
"description": "Build tools, setup and config for frontend apps",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
"image-minimizer-webpack-plugin": "3.8.3",
|
|
87
87
|
"jest": "^29.7.0",
|
|
88
88
|
"jest-environment-jsdom": "^29.7.0",
|
|
89
|
+
"jest-localstorage-mock": "^2.4.26",
|
|
89
90
|
"jwt-decode": "^3.1.2",
|
|
90
91
|
"localforage": "^1.10.0",
|
|
91
92
|
"localforage-memoryStorageDriver": "^0.9.2",
|
|
@@ -101,7 +102,7 @@
|
|
|
101
102
|
"postcss-rtlcss": "^5.5.0",
|
|
102
103
|
"prop-types": "^15.8.1",
|
|
103
104
|
"react-dev-utils": "12.0.1",
|
|
104
|
-
"react-focus-on": "
|
|
105
|
+
"react-focus-on": "<3.10.0",
|
|
105
106
|
"react-intl": "^6.6.6",
|
|
106
107
|
"react-refresh": "0.16.0",
|
|
107
108
|
"react-refresh-typescript": "^2.0.9",
|
|
@@ -146,7 +147,7 @@
|
|
|
146
147
|
"nodemon": "^3.1.4"
|
|
147
148
|
},
|
|
148
149
|
"peerDependencies": {
|
|
149
|
-
"@openedx/paragon": "^
|
|
150
|
+
"@openedx/paragon": "^23.4.5",
|
|
150
151
|
"@tanstack/react-query": "^5.81.2",
|
|
151
152
|
"react": "^18.3.1",
|
|
152
153
|
"react-dom": "^18.3.1",
|
package/runtime/config/index.ts
CHANGED
|
@@ -124,6 +124,7 @@ let siteConfig: SiteConfig = {
|
|
|
124
124
|
externalRoutes: [],
|
|
125
125
|
externalLinkUrlOverrides: [],
|
|
126
126
|
mfeConfigApiUrl: null,
|
|
127
|
+
theme: {},
|
|
127
128
|
accessTokenCookieName: 'edx-jwt-cookie-header-payload',
|
|
128
129
|
csrfTokenApiPath: '/csrf/api/v1/token',
|
|
129
130
|
ignoredErrorRegex: null,
|
|
@@ -238,9 +239,7 @@ export function getActiveRouteRoles() {
|
|
|
238
239
|
const activeWidgetRoles: Record<string, number> = {};
|
|
239
240
|
|
|
240
241
|
export function addActiveWidgetRole(role: string) {
|
|
241
|
-
|
|
242
|
-
activeWidgetRoles[role] = 0;
|
|
243
|
-
}
|
|
242
|
+
activeWidgetRoles[role] ??= 0;
|
|
244
243
|
activeWidgetRoles[role] += 1;
|
|
245
244
|
publish(ACTIVE_ROLES_CHANGED);
|
|
246
245
|
}
|
package/runtime/index.ts
CHANGED
package/runtime/jest.config.js
CHANGED
|
@@ -7,6 +7,7 @@ module.exports = {
|
|
|
7
7
|
'\\.(jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$': '<rootDir/__mocks__/file.js',
|
|
8
8
|
'\\.(css|scss)$': require.resolve('identity-obj-proxy'),
|
|
9
9
|
'site.config': '<rootDir>/site.config.test.tsx',
|
|
10
|
+
'^@src/(.*)$': '<rootDir>/src/$1',
|
|
10
11
|
},
|
|
11
12
|
testEnvironment: 'jsdom',
|
|
12
13
|
testEnvironmentOptions: {
|
|
@@ -13,7 +13,13 @@ import {
|
|
|
13
13
|
import CombinedAppProvider from './CombinedAppProvider';
|
|
14
14
|
import ErrorBoundary from './ErrorBoundary';
|
|
15
15
|
import SiteContext from './SiteContext';
|
|
16
|
-
import {
|
|
16
|
+
import { SELECTED_THEME_VARIANT_KEY } from './constants';
|
|
17
|
+
import {
|
|
18
|
+
useTheme,
|
|
19
|
+
useSiteEvent,
|
|
20
|
+
useTrackColorSchemeChoice
|
|
21
|
+
} from './hooks';
|
|
22
|
+
import { themeActions } from './reducers';
|
|
17
23
|
|
|
18
24
|
interface SiteProviderProps {
|
|
19
25
|
children: ReactNode,
|
|
@@ -37,6 +43,7 @@ interface SiteProviderProps {
|
|
|
37
43
|
* - An error boundary as described above.
|
|
38
44
|
* - An `SiteContext` provider for React context data.
|
|
39
45
|
* - IntlProvider for @edx/frontend-i18n internationalization
|
|
46
|
+
* - A theme manager for Paragon.
|
|
40
47
|
*
|
|
41
48
|
* @param {Object} props
|
|
42
49
|
* @memberof module:React
|
|
@@ -58,11 +65,27 @@ export default function SiteProvider({ children }: SiteProviderProps) {
|
|
|
58
65
|
setLocale(getLocale());
|
|
59
66
|
});
|
|
60
67
|
|
|
68
|
+
useTrackColorSchemeChoice();
|
|
69
|
+
const [themeState, themeDispatch] = useTheme();
|
|
70
|
+
|
|
61
71
|
const siteContextValue = useMemo(() => ({
|
|
62
72
|
authenticatedUser,
|
|
63
73
|
siteConfig,
|
|
64
|
-
locale
|
|
65
|
-
|
|
74
|
+
locale,
|
|
75
|
+
theme: {
|
|
76
|
+
state: themeState,
|
|
77
|
+
setThemeVariant: (themeVariant: string) => {
|
|
78
|
+
themeDispatch(themeActions.setThemeVariant(themeVariant));
|
|
79
|
+
|
|
80
|
+
// Persist selected theme variant to localStorage.
|
|
81
|
+
window.localStorage.setItem(SELECTED_THEME_VARIANT_KEY, themeVariant);
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
}), [authenticatedUser, siteConfig, locale, themeState, themeDispatch]);
|
|
85
|
+
|
|
86
|
+
if (!themeState?.isThemeLoaded) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
66
89
|
|
|
67
90
|
return (
|
|
68
91
|
<IntlProvider locale={locale} messages={getMessages()}>
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { default as useAppConfig } from './useAppConfig';
|
|
2
|
+
export { default as useAuthenticatedUser } from './useAuthenticatedUser';
|
|
3
|
+
export { default as useActiveRouteRoleWatcher } from './useActiveRouteRoleWatcher';
|
|
4
|
+
export { default as useActiveRoles } from './useActiveRoles';
|
|
5
|
+
export { default as useSiteConfig } from './useSiteConfig';
|
|
6
|
+
export { default as useSiteEvent } from './useSiteEvent';
|
|
7
|
+
|
|
8
|
+
export * from './theme';
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { act, fireEvent, renderHook } from '@testing-library/react';
|
|
2
|
+
|
|
3
|
+
import useTheme from './useTheme';
|
|
4
|
+
import * as config from '../../../config';
|
|
5
|
+
import { logError } from '../../../logging';
|
|
6
|
+
|
|
7
|
+
jest.mock('../../../logging');
|
|
8
|
+
|
|
9
|
+
const baseSiteConfig = config.getSiteConfig();
|
|
10
|
+
|
|
11
|
+
const theme = {
|
|
12
|
+
core: {
|
|
13
|
+
url: 'https://cdn.jsdelivr.net/npm/@openedx/brand@1.0.0/dist/core.min.css',
|
|
14
|
+
},
|
|
15
|
+
defaults: {
|
|
16
|
+
light: 'light',
|
|
17
|
+
dark: 'dark',
|
|
18
|
+
},
|
|
19
|
+
variants: {
|
|
20
|
+
light: {
|
|
21
|
+
url: 'https://cdn.jsdelivr.net/npm/@openedx/brand@1.0.0/dist/light.min.css',
|
|
22
|
+
},
|
|
23
|
+
dark: {
|
|
24
|
+
url: 'https://cdn.jsdelivr.net/npm/@openedx/brand@1.0.0/dist/dark.min.css',
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
let mockMediaQueryListEvent;
|
|
30
|
+
const mockAddEventListener = jest.fn((_, fn) => fn(mockMediaQueryListEvent));
|
|
31
|
+
const mockRemoveEventListener = jest.fn();
|
|
32
|
+
|
|
33
|
+
Object.defineProperty(window, 'matchMedia', {
|
|
34
|
+
value: jest.fn(() => ({
|
|
35
|
+
addEventListener: mockAddEventListener,
|
|
36
|
+
removeEventListener: mockRemoveEventListener,
|
|
37
|
+
matches: mockMediaQueryListEvent.matches,
|
|
38
|
+
})),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
Object.defineProperty(window, 'localStorage', {
|
|
42
|
+
value: {
|
|
43
|
+
getItem: jest.fn(),
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe('useTheme', () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
document.head.innerHTML = '';
|
|
50
|
+
mockMediaQueryListEvent = { matches: true };
|
|
51
|
+
mockAddEventListener.mockClear();
|
|
52
|
+
mockRemoveEventListener.mockClear();
|
|
53
|
+
jest.mocked(window.localStorage.getItem).mockClear();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterEach(() => {
|
|
57
|
+
jest.spyOn(config, 'getSiteConfig').mockRestore();
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it.each([
|
|
61
|
+
['dark', 'stylesheet', 'alternate stylesheet', true], // preference is dark
|
|
62
|
+
['light', 'alternate stylesheet', 'stylesheet', false], // preference is light
|
|
63
|
+
])(
|
|
64
|
+
'should configure theme variant for system preference %s and handle theme change events',
|
|
65
|
+
(initialPreference, expectedDarkRel, expectedLightRel, isDarkMediaMatch) => {
|
|
66
|
+
jest.spyOn(config, 'getSiteConfig').mockReturnValue({
|
|
67
|
+
...baseSiteConfig,
|
|
68
|
+
theme
|
|
69
|
+
});
|
|
70
|
+
// Mock the matchMedia behavior to simulate system preference
|
|
71
|
+
mockMediaQueryListEvent = { matches: isDarkMediaMatch };
|
|
72
|
+
// Set up the hook and initial theme configuration
|
|
73
|
+
const { result, unmount } = renderHook(() => useTheme());
|
|
74
|
+
const themeLinks = document.head.querySelectorAll('link');
|
|
75
|
+
|
|
76
|
+
const checkThemeLinks = () => {
|
|
77
|
+
const darkLink: HTMLAnchorElement | null = document.head.querySelector('link[data-theme-variant="dark"]');
|
|
78
|
+
const lightLink: HTMLAnchorElement | null = document.head.querySelector('link[data-theme-variant="light"]');
|
|
79
|
+
expect(darkLink?.rel).toBe(expectedDarkRel);
|
|
80
|
+
expect(lightLink?.rel).toBe(expectedLightRel);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Simulate initial theme configuration based on system preference
|
|
84
|
+
act(() => {
|
|
85
|
+
themeLinks.forEach((link) => fireEvent.load(link));
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Ensure matchMedia was called with the correct system preference
|
|
89
|
+
expect(window.matchMedia).toHaveBeenCalledWith('(prefers-color-scheme: dark)');
|
|
90
|
+
expect(mockAddEventListener).toHaveBeenCalled();
|
|
91
|
+
|
|
92
|
+
// Check initial theme setup
|
|
93
|
+
checkThemeLinks();
|
|
94
|
+
expect(result.current[0]).toEqual({
|
|
95
|
+
isThemeLoaded: true,
|
|
96
|
+
themeVariant: initialPreference,
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
unmount();
|
|
100
|
+
expect(mockRemoveEventListener).toHaveBeenCalled();
|
|
101
|
+
},
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
it('should configure theme variants according with user preference if is defined (localStorage)', () => {
|
|
105
|
+
jest.spyOn(config, 'getSiteConfig').mockReturnValue({
|
|
106
|
+
...baseSiteConfig,
|
|
107
|
+
theme
|
|
108
|
+
});
|
|
109
|
+
jest.mocked(window.localStorage.getItem).mockReturnValue('light');
|
|
110
|
+
const { result, unmount } = renderHook(() => useTheme());
|
|
111
|
+
const themeLinks = document.head.querySelectorAll('link');
|
|
112
|
+
const darkLink: HTMLAnchorElement | null = document.head.querySelector('link[data-theme-variant="dark"]');
|
|
113
|
+
const lightLink: HTMLAnchorElement | null = document.head.querySelector('link[data-theme-variant="light"]');
|
|
114
|
+
|
|
115
|
+
act(() => {
|
|
116
|
+
themeLinks.forEach((link) => fireEvent.load(link));
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(window.matchMedia).toHaveBeenCalledWith('(prefers-color-scheme: dark)');
|
|
120
|
+
expect(mockAddEventListener).toHaveBeenCalled();
|
|
121
|
+
|
|
122
|
+
expect(darkLink?.rel).toBe('alternate stylesheet');
|
|
123
|
+
expect(lightLink?.rel).toBe('stylesheet');
|
|
124
|
+
expect(result.current[0]).toEqual({ isThemeLoaded: true, themeVariant: 'light' });
|
|
125
|
+
|
|
126
|
+
unmount();
|
|
127
|
+
expect(mockRemoveEventListener).toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('should define the theme variant as default if only 1 is configured', () => {
|
|
131
|
+
jest.spyOn(config, 'getSiteConfig').mockReturnValue({
|
|
132
|
+
...baseSiteConfig,
|
|
133
|
+
theme: { ...theme, variants: { light: theme.variants.light } }
|
|
134
|
+
});
|
|
135
|
+
jest.mocked(window.localStorage.getItem).mockReturnValue('light');
|
|
136
|
+
const { result, unmount } = renderHook(() => useTheme());
|
|
137
|
+
const themeLinks = document.head.querySelectorAll('link');
|
|
138
|
+
const themeVariantLinks: NodeListOf<HTMLAnchorElement> | null = document.head.querySelectorAll('link[data-theme-variant]');
|
|
139
|
+
|
|
140
|
+
act(() => {
|
|
141
|
+
themeLinks.forEach((link) => fireEvent.load(link));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
expect(themeVariantLinks.length).toBe(1);
|
|
145
|
+
expect((themeVariantLinks[0]).rel).toBe('stylesheet');
|
|
146
|
+
expect(result.current[0]).toEqual({ isThemeLoaded: true, themeVariant: 'light' });
|
|
147
|
+
|
|
148
|
+
unmount();
|
|
149
|
+
expect(mockRemoveEventListener).toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('should not configure any theme if theme is undefined', () => {
|
|
153
|
+
const { result, unmount } = renderHook(() => useTheme());
|
|
154
|
+
const themeLinks = document.head.querySelectorAll('link');
|
|
155
|
+
|
|
156
|
+
expect(result.current[0]).toEqual({ isThemeLoaded: true, themeVariant: undefined });
|
|
157
|
+
expect(themeLinks.length).toBe(0);
|
|
158
|
+
unmount();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('should return themeVariant undefined if a default variant cannot be configured', () => {
|
|
162
|
+
jest.spyOn(config, 'getSiteConfig').mockReturnValue({
|
|
163
|
+
...baseSiteConfig,
|
|
164
|
+
theme: {
|
|
165
|
+
...theme,
|
|
166
|
+
defaults: {
|
|
167
|
+
light: 'red'
|
|
168
|
+
},
|
|
169
|
+
variants: {
|
|
170
|
+
light: theme.variants.light,
|
|
171
|
+
green: { url: 'green-url' }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
jest.mocked(window.localStorage.getItem).mockReturnValue(null);
|
|
176
|
+
|
|
177
|
+
const { result, unmount } = renderHook(() => useTheme());
|
|
178
|
+
const themeLinks = document.head.querySelectorAll('link');
|
|
179
|
+
const themeVariantLinks = document.head.querySelectorAll('link[data-theme-variant]');
|
|
180
|
+
act(() => {
|
|
181
|
+
themeLinks.forEach((link) => fireEvent.load(link));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
expect(result.current[0]).toEqual({ isThemeLoaded: true, themeVariant: undefined });
|
|
185
|
+
expect(themeLinks.length).toBe(3);
|
|
186
|
+
themeVariantLinks.forEach((link: HTMLAnchorElement) => expect(link.rel).toBe('alternate stylesheet'));
|
|
187
|
+
unmount();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it('should log an error if the preferred theme variant cannot be set', async () => {
|
|
191
|
+
jest.spyOn(config, 'getSiteConfig').mockReturnValue({
|
|
192
|
+
...baseSiteConfig,
|
|
193
|
+
theme: {
|
|
194
|
+
...theme,
|
|
195
|
+
defaults: {
|
|
196
|
+
light: 'light',
|
|
197
|
+
dark: 'dark'
|
|
198
|
+
},
|
|
199
|
+
variants: {
|
|
200
|
+
light: theme.variants.light,
|
|
201
|
+
green: { url: 'green-url' }
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
jest.mocked(window.localStorage.getItem).mockReturnValue(null);
|
|
206
|
+
|
|
207
|
+
const { result, unmount } = renderHook(() => useTheme());
|
|
208
|
+
const themeLinks = document.head.querySelectorAll('link');
|
|
209
|
+
const themeVariantLinks = document.head.querySelectorAll('link[data-theme-variant]');
|
|
210
|
+
|
|
211
|
+
act(() => {
|
|
212
|
+
themeLinks.forEach((link) => fireEvent.load(link));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
expect(result.current[0]).toEqual({ isThemeLoaded: true, themeVariant: 'dark' });
|
|
216
|
+
expect(logError).toHaveBeenCalled();
|
|
217
|
+
expect(themeVariantLinks.length).toBe(2);
|
|
218
|
+
themeVariantLinks.forEach((link: HTMLAnchorElement) => expect(link.rel).toBe('alternate stylesheet'));
|
|
219
|
+
unmount();
|
|
220
|
+
});
|
|
221
|
+
});
|