@openedx/frontend-base 1.0.0-alpha.0 → 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.
Files changed (232) hide show
  1. package/config/eslint/base.eslint.config.js +1 -1
  2. package/config/jest/jest.config.js +1 -0
  3. package/config/types.js +0 -2
  4. package/config/webpack/common-config/all/getStylesheetRule.js +1 -1
  5. package/config/webpack/plugins/html-webpack-new-relic-plugin/test/HtmlWebpackNewRelicPlugin.test.js +66 -0
  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 +11 -3
  10. package/runtime/__mocks__/file.js +1 -0
  11. package/runtime/__mocks__/svg.js +1 -0
  12. package/runtime/__mocks__/universal-cookie.js +6 -0
  13. package/runtime/analytics/interface.test.js +242 -0
  14. package/runtime/auth/AxiosJwtAuthService.test.jsx +1076 -0
  15. package/runtime/auth/interceptors/createRetryInterceptor.test.js +23 -0
  16. package/runtime/config/getExternalLinkUrl.test.js +76 -0
  17. package/runtime/config/index.ts +2 -3
  18. package/runtime/i18n/lib.test.js +230 -0
  19. package/runtime/index.ts +5 -0
  20. package/runtime/initialize.async.function.config.test.js +43 -0
  21. package/runtime/initialize.const.config.test.js +41 -0
  22. package/runtime/initialize.function.config.test.js +41 -0
  23. package/runtime/initialize.test.js +356 -0
  24. package/runtime/jest.config.js +1 -0
  25. package/runtime/logging/NewRelicLoggingService.test.js +214 -0
  26. package/runtime/react/AuthenticatedPageRoute.test.jsx +135 -0
  27. package/runtime/react/ErrorBoundary.test.jsx +83 -0
  28. package/runtime/react/SiteProvider.test.jsx +66 -0
  29. package/runtime/react/SiteProvider.tsx +26 -3
  30. package/runtime/react/constants.ts +3 -0
  31. package/runtime/react/hooks/index.ts +8 -0
  32. package/runtime/react/hooks/theme/index.ts +2 -0
  33. package/runtime/react/hooks/theme/useTheme.test.ts +221 -0
  34. package/runtime/react/hooks/theme/useTheme.ts +179 -0
  35. package/runtime/react/hooks/theme/useThemeConfig.test.ts +107 -0
  36. package/runtime/react/hooks/theme/useThemeConfig.ts +34 -0
  37. package/runtime/react/hooks/theme/useThemeCore.test.ts +65 -0
  38. package/runtime/react/hooks/theme/useThemeCore.ts +52 -0
  39. package/runtime/react/hooks/theme/useThemeVariants.test.ts +97 -0
  40. package/runtime/react/hooks/theme/useThemeVariants.ts +116 -0
  41. package/runtime/react/hooks/theme/useTrackColorSchemeChoice.test.ts +54 -0
  42. package/runtime/react/hooks/theme/useTrackColorSchemeChoice.ts +30 -0
  43. package/runtime/react/hooks/theme/utils.ts +11 -0
  44. package/runtime/react/hooks/useActiveRoles.ts +15 -0
  45. package/runtime/react/hooks/useActiveRouteRoleWatcher.ts +31 -0
  46. package/runtime/react/hooks/useAppConfig.ts +9 -0
  47. package/runtime/react/hooks/useAuthenticatedUser.test.tsx +41 -0
  48. package/runtime/react/hooks/useAuthenticatedUser.ts +9 -0
  49. package/runtime/react/hooks/useSiteConfig.test.tsx +13 -0
  50. package/runtime/react/hooks/useSiteConfig.ts +9 -0
  51. package/runtime/react/hooks/useSiteEvent.ts +24 -0
  52. package/runtime/react/reducers.ts +40 -0
  53. package/runtime/routing/utils.test.ts +7 -0
  54. package/runtime/scripts/GoogleAnalyticsLoader.test.ts +77 -0
  55. package/runtime/setupTest.js +0 -35
  56. package/runtime/site.config.test.tsx +33 -0
  57. package/runtime/slots/Slot.test.tsx +40 -0
  58. package/runtime/slots/layout/DefaultSlotLayout.test.tsx +31 -0
  59. package/runtime/slots/layout/hooks.test.tsx +178 -0
  60. package/runtime/slots/layout/utils.test.ts +67 -0
  61. package/runtime/slots/types.ts +1 -0
  62. package/runtime/slots/utils.test.ts +64 -0
  63. package/runtime/slots/utils.ts +28 -9
  64. package/runtime/slots/widget/iframe/hooks.ts +1 -1
  65. package/runtime/testing/initializeMockApp.test.ts +66 -0
  66. package/runtime/testing/initializeMockApp.ts +5 -0
  67. package/runtime/utils.test.js +116 -0
  68. package/shell/Logo.test.tsx +32 -0
  69. package/shell/__mocks__/file.js +1 -0
  70. package/shell/__mocks__/svg.js +1 -0
  71. package/shell/__mocks__/universal-cookie.js +6 -0
  72. package/shell/app.scss +2 -1
  73. package/shell/app.ts +14 -0
  74. package/shell/dev/devHome/app.ts +2 -2
  75. package/shell/dev/slotShowcase/app.tsx +9 -9
  76. package/shell/header/app.tsx +3 -3
  77. package/shell/jest.config.js +1 -0
  78. package/shell/router/createRouter.test.tsx +50 -0
  79. package/shell/router/getAppRoutes.test.tsx +59 -0
  80. package/shell/setupTest.js +0 -35
  81. package/shell/site.config.dev.tsx +3 -3
  82. package/shell/site.config.test.tsx +16 -0
  83. package/shell/site.tsx +1 -1
  84. package/tools/dist/cli/intl-imports.test.js +146 -0
  85. package/tools/dist/cli/openedx.js +1 -15
  86. package/tools/dist/cli/utils/printUsage.js +0 -9
  87. package/tools/dist/eslint/base.eslint.config.js +1 -1
  88. package/tools/dist/jest/jest.config.js +1 -0
  89. package/tools/dist/types.js +0 -2
  90. package/tools/dist/webpack/common-config/all/getStylesheetRule.js +1 -1
  91. package/tools/dist/webpack/plugins/html-webpack-new-relic-plugin/test/HtmlWebpackNewRelicPlugin.test.js +66 -0
  92. package/tools/dist/webpack/webpack.config.build.js +1 -11
  93. package/tools/dist/webpack/webpack.config.dev.js +5 -11
  94. package/tools/dist/webpack/webpack.config.dev.shell.js +5 -11
  95. package/types.ts +21 -1
  96. package/config/webpack/plugins/paragon-webpack-plugin/ParagonWebpackPlugin.js +0 -108
  97. package/config/webpack/plugins/paragon-webpack-plugin/index.js +0 -7
  98. package/config/webpack/plugins/paragon-webpack-plugin/utils/assetUtils.js +0 -64
  99. package/config/webpack/plugins/paragon-webpack-plugin/utils/htmlUtils.js +0 -53
  100. package/config/webpack/plugins/paragon-webpack-plugin/utils/index.js +0 -9
  101. package/config/webpack/plugins/paragon-webpack-plugin/utils/paragonStylesheetUtils.js +0 -114
  102. package/config/webpack/plugins/paragon-webpack-plugin/utils/scriptUtils.js +0 -146
  103. package/config/webpack/plugins/paragon-webpack-plugin/utils/stylesheetUtils.js +0 -126
  104. package/config/webpack/plugins/paragon-webpack-plugin/utils/tagUtils.js +0 -57
  105. package/config/webpack/types.js +0 -2
  106. package/config/webpack/utils/paragonUtils.js +0 -138
  107. package/eslint.config.js +0 -18
  108. package/frontend-base.d.ts +0 -8
  109. package/jest.config.js +0 -7
  110. package/openedx-frontend-base.tgz +0 -0
  111. package/runtime/react/hooks.ts +0 -106
  112. package/test-site/app.d.ts +0 -15
  113. package/test-site/dist/176.436443549ebb858db483.js +0 -2
  114. package/test-site/dist/176.436443549ebb858db483.js.map +0 -1
  115. package/test-site/dist/362.536eff787d2380fe246c.js +0 -2
  116. package/test-site/dist/362.536eff787d2380fe246c.js.map +0 -1
  117. package/test-site/dist/653.486966b108d224551296.js +0 -2
  118. package/test-site/dist/653.486966b108d224551296.js.map +0 -1
  119. package/test-site/dist/74e025d3fe9a7b7f8503054e2563b353.jpg +0 -0
  120. package/test-site/dist/806.323cf6496ad0a7fe73a7.js +0 -3
  121. package/test-site/dist/806.323cf6496ad0a7fe73a7.js.LICENSE.txt +0 -106
  122. package/test-site/dist/806.323cf6496ad0a7fe73a7.js.map +0 -1
  123. package/test-site/dist/95ec738c0b7faac5b5c9126794446bbd.svg +0 -4
  124. package/test-site/dist/app.612058b36c74787759ac.css +0 -61
  125. package/test-site/dist/app.612058b36c74787759ac.css.map +0 -1
  126. package/test-site/dist/app.612058b36c74787759ac.js +0 -2
  127. package/test-site/dist/app.612058b36c74787759ac.js.map +0 -1
  128. package/test-site/dist/cb28cdb1468c915e27e5cec9af64f22f.svg +0 -1
  129. package/test-site/dist/index.html +0 -1
  130. package/test-site/dist/report.html +0 -39
  131. package/test-site/dist/runtime.c7aeaf7b967496cb076f.js +0 -2
  132. package/test-site/dist/runtime.c7aeaf7b967496cb076f.js.map +0 -1
  133. package/test-site/eslint.config.js +0 -12
  134. package/test-site/package-lock.json +0 -19226
  135. package/test-site/package.json +0 -29
  136. package/test-site/public/index.html +0 -10
  137. package/test-site/site.config.build.tsx +0 -27
  138. package/test-site/site.config.dev.tsx +0 -27
  139. package/test-site/src/authenticated-page/AuthenticatedPage.tsx +0 -18
  140. package/test-site/src/authenticated-page/i18n/index.ts +0 -27
  141. package/test-site/src/authenticated-page/index.tsx +0 -28
  142. package/test-site/src/example-page/ExamplePage.tsx +0 -79
  143. package/test-site/src/example-page/Image.tsx +0 -11
  144. package/test-site/src/example-page/ParagonPreview.jsx +0 -66
  145. package/test-site/src/example-page/apple.jpg +0 -0
  146. package/test-site/src/example-page/apple.svg +0 -1
  147. package/test-site/src/example-page/index.ts +0 -16
  148. package/test-site/src/i18n/README.md +0 -3
  149. package/test-site/src/i18n/messages/frontend-app-sample/ar.json +0 -4
  150. package/test-site/src/i18n/messages/frontend-app-sample/eo.json +0 -1
  151. package/test-site/src/i18n/messages/frontend-app-sample/es_419.json +0 -4
  152. package/test-site/src/i18n/messages/frontend-component-emptylangs/ar.json +0 -1
  153. package/test-site/src/i18n/messages/frontend-component-singlelang/ar.json +0 -3
  154. package/test-site/src/iframe-widget/IframeWidget.tsx +0 -14
  155. package/test-site/src/iframe-widget/index.ts +0 -16
  156. package/test-site/src/index.tsx +0 -3
  157. package/test-site/src/messages.js +0 -11
  158. package/test-site/src/site.scss +0 -11
  159. package/test-site/tsconfig.json +0 -14
  160. package/tools/babel/babel.config.js +0 -27
  161. package/tools/babel.config.js +0 -3
  162. package/tools/cli/README.md +0 -29
  163. package/tools/cli/commands/pack.ts +0 -9
  164. package/tools/cli/commands/release.ts +0 -27
  165. package/tools/cli/commands/serve.ts +0 -43
  166. package/tools/cli/intl-imports.ts +0 -274
  167. package/tools/cli/openedx.ts +0 -101
  168. package/tools/cli/transifex-utils.ts +0 -75
  169. package/tools/cli/utils/ensureConfigFilenameOption.ts +0 -40
  170. package/tools/cli/utils/formatter.ts +0 -10
  171. package/tools/cli/utils/getResolvedConfigPath.ts +0 -23
  172. package/tools/cli/utils/prettyPrintTitle.ts +0 -15
  173. package/tools/cli/utils/printUsage.ts +0 -53
  174. package/tools/config-helpers/createConfig.ts +0 -8
  175. package/tools/config-helpers/createLintConfig.ts +0 -14
  176. package/tools/config-helpers/getBaseConfig.ts +0 -11
  177. package/tools/defaultConfigPaths.ts +0 -30
  178. package/tools/dist/cli/commands/pack.js +0 -14
  179. package/tools/dist/cli/commands/release.js +0 -28
  180. package/tools/dist/webpack/plugins/paragon-webpack-plugin/ParagonWebpackPlugin.js +0 -108
  181. package/tools/dist/webpack/plugins/paragon-webpack-plugin/index.js +0 -7
  182. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/assetUtils.js +0 -64
  183. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/htmlUtils.js +0 -53
  184. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/index.js +0 -9
  185. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/paragonStylesheetUtils.js +0 -114
  186. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/scriptUtils.js +0 -146
  187. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/stylesheetUtils.js +0 -126
  188. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/tagUtils.js +0 -57
  189. package/tools/dist/webpack/types.js +0 -2
  190. package/tools/dist/webpack/utils/paragonUtils.js +0 -138
  191. package/tools/eslint/base.eslint.config.js +0 -124
  192. package/tools/eslint/modules.d.ts +0 -5
  193. package/tools/eslint.config.js +0 -15
  194. package/tools/index.ts +0 -3
  195. package/tools/jest/jest.config.js +0 -30
  196. package/tools/jest.config.js +0 -19
  197. package/tools/tsconfig.json +0 -24
  198. package/tools/types.ts +0 -21
  199. package/tools/typescript/tsconfig.json +0 -32
  200. package/tools/webpack/common-config/README.md +0 -15
  201. package/tools/webpack/common-config/all/getCodeRules.ts +0 -51
  202. package/tools/webpack/common-config/all/getFileLoaderRules.ts +0 -23
  203. package/tools/webpack/common-config/all/getIgnoreWarnings.ts +0 -13
  204. package/tools/webpack/common-config/all/getImageMinimizer.ts +0 -26
  205. package/tools/webpack/common-config/all/getStylesheetRule.ts +0 -111
  206. package/tools/webpack/common-config/dev/getDevServer.ts +0 -35
  207. package/tools/webpack/common-config/index.ts +0 -6
  208. package/tools/webpack/common-config/site/getHtmlWebpackPlugin.ts +0 -11
  209. package/tools/webpack/modules.d.ts +0 -6
  210. package/tools/webpack/plugins/html-webpack-new-relic-plugin/HtmlWebpackNewRelicPlugin.ts +0 -102
  211. package/tools/webpack/plugins/html-webpack-new-relic-plugin/LICENSE +0 -21
  212. package/tools/webpack/plugins/html-webpack-new-relic-plugin/README.md +0 -7
  213. package/tools/webpack/plugins/html-webpack-new-relic-plugin/index.js +0 -3
  214. package/tools/webpack/plugins/html-webpack-new-relic-plugin/test/fixtures/entry.js +0 -1
  215. package/tools/webpack/plugins/paragon-webpack-plugin/ParagonWebpackPlugin.ts +0 -134
  216. package/tools/webpack/plugins/paragon-webpack-plugin/index.ts +0 -3
  217. package/tools/webpack/plugins/paragon-webpack-plugin/utils/assetUtils.ts +0 -71
  218. package/tools/webpack/plugins/paragon-webpack-plugin/utils/htmlUtils.ts +0 -72
  219. package/tools/webpack/plugins/paragon-webpack-plugin/utils/index.ts +0 -6
  220. package/tools/webpack/plugins/paragon-webpack-plugin/utils/paragonStylesheetUtils.ts +0 -131
  221. package/tools/webpack/plugins/paragon-webpack-plugin/utils/scriptUtils.ts +0 -144
  222. package/tools/webpack/plugins/paragon-webpack-plugin/utils/stylesheetUtils.ts +0 -106
  223. package/tools/webpack/plugins/paragon-webpack-plugin/utils/tagUtils.ts +0 -54
  224. package/tools/webpack/types.ts +0 -69
  225. package/tools/webpack/utils/getLocalAliases.ts +0 -65
  226. package/tools/webpack/utils/getPublicPath.ts +0 -3
  227. package/tools/webpack/utils/getResolvedSiteConfigPath.ts +0 -28
  228. package/tools/webpack/utils/paragonUtils.ts +0 -152
  229. package/tools/webpack/webpack.config.build.ts +0 -93
  230. package/tools/webpack/webpack.config.dev.shell.ts +0 -122
  231. package/tools/webpack/webpack.config.dev.ts +0 -90
  232. package/tsconfig.json +0 -23
