@openedx/frontend-base 1.0.0-alpha.1 → 1.0.0-alpha.11

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.
Files changed (79) hide show
  1. package/README.md +27 -0
  2. package/config/eslint/base.eslint.config.js +1 -1
  3. package/config/jest/jest.config.js +1 -0
  4. package/config/types.js +0 -2
  5. package/config/webpack/common-config/all/getStylesheetRule.js +1 -1
  6. package/config/webpack/webpack.config.build.js +1 -11
  7. package/config/webpack/webpack.config.dev.js +5 -11
  8. package/config/webpack/webpack.config.dev.shell.js +5 -11
  9. package/package.json +8 -4
  10. package/runtime/config/index.ts +2 -3
  11. package/runtime/index.ts +5 -0
  12. package/runtime/jest.config.js +1 -0
  13. package/runtime/react/SiteProvider.tsx +26 -3
  14. package/runtime/react/constants.ts +3 -0
  15. package/runtime/react/hooks/index.ts +8 -0
  16. package/runtime/react/hooks/theme/index.ts +2 -0
  17. package/runtime/react/hooks/theme/useTheme.test.ts +221 -0
  18. package/runtime/react/hooks/theme/useTheme.ts +179 -0
  19. package/runtime/react/hooks/theme/useThemeConfig.test.ts +107 -0
  20. package/runtime/react/hooks/theme/useThemeConfig.ts +34 -0
  21. package/runtime/react/hooks/theme/useThemeCore.test.ts +65 -0
  22. package/runtime/react/hooks/theme/useThemeCore.ts +52 -0
  23. package/runtime/react/hooks/theme/useThemeVariants.test.ts +97 -0
  24. package/runtime/react/hooks/theme/useThemeVariants.ts +116 -0
  25. package/runtime/react/hooks/theme/useTrackColorSchemeChoice.test.ts +54 -0
  26. package/runtime/react/hooks/theme/useTrackColorSchemeChoice.ts +30 -0
  27. package/runtime/react/hooks/theme/utils.ts +11 -0
  28. package/runtime/react/hooks/useActiveRoles.ts +15 -0
  29. package/runtime/react/hooks/useActiveRouteRoleWatcher.ts +31 -0
  30. package/runtime/react/hooks/useAppConfig.ts +9 -0
  31. package/runtime/react/hooks/useAuthenticatedUser.test.tsx +41 -0
  32. package/runtime/react/hooks/useAuthenticatedUser.ts +9 -0
  33. package/runtime/react/hooks/useSiteConfig.test.tsx +13 -0
  34. package/runtime/react/hooks/useSiteConfig.ts +9 -0
  35. package/runtime/react/hooks/useSiteEvent.ts +24 -0
  36. package/runtime/react/reducers.ts +40 -0
  37. package/runtime/setupTest.js +0 -35
  38. package/runtime/slots/widget/iframe/hooks.ts +1 -1
  39. package/runtime/testing/initializeMockApp.ts +5 -0
  40. package/shell/app.scss +2 -1
  41. package/shell/jest.config.js +1 -0
  42. package/shell/setupTest.js +0 -35
  43. package/shell/site.tsx +1 -1
  44. package/tools/dist/cli/openedx.js +1 -15
  45. package/tools/dist/cli/utils/printUsage.js +0 -9
  46. package/tools/dist/eslint/base.eslint.config.js +1 -1
  47. package/tools/dist/jest/jest.config.js +1 -0
  48. package/tools/dist/types.js +0 -2
  49. package/tools/dist/webpack/common-config/all/getStylesheetRule.js +1 -1
  50. package/tools/dist/webpack/webpack.config.build.js +1 -11
  51. package/tools/dist/webpack/webpack.config.dev.js +5 -11
  52. package/tools/dist/webpack/webpack.config.dev.shell.js +5 -11
  53. package/types.ts +20 -0
  54. package/config/webpack/plugins/paragon-webpack-plugin/ParagonWebpackPlugin.js +0 -108
  55. package/config/webpack/plugins/paragon-webpack-plugin/index.js +0 -7
  56. package/config/webpack/plugins/paragon-webpack-plugin/utils/assetUtils.js +0 -64
  57. package/config/webpack/plugins/paragon-webpack-plugin/utils/htmlUtils.js +0 -53
  58. package/config/webpack/plugins/paragon-webpack-plugin/utils/index.js +0 -9
  59. package/config/webpack/plugins/paragon-webpack-plugin/utils/paragonStylesheetUtils.js +0 -114
  60. package/config/webpack/plugins/paragon-webpack-plugin/utils/scriptUtils.js +0 -146
  61. package/config/webpack/plugins/paragon-webpack-plugin/utils/stylesheetUtils.js +0 -126
  62. package/config/webpack/plugins/paragon-webpack-plugin/utils/tagUtils.js +0 -57
  63. package/config/webpack/types.js +0 -2
  64. package/config/webpack/utils/paragonUtils.js +0 -138
  65. package/runtime/react/hooks.test.jsx +0 -104
  66. package/runtime/react/hooks.ts +0 -106
  67. package/tools/dist/cli/commands/pack.js +0 -14
  68. package/tools/dist/cli/commands/release.js +0 -28
  69. package/tools/dist/webpack/plugins/paragon-webpack-plugin/ParagonWebpackPlugin.js +0 -108
  70. package/tools/dist/webpack/plugins/paragon-webpack-plugin/index.js +0 -7
  71. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/assetUtils.js +0 -64
  72. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/htmlUtils.js +0 -53
  73. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/index.js +0 -9
  74. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/paragonStylesheetUtils.js +0 -114
  75. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/scriptUtils.js +0 -146
  76. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/stylesheetUtils.js +0 -126
  77. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/tagUtils.js +0 -57
  78. package/tools/dist/webpack/types.js +0 -2
  79. package/tools/dist/webpack/utils/paragonUtils.js +0 -138
