@openedx/frontend-base 1.0.0-alpha.6 → 1.0.0-alpha.8

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 (65) hide show
  1. package/config/eslint/base.eslint.config.js +1 -1
  2. package/config/webpack/common-config/all/getStylesheetRule.js +1 -1
  3. package/config/webpack/webpack.config.build.js +0 -11
  4. package/config/webpack/webpack.config.dev.js +0 -11
  5. package/config/webpack/webpack.config.dev.shell.js +0 -11
  6. package/package.json +4 -3
  7. package/runtime/config/index.ts +2 -3
  8. package/runtime/react/SiteProvider.tsx +26 -3
  9. package/runtime/react/constants.ts +3 -0
  10. package/runtime/react/hooks/index.ts +8 -0
  11. package/runtime/react/hooks/theme/index.ts +2 -0
  12. package/runtime/react/hooks/theme/useTheme.test.ts +221 -0
  13. package/runtime/react/hooks/theme/useTheme.ts +179 -0
  14. package/runtime/react/hooks/theme/useThemeConfig.test.ts +107 -0
  15. package/runtime/react/hooks/theme/useThemeConfig.ts +34 -0
  16. package/runtime/react/hooks/theme/useThemeCore.test.ts +65 -0
  17. package/runtime/react/hooks/theme/useThemeCore.ts +52 -0
  18. package/runtime/react/hooks/theme/useThemeVariants.test.ts +97 -0
  19. package/runtime/react/hooks/theme/useThemeVariants.ts +116 -0
  20. package/runtime/react/hooks/theme/useTrackColorSchemeChoice.test.ts +54 -0
  21. package/runtime/react/hooks/theme/useTrackColorSchemeChoice.ts +30 -0
  22. package/runtime/react/hooks/theme/utils.ts +11 -0
  23. package/runtime/react/hooks/useActiveRoles.ts +15 -0
  24. package/runtime/react/hooks/useActiveRouteRoleWatcher.ts +31 -0
  25. package/runtime/react/hooks/useAppConfig.ts +9 -0
  26. package/runtime/react/hooks/useAuthenticatedUser.test.tsx +41 -0
  27. package/runtime/react/hooks/useAuthenticatedUser.ts +9 -0
  28. package/runtime/react/hooks/useSiteConfig.test.tsx +13 -0
  29. package/runtime/react/hooks/useSiteConfig.ts +9 -0
  30. package/runtime/react/hooks/useSiteEvent.ts +24 -0
  31. package/runtime/react/reducers.ts +40 -0
  32. package/runtime/setupTest.js +0 -35
  33. package/runtime/testing/initializeMockApp.ts +5 -0
  34. package/shell/app.scss +2 -1
  35. package/shell/setupTest.js +0 -35
  36. package/tools/dist/eslint/base.eslint.config.js +1 -1
  37. package/tools/dist/webpack/common-config/all/getStylesheetRule.js +1 -1
  38. package/tools/dist/webpack/webpack.config.build.js +0 -11
  39. package/tools/dist/webpack/webpack.config.dev.js +0 -11
  40. package/tools/dist/webpack/webpack.config.dev.shell.js +0 -11
  41. package/types.ts +20 -0
  42. package/config/webpack/plugins/paragon-webpack-plugin/ParagonWebpackPlugin.js +0 -108
  43. package/config/webpack/plugins/paragon-webpack-plugin/index.js +0 -7
  44. package/config/webpack/plugins/paragon-webpack-plugin/utils/assetUtils.js +0 -64
  45. package/config/webpack/plugins/paragon-webpack-plugin/utils/htmlUtils.js +0 -53
  46. package/config/webpack/plugins/paragon-webpack-plugin/utils/index.js +0 -9
  47. package/config/webpack/plugins/paragon-webpack-plugin/utils/paragonStylesheetUtils.js +0 -114
  48. package/config/webpack/plugins/paragon-webpack-plugin/utils/scriptUtils.js +0 -146
  49. package/config/webpack/plugins/paragon-webpack-plugin/utils/stylesheetUtils.js +0 -126
  50. package/config/webpack/plugins/paragon-webpack-plugin/utils/tagUtils.js +0 -57
  51. package/config/webpack/types.js +0 -2
  52. package/config/webpack/utils/paragonUtils.js +0 -138
  53. package/runtime/react/hooks.test.jsx +0 -104
  54. package/runtime/react/hooks.ts +0 -106
  55. package/tools/dist/webpack/plugins/paragon-webpack-plugin/ParagonWebpackPlugin.js +0 -108
  56. package/tools/dist/webpack/plugins/paragon-webpack-plugin/index.js +0 -7
  57. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/assetUtils.js +0 -64
  58. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/htmlUtils.js +0 -53
  59. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/index.js +0 -9
  60. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/paragonStylesheetUtils.js +0 -114
  61. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/scriptUtils.js +0 -146
  62. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/stylesheetUtils.js +0 -126
  63. package/tools/dist/webpack/plugins/paragon-webpack-plugin/utils/tagUtils.js +0 -57
  64. package/tools/dist/webpack/types.js +0 -2
  65. package/tools/dist/webpack/utils/paragonUtils.js +0 -138