@@ -0,0 +1,135 @@
1
+ /* eslint-disable react/jsx-no-constructed-context-values */
2
+ import { render } from '@testing-library/react';
3
+ import { MemoryRouter, Route, Routes } from 'react-router-dom';
4
+ import { sendPageEvent } from '../analytics';
5
+ import { getAuthenticatedUser, getLoginRedirectUrl } from '../auth';
6
+ import { getSiteConfig } from '../config';
7
+ import SiteContext from './SiteContext';
8
+ import AuthenticatedPageRoute from './AuthenticatedPageRoute';
9
+
10
+ jest.mock('../subscriptions');
11
+ jest.mock('../analytics');
12
+ jest.mock('../auth');
13
+
14
+ describe('AuthenticatedPageRoute', () => {
15
+ const { location } = global;
16
+
17
+ beforeEach(() => {
18
+ delete global.location;
19
+ global.location = {
20
+ assign: jest.fn(),
21
+ };
22
+ sendPageEvent.mockReset();
23
+ getLoginRedirectUrl.mockReset();
24
+ getAuthenticatedUser.mockReset();
25
+ });
26
+
27
+ afterEach(() => {
28
+ global.location = location;
29
+ });
30
+
31
+ it('should redirect to login if not authenticated', () => {
32
+ getAuthenticatedUser.mockReturnValue(null);
33
+ getLoginRedirectUrl.mockReturnValue('http://localhost/login?next=http%3A%2F%2Flocalhost%2Fauthenticated');
34
+ const component = (
35
+ <SiteContext.Provider
36
+ value={{
37
+ authenticatedUser: getAuthenticatedUser(),
38
+ siteConfig: getSiteConfig(),
39
+ }}
40
+ >
41
+ <MemoryRouter initialEntries={['/authenticated']}>
42
+ <Routes>
43
+ <Route path="/" element={() => <p>Anonymous</p>} />
44
+ <Route path="/authenticated" element={<AuthenticatedPageRoute><p>Authenticated</p></AuthenticatedPageRoute>} />
45
+ </Routes>
46
+ </MemoryRouter>
47
+ </SiteContext.Provider>
48
+ );
49
+ global.location.href = 'http://localhost/authenticated';
50
+ render(component);
51
+ expect(getLoginRedirectUrl).toHaveBeenCalledWith('http://localhost/authenticated');
52
+ expect(sendPageEvent).not.toHaveBeenCalled();
53
+ expect(global.location.assign).toHaveBeenCalledWith('http://localhost/login?next=http%3A%2F%2Flocalhost%2Fauthenticated');
54
+ });
55
+
56
+ it('should redirect to custom redirect URL if not authenticated', () => {
57
+ getAuthenticatedUser.mockReturnValue(null);
58
+ getLoginRedirectUrl.mockReturnValue('http://localhost/login?next=http%3A%2F%2Flocalhost%2Fauthenticated');
59
+ const authenticatedElement = (
60
+ <AuthenticatedPageRoute redirectUrl="http://localhost/elsewhere">
61
+ <p>Authenticated</p>
62
+ </AuthenticatedPageRoute>
63
+ );
64
+ const component = (
65
+ <SiteContext.Provider
66
+ value={{
67
+ authenticatedUser: getAuthenticatedUser(),
68
+ siteConfig: getSiteConfig(),
69
+ }}
70
+ >
71
+ <MemoryRouter initialEntries={['/authenticated']}>
72
+ <Routes>
73
+ <Route path="/" component={() => <p>Anonymous</p>} />
74
+ <Route path="/authenticated" element={authenticatedElement} />
75
+ </Routes>
76
+ </MemoryRouter>
77
+ </SiteContext.Provider>
78
+ );
79
+ render(component);
80
+ expect(getLoginRedirectUrl).not.toHaveBeenCalled();
81
+ expect(sendPageEvent).not.toHaveBeenCalled();
82
+ expect(global.location.assign).toHaveBeenCalledWith('http://localhost/elsewhere');
83
+ });
84
+
85
+ it('should not call login if not the current route', () => {
86
+ getAuthenticatedUser.mockReturnValue(null);
87
+ getLoginRedirectUrl.mockReturnValue('http://localhost/login?next=http%3A%2F%2Flocalhost%2Fauthenticated');
88
+ const component = (
89
+ <SiteContext.Provider
90
+ value={{
91
+ authenticatedUser: getAuthenticatedUser(),
92
+ siteConfig: getSiteConfig(),
93
+ }}
94
+ >
95
+ <MemoryRouter>
96
+ <Routes>
97
+ <Route path="/" element={<p>Anonymous</p>} />
98
+ <Route path="/authenticated" element={<AuthenticatedPageRoute><p>Authenticated</p></AuthenticatedPageRoute>} />
99
+ </Routes>
100
+ </MemoryRouter>
101
+ </SiteContext.Provider>
102
+ );
103
+ const wrapper = render(component);
104
+
105
+ expect(getLoginRedirectUrl).not.toHaveBeenCalled();
106
+ expect(global.location.assign).not.toHaveBeenCalled();
107
+ expect(sendPageEvent).not.toHaveBeenCalled();
108
+ const element = wrapper.container.querySelector('p');
109
+ expect(element.textContent).toEqual('Anonymous'); // This is just a sanity check on our setup.
110
+ });
111
+
112
+ it('should render authenticated route if authenticated', () => {
113
+ const component = (
114
+ <SiteContext.Provider
115
+ value={{
116
+ authenticatedUser: { userId: 12345, username: 'edx' },
117
+ siteConfig: getSiteConfig(),
118
+ }}
119
+ >
120
+ <MemoryRouter initialEntries={['/authenticated']}>
121
+ <Routes>
122
+ <Route path="/" element={<p>Anonymous</p>} />
123
+ <Route path="/authenticated" element={<AuthenticatedPageRoute><p>Authenticated</p></AuthenticatedPageRoute>} />
124
+ </Routes>
125
+ </MemoryRouter>
126
+ </SiteContext.Provider>
127
+ );
128
+ const wrapper = render(component);
129
+ expect(getLoginRedirectUrl).not.toHaveBeenCalled();
130
+ expect(global.location.assign).not.toHaveBeenCalled();
131
+ expect(sendPageEvent).toHaveBeenCalled();
132
+ const element = wrapper.container.querySelector('p');
133
+ expect(element.textContent).toEqual('Authenticated');
134
+ });
135
+ });
@@ -0,0 +1,83 @@
1
+ import { render } from '@testing-library/react';
2
+
3
+ import ErrorBoundary from './ErrorBoundary';
4
+ import { initializeMockApp } from '..';
5
+
6
+ describe('ErrorBoundary', () => {
7
+ let logError = jest.fn();
8
+
9
+ beforeEach(async () => {
10
+ // This is a gross hack to suppress error logs in the invalid parentSelector test
11
+ jest.spyOn(console, 'error');
12
+ global.console.error.mockImplementation(() => { });
13
+
14
+ const { loggingService } = initializeMockApp();
15
+ logError = loggingService.logError;
16
+ });
17
+
18
+ afterEach(() => {
19
+ global.console.error.mockRestore();
20
+ jest.clearAllMocks();
21
+ });
22
+
23
+ it('should render children if no error', () => {
24
+ const component = (
25
+ <ErrorBoundary>
26
+ <div>Yay</div>
27
+ </ErrorBoundary>
28
+ );
29
+ const { container: wrapper } = render(component);
30
+
31
+ const element = wrapper.querySelector('div');
32
+ expect(element.textContent).toEqual('Yay');
33
+ });
34
+
35
+ it('should render ErrorPage if it has an error', () => {
36
+ const ExplodingComponent = () => {
37
+ throw new Error('booyah');
38
+ };
39
+
40
+ const component = (
41
+ <ErrorBoundary>
42
+ <ExplodingComponent />
43
+ </ErrorBoundary>
44
+ );
45
+
46
+ render(component);
47
+
48
+ expect(logError).toHaveBeenCalledTimes(1);
49
+ expect(logError).toHaveBeenCalledWith(
50
+ new Error('booyah'),
51
+ expect.objectContaining({
52
+ stack: expect.stringContaining('ExplodingComponent'),
53
+ }),
54
+ );
55
+ });
56
+ it('should render the fallback component when an error occurs', () => {
57
+ function FallbackComponent() {
58
+ return <div data-testid="fallback-component">Oops, something went wrong!</div>;
59
+ }
60
+ function ComponentError() {
61
+ throw new Error('An error occurred during the click event!');
62
+ }
63
+ const wrapper = render(
64
+ <ErrorBoundary fallbackComponent={<FallbackComponent />}>
65
+ <ComponentError />
66
+ </ErrorBoundary>,
67
+ );
68
+
69
+ expect(wrapper.queryByTestId('fallback-component')).toBeInTheDocument();
70
+ });
71
+
72
+ it('should render the ErrorPage fallbackComponent is null', () => {
73
+ function ComponentError() {
74
+ throw new Error('An error occurred during the click event!');
75
+ }
76
+ const wrapper = render(
77
+ <ErrorBoundary fallbackComponent={null}>
78
+ <ComponentError />
79
+ </ErrorBoundary>,
80
+ );
81
+ expect(wrapper.queryByTestId('error-page')).toBeInTheDocument();
82
+ });
83
+ });
@@ -0,0 +1,66 @@
1
+ import { render } from '@testing-library/react';
2
+ import { initialize } from '../initialize';
3
+ import SiteProvider from './SiteProvider';
4
+
5
+ jest.mock('../auth', () => ({
6
+ configureAuth: () => { },
7
+ getAuthenticatedUser: () => null,
8
+ fetchAuthenticatedUser: () => null,
9
+ getAuthenticatedHttpClient: () => ({}),
10
+ AUTHENTICATED_USER_CHANGED: 'user_changed',
11
+ }));
12
+
13
+ jest.mock('../analytics', () => ({
14
+ configureAnalytics: () => { },
15
+ identifyAnonymousUser: jest.fn(),
16
+ identifyAuthenticatedUser: jest.fn(),
17
+ }));
18
+
19
+ jest.mock('./hooks', () => ({
20
+ ...jest.requireActual('./hooks'),
21
+ useTrackColorSchemeChoice: jest.fn(),
22
+ }));
23
+
24
+ describe('SiteProvider', () => {
25
+ beforeEach(async () => {
26
+ await initialize({
27
+ loggingService: jest.fn(() => ({
28
+ logError: jest.fn(),
29
+ logInfo: jest.fn(),
30
+ })),
31
+ messages: {
32
+ ar: {},
33
+ 'es-419': {},
34
+ fr: {},
35
+ 'zh-cn': {},
36
+ ca: {},
37
+ he: {},
38
+ id: {},
39
+ 'ko-kr': {},
40
+ pl: {},
41
+ 'pt-br': {},
42
+ ru: {},
43
+ th: {},
44
+ uk: {},
45
+ 'fa-ir': {},
46
+ fa: {},
47
+ },
48
+ });
49
+ });
50
+
51
+ it('should render its children with a router', () => {
52
+ const component = (
53
+ <SiteProvider>
54
+ <div className="child">Child One</div>
55
+ <div className="child">Child Two</div>
56
+ </SiteProvider>
57
+ );
58
+
59
+ const wrapper = render(component);
60
+ const list = wrapper.container.querySelectorAll('div.child');
61
+
62
+ expect(list.length).toEqual(2);
63
+ expect(list[0].textContent).toEqual('Child One');
64
+ expect(list[1].textContent).toEqual('Child Two');
65
+ });
66
+ });
@@ -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
+ });