forked from openedx/frontend-platform
-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
chore: split hooks.js up into separate files and begin some related t…
…ests
- Loading branch information
1 parent
d256c01
commit df5a47e
Showing
12 changed files
with
782 additions
and
656 deletions.
There are no files selected for viewing
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
export { default as useAppEvent } from './useAppEvent'; | ||
|
||
export * from './paragon'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Oops, something went wrong.