Skip to content

Commit

Permalink
feat: support dark mode
Browse files Browse the repository at this point in the history
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 (openedx#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
  • Loading branch information
dcoa committed May 14, 2024
1 parent 4863b8a commit 2d275d1
Show file tree
Hide file tree
Showing 18 changed files with 1,290 additions and 576 deletions.
90 changes: 77 additions & 13 deletions docs/how_tos/theming.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,47 @@
# Theming support with Paragon

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.
# Theming support with `@edx/paragon` and `@edx/brand`

## Overview

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).

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.

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.

### Dark mode and theme variant preferences

`@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:

1. **Get theme preference from localStorage.** Supports persisting and loading the user's preference for their selected theme variant, until cleared.
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.
1. **Use default theme variant as configured (see below).** Otherwise, load the default theme variant as configured by the `defaults` option described below.

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.

### Supporting custom theme variants beyond `light` and `dark`

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.

### Performance implications

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.

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).

### Falling back to styles installed in consuming application

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.

## Technical architecture

![overview of paragon theme loader](./assets/paragon-theme-loader.png "Paragon theme loader")

## Basic theme URL configuration
## Development

### Basic theme URL configuration

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

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.:
Expand All @@ -19,16 +51,45 @@ Using either configuration mechanism, a `PARAGON_THEME_URLS` configuration setti
"core": {
"url": "https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/core.min.css"
},
"defaults": {
"light": "light",
},
"variants": {
"light": {
"url": "https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/light.min.css",
"default": true,
"dark": false,
}
}
}
```

### Configuration options

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.

The `dark` theme variant options are optional.

| Property | Data Type | Description |
| -------- | ----------- | ----------- |
| `core` | Object | Metadata about the core styles from `@edx/paragon` and `@edx/brand`. |
| `core.url` | String | URL for the `core.css` file from `@edx/paragon`. |
| `core.urls` | Object | URL(s) for the `core.css` files from `@edx/paragon` CSS and (optionally) `@edx/brand`. |
| `core.urls.default` | String | URL for the `core.css` file from `@edx/paragon`. |
| `core.urls.brandOverride` | Object | URL for the `core.css` file from `@edx/brand`. |
| `defaults` | Object | Mapping of theme variants to Paragon's default supported light and dark theme variants. |
| `defaults.light` | String | Default `light` theme variant from the theme variants in the `variants` object. |
| `defaults.dark` | String | Default `dark` theme variant from the theme variants in the `variants` object. |
| `variants` | Object | Metadata about each supported theme variant. |
| `variants.light` | Object | Metadata about the light theme variant styles from `@edx/paragon` and (optionally)`@edx/brand`. |
| `variants.light.url` | String | URL for the `light.css` file from `@edx/paragon`. |
| `variants.light.urls` | Object | URL(s) for the `light.css` files from `@edx/paragon` CSS and (optionally) `@edx/brand`. |
| `variants.light.urls.default` | String | URL for the `light.css` file from `@edx/paragon`. |
| `variants.light.urls.brandOverride` | String | URL for the `light.css` file from `@edx/brand`. |
| `variants.dark` | Object | Metadata about the dark theme variant styles from `@edx/paragon` and (optionally)`@edx/brand`. |
| `variants.dark.url` | String | URL for the `dark.css` file from `@edx/paragon`. |
| `variants.dark.urls` | Object | URL(s) for the `dark.css` files from `@edx/paragon` CSS and (optionally) `@edx/brand`. |
| `variants.dark.urls.default` | String | URL for the `dark.css` file from `@edx/paragon`. |
| `variants.dark.urls.brandOverride` | String | URL for the `dark.css` file from `@edx/brand`. |

### JavaScript-based configuration

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.
Expand All @@ -41,11 +102,12 @@ const config = {
core: {
url: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/core.min.css',
},
defaults: {
light: 'light',
},
variants: {
light: {
url: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/light.min.css',
default: true,
dark: false,
},
},
},
Expand All @@ -70,11 +132,12 @@ MFE_CONFIG_OVERRIDES = {
'core': {
'url': 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/core.min.css',
},
'defaults': {
'light': 'light',
},
'variants': {
'light': {
'url': 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/light.min.css',
'default': True,
'dark': False,
},
},
},
Expand Down Expand Up @@ -112,14 +175,15 @@ const config = {
brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand-edx.org@#brandVersion/dist/core.min.css',
},
},
defaults: {
light: 'light',
},
variants: {
light: {
urls: {
default: 'https://cdn.jsdelivr.net/npm/@edx/paragon@$paragonVersion/dist/light.min.css',
brandOverride: 'https://cdn.jsdelivr.net/npm/@edx/brand-edx.org@$brandVersion/dist/light.min.css',
},
default: true,
dark: false,
},
},
},
Expand Down
5 changes: 5 additions & 0 deletions src/react/AppProvider.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
LOCALE_CHANGED,
} from '../i18n';
import { basename } from '../initialize';
import { SELECTED_THEME_VARIANT_KEY } from './constants';

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

