From 2366c9cd1780de624b3cd2810512377b657e4f71 Mon Sep 17 00:00:00 2001 From: Taylor Payne Date: Thu, 22 May 2025 19:15:10 -0600 Subject: [PATCH 1/3] feat: allow customization of external URLs Create the getExternalLinkUrl function and make it available for use in frontend apps. It checks for a `customExternalUrls` object in the config to see if a custom URL has been provided for a given URL. Use getExternalLinkUrl in the example app for this package. One link is overridden with a custom URL while the other is not. --- env.config.js | 3 +++ example/ExamplePage.jsx | 6 +++++- src/config.js | 28 ++++++++++++++++++++++++++++ src/index.js | 1 + 4 files changed, 37 insertions(+), 1 deletion(-) diff --git a/env.config.js b/env.config.js index f4585f66d..15227c4fe 100644 --- a/env.config.js +++ b/env.config.js @@ -3,6 +3,9 @@ // Also note that in an actual application this file would be added to .gitignore. const config = { JS_FILE_VAR: 'JS_FILE_VAR_VALUE_FOR_EXAMPLE_APP', + externalLinkUrlOverrides: { + "https://github.com/openedx/docs.openedx.org/": "https://docs.openedx.org/", + } }; export default config; diff --git a/example/ExamplePage.jsx b/example/ExamplePage.jsx index 522512698..9d31fd8c2 100644 --- a/example/ExamplePage.jsx +++ b/example/ExamplePage.jsx @@ -5,7 +5,9 @@ import { Link } from 'react-router-dom'; import { injectIntl, useIntl } from '@edx/frontend-platform/i18n'; import { logInfo } from '@edx/frontend-platform/logging'; import { AppContext } from '@edx/frontend-platform/react'; -import { ensureConfig, mergeConfig, getConfig } from '@edx/frontend-platform'; +import { + ensureConfig, mergeConfig, getConfig, getExternalLinkUrl, +} from '@edx/frontend-platform'; /* eslint-enable import/no-extraneous-dependencies */ import messages from './messages'; @@ -49,6 +51,8 @@ function ExamplePage() {

EXAMPLE_VAR env var came through: {getConfig().EXAMPLE_VAR}

JS_FILE_VAR var came through: {getConfig().JS_FILE_VAR}

+

External link to Open edX docs (customized link).

+

External link to Open edX OEPs (non-customized link).

Visit authenticated page.

Visit error page.