@@ -0,0 +1,107 @@
1
+ import { renderHook } from '@testing-library/react';
2
+
3
+ import useThemeConfig from './useThemeConfig';
4
+ import * as config from '../../../config';
5
+
6
+ const baseSiteConfig = config.getSiteConfig();
7
+
8
+ describe('useThemeConfig', () => {
9
+ afterEach(() => {
10
+ jest.spyOn(config, 'getSiteConfig').mockRestore();
11
+ jest.resetAllMocks();
12
+ });
13
+
14
+ it.each([
15
+ [undefined, {}],
16
+ [{}, {}],
17
+ ])('handles when `siteConfig.theme` is not present (%s)', (theme, expectedURLConfig) => {
18
+ jest.spyOn(config, 'getSiteConfig').mockReturnValue({
19
+ ...baseSiteConfig,
20
+ theme
21
+ });
22
+ const { result } = renderHook(() => useThemeConfig());
23
+ expect(result.current).toEqual(expectedURLConfig);
24
+ });
25
+
26
+ describe('when `siteConfig.theme` is present', () => {
27
+ it('returns empty object if core is not defined and no variants are defined', () => {
28
+ jest.spyOn(config, 'getSiteConfig').mockReturnValue({
29
+ ...baseSiteConfig,
30
+ theme: {
31
+ core: undefined,
32
+ variants: {}
33
+ },
34
+ });
35
+ const { result } = renderHook(() => useThemeConfig());
36
+ expect(result.current).toStrictEqual({});
37
+ });
38
+ it('returns expected object when only core is defined', () => {
39
+ jest.spyOn(config, 'getSiteConfig').mockReturnValue({
40
+ ...baseSiteConfig,
41
+ theme: {
42
+ core: {
43
+ url: 'core.css',
44
+ },
45
+ },
46
+ });
47
+ const { result } = renderHook(() => useThemeConfig());
48
+ expect(result.current).toStrictEqual({
49
+ core: {
50
+ url: 'core.css',
51
+ },
52
+ defaults: undefined,
53
+ variants: undefined,
54
+ });
55
+ });
56
+ it('returns expected object when only a light variant is defined', () => {
57
+ jest.spyOn(config, 'getSiteConfig').mockReturnValue({
58
+ ...baseSiteConfig,
59
+ theme: {
60
+ variants: {
61
+ light: { url: 'light.css' },
62
+ }
63
+ },
64
+ });
65
+ const { result } = renderHook(() => useThemeConfig());
66
+ expect(result.current).toStrictEqual({
67
+ core: undefined,
68
+ defaults: undefined,
69
+ variants: {
70
+ light: { url: 'light.css' },
71
+ }
72
+ });
73
+ });
74
+ it('returns expected object when configuration is valid', () => {
75
+ jest.spyOn(config, 'getSiteConfig').mockReturnValue({
76
+ ...baseSiteConfig,
77
+ theme: {
78
+ core: {
79
+ url: 'core.css',
80
+ },
81
+ defaults: {
82
+ light: 'light',
83
+ },
84
+ variants: {
85
+ light: {
86
+ url: 'light.css',
87
+ },
88
+ },
89
+ },
90
+ });
91
+ const { result } = renderHook(() => useThemeConfig());
92
+ expect(result.current).toStrictEqual({
93
+ core: {
94
+ url: 'core.css',
95
+ },
96
+ defaults: {
97
+ light: 'light',
98
+ },
99
+ variants: {
100
+ light: {
101
+ url: 'light.css',
102
+ },
103
+ },
104
+ });
105
+ });
106
+ });
107
+ });
@@ -0,0 +1,34 @@
1
+ import { useMemo } from 'react';
2
+
3
+ import { Theme } from '../../../../types';
4
+ import { getSiteConfig } from '../../../config';
5
+ import { isEmptyObject } from './utils';
6
+
7
+ /**
8
+ * Custom React hook that retrieves the theme configuration.
9
+ *
10
+ * Configuration is considered entirely invalid if it doesn't define at least one variant, including core.
11
+ *
12
+ * Example:
13
+ *
14
+ * const themeConfig = useThemeConfig();
15
+ * if (themeConfig) {
16
+ * console.log(themeConfig.core.url); // Outputs the URL of theme's core CSS
17
+ * console.log(themeConfig.variants['dark'].url); // Outputs the URL of the theme's dark variant CSS
18
+ * }
19
+ */
20
+ const useThemeConfig = (): Theme => useMemo(() => {
21
+ const { theme } = getSiteConfig();
22
+
23
+ if (!theme || (isEmptyObject(theme.core) && isEmptyObject(theme.variants))) {
24
+ return {};
25
+ }
26
+
27
+ return {
28
+ core: theme.core,
29
+ defaults: theme.defaults,
30
+ variants: theme.variants,
31
+ };
32
+ }, []);
33
+
34
+ export default useThemeConfig;
@@ -0,0 +1,65 @@
1
+ import { act, renderHook, fireEvent } from '@testing-library/react';
2
+
3
+ import { logError } from '../../../logging';
4
+
5
+ import useThemeCore from './useThemeCore';
6
+
7
+ jest.mock('../../../logging');
8
+
9
+ describe('useThemeCore', () => {
10
+ const onComplete = jest.fn();
11
+ let testParams: any;
12
+
13
+ beforeEach(() => {
14
+ document.head.innerHTML = '';
15
+ testParams = {
16
+ themeCore: {
17
+ url: 'https://cdn.jsdelivr.net/npm/@openedx/brand@1.0.0/dist/core.min.css',
18
+ },
19
+ onComplete,
20
+ };
21
+ });
22
+
23
+ afterEach(() => {
24
+ jest.clearAllMocks();
25
+ });
26
+
27
+ it('should load the core url and change the loading state to true', () => {
28
+ renderHook(() => useThemeCore(testParams));
29
+
30
+ const createdLinkTag: HTMLAnchorElement | null = document.head.querySelector('link[data-theme-core="true"]');
31
+ act(() => {
32
+ if (createdLinkTag) {
33
+ fireEvent.load(createdLinkTag);
34
+ }
35
+ });
36
+ expect(createdLinkTag?.href).toBe(testParams.themeCore.url);
37
+ expect(onComplete).toHaveBeenCalledTimes(1);
38
+ });
39
+
40
+ it('should dispatch a log error if the theme link cannot be loaded', () => {
41
+ renderHook(() => useThemeCore(testParams));
42
+
43
+ const createdLinkTag: HTMLAnchorElement | null = document.head.querySelector('link[data-theme-core="true"]');
44
+ act(() => {
45
+ if (createdLinkTag) {
46
+ fireEvent.error(createdLinkTag);
47
+ }
48
+ });
49
+ expect(logError).toHaveBeenCalledTimes(1);
50
+ expect(onComplete).toHaveBeenCalledTimes(1);
51
+ });
52
+
53
+ it('should not create a link if there is no configured theme core URL', () => {
54
+ testParams = {
55
+ themeCore: {
56
+ foo: 'bar',
57
+ },
58
+ onComplete,
59
+ };
60
+
61
+ renderHook(() => useThemeCore(testParams));
62
+ expect(document.head.querySelectorAll('link').length).toBe(0);
63
+ expect(onComplete).toHaveBeenCalledTimes(1);
64
+ });
65
+ });
@@ -0,0 +1,52 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ import { logError } from '../../../logging';
4
+ import { removeExistingLinks } from './utils';
5
+
6
+ /**
7
+ * Custom React hook that manages the loading and updating of a theme's core
8
+ * CSS, ensuring the theme's core CSS is added to the document `<head>` as a
9
+ * `<link>` element.
10
+ */
11
+ const useThemeCore = ({
12
+ themeCore,
13
+ onComplete,
14
+ }) => {
15
+ const [isThemeCoreComplete, setIsThemeCoreComplete] = useState(false);
16
+
17
+ useEffect(() => {
18
+ if (isThemeCoreComplete) {
19
+ onComplete();
20
+ }
21
+ }, [isThemeCoreComplete, onComplete]);
22
+
23
+ useEffect(() => {
24
+ // If there is no theme core config, do nothing.
25
+ if (!themeCore?.url) {
26
+ setIsThemeCoreComplete(true);
27
+ return;
28
+ }
29
+
30
+ const themeCoreLink = document.createElement('link');
31
+ themeCoreLink.href = themeCore.url;
32
+ themeCoreLink.rel = 'stylesheet';
33
+ themeCoreLink.dataset.themeCore = 'true';
34
+ themeCoreLink.onload = () => {
35
+ setIsThemeCoreComplete(true);
36
+ };
37
+ themeCoreLink.onerror = () => {
38
+ setIsThemeCoreComplete(true);
39
+ const otherExistingLinks = document.head.querySelectorAll('link[data-theme-core="true"]');
40
+ removeExistingLinks(otherExistingLinks);
41
+ logError(`Failed to load core theme CSS from ${themeCore.url}. Aborting.`);
42
+ return;
43
+ };
44
+
45
+ document.head.insertAdjacentElement(
46
+ 'beforeend',
47
+ themeCoreLink,
48
+ );
49
+ }, [themeCore?.url, onComplete]);
50
+ };
51
+
52
+ export default useThemeCore;
@@ -0,0 +1,97 @@
1
+ import { act, fireEvent, renderHook } from '@testing-library/react';
2
+
3
+ import { getSiteConfig } from '../../../config';
4
+ import { logError } from '../../../logging';
5
+
6
+ import useThemeVariants from './useThemeVariants';
7
+
8
+ jest.mock('../../../logging');
9
+
10
+ describe('useThemeVariants', () => {
11
+ const onComplete = jest.fn();
12
+ const onDarkModeSystemPreferenceChange = jest.fn();
13
+ const originalWindowLocation = window.location;
14
+ const mockWindowLocationOrigin = jest.fn();
15
+
16
+ beforeEach(() => {
17
+ Object.defineProperty(window, 'location', {
18
+ value: {
19
+ get origin() {
20
+ return mockWindowLocationOrigin();
21
+ },
22
+ },
23
+ });
24
+ mockWindowLocationOrigin.mockReturnValue(getSiteConfig().baseUrl);
25
+ });
26
+
27
+ afterEach(() => {
28
+ document.head.innerHTML = '';
29
+ jest.clearAllMocks();
30
+ });
31
+
32
+ afterAll(() => {
33
+ Object.defineProperty(window, 'location', originalWindowLocation);
34
+ });
35
+
36
+ it('should do nothing if themeVariants is not configured', () => {
37
+ const themeVariants = undefined;
38
+ const currentThemeVariant = 'light';
39
+
40
+ renderHook(() => useThemeVariants({ themeVariants, currentThemeVariant, onComplete, onDarkModeSystemPreferenceChange }));
41
+ expect(document.head.querySelectorAll('link').length).toBe(0);
42
+ });
43
+
44
+ it('should create the links tags for each theme variant', () => {
45
+ const themeVariants = {
46
+ light: {
47
+ url: 'https://cdn.jsdelivr.net/npm/@openedx/brand@1.0.0/dist/light.min.css',
48
+ },
49
+ dark: {
50
+ url: 'https://cdn.jsdelivr.net/npm/@openedx/brand@1.0.0/dist/dark.min.css',
51
+ },
52
+ };
53
+ const currentThemeVariant = 'light';
54
+
55
+ renderHook(() => useThemeVariants({ themeVariants, currentThemeVariant, onComplete, onDarkModeSystemPreferenceChange }));
56
+ const themeLinks = document.head.querySelectorAll('link');
57
+ expect(themeLinks.length).toBe(2);
58
+ });
59
+
60
+ it('should dispatch a log error if the variant theme link cannot be loaded', () => {
61
+ const themeVariants = {
62
+ light: {
63
+ url: 'https://cdn.jsdelivr.net/npm/@edx/brand@1.0.0/dist/light.min.css',
64
+ },
65
+ };
66
+ const currentThemeVariant = 'light';
67
+
68
+ renderHook(() => useThemeVariants({ themeVariants, currentThemeVariant, onComplete, onDarkModeSystemPreferenceChange }));
69
+ const themeLinks = document.head.querySelectorAll('link');
70
+
71
+ act(() => {
72
+ themeLinks.forEach((link) => fireEvent.error(link));
73
+ });
74
+ expect(logError).toHaveBeenCalledTimes(1);
75
+ });
76
+
77
+ it('shoud not create a new link if it already exists', () => {
78
+ document.head.innerHTML = '<link rel="alternate stylesheet" href="https://cdn.jsdelivr.net/npm/@openedx/brand@1.0.0/dist/light.min.css">';
79
+
80
+ const themeVariants = {
81
+ light: {
82
+ url: 'https://cdn.jsdelivr.net/npm/@openedx/brand@1.0.0/dist/light.min.css',
83
+ },
84
+ dark: {
85
+ url: 'https://cdn.jsdelivr.net/npm/@openedx/brand@1.0.0/dist/dark.min.css',
86
+ },
87
+ };
88
+
89
+ const currentThemeVariant = 'light';
90
+ renderHook(() => useThemeVariants({ themeVariants, currentThemeVariant, onComplete, onDarkModeSystemPreferenceChange }));
91
+ const themeLinks = document.head.querySelectorAll('link');
92
+ const lightThemeBrandLink: HTMLAnchorElement | null = document.head.querySelector('link[data-theme-variant="light"]');
93
+
94
+ expect(themeLinks.length).toBe(2);
95
+ expect(lightThemeBrandLink?.rel).toContain('stylesheet');
96
+ });
97
+ });
@@ -0,0 +1,116 @@
1
+ import { useEffect, useState } from 'react';
2
+
3
+ import { logError } from '../../../logging';
4
+
5
+ import { ThemeVariants } from '../../../../types';
6
+
7
+ /**
8
+ * A custom React hook that manages the loading of theme variant CSS files dynamically.
9
+ * Adds/updates a `<link>` element in the HTML document to load each theme variant's CSS, setting the
10
+ * non-current theme variants as "alternate" stylesheets. That is, the browser will download
11
+ * the CSS for the non-current theme variants, but at a lower priority than the current one.
12
+ * This ensures that if the theme variant is changed at runtime, the new theme's CSS will already be loaded.
13
+ *
14
+ * The hook also listens for changes in the system's preference and triggers the provided callback accordingly.
15
+ */
16
+ const useThemeVariants = ({
17
+ themeVariants,
18
+ currentThemeVariant,
19
+ onComplete,
20
+ onDarkModeSystemPreferenceChange,
21
+ }: {
22
+ themeVariants: ThemeVariants | undefined,
23
+ currentThemeVariant: string,
24
+ onComplete: () => void,
25
+ onDarkModeSystemPreferenceChange: (prefersDarkMode: boolean) => void,
26
+ }) => {
27
+ const [isThemeVariantComplete, setIsThemeVariantComplete] = useState(false);
28
+
29
+ // Effect hook that listens for changes in the system's dark mode preference.
30
+ useEffect(() => {
31
+ const changeColorScheme = (colorSchemeQuery) => {
32
+ onDarkModeSystemPreferenceChange(colorSchemeQuery.matches);
33
+ };
34
+ const colorSchemeQuery = window.matchMedia?.('(prefers-color-scheme: dark)');
35
+ if (colorSchemeQuery) {
36
+ colorSchemeQuery.addEventListener('change', changeColorScheme);
37
+ }
38
+ return () => {
39
+ if (colorSchemeQuery) {
40
+ colorSchemeQuery.removeEventListener('change', changeColorScheme);
41
+ }
42
+ };
43
+ }, [onDarkModeSystemPreferenceChange]);
44
+
45
+ // Effect hook to set the theme current variant on the HTML element.
46
+ useEffect(() => {
47
+ if (currentThemeVariant && themeVariants?.[currentThemeVariant]) {
48
+ const htmlDataThemeVariantAttr = 'data-theme-variant';
49
+ document.querySelector('html')?.setAttribute(htmlDataThemeVariantAttr, currentThemeVariant);
50
+ return () => {
51
+ document.querySelector('html')?.removeAttribute(htmlDataThemeVariantAttr);
52
+ };
53
+ }
54
+ return () => { }; // Cleanup: no action needed when theme variant is not set
55
+ }, [themeVariants, currentThemeVariant]);
56
+
57
+ // Effect hook that calls `onComplete` when brand theme variant processing is complete.
58
+ useEffect(() => {
59
+ if (isThemeVariantComplete) {
60
+ onComplete();
61
+ }
62
+ }, [isThemeVariantComplete, onComplete]);
63
+
64
+ useEffect(() => {
65
+ if (!themeVariants) {
66
+ return;
67
+ }
68
+
69
+ /**
70
+ * Determines the value for the `rel` attribute for a given theme variant based
71
+ * on if its the currently applied variant.
72
+ */
73
+ const getActiveOrAlternateRel = (themeVariant: string): string => (currentThemeVariant === themeVariant ? 'stylesheet' : 'alternate stylesheet');
74
+
75
+ // Iterate over each theme variant URLs and inject them into the HTML document, if each doesn't already exist.
76
+ Object.entries(themeVariants).forEach(([themeVariant, { url }]) => {
77
+ // If the config for the theme variant does not have a URL, set the variant to complete and continue.
78
+ if (!url) {
79
+ setIsThemeVariantComplete(true);
80
+ return;
81
+ }
82
+
83
+ const existingThemeVariantLink: HTMLAnchorElement | null = document.head.querySelector(`link[href='${url}']`);
84
+ if (existingThemeVariantLink) {
85
+ existingThemeVariantLink.rel = getActiveOrAlternateRel(themeVariant);
86
+ existingThemeVariantLink.dataset.themeVariant = themeVariant;
87
+ return;
88
+ }
89
+
90
+ const themeVariantLink = document.createElement('link');
91
+ themeVariantLink.href = url;
92
+ themeVariantLink.rel = getActiveOrAlternateRel(themeVariant);
93
+ themeVariantLink.dataset.themeVariant = themeVariant;
94
+
95
+ themeVariantLink.onload = () => {
96
+ if (themeVariant === currentThemeVariant) {
97
+ setIsThemeVariantComplete(true);
98
+ }
99
+ };
100
+
101
+ themeVariantLink.onerror = () => {
102
+ logError(`Failed to load theme variant (${themeVariant}) CSS from ${url}. Aborting.`);
103
+ setIsThemeVariantComplete(true);
104
+ };
105
+
106
+ document.head.insertAdjacentElement(
107
+ 'beforeend',
108
+ themeVariantLink,
109
+ );
110
+
111
+ setIsThemeVariantComplete(true);
112
+ });
113
+ }, [themeVariants, currentThemeVariant, onComplete]);
114
+ };
115
+
116
+ export default useThemeVariants;
@@ -0,0 +1,54 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { sendTrackEvent } from '../../../analytics';
3
+ import useTrackColorSchemeChoice from './useTrackColorSchemeChoice';
4
+
5
+ jest.mock('../../../analytics');
6
+
7
+ const mockAddEventListener = jest.fn();
8
+ const mockRemoveEventListener = jest.fn();
9
+ let matchesMock;
10
+
11
+ Object.defineProperty(window, 'matchMedia', {
12
+ value: jest.fn(() => ({
13
+ addEventListener: mockAddEventListener,
14
+ removeEventListener: mockRemoveEventListener,
15
+ matches: matchesMock,
16
+ })),
17
+ });
18
+
19
+ describe('useTrackColorSchemeChoice hook', () => {
20
+ afterEach(() => {
21
+ mockAddEventListener.mockClear();
22
+ mockRemoveEventListener.mockClear();
23
+ jest.mocked(sendTrackEvent).mockClear();
24
+ });
25
+
26
+ it('sends dark preferred color schema event if query matches', async () => {
27
+ matchesMock = true;
28
+ renderHook(() => useTrackColorSchemeChoice());
29
+
30
+ expect(sendTrackEvent).toHaveBeenCalledTimes(1);
31
+ expect(sendTrackEvent).toHaveBeenCalledWith(
32
+ 'openedx.ui.frontend-base.prefers-color-scheme.selected',
33
+ { preferredColorScheme: 'dark' },
34
+ );
35
+ });
36
+
37
+ it('sends light preferred color schema event if query does not match', async () => {
38
+ matchesMock = false;
39
+ renderHook(() => useTrackColorSchemeChoice());
40
+
41
+ expect(sendTrackEvent).toHaveBeenCalledTimes(1);
42
+ expect(sendTrackEvent).toHaveBeenCalledWith(
43
+ 'openedx.ui.frontend-base.prefers-color-scheme.selected',
44
+ { preferredColorScheme: 'light' },
45
+ );
46
+ });
47
+
48
+ it('adds change event listener to matchMedia query', async () => {
49
+ renderHook(() => useTrackColorSchemeChoice());
50
+
51
+ expect(mockAddEventListener).toHaveBeenCalledTimes(1);
52
+ expect(mockAddEventListener).toHaveBeenCalledWith('change', expect.any(Function));
53
+ });
54
+ });
@@ -0,0 +1,30 @@
1
+ import { useEffect } from 'react';
2
+ import { sendTrackEvent } from '../../../analytics';
3
+
4
+ /**
5
+ * A React hook that tracks user's preferred color scheme (light or dark) and sends respective
6
+ * event to the tracking service.
7
+ *
8
+ * @memberof module:React
9
+ */
10
+ const useTrackColorSchemeChoice = () => {
11
+ useEffect(() => {
12
+ const trackColorSchemeChoice = ({ matches }) => {
13
+ const preferredColorScheme = matches ? 'dark' : 'light';
14
+ sendTrackEvent('openedx.ui.frontend-base.prefers-color-scheme.selected', { preferredColorScheme });
15
+ };
16
+ const colorSchemeQuery = window.matchMedia?.('(prefers-color-scheme: dark)');
17
+ if (colorSchemeQuery) {
18
+ // send user's initial choice
19
+ trackColorSchemeChoice(colorSchemeQuery);
20
+ colorSchemeQuery.addEventListener('change', trackColorSchemeChoice);
21
+ }
22
+ return () => {
23
+ if (colorSchemeQuery) {
24
+ colorSchemeQuery.removeEventListener('change', trackColorSchemeChoice);
25
+ }
26
+ };
27
+ }, []);
28
+ };
29
+
30
+ export default useTrackColorSchemeChoice;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Iterates through each given `<link>` element and removes it from the DOM.
3
+ * @param {HTMLLinkElement[]} existingLinks
4
+ */
5
+ export const removeExistingLinks = (existingLinks) => {
6
+ existingLinks.forEach((link) => {
7
+ link.remove();
8
+ });
9
+ };
10
+
11
+ export const isEmptyObject = (obj) => !obj || Object.keys(obj).length === 0;
@@ -0,0 +1,15 @@
1
+ import { useState } from 'react';
2
+ import { getActiveRoles } from '../../config';
3
+ import { ACTIVE_ROLES_CHANGED } from '../../constants';
4
+ import useSiteEvent from './useSiteEvent';
5
+
6
+ const useActiveRoles = () => {
7
+ const [roles, setRoles] = useState<string[]>(getActiveRoles());
8
+ useSiteEvent(ACTIVE_ROLES_CHANGED, () => {
9
+ setRoles(getActiveRoles());
10
+ });
11
+
12
+ return roles;
13
+ };
14
+
15
+ export default useActiveRoles;
@@ -0,0 +1,31 @@
1
+ import { useCallback, useEffect } from 'react';
2
+ import { useMatches } from 'react-router';
3
+ import { setActiveRouteRoles } from '../../config';
4
+ import { isRoleRouteObject } from '../../routing';
5
+
6
+ const useActiveRouteRoleWatcher = () => {
7
+ const matches = useMatches();
8
+
9
+ // We create this callback so we can use it right away to populate the default state value.
10
+ const findActiveRouteRoles = useCallback(() => {
11
+ // Starts with the widget roles and adds the others in.
12
+ const roles: string[] = [];
13
+
14
+ // Route roles
15
+ for (const match of matches) {
16
+ if (isRoleRouteObject(match)) {
17
+ if (!roles.includes(match.handle.role)) {
18
+ roles.push(match.handle.role);
19
+ }
20
+ }
21
+ }
22
+
23
+ return roles;
24
+ }, [matches]);
25
+
26
+ useEffect(() => {
27
+ setActiveRouteRoles(findActiveRouteRoles());
28
+ }, [matches, findActiveRouteRoles]);
29
+ };
30
+
31
+ export default useActiveRouteRoleWatcher;
@@ -0,0 +1,9 @@
1
+ import { useContext } from 'react';
2
+ import CurrentAppContext from '../CurrentAppContext';
3
+
4
+ const useAppConfig = () => {
5
+ const { appConfig } = useContext(CurrentAppContext);
6
+ return appConfig;
7
+ };
8
+
9
+ export default useAppConfig;
@@ -0,0 +1,41 @@
1
+ import { act, renderHook } from '@testing-library/react';
2
+ import { setAuthenticatedUser } from '../../auth';
3
+ import { initializeMockApp } from '../../testing';
4
+ import SiteProvider from '../SiteProvider';
5
+ import useAuthenticatedUser from './useAuthenticatedUser';
6
+
7
+ describe('useAuthenticatedUser', () => {
8
+ it('returns null when the user is anonymous', () => {
9
+ const { result } = renderHook(() => useAuthenticatedUser());
10
+ expect(result.current).toBeNull();
11
+ });
12
+
13
+ describe('with a user', () => {
14
+ const user = {
15
+ administrator: true,
16
+ email: 'admin@example.com',
17
+ name: 'Admin',
18
+ roles: ['admin'],
19
+ userId: 1,
20
+ username: 'admin-user',
21
+ avatar: 'http://localhost/admin.png',
22
+ };
23
+
24
+ beforeEach(() => {
25
+ initializeMockApp({
26
+ authenticatedUser: user,
27
+ });
28
+ });
29
+
30
+ afterEach(() => {
31
+ act(() => {
32
+ setAuthenticatedUser(null);
33
+ });
34
+ });
35
+
36
+ it('returns a User when the user exists', () => {
37
+ const { result } = renderHook(() => useAuthenticatedUser(), { wrapper: SiteProvider });
38
+ expect(result.current).toBe(user);
39
+ });
40
+ });
41
+ });
@@ -0,0 +1,9 @@
1
+ import { useContext } from 'react';
2
+ import SiteContext from '../SiteContext';
3
+
4
+ const useAuthenticatedUser = () => {
5
+ const { authenticatedUser } = useContext(SiteContext);
6
+ return authenticatedUser;
7
+ };
8
+
9
+ export default useAuthenticatedUser;