useTrackColorSchemeChoice();
const [paragonThemeState, paragonThemeDispatch] = useParagonTheme(config);

const appContextValue = useMemo(() => ({
Expand All @@ -76,6 +78,9 @@ export default function AppProvider({ store, children, wrapWithRouter }) {
state: paragonThemeState,
setThemeVariant: (themeVariant) => {
paragonThemeDispatch(paragonThemeActions.setParagonThemeVariant(themeVariant));

// Persist selected theme variant to localStorage.
window.localStorage.setItem(SELECTED_THEME_VARIANT_KEY, themeVariant);
},
},
}), [authenticatedUser, config, locale, paragonThemeState, paragonThemeDispatch]);
Expand Down
190 changes: 184 additions & 6 deletions src/react/AppProvider.test.jsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,65 @@
import React from 'react';
import { createStore } from 'redux';
import { render } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom/extend-expect';

import AppProvider from './AppProvider';
import { initialize } from '../initialize';
import { useAppEvent, useTrackColorSchemeChoice, useParagonTheme } from './hooks';
import { AUTHENTICATED_USER_CHANGED, getAuthenticatedUser } from '../auth';
import { CONFIG_CHANGED } from '../constants';
import { getConfig } from '../config';
import { getLocale, LOCALE_CHANGED } from '../i18n';
import AppContext from './AppContext';
import { SELECTED_THEME_VARIANT_KEY, SET_THEME_VARIANT } from './constants';

jest.mock('../auth', () => ({
configure: () => {},
getAuthenticatedUser: () => null,
fetchAuthenticatedUser: () => null,
getAuthenticatedHttpClient: () => ({}),
...jest.requireActual('../auth'),
getAuthenticatedUser: jest.fn(),
fetchAuthenticatedUser: jest.fn(),
getAuthenticatedHttpClient: jest.fn().mockReturnValue({}),
AUTHENTICATED_USER_CHANGED: 'user_changed',
}));

jest.mock('../config', () => ({
...jest.requireActual('../config'),
getConfig: jest.fn().mockReturnValue({
BASE_URL: 'localhost:8080',
LMS_BASE_URL: 'localhost:18000',
LOGIN_URL: 'localhost:18000/login',
LOGOUT_URL: 'localhost:18000/logout',
REFRESH_ACCESS_TOKEN_ENDPOINT: 'localhost:18000/oauth2/access_token',
ACCESS_TOKEN_COOKIE_NAME: 'access_token',
CSRF_TOKEN_API_PATH: 'localhost:18000/csrf',
}),
}));

jest.mock('../i18n', () => ({
...jest.requireActual('../i18n'),
getLocale: jest.fn().mockReturnValue('en'),
}));

jest.mock('../analytics', () => ({
configure: () => {},
configure: () => { },
identifyAnonymousUser: jest.fn(),
identifyAuthenticatedUser: jest.fn(),
}));

jest.mock('./hooks', () => ({
...jest.requireActual('./hooks'),
useAppEvent: jest.fn(),
useTrackColorSchemeChoice: jest.fn(),
useParagonTheme: jest.fn().mockImplementation(() => [
{ isThemeLoaded: true, themeVariant: 'light' },
jest.fn(),
]),
}));

