Skip to content

Commit

Permalink
chore: split hooks.js up into separate files and begin some related t…
Browse files Browse the repository at this point in the history
…ests
  • Loading branch information
adamstankiewicz authored and ArturGaspar committed Jul 29, 2024
1 parent d256c01 commit df5a47e
Show file tree
Hide file tree
Showing 12 changed files with 782 additions and 656 deletions.
652 changes: 0 additions & 652 deletions src/react/hooks.js

This file was deleted.

3 changes: 3 additions & 0 deletions src/react/hooks/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { default as useAppEvent } from './useAppEvent';

export * from './paragon';
2 changes: 2 additions & 0 deletions src/react/hooks/paragon/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { default as useTrackColorSchemeChoice } from './useTrackColorSchemeChoice';
export { default as useParagonTheme } from './useParagonTheme';
109 changes: 109 additions & 0 deletions src/react/hooks/paragon/useParagonTheme.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {
useCallback, useEffect, useReducer, useState,
} from 'react';
import useParagonThemeUrls from './useParagonThemeUrls';
import { getDefaultThemeVariant } from './utils';
import { paragonThemeActions, paragonThemeReducer } from '../../reducers';
import useParagonThemeCore from './useParagonThemeCore';
import { SELECTED_THEME_VARIANT_KEY } from '../../constants';
import { logError } from '../../../logging';
import useParagonThemeVariants from './useParagonThemeVariants';

/**
* Given the inputs of URLs to the CSS for the core application theme and the theme variants (e.g., light), this hook
* will inject the CSS as `<link>` elements into the HTML document, loading each theme variant's CSS with an appropriate
* priority based on whether its the currently active theme variant. This is done using "alternate" stylesheets. That
* is,the browser will still download the CSS for the non-current theme variants, but at a lower priority than the
* current theme variant's CSS. This ensures that if the theme variant is changed at runtime, the CSS for the new theme
* variant will already be loaded.
*
* @memberof module:React
* @param {object} config An object containing the URLs for the theme's core CSS and any theme (i.e., `getConfig()`)
*
* @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.
*/
const useParagonTheme = (config) => {
const paragonThemeUrls = useParagonThemeUrls(config);
const {
core: themeCore,
defaults: themeVariantDefaults,
variants: themeVariants,
} = paragonThemeUrls || {};
const initialParagonThemeState = {
isThemeLoaded: false,
themeVariant: getDefaultThemeVariant({ themeVariants, themeVariantDefaults })?.name,
};
const [themeState, dispatch] = useReducer(paragonThemeReducer, initialParagonThemeState);

const [isCoreThemeLoaded, setIsCoreThemeLoaded] = useState(false);
const onLoadThemeCore = useCallback(() => {
setIsCoreThemeLoaded(true);
}, []);

const [hasLoadedThemeVariants, setHasLoadedThemeVariants] = useState(false);
const onLoadThemeVariants = useCallback(() => {
setHasLoadedThemeVariants(true);
}, []);

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

// respond to system preference changes with regard to `prefers-color-scheme: dark`.
const handleDarkModeSystemPreferenceChange = useCallback((prefersDarkMode) => {
// Ignore system preference change if the theme variant is already set in localStorage.
if (localStorage.getItem(SELECTED_THEME_VARIANT_KEY)) {
return;
}

if (prefersDarkMode && themeVariantDefaults.dark) {
dispatch(paragonThemeActions.setParagonThemeVariant(themeVariantDefaults.dark));
} else if (!prefersDarkMode && themeVariantDefaults.light) {
dispatch(paragonThemeActions.setParagonThemeVariant(themeVariantDefaults.light));
} else {
logError(`Could not set theme variant based on system preference (prefers dark mode: ${prefersDarkMode})`, themeVariantDefaults, themeVariants);
}
}, [themeVariantDefaults, themeVariants]);

// load the theme variant(s) CSS
useParagonThemeVariants({
themeVariants,
onLoad: onLoadThemeVariants,
currentThemeVariant: themeState.themeVariant,
onDarkModeSystemPreferenceChange: handleDarkModeSystemPreferenceChange,
});

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

const hasThemeConfig = (themeCore?.urls && Object.keys(themeVariants).length > 0);
if (!hasThemeConfig) {
// no theme URLs to load, set loading to false.
dispatch(paragonThemeActions.setParagonThemeLoaded(true));
}

// Return early if neither the core theme CSS nor any theme variant CSS is loaded.
if (!isCoreThemeLoaded || !hasLoadedThemeVariants) {
return;
}

// All application theme URLs are loaded
dispatch(paragonThemeActions.setParagonThemeLoaded(true));
}, [
themeState.isThemeLoaded,
isCoreThemeLoaded,
hasLoadedThemeVariants,
themeCore?.urls,
themeVariants,
]);