package/README.md CHANGED
@@ -67,6 +67,33 @@ npm run dev
67
67
 
68
68
  The development site will be available at `http://apps.local.openedx.io:8080`.
69
69
 
70
+ ### Developing an app and `frontend-base` concurrently
71
+
72
+ Concurrent development with `frontend-base` uses a tarball-based workflow rather than traditional local linking approaches. See [test-site/tools/autoinstall/README.md](./test-site/tools/autoinstall/README.md) for details.
73
+
74
+ #### In `frontend-base`
75
+
76
+ This watches for changes in `frontend-base` and rebuilds the packaged tarball on each change.
77
+
78
+ ```sh
79
+ nvm use
80
+ npm ci
81
+ npm run dev:pack
82
+ ```
83
+
84
+ #### In the consuming application
85
+
86
+ > [!NOTE]
87
+ > This assumes the consuming application has the same tooling as [test-site/tools/autoinstall/](./test-site/tools/autoinstall/)
88
+
89
+ This watches for changes to the generated .tgz, installs the updated package, and restarts the dev server.
90
+
91
+ ```sh
92
+ nvm use
93
+ npm ci
94
+ npm run dev:autoinstall
95
+ ```
96
+
70
97
  ## Migrating an MFE to `frontend-base`
71
98
 
72
99
  See the [Frontend App Migration How To](./docs/how_tos/migrate-frontend-app.md).
@@ -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', 'mixed-decls', 'global-builtin', 'legacy-js-api'],
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.1",
3
+ "version": "1.0.0-alpha.11",
4
4
  "description": "Build tools, setup and config for frontend apps",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -26,7 +26,10 @@
26
26
  "docs:watch": "nodemon -w runtime -w docs/template -w README.md -e js,jsx,ts,tsx --exec npm run docs",
27
27
  "lint": "eslint .; npm run lint:tools; npm --prefix ./test-site run lint",
28
28
  "lint:tools": "cd ./tools && eslint . && cd ..",
29
- "test": "jest"
29
+ "test": "jest",
30
+ "pack:local": "mkdir -p pack && npm pack --silent --pack-destination pack >/dev/null && mv \"$(ls -t pack/*.tgz | head -n 1)\" pack/openedx-frontend-base.tgz",
31
+ "build:pack": "make build && npm run pack:local",
32
+ "dev:pack": "nodemon --config nodemon.pack.json"
30
33
  },
31
34
  "repository": {
32
35
  "type": "git",
@@ -86,6 +89,7 @@
86
89
  "image-minimizer-webpack-plugin": "3.8.3",
87
90
  "jest": "^29.7.0",
88
91
  "jest-environment-jsdom": "^29.7.0",
92
+ "jest-localstorage-mock": "^2.4.26",
89
93
  "jwt-decode": "^3.1.2",
90
94
  "localforage": "^1.10.0",
91
95
  "localforage-memoryStorageDriver": "^0.9.2",
@@ -101,7 +105,7 @@
101
105
  "postcss-rtlcss": "^5.5.0",
102
106
  "prop-types": "^15.8.1",
103
107
  "react-dev-utils": "12.0.1",
104
- "react-focus-on": "^3.9.4",
108
+ "react-focus-on": "<3.10.0",
105
109
  "react-intl": "^6.6.6",
106
110
  "react-refresh": "0.16.0",
107
111
  "react-refresh-typescript": "^2.0.9",
@@ -146,7 +150,7 @@
146
150
  "nodemon": "^3.1.4"
147
151
  },
148
152
  "peerDependencies": {
149
- "@openedx/paragon": "^22.20.2",
153
+ "@openedx/paragon": "^23.4.5",
150
154
  "@tanstack/react-query": "^5.81.2",
151
155
  "react": "^18.3.1",
152
156
  "react-dom": "^18.3.1",
@@ -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
- if (activeWidgetRoles[role] === undefined) {
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
@@ -110,6 +110,11 @@ export {
110
110
  useAppConfig
111
111
  } from './react';
112
112
 
113
+ export {
114
+ getUrlByRouteRole,
115
+ isRoleRouteObject
116
+ } from './routing';
117
+
113
118
  export {
114
119
  clearAllSubscriptions,
115
120
  publish,
@@ -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 { useSiteEvent } from './hooks';
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
- }), [authenticatedUser, siteConfig, locale]);
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,3 @@
1
+ export const SET_THEME_VARIANT = 'SET_THEME_VARIANT';
2
+ export const SET_IS_THEME_LOADED = 'SET_IS_THEME_LOADED';
3
+ export const SELECTED_THEME_VARIANT_KEY = 'selected-theme-variant';
@@ -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,2 @@
1
+ export { default as useTrackColorSchemeChoice } from './useTrackColorSchemeChoice';
2
+ export { default as useTheme } from './useTheme';
@@ -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
+ });