diff --git a/src/config.js b/src/config.js index ebbb3be99..43486d18e 100644 --- a/src/config.js +++ b/src/config.js @@ -307,6 +307,34 @@ export function ensureConfig(keys, requester = 'unspecified application code') { }); } +/** + * Get an external link URL based on the URL provided. If the passed in URL is overridden in the + * `externalLinkUrlOverrides` object, it will return the overridden URL. Otherwise, it will return + * the provided URL. + * + * + * @param {string} url - The default URL. + * @returns {string} - The external link URL. Defaults to the input URL if not found in the + * `externalLinkUrlOverrides` object. If the input URL is invalid, '#' is returned. + * + * @example + * import { getExternalLinkUrl } from '@edx/frontend-platform'; + * + * + */ +export function getExternalLinkUrl(url) { + // Guard against non-strings or whitespace-only strings + if (typeof url !== 'string' || !url.trim()) { + return '#'; + } + + const overriddenLinkUrls = getConfig().externalLinkUrlOverrides || {}; + return overriddenLinkUrls[url] || url; +} + /** * An object describing the current application configuration. * diff --git a/src/index.js b/src/index.js index 9a4137740..864d061e1 100644 --- a/src/index.js +++ b/src/index.js @@ -37,6 +37,7 @@ export { setConfig, mergeConfig, ensureConfig, + getExternalLinkUrl, } from './config'; export { initializeMockApp, From 46351cbb1e1e9d67c35d3fe69ca4ac833f266099 Mon Sep 17 00:00:00 2001 From: Taylor Payne Date: Thu, 22 May 2025 19:26:27 -0600 Subject: [PATCH 2/3] test: add coverage for getExternalLinkUrl --- src/getExternalLinkUrl.test.js | 76 ++++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 src/getExternalLinkUrl.test.js diff --git a/src/getExternalLinkUrl.test.js b/src/getExternalLinkUrl.test.js new file mode 100644 index 000000000..f56114213 --- /dev/null +++ b/src/getExternalLinkUrl.test.js @@ -0,0 +1,76 @@ +import { getExternalLinkUrl, setConfig } from './config'; + +describe('getExternalLinkUrl', () => { + afterEach(() => { + // Reset config after each test to avoid cross-test pollution + setConfig({}); + }); + + it('should return the url passed in when externalLinkUrlOverrides is not set', () => { + setConfig({}); + const url = 'https://foo.example.com'; + expect(getExternalLinkUrl(url)).toBe(url); + }); + + it('should return the url passed in when externalLinkUrlOverrides does not have the url mapping', () => { + setConfig({ + externalLinkUrlOverrides: { + 'https://bar.example.com': 'https://mapped.example.com', + }, + }); + const url = 'https://foo.example.com'; + expect(getExternalLinkUrl(url)).toBe(url); + }); + + it('should return the mapped url when externalLinkUrlOverrides has the url mapping', () => { + const url = 'https://foo.example.com'; + const mappedUrl = 'https://mapped.example.com'; + setConfig({ externalLinkUrlOverrides: { [url]: mappedUrl } }); + expect(getExternalLinkUrl(url)).toBe(mappedUrl); + }); + + it('should handle empty externalLinkUrlOverrides object', () => { + setConfig({ externalLinkUrlOverrides: {} }); + const url = 'https://foo.example.com'; + expect(getExternalLinkUrl(url)).toBe(url); + }); + + it('should guard against empty string argument', () => { + const fallbackResult = '#'; + setConfig({ externalLinkUrlOverrides: { foo: 'bar' } }); + expect(getExternalLinkUrl(undefined)).toBe(fallbackResult); + }); + + it('should guard against non-string argument', () => { + const fallbackResult = '#'; + setConfig({ externalLinkUrlOverrides: { foo: 'bar' } }); + expect(getExternalLinkUrl(null)).toBe(fallbackResult); + expect(getExternalLinkUrl(42)).toBe(fallbackResult); + }); + + it('should not throw if externalLinkUrlOverrides is not an object', () => { + setConfig({ externalLinkUrlOverrides: null }); + const url = 'https://foo.example.com'; + expect(getExternalLinkUrl(url)).toBe(url); + setConfig({ externalLinkUrlOverrides: 42 }); + expect(getExternalLinkUrl(url)).toBe(url); + }); + + it('should work with multiple mappings', () => { + setConfig({ + externalLinkUrlOverrides: { + 'https://a.example.com': 'https://mapped-a.example.com', + 'https://b.example.com': 'https://mapped-b.example.com', + }, + }); + expect(getExternalLinkUrl('https://a.example.com')).toBe( + 'https://mapped-a.example.com', + ); + expect(getExternalLinkUrl('https://b.example.com')).toBe( + 'https://mapped-b.example.com', + ); + expect(getExternalLinkUrl('https://c.example.com')).toBe( + 'https://c.example.com', + ); + }); +}); From 1dc67f99d829a149f6a4a3cc868acc881eecc6bb Mon Sep 17 00:00:00 2001 From: Taylor Payne Date: Thu, 22 May 2025 19:32:03 -0600 Subject: [PATCH 3/3] docs: add section on how to customize external links --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/README.md b/README.md index 9c42ab660..a84784317 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,24 @@ const ExampleComponent = () => { }; ``` +#### Overriding default external links + +A `getExternalLinkUrl` function is provided in `config.js` which can be used to override default external links. To make use of this function, provide an object that maps default links to custom links. This object should be added to the `config` object defined in the `env.config.[js,jsx,ts,tsx]`, and must be named `externalLinkUrlOverrides`. Here is an example: + +```js +// env.config.js + +const config = { + // other custom configuration here + externalLinkUrlOverrides: { + "https://docs.openedx.org/en/latest/educators/index.html": "https://custom.example.com/educators/index.html", + "https://creativecommons.org/licenses": "https://www.tldrlegal.com/license/creative-commons-attribution-cc", + }, +}; + +export default config; +``` + ### Service interfaces Each service (analytics, auth, i18n, logging) provided by frontend-platform has an API contract which all implementations of that service are guaranteed to fulfill. Applications that use frontend-platform can use its configured services via a convenient set of exported functions. An application that wants to use the service interfaces need only initialize them via the initialize() function, optionally providing custom service interfaces as desired (you probably won't need to).