return [themeState, dispatch];
};

export default useParagonTheme;
133 changes: 133 additions & 0 deletions src/react/hooks/paragon/useParagonThemeCore.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { useEffect, useState } from 'react';

import { logError, logInfo } from '../../../logging';
import { removeExistingLinks } from './utils';
import { getConfig } from '../../../config';

/**
* Adds/updates a `<link>` element in the HTML document to load the core application theme CSS.
*
* @memberof module:React
*
* @param {object} args
* @param {object} args.themeCore Object representing the core Paragon theme CSS.
* @param {string} args.onLoad A callback function called when the core theme CSS is loaded.
*/
const useParagonThemeCore = ({
themeCore,
onLoad,
}) => {
const [isParagonThemeCoreLoaded, setIsParagonThemeCoreLoaded] = useState(false);
const [isBrandThemeCoreLoaded, setIsBrandThemeCoreLoaded] = useState(false);

useEffect(() => {
// Call `onLoad` once both the paragon and brand theme core are loaded.
if (isParagonThemeCoreLoaded && isBrandThemeCoreLoaded) {
onLoad();
}
}, [isParagonThemeCoreLoaded, isBrandThemeCoreLoaded, onLoad]);

useEffect(() => {
// If there is no config for the core theme url, do nothing.
if (!themeCore?.urls) {
setIsParagonThemeCoreLoaded(true);
setIsBrandThemeCoreLoaded(true);
return;
}
const getParagonThemeCoreLink = () => document.head.querySelector('link[data-paragon-theme-core="true"');
const existingCoreThemeLink = document.head.querySelector(`link[href='${themeCore.urls.default}']`);
if (!existingCoreThemeLink) {
const getExistingCoreThemeLinks = (isBrandOverride) => {
const coreThemeLinkSelector = `link[data-${isBrandOverride ? 'brand' : 'paragon'}-theme-core="true"]`;
return document.head.querySelectorAll(coreThemeLinkSelector);
};
const createCoreThemeLink = (
url,
{
isFallbackThemeUrl = false,
isBrandOverride = false,
} = {},
) => {
let coreThemeLink = document.createElement('link');
coreThemeLink.href = url;
coreThemeLink.rel = 'stylesheet';
if (isBrandOverride) {
coreThemeLink.dataset.brandThemeCore = true;
} else {
coreThemeLink.dataset.paragonThemeCore = true;
}
coreThemeLink.onload = () => {
if (isBrandOverride) {
setIsBrandThemeCoreLoaded(true);
} else {
setIsParagonThemeCoreLoaded(true);
}
};
coreThemeLink.onerror = () => {
logError(`Failed to load core theme CSS from ${url}`);
if (isFallbackThemeUrl) {
logError(`Could not load core theme CSS from ${url} or fallback URL. Aborting.`);
if (isBrandOverride) {
setIsBrandThemeCoreLoaded(true);
} else {
setIsParagonThemeCoreLoaded(true);
}
const otherExistingLinks = getExistingCoreThemeLinks(isBrandOverride);
removeExistingLinks(otherExistingLinks);
return;
}
const paragonThemeAccessor = isBrandOverride ? 'brand' : 'paragon';
const themeUrls = PARAGON_THEME?.[paragonThemeAccessor]?.themeUrls ?? {};
if (themeUrls.core) {
const coreThemeFallbackUrl = `${getConfig().BASE_URL}/${themeUrls.core.fileName}`;
logInfo(`Falling back to locally installed core theme CSS: ${coreThemeFallbackUrl}`);
coreThemeLink = createCoreThemeLink(coreThemeFallbackUrl, { isFallbackThemeUrl: true, isBrandOverride });
const otherExistingLinks = getExistingCoreThemeLinks(isBrandOverride);
removeExistingLinks(otherExistingLinks);
const foundParagonThemCoreLink = getParagonThemeCoreLink();
if (foundParagonThemCoreLink) {
foundParagonThemCoreLink.insertAdjacentElement(
'afterend',
coreThemeLink,
);
} else {
document.head.insertAdjacentElement(
'afterbegin',
coreThemeLink,
);
}
} else {
logError(`Failed to load core theme CSS from ${url} or fallback URL. Aborting.`);
}
};
return coreThemeLink;
};

const paragonCoreThemeLink = createCoreThemeLink(themeCore.urls.default);
document.head.insertAdjacentElement(
'afterbegin',
paragonCoreThemeLink,
);

if (themeCore.urls.brandOverride) {
const brandCoreThemeLink = createCoreThemeLink(themeCore.urls.brandOverride, { isBrandOverride: true });
const foundParagonThemeCoreLink = getParagonThemeCoreLink();
if (foundParagonThemeCoreLink) {
foundParagonThemeCoreLink.insertAdjacentElement(
'afterend',
brandCoreThemeLink,
);
} else {
document.head.insertAdjacentElement(
'afterbegin',
brandCoreThemeLink,
);
}
} else {
setIsBrandThemeCoreLoaded(true);
}
}
}, [themeCore?.urls, onLoad]);
};