describe('AppProvider', () => {
beforeEach(async () => {
jest.clearAllMocks();

await initialize({
loggingService: jest.fn(() => ({
logError: jest.fn(),
Expand Down Expand Up @@ -104,4 +139,147 @@ describe('AppProvider', () => {
const reduxProvider = wrapper.queryByTestId('redux-provider');
expect(reduxProvider).not.toBeInTheDocument();
});

describe('paragon theme and brand', () => {
it('calls trackColorSchemeChoice', () => {
const Component = (
<AppProvider>
<div>Child One</div>
<div>Child Two</div>
</AppProvider>
);
render(Component);
expect(useTrackColorSchemeChoice).toHaveBeenCalled();
});

it('calls useParagonTheme', () => {
const Component = (
<AppProvider>
<div>Child One</div>
<div>Child Two</div>
</AppProvider>
);
render(Component);
expect(useParagonTheme).toHaveBeenCalled();
expect(useParagonTheme).toHaveBeenCalledWith(
expect.objectContaining({
BASE_URL: 'localhost:8080',
LMS_BASE_URL: 'localhost:18000',
LOGIN_URL: 'localhost:18000/login',
LOGOUT_URL: 'localhost:18000/logout',
REFRESH_ACCESS_TOKEN_ENDPOINT: 'localhost:18000/oauth2/access_token',
ACCESS_TOKEN_COOKIE_NAME: 'access_token',
CSRF_TOKEN_API_PATH: 'localhost:18000/csrf',
}),
);
});

it('blocks rendering until paragon theme is loaded', () => {
useParagonTheme.mockImplementationOnce(() => [
{ isThemeLoaded: false },
jest.fn(),
]);
const Component = (
<AppProvider>
<div>Child One</div>
<div>Child Two</div>
</AppProvider>
);
const { container } = render(Component);
expect(container).toBeEmptyDOMElement();
});

it('returns correct `paragonTheme` in context value', async () => {
const mockUseParagonThemeDispatch = jest.fn();
useParagonTheme.mockImplementationOnce(() => [
{ isThemeLoaded: true, themeVariant: 'light' },
mockUseParagonThemeDispatch,
]);
const Component = (
<AppProvider>
<AppContext.Consumer>
{({ paragonTheme }) => (
<div>
<p>Is theme loaded: {paragonTheme.state.isThemeLoaded ? 'yes' : 'no'}</p>
<p>Current theme variant: {paragonTheme.state.themeVariant}</p>
<button
type="button"
onClick={() => {
const nextThemeVariant = paragonTheme.state.themeVariant === 'light' ? 'dark' : 'light';
paragonTheme.setThemeVariant(nextThemeVariant);
}}
>
Set theme variant
</button>
</div>
)}
</AppContext.Consumer>
</AppProvider>
);
render(Component);
expect(screen.getByText('Is theme loaded: yes')).toBeInTheDocument();
expect(screen.getByText('Current theme variant: light')).toBeInTheDocument();

const setThemeVariantBtn = screen.getByRole('button', { name: 'Set theme variant' });
expect(setThemeVariantBtn).toBeInTheDocument();
await userEvent.click(setThemeVariantBtn);

expect(mockUseParagonThemeDispatch).toHaveBeenCalledTimes(1);
expect(mockUseParagonThemeDispatch).toHaveBeenCalledWith({
payload: 'dark',
type: SET_THEME_VARIANT,
});
expect(localStorage.setItem).toHaveBeenLastCalledWith(SELECTED_THEME_VARIANT_KEY, 'dark');
});
});

describe('useAppEvent', () => {
it('subscribes to `AUTHENTICATED_USER_CHANGED`', async () => {
const Component = (
<AppProvider>
<div>Child</div>
</AppProvider>
);
render(Component);
expect(useAppEvent).toHaveBeenCalledWith(AUTHENTICATED_USER_CHANGED, expect.any(Function));
const useAppEventMockCalls = useAppEvent.mock.calls;
const authUserChangedFn = useAppEventMockCalls.find(([event]) => event === AUTHENTICATED_USER_CHANGED)[1];
expect(authUserChangedFn).toBeDefined();
const getAuthUserCallCount = getAuthenticatedUser.mock.calls.length;
authUserChangedFn();
expect(getAuthUserCallCount + 1).toEqual(getAuthenticatedUser.mock.calls.length);
});

it('subscribes to `CONFIG_CHANGED`', async () => {
const Component = (
<AppProvider>
<div>Child</div>
</AppProvider>
);
render(Component);
expect(useAppEvent).toHaveBeenCalledWith(CONFIG_CHANGED, expect.any(Function));
const useAppEventMockCalls = useAppEvent.mock.calls;
const configChangedFn = useAppEventMockCalls.find(([event]) => event === CONFIG_CHANGED)[1];
expect(configChangedFn).toBeDefined();
const getConfigCallCount = getConfig.mock.calls.length;
configChangedFn();
expect(getConfig.mock.calls.length).toEqual(getConfigCallCount + 1);
});

it('subscribes to `LOCALE_CHANGED`', async () => {
const Component = (
<AppProvider>
<div>Child</div>
</AppProvider>
);
render(Component);
expect(useAppEvent).toHaveBeenCalledWith(LOCALE_CHANGED, expect.any(Function));
const useAppEventMockCalls = useAppEvent.mock.calls;
const localeChangedFn = useAppEventMockCalls.find(([event]) => event === LOCALE_CHANGED)[1];
expect(localeChangedFn).toBeDefined();
const getLocaleCallCount = getLocale.mock.calls.length;
localeChangedFn();
expect(getLocale.mock.calls.length).toEqual(getLocaleCallCount + 1);
});
});
});
1 change: 1 addition & 0 deletions src/react/constants.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export const SET_THEME_VARIANT = 'SET_THEME_VARIANT';
export const SET_IS_THEME_LOADED = 'SET_IS_THEME_LOADED';
export const SELECTED_THEME_VARIANT_KEY = 'selected-paragon-theme-variant';
Loading

0 comments on commit 2d275d1

Please sign in to comment.