Skip to content

Commit

Permalink
fix: prefer runtime config for paragon theme
Browse files Browse the repository at this point in the history
  • Loading branch information
adamstankiewicz committed Feb 27, 2023
1 parent e8777c1 commit f790b9d
Show file tree
Hide file tree
Showing 10 changed files with 125 additions and 70 deletions.
4 changes: 2 additions & 2 deletions .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,5 @@ IGNORED_ERROR_REGEX=
MFE_CONFIG_API_URL=
APP_ID=
SUPPORT_URL=https://support.edx.org
APP_THEME_CORE_URL='http://localhost:3000/tokens/theme/css/core'
APP_THEME_LIGHT_URL='http://localhost:3000/tokens/theme/css/light'
PARAGON_THEME_CORE_URL=
PARAGON_THEME_VARIANTS_LIGHT_URL=
4 changes: 2 additions & 2 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -28,5 +28,5 @@ IGNORED_ERROR_REGEX=
MFE_CONFIG_API_URL=
APP_ID=
SUPPORT_URL=https://support.edx.org
APP_THEME_CORE_URL=
APP_THEME_LIGHT_URL=
PARAGON_THEME_CORE_URL=
PARAGON_THEME_VARIANTS_LIGHT_URL=
40 changes: 40 additions & 0 deletions docs/how_tos/theming.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
# Theming support with Paragon

Status: Draft

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 from 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 instead. 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.

## Theme URL configuration

Paragon supports 2 mechanisms for configuring the Paragon theme URLs:
* Environment variable configuration
* Runtime configuration

The Paragon theming extension to dynamically load external theme CSS prefers the theme configuration in the runtime config over the environment variable configuration.

### Environment variable configuration

The standard way to configure MFEs is to use environment variables specific to the application environment they are running in. For example, during local development, environment variables are defined and loaded via the `.env.development` file.

Two new environment variables are exposed to configure the Paragon theme URLs:
* `PARAGON_THEME_CORE_URL`. This URL represents the foundational theme styles provided by Paragon's `core.css` file.
* `PARAGON_THEME_VARIANTS_LIGHT_URL`. This URL represents the light theme variant specific styles provided by Paragon's `light.css` file.

### Runtime configuration

`@edx/frontend-platform` additionally supports loading application configuration from an API at runtime rather than environment variables. For example, in `edx-platform`, there is an API endpoint for MFE runtime configuration at `http://localhost:18000/api/mfe_config/v1`. The application configuration may be setup via Django settings as follows:

```
ENABLE_MFE_CONFIG_API = True
MFE_CONFIG = {}
MFE_CONFIG_OVERRIDES = {
"profile": {
"PARAGON_THEME_URLS": {
'core': 'https://cdn.jsdelivr.net/npm/@edx/[email protected]/dist/paragon.css',
'variants': {
'light': 'https://cdn.jsdelivr.net/npm/@edx/[email protected]/scss/core/css/variables.css',
},
},
},
}
```
8 changes: 4 additions & 4 deletions src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ let config = {
MFE_CONFIG_API_URL: process.env.MFE_CONFIG_API_URL,
APP_ID: process.env.APP_ID,
SUPPORT_URL: process.env.SUPPORT_URL,
APP_THEME_CORE_URL: process.env.APP_THEME_CORE_URL,
APP_THEME_LIGHT_URL: process.env.APP_THEME_LIGHT_URL,
PARAGON_THEME_CORE_URL: process.env.PARAGON_THEME_CORE_URL,
PARAGON_THEME_VARIANTS_LIGHT_URL: process.env.PARAGON_THEME_VARIANTS_LIGHT_URL,
};

/**
Expand Down Expand Up @@ -205,6 +205,6 @@ export function ensureConfig(keys, requester = 'unspecified application code') {
* @property {string} MFE_CONFIG_API_URL
* @property {string} APP_ID
* @property {string} SUPPORT_URL
* @property {string} APP_THEME_CORE_URL
* @property {string} APP_THEME_LIGHT_URL
* @property {string} PARAGON_THEME_CORE_URL
* @property {string} PARAGON_THEME_VARIANTS_LIGHT_URL
*/
25 changes: 7 additions & 18 deletions src/react/AppProvider.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,8 @@ import ErrorBoundary from './ErrorBoundary';
import AppContext from './AppContext';
import {
useAppEvent,
useAppTheme,
useParagonTheme,
} from './hooks';
import {
APP_THEME_CORE,
APP_THEME_LIGHT,
} from './constants';
import { getAuthenticatedUser, AUTHENTICATED_USER_CHANGED } from '../auth';
import { getConfig } from '../config';
import { CONFIG_CHANGED } from '../constants';
Expand Down Expand Up @@ -68,26 +64,19 @@ export default function AppProvider({ store, children }) {
setLocale(getLocale());
});

const [appThemeState, appThemeDispatch] = useAppTheme({
themeUrls: {
[APP_THEME_CORE]: config.APP_THEME_CORE_URL,
variants: {
[APP_THEME_LIGHT]: config.APP_THEME_LIGHT_URL,
},
},
});
const [paragonThemeState, paragonThemeDispatch] = useParagonTheme(config);

const appContextValue = useMemo(() => ({
authenticatedUser,
config,
locale,
appTheme: {
state: appThemeState,
dispatch: appThemeDispatch,
paragonTheme: {
state: paragonThemeState,
dispatch: paragonThemeDispatch,
},
}), [authenticatedUser, config, locale, appThemeState, appThemeDispatch]);
}), [authenticatedUser, config, locale, paragonThemeState, paragonThemeDispatch]);

if (!appThemeState?.isThemeLoaded) {
if (!paragonThemeState?.isThemeLoaded) {
return null;
}

Expand Down
4 changes: 2 additions & 2 deletions src/react/constants.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export const APP_THEME_CORE = 'core';
export const APP_THEME_LIGHT = 'light';
export const PARAGON_THEME_CORE = 'core';
export const PARAGON_THEME_VARIANT_LIGHT = 'light';

export const SET_THEME_VARIANT = 'SET_THEME_VARIANT';
export const SET_IS_THEME_LOADED = 'SET_IS_THEME_LOADED';
90 changes: 57 additions & 33 deletions src/react/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ import {
useCallback, useEffect, useState, useReducer,
} from 'react';
import { subscribe, unsubscribe } from '../pubSub';
import { APP_THEME_CORE, APP_THEME_LIGHT } from './constants';
import { appThemeReducer, appThemeActions } from './reducers';
import {
PARAGON_THEME_CORE,
PARAGON_THEME_VARIANT_LIGHT,
} from './constants';
import { paragonThemeReducer, paragonThemeActions } from './reducers';

/**
* A React hook that allows functional components to subscribe to application events. This should
Expand All @@ -25,9 +28,9 @@ export const useAppEvent = (type, callback) => {
}, [callback, type]);
};

const initialAppThemeState = {
const initialParagonThemeState = {
isThemeLoaded: false,
themeVariant: APP_THEME_LIGHT,
themeVariant: PARAGON_THEME_VARIANT_LIGHT,
};

/**
Expand All @@ -38,12 +41,12 @@ const initialAppThemeState = {
* @param {string} args.coreThemeUrl The url of the core theme CSS.
* @param {string} args.onLoad A callback function called when the core theme CSS is loaded.
*/
export const useAppThemeCore = ({
export const useParagonThemeCore = ({
coreThemeUrl,
onLoad,
}) => {
useEffect(() => {
// If the config for the core theme url, do nothing.
// If there is no config for the core theme url, do nothing.
if (!coreThemeUrl) {
return;
}
Expand Down Expand Up @@ -77,10 +80,10 @@ export const useAppThemeCore = ({
* @param {object} args.themeVariantUrls An object representing the URLs for each supported theme variant, e.g.: `{ light: 'https://path/to/light.css' }`.
* @param {string} args.onLoad A callback function called when the core theme CSS is loaded.
*/
const useAppThemeVariants = ({
const useParagonThemeVariants = ({
themeVariantUrls,
currentThemeVariant,
onLoadVariantLight,
onLoadThemeVariantLight,
}) => {
useEffect(() => {
/**
Expand All @@ -94,15 +97,16 @@ const useAppThemeVariants = ({
* based on the current theme variant.
*/
const setThemeVariantLoaded = (themeVariant) => {
if (themeVariant === APP_THEME_LIGHT) {
onLoadVariantLight();
if (themeVariant === PARAGON_THEME_VARIANT_LIGHT) {
onLoadThemeVariantLight();
}
};

/**
* Iterate over each theme variant URL and inject it into the HTML document if it doesn't already exist.
*/
Object.entries(themeVariantUrls).forEach(([themeVariant, themeVariantUrl]) => {
// If there is no config for the theme variant URL, set the theme variant to loaded and continue.
if (!themeVariantUrl) {
setThemeVariantLoaded(themeVariant);
return;
Expand All @@ -124,7 +128,26 @@ const useAppThemeVariants = ({
themeVariantLink.rel = stylesheetRelForVariant;
}
});
}, [themeVariantUrls, currentThemeVariant, onLoadVariantLight]);
}, [themeVariantUrls, currentThemeVariant, onLoadThemeVariantLight]);
};

/**
* TODO
* @param {*} config
* @returns
*/
const getParagonThemeUrls = (config) => {
if (config.PARAGON_THEME_URLS) {
return config.PARAGON_THEME_URLS;
}
return {
[PARAGON_THEME_CORE]: config.PARAGON_THEME_CORE_URL,
// [PARAGON_THEME_CORE]: undefined,
variants: {
[PARAGON_THEME_VARIANT_LIGHT]: config.PARAGON_THEME_VARIANTS_LIGHT_URL,
// [PARAGON_THEME_VARIANT_LIGHT]: undefined,
},
};
};

/**
Expand All @@ -146,65 +169,66 @@ const useAppThemeVariants = ({
* @returns An array containing 2 elements: 1) an object containing the app
* theme state, and 2) a dispatch function to mutate the app theme state.
*/
export const useAppTheme = ({
themeUrls: {
[APP_THEME_CORE]: coreThemeUrl,
export const useParagonTheme = (config) => {
const paragonThemeUrls = getParagonThemeUrls(config);
const {
core: coreThemeUrl,
variants: themeVariantUrls,
},
}) => {
const [appThemeState, dispatch] = useReducer(appThemeReducer, initialAppThemeState);
} = paragonThemeUrls;

const [themeState, dispatch] = useReducer(paragonThemeReducer, initialParagonThemeState);

const [isCoreThemeLoaded, setIsCoreThemeLoaded] = useState(false);
const [isLightVariantLoaded, setIsLightVariantLoaded] = useState(false);
const [isLightThemeVariantLoaded, setIsLightThemeVariantLoaded] = useState(false);

const onThemeCoreLoad = useCallback(() => {
const onLoadThemeCore = useCallback(() => {
setIsCoreThemeLoaded(true);
}, []);

const onLoadThemeVariantLight = useCallback(() => {
setIsLightVariantLoaded(true);
setIsLightThemeVariantLoaded(true);
}, []);

// load the core theme CSS
useAppThemeCore({
useParagonThemeCore({
coreThemeUrl,
onLoad: onThemeCoreLoad,
onLoad: onLoadThemeCore,
});

// load the theme variant(s) CSS
useAppThemeVariants({
useParagonThemeVariants({
themeVariantUrls,
onLoadVariantLight: onLoadThemeVariantLight,
currentThemeVariant: appThemeState.themeVariant,
onLoadThemeVariantLight,
currentThemeVariant: themeState.themeVariant,
});

useEffect(() => {
// theme is already loaded, do nothing
if (appThemeState.isThemeLoaded) {
if (themeState.isThemeLoaded) {
return;
}

// the core theme and light theme variant is still loading, do nothing.
const hasDefaultThemeConfig = (coreThemeUrl && themeVariantUrls[APP_THEME_LIGHT]);
const hasDefaultThemeConfig = (coreThemeUrl && themeVariantUrls[PARAGON_THEME_VARIANT_LIGHT]);
if (!hasDefaultThemeConfig) {
// no theme URLs to load, set loading to false.
dispatch(appThemeActions.setAppThemeLoaded(true));
dispatch(paragonThemeActions.setParagonThemeLoaded(true));
}

const isDefaultThemeLoaded = (isCoreThemeLoaded && isLightVariantLoaded);
const isDefaultThemeLoaded = (isCoreThemeLoaded && isLightThemeVariantLoaded);
if (!isDefaultThemeLoaded) {
return;
}

// All application theme URLs are loaded
dispatch(appThemeActions.setAppThemeLoaded(true));
dispatch(paragonThemeActions.setParagonThemeLoaded(true));
}, [
appThemeState.isThemeLoaded,
themeState.isThemeLoaded,
isCoreThemeLoaded,
isLightVariantLoaded,
isLightThemeVariantLoaded,
themeVariantUrls,
coreThemeUrl,
]);

return [appThemeState, dispatch];
return [themeState, dispatch];
};
6 changes: 3 additions & 3 deletions src/react/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@ export { default as ErrorBoundary } from './ErrorBoundary';
export { default as ErrorPage } from './ErrorPage';
export { default as LoginRedirect } from './LoginRedirect';
export { default as PageRoute } from './PageRoute';
export { useAppEvent, useAppTheme } from './hooks';
export { appThemeActions } from './reducers';
export { APP_THEME_LIGHT } from './constants';
export { useAppEvent, useParagonTheme } from './hooks';
export { paragonThemeActions } from './reducers';
export { PARAGON_THEME_VARIANT_LIGHT } from './constants';
12 changes: 6 additions & 6 deletions src/react/reducers.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
SET_IS_THEME_LOADED,
} from './constants';

export function appThemeReducer(state, action) {
export function paragonThemeReducer(state, action) {
switch (action.type) {
case SET_THEME_VARIANT: {
const requestedThemeVariant = action.payload;
Expand All @@ -24,17 +24,17 @@ export function appThemeReducer(state, action) {
}
}

const setAppThemeVariant = (payload) => ({
const setParagonThemeVariant = (payload) => ({
type: SET_THEME_VARIANT,
payload,
});

const setAppThemeLoaded = (payload) => ({
const setParagonThemeLoaded = (payload) => ({
type: SET_IS_THEME_LOADED,
payload,
});

export const appThemeActions = {
setAppThemeVariant,
setAppThemeLoaded,
export const paragonThemeActions = {
setParagonThemeVariant,
setParagonThemeLoaded,
};
2 changes: 2 additions & 0 deletions src/setupTest.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ process.env.FAVICON_URL = 'https://edx-cdn.org/v3/default/favicon.ico';
process.env.MFE_CONFIG_API_URL = '';
process.env.APP_ID = '';
process.env.SUPPORT_URL = 'https://support.edx.org';
process.env.PARAGON_THEME_CORE_URL = '';
process.env.PARAGON_THEME_VARIANTS_LIGHT_URL = '';

/* Auth test variables
Expand Down

0 comments on commit f790b9d

Please sign in to comment.