export default useParagonThemeCore;
85 changes: 85 additions & 0 deletions src/react/hooks/paragon/useParagonThemeUrls.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { useMemo } from 'react';
import { handleVersionSubstitution } from './utils';

/**
* Returns an object containing the URLs for the theme's core CSS and any theme variants.
*
* @param {*} config
* @returns {ParagonThemeUrls|undefined} An object containing the URLs for the theme's core CSS and any theme variants.
*/
const useParagonThemeUrls = (config) => useMemo(() => {
if (!config?.PARAGON_THEME_URLS) {
return undefined;
}
const paragonThemeUrls = config.PARAGON_THEME_URLS;
const paragonCoreCssUrl = typeof paragonThemeUrls.core.urls === 'object' ? paragonThemeUrls.core.urls.default : paragonThemeUrls.core.url;
const brandCoreCssUrl = typeof paragonThemeUrls.core.urls === 'object' ? paragonThemeUrls.core.urls.brandOverride : undefined;
const defaultThemeVariants = paragonThemeUrls.defaults;

// Local versions of @edx/paragon and @edx/brand
const localParagonVersion = PARAGON_THEME?.paragon?.version;
const localBrandVersion = PARAGON_THEME?.brand?.version;

const coreCss = {
default: handleVersionSubstitution({ url: paragonCoreCssUrl, wildcardKeyword: '$paragonVersion', localVersion: localParagonVersion }),
brandOverride: handleVersionSubstitution({ url: brandCoreCssUrl, wildcardKeyword: '$brandVersion', localVersion: localBrandVersion }),
};

const themeVariantsCss = {};
const themeVariantsEntries = Object.entries(paragonThemeUrls.variants || {});
themeVariantsEntries.forEach(([themeVariant, { url, urls }]) => {
const themeVariantMetadata = { urls: null };
if (url) {
themeVariantMetadata.urls = {
default: handleVersionSubstitution({
url,
wildcardKeyword: '$paragonVersion',
localVersion: localParagonVersion,
}),
};
} else {
themeVariantMetadata.urls = {
default: handleVersionSubstitution({
url: urls.default,
wildcardKeyword: '$paragonVersion',
localVersion: localParagonVersion,
}),
brandOverride: handleVersionSubstitution({
url: urls.brandOverride,
wildcardKeyword: '$brandVersion',
localVersion: localBrandVersion,
}),
};
}
themeVariantsCss[themeVariant] = themeVariantMetadata;
});

const hasMissingCssUrls = !coreCss.default || Object.keys(themeVariantsCss).length === 0;
if (hasMissingCssUrls) {
if (!PARAGON_THEME) {
return undefined;
}
const themeVariants = {};
const baseUrl = config.BASE_URL || window.location?.origin;
const prependBaseUrl = (url) => `${baseUrl}/${url}`;
themeVariantsEntries.forEach(([themeVariant, { fileName, ...rest }]) => {
themeVariants[themeVariant] = {
url: prependBaseUrl(fileName),
...rest,
};
});
return {
core: { urls: coreCss },
defaults: defaultThemeVariants,
variants: themeVariants,
};
}

return {
core: { urls: coreCss },
defaults: defaultThemeVariants,
variants: themeVariantsCss,
};
}, [config?.BASE_URL, config?.PARAGON_THEME_URLS]);

export default useParagonThemeUrls;
Loading

0 comments on commit df5a47e

Please sign in to comment.