Skip to content

Commit 383979d

Browse files
committed
feat: support dark mode
chore: update package-lock.json chore: update package-lock.json take 2 chore: remove console.log statements fix: ignore system preference change when theme variant set in localstorage chore: add tests for updates to AppProvider chore: update react-intl to pass peer dependencies after pinning all deps chore: split hooks.js up into separate files and begin some related tests test: add testing to useParagonTheme hooks (#514) * test: add testing to useParagonThemeCore * test: add test to useThemeVariants hook * fix: Paragon definition and remove onload mock * test: change test message to be clear
1 parent 5a1a253 commit 383979d

18 files changed

+1290
-576
lines changed

docs/how_tos/theming.md

Lines changed: 77 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,47 @@
1-
# Theming support with Paragon
2-
3-
This document serves as a guide to using `@edx/frontend-platform` to support MFE theming with Paragon using theme CSS loaded externally (e.g., from a CDN). By serving CSS loaded externally, consuming applications of Paragon no longer need to be responsible for compiling the theme SCSS to CSS themselves and instead use a pre-compiled CSS file. In doing so, this allows making changes to the Paragon theme without needing to necessarily re-build and re-deploy all consuming applications. We would also get a meaningful gain in performance as loading the compiled theme CSS from an external CDN means micro-frontends (MFEs) can include cached styles instead of needing to load essentially duplicate theme styles as users navigate across different MFEs.
1+
# Theming support with `@edx/paragon` and `@edx/brand`
42

53
## Overview
64

5+
This document serves as a guide to using `@edx/frontend-platform` to support MFE theming with Paragon using theme CSS loaded externally (e.g., from a CDN).
6+
7+
To do this, configured URLs pointing to relevant CSS files from `@edx/paragon` and (optionally) `@edx/brand` are loaded and injected to the HTML document at runtime. This differs than the consuming application importing the styles from `@edx/paragon` and `@edx/brand` directly, which includes these styles in the application's production assets.
8+
9+
By serving CSS loaded externally, consuming applications of Paragon no longer need to be responsible for compiling the theme SCSS to CSS themselves and instead use a pre-compiled CSS file. In doing so, this allows making changes to the Paragon theme without needing to necessarily re-build and re-deploy all consuming applications.
10+
11+
### Dark mode and theme variant preferences
12+
13+
`@edx/frontend-platform` supports both `light` (required) and `dark` (optional) theme variants. The choice of which theme variant should be applied on page load is based on the following preference cascade:
14+
15+
1. **Get theme preference from localStorage.** Supports persisting and loading the user's preference for their selected theme variant, until cleared.
16+
1. **Detect user system settings.** Rely on the `prefers-color-scheme` media query to detect if the user's system indicates a preference for dark mode. If so, use the default dark theme variant, if one is configured.
17+
1. **Use default theme variant as configured (see below).** Otherwise, load the default theme variant as configured by the `defaults` option described below.
18+
19+
Whenever the current theme variant changes, an attrivbute `data-paragon-theme-variant="*"` is updated on the `<html>` element. This attribute enables applications' both JS and CSS to have knowledge of the currently applied theme variant.
20+
21+
### Supporting custom theme variants beyond `light` and `dark`
22+
23+
If your use case necessitates additional variants beyond the default supported `light` and `dark` theme variants, you may pass any number of custom theme variants. Custom theme variants will work the user's persisted localStorage setting (i.e., if a user switches to a custom theme variant, the MFE will continue to load the custom theme variant by default). By supporting custom theme variants, it also supports having multiple or alternative `light` and/or `dark` theme variants.
24+
25+
### Performance implications
26+
27+
There is also a meaningful improvement in performance as loading the compiled theme CSS from an external CDN means micro-frontends (MFEs) can include cached styles instead of needing to load essentially duplicate theme styles included in each individual MFE as users navigate across the platform.
28+
29+
However, as the styles from `@edx/paragon` and `@edx/brand` get loaded at runtime by `@edx/frontend-platform`, the associated CSS files do not get processed through the consuming application's Webpack build process (e.g., if the MFE used PurgeCSS or any custom PostCSS plugins specifically for Paragon).
30+
31+
### Falling back to styles installed in consuming application
32+
33+
If any of the configured external `PARAGON_THEME_URLS` fail to load for whatever reason (e.g., CDN is down, URL is incorrectly configured), `@edx/paragon` will attempt to fallback to the relevant files installed in `node_modules` from the consuming application.
34+
35+
## Technical architecture
36+
737
![overview of paragon theme loader](./assets/paragon-theme-loader.png "Paragon theme loader")
838

9-
## Basic theme URL configuration
39+
## Development
40+
41+
### Basic theme URL configuration
1042

1143
Paragon supports 2 mechanisms for configuring the Paragon theme urls:
12-
* JavaScript-based configuration via `env.config.js`.
44+
* JavaScript-based configuration via `env.config.js`
1345
* MFE runtime configuration API via `edx-platform`
1446

1547
Using either configuration mechanism, a `PARAGON_THEME_URLS` configuration setting must be created to point to the externally hosted Paragon theme CSS files, e.g.:
@@ -19,16 +51,45 @@ Using either configuration mechanism, a `PARAGON_THEME_URLS` configuration setti
1951
"core": {
2052
"url": "https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/core.min.css"
2153
},
54+
"defaults": {
55+
"light": "light",
56+
},
2257
"variants": {
2358
"light": {
2459
"url": "https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/light.min.css",
25-
"default": true,
26-
"dark": false,
2760
}
2861
}
2962
}
3063
```
3164

65+
### Configuration options
66+
67+
The `PARAGON_THEME_URLS` configuration object supports using only the default styles from `@edx/paragon` or, optionally, extended/overridden styles via `@edx/brand`. To utilize `@edx/brand` overrides, see the `core.urls` and `variants.*.urls` options below.
68+
69+
The `dark` theme variant options are optional.
70+
71+
| Property | Data Type | Description |
72+
| -------- | ----------- | ----------- |
73+
| `core` | Object | Metadata about the core styles from `@edx/paragon` and `@edx/brand`. |
74+
| `core.url` | String | URL for the `core.css` file from `@edx/paragon`. |
75+
| `core.urls` | Object | URL(s) for the `core.css` files from `@edx/paragon` CSS and (optionally) `@edx/brand`. |
76+
| `core.urls.default` | String | URL for the `core.css` file from `@edx/paragon`. |
77+
| `core.urls.brandOverride` | Object | URL for the `core.css` file from `@edx/brand`. |
78+
| `defaults` | Object | Mapping of theme variants to Paragon's default supported light and dark theme variants. |
79+
| `defaults.light` | String | Default `light` theme variant from the theme variants in the `variants` object. |
80+
| `defaults.dark` | String | Default `dark` theme variant from the theme variants in the `variants` object. |
81+
| `variants` | Object | Metadata about each supported theme variant. |
82+
| `variants.light` | Object | Metadata about the light theme variant styles from `@edx/paragon` and (optionally)`@edx/brand`. |
83+
| `variants.light.url` | String | URL for the `light.css` file from `@edx/paragon`. |
84+
| `variants.light.urls` | Object | URL(s) for the `light.css` files from `@edx/paragon` CSS and (optionally) `@edx/brand`. |
85+
| `variants.light.urls.default` | String | URL for the `light.css` file from `@edx/paragon`. |
86+
| `variants.light.urls.brandOverride` | String | URL for the `light.css` file from `@edx/brand`. |
87+
| `variants.dark` | Object | Metadata about the dark theme variant styles from `@edx/paragon` and (optionally)`@edx/brand`. |
88+
| `variants.dark.url` | String | URL for the `dark.css` file from `@edx/paragon`. |
89+
| `variants.dark.urls` | Object | URL(s) for the `dark.css` files from `@edx/paragon` CSS and (optionally) `@edx/brand`. |
90+
| `variants.dark.urls.default` | String | URL for the `dark.css` file from `@edx/paragon`. |
91+
| `variants.dark.urls.brandOverride` | String | URL for the `dark.css` file from `@edx/brand`. |
92+
3293
### JavaScript-based configuration
3394

3495
One approach to configuring the `PARAGON_THEME_URLS` is to create a `env.config.js` file in the root of the repository. The configuration is defined as a JavaScript file, which affords consumers to use more complex data types, amongst other benefits.
@@ -41,11 +102,12 @@ const config = {
41102
core: {
42103
url: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/core.min.css',
43104
},
105+
defaults: {
106+
light: 'light',
107+
},
44108
variants: {
45109
light: {
46110
url: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/light.min.css',
47-
default: true,
48-
dark: false,
49111
},
50112
},
51113
},
@@ -70,11 +132,12 @@ MFE_CONFIG_OVERRIDES = {
70132
'core': {
71133
'url': 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/core.min.css',
72134
},
135+
'defaults': {
136+
'light': 'light',
137+
},
73138
'variants': {
74139
'light': {
75140
'url': 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/light.min.css',
76-
'default': True,
77-
'dark': False,
78141
},
79142
},
80143
},
@@ -112,14 +175,15 @@ const config = {
112175
brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand-edx.org@#brandVersion/dist/core.min.css',
113176
},
114177
},
178+
defaults: {
179+
light: 'light',
180+
},
115181
variants: {
116182
light: {
117183
urls: {
118184
default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/light.min.css',
119185
brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand-edx.org@$brandVersion/dist/light.min.css',
120186
},
121-
default: true,
122-
dark: false,
123187
},
124188
},
125189
},

src/react/AppProvider.jsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import {
2222
LOCALE_CHANGED,
2323
} from '../i18n';
2424
import { basename } from '../initialize';
25+
import { SELECTED_THEME_VARIANT_KEY } from './constants';
2526

2627
/**
2728
* A wrapper component for React-based micro-frontends to initialize a number of common data/
@@ -66,6 +67,7 @@ export default function AppProvider({ store, children, wrapWithRouter }) {
6667
setLocale(getLocale());
6768
});
6869

70+
useTrackColorSchemeChoice();
6971
const [paragonThemeState, paragonThemeDispatch] = useParagonTheme(config);
7072

7173
const appContextValue = useMemo(() => ({
@@ -76,6 +78,9 @@ export default function AppProvider({ store, children, wrapWithRouter }) {
7678
state: paragonThemeState,
7779
setThemeVariant: (themeVariant) => {
7880
paragonThemeDispatch(paragonThemeActions.setParagonThemeVariant(themeVariant));
81+
82+
// Persist selected theme variant to localStorage.
83+
window.localStorage.setItem(SELECTED_THEME_VARIANT_KEY, themeVariant);
7984
},
8085
},
8186
}), [authenticatedUser, config, locale, paragonThemeState, paragonThemeDispatch]);

src/react/AppProvider.test.jsx

Lines changed: 184 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,65 @@
11
import React from 'react';
22
import { createStore } from 'redux';
3-
import { render } from '@testing-library/react';
3+
import { render, screen } from '@testing-library/react';
4+
import userEvent from '@testing-library/user-event';
5+
import '@testing-library/jest-dom/extend-expect';
6+
47
import AppProvider from './AppProvider';
58
import { initialize } from '../initialize';
9+
import { useAppEvent, useTrackColorSchemeChoice, useParagonTheme } from './hooks';
10+
import { AUTHENTICATED_USER_CHANGED, getAuthenticatedUser } from '../auth';
11+
import { CONFIG_CHANGED } from '../constants';
12+
import { getConfig } from '../config';
13+
import { getLocale, LOCALE_CHANGED } from '../i18n';
14+
import AppContext from './AppContext';
15+
import { SELECTED_THEME_VARIANT_KEY, SET_THEME_VARIANT } from './constants';
616

717
jest.mock('../auth', () => ({
8-
configure: () => {},
9-
getAuthenticatedUser: () => null,
10-
fetchAuthenticatedUser: () => null,
11-
getAuthenticatedHttpClient: () => ({}),
18+
...jest.requireActual('../auth'),
19+
getAuthenticatedUser: jest.fn(),
20+
fetchAuthenticatedUser: jest.fn(),
21+
getAuthenticatedHttpClient: jest.fn().mockReturnValue({}),
1222
AUTHENTICATED_USER_CHANGED: 'user_changed',
1323
}));
1424

25+
jest.mock('../config', () => ({
26+
...jest.requireActual('../config'),
27+
getConfig: jest.fn().mockReturnValue({
28+
BASE_URL: 'localhost:8080',
29+
LMS_BASE_URL: 'localhost:18000',
30+
LOGIN_URL: 'localhost:18000/login',
31+
LOGOUT_URL: 'localhost:18000/logout',
32+
REFRESH_ACCESS_TOKEN_ENDPOINT: 'localhost:18000/oauth2/access_token',
33+
ACCESS_TOKEN_COOKIE_NAME: 'access_token',
34+
CSRF_TOKEN_API_PATH: 'localhost:18000/csrf',
35+
}),
36+
}));
37+
38+
jest.mock('../i18n', () => ({
39+
...jest.requireActual('../i18n'),
40+
getLocale: jest.fn().mockReturnValue('en'),
41+
}));
42+
1543
jest.mock('../analytics', () => ({
16-
configure: () => {},
44+
configure: () => { },
1745
identifyAnonymousUser: jest.fn(),
1846
identifyAuthenticatedUser: jest.fn(),
1947
}));
2048

2149
jest.mock('./hooks', () => ({
2250
...jest.requireActual('./hooks'),
51+
useAppEvent: jest.fn(),
2352
useTrackColorSchemeChoice: jest.fn(),
53+
useParagonTheme: jest.fn().mockImplementation(() => [
54+
{ isThemeLoaded: true, themeVariant: 'light' },
55+
jest.fn(),
56+
]),
2457
}));
2558

2659
describe('AppProvider', () => {
2760
beforeEach(async () => {
61+
jest.clearAllMocks();
62+
2863
await initialize({
2964
loggingService: jest.fn(() => ({
3065
logError: jest.fn(),
@@ -104,4 +139,147 @@ describe('AppProvider', () => {
104139
const reduxProvider = wrapper.queryByTestId('redux-provider');
105140
expect(reduxProvider).not.toBeInTheDocument();
106141
});
142+
143+
describe('paragon theme and brand', () => {
144+
it('calls trackColorSchemeChoice', () => {
145+
const Component = (
146+
<AppProvider>
147+
<div>Child One</div>
148+
<div>Child Two</div>
149+
</AppProvider>
150+
);
151+
render(Component);
152+
expect(useTrackColorSchemeChoice).toHaveBeenCalled();
153+
});
154+
155+
it('calls useParagonTheme', () => {
156+
const Component = (
157+
<AppProvider>
158+
<div>Child One</div>
159+
<div>Child Two</div>
160+
</AppProvider>
161+
);
162+
render(Component);
163+
expect(useParagonTheme).toHaveBeenCalled();
164+
expect(useParagonTheme).toHaveBeenCalledWith(
165+
expect.objectContaining({
166+
BASE_URL: 'localhost:8080',
167+
LMS_BASE_URL: 'localhost:18000',
168+
LOGIN_URL: 'localhost:18000/login',
169+
LOGOUT_URL: 'localhost:18000/logout',
170+
REFRESH_ACCESS_TOKEN_ENDPOINT: 'localhost:18000/oauth2/access_token',
171+
ACCESS_TOKEN_COOKIE_NAME: 'access_token',
172+
CSRF_TOKEN_API_PATH: 'localhost:18000/csrf',
173+
}),
174+
);
175+
});
176+
177+
it('blocks rendering until paragon theme is loaded', () => {
178+
useParagonTheme.mockImplementationOnce(() => [
179+
{ isThemeLoaded: false },
180+
jest.fn(),
181+
]);
182+
const Component = (
183+
<AppProvider>
184+
<div>Child One</div>
185+
<div>Child Two</div>
186+
</AppProvider>
187+
);
188+
const { container } = render(Component);
189+
expect(container).toBeEmptyDOMElement();
190+
});
191+
192+
it('returns correct `paragonTheme` in context value', async () => {
193+
const mockUseParagonThemeDispatch = jest.fn();
194+
useParagonTheme.mockImplementationOnce(() => [
195+
{ isThemeLoaded: true, themeVariant: 'light' },
196+
mockUseParagonThemeDispatch,
197+
]);
198+
const Component = (
199+
<AppProvider>
200+
<AppContext.Consumer>
201+
{({ paragonTheme }) => (
202+
<div>
203+
<p>Is theme loaded: {paragonTheme.state.isThemeLoaded ? 'yes' : 'no'}</p>
204+
<p>Current theme variant: {paragonTheme.state.themeVariant}</p>
205+
<button
206+
type="button"
207+
onClick={() => {
208+
const nextThemeVariant = paragonTheme.state.themeVariant === 'light' ? 'dark' : 'light';
209+
paragonTheme.setThemeVariant(nextThemeVariant);
210+
}}
211+
>
212+
Set theme variant
213+
</button>
214+
</div>
215+
)}
216+
</AppContext.Consumer>
217+
</AppProvider>
218+
);
219+
render(Component);
220+
expect(screen.getByText('Is theme loaded: yes')).toBeInTheDocument();
221+
expect(screen.getByText('Current theme variant: light')).toBeInTheDocument();
222+
223+
const setThemeVariantBtn = screen.getByRole('button', { name: 'Set theme variant' });
224+
expect(setThemeVariantBtn).toBeInTheDocument();
225+
await userEvent.click(setThemeVariantBtn);
226+
227+
expect(mockUseParagonThemeDispatch).toHaveBeenCalledTimes(1);
228+
expect(mockUseParagonThemeDispatch).toHaveBeenCalledWith({
229+
payload: 'dark',
230+
type: SET_THEME_VARIANT,
231+
});
232+
expect(localStorage.setItem).toHaveBeenLastCalledWith(SELECTED_THEME_VARIANT_KEY, 'dark');
233+
});
234+
});
235+
236+
describe('useAppEvent', () => {
237+
it('subscribes to `AUTHENTICATED_USER_CHANGED`', async () => {
238+
const Component = (
239+
<AppProvider>
240+
<div>Child</div>
241+
</AppProvider>
242+
);
243+
render(Component);
244+
expect(useAppEvent).toHaveBeenCalledWith(AUTHENTICATED_USER_CHANGED, expect.any(Function));
245+
const useAppEventMockCalls = useAppEvent.mock.calls;
246+
const authUserChangedFn = useAppEventMockCalls.find(([event]) => event === AUTHENTICATED_USER_CHANGED)[1];
247+
expect(authUserChangedFn).toBeDefined();
248+
const getAuthUserCallCount = getAuthenticatedUser.mock.calls.length;
249+
authUserChangedFn();
250+
expect(getAuthUserCallCount + 1).toEqual(getAuthenticatedUser.mock.calls.length);
251+
});
252+
253+
it('subscribes to `CONFIG_CHANGED`', async () => {
254+
const Component = (
255+
<AppProvider>
256+
<div>Child</div>
257+
</AppProvider>
258+
);
259+
render(Component);
260+
expect(useAppEvent).toHaveBeenCalledWith(CONFIG_CHANGED, expect.any(Function));
261+
const useAppEventMockCalls = useAppEvent.mock.calls;
262+
const configChangedFn = useAppEventMockCalls.find(([event]) => event === CONFIG_CHANGED)[1];
263+
expect(configChangedFn).toBeDefined();
264+
const getConfigCallCount = getConfig.mock.calls.length;
265+
configChangedFn();
266+
expect(getConfig.mock.calls.length).toEqual(getConfigCallCount + 1);
267+
});
268+
269+
it('subscribes to `LOCALE_CHANGED`', async () => {
270+
const Component = (
271+
<AppProvider>
272+
<div>Child</div>
273+
</AppProvider>
274+
);
275+
render(Component);
276+
expect(useAppEvent).toHaveBeenCalledWith(LOCALE_CHANGED, expect.any(Function));
277+
const useAppEventMockCalls = useAppEvent.mock.calls;
278+
const localeChangedFn = useAppEventMockCalls.find(([event]) => event === LOCALE_CHANGED)[1];
279+
expect(localeChangedFn).toBeDefined();
280+
const getLocaleCallCount = getLocale.mock.calls.length;
281+
localeChangedFn();
282+
expect(getLocale.mock.calls.length).toEqual(getLocaleCallCount + 1);
283+
});
284+
});
107285
});

src/react/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
export const SET_THEME_VARIANT = 'SET_THEME_VARIANT';
22
export const SET_IS_THEME_LOADED = 'SET_IS_THEME_LOADED';
3+
export const SELECTED_THEME_VARIANT_KEY = 'selected-paragon-theme-variant';

0 commit comments

Comments
 (0)