Skip to content

Commit 8c88998

Browse files
feat: allow customization of external URLs (#809)
This adds a getExternalLinkUrl function to the config.js module. It provides a way for external links to be customized or overridden in frontend apps.
1 parent 0a849f5 commit 8c88998

File tree

6 files changed

+131
-1
lines changed

6 files changed

+131
-1
lines changed

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,24 @@ const ExampleComponent = () => {
106106
};
107107
```
108108

109+
#### Overriding default external links
110+
111+
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:
112+
113+
```js
114+
// env.config.js
115+
116+
const config = {
117+
// other custom configuration here
118+
externalLinkUrlOverrides: {
119+
"https://docs.openedx.org/en/latest/educators/index.html": "https://custom.example.com/educators/index.html",
120+
"https://creativecommons.org/licenses": "https://www.tldrlegal.com/license/creative-commons-attribution-cc",
121+
},
122+
};
123+
124+
export default config;
125+
```
126+
109127
### Service interfaces
110128

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

env.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
// Also note that in an actual application this file would be added to .gitignore.
44
const config = {
55
JS_FILE_VAR: 'JS_FILE_VAR_VALUE_FOR_EXAMPLE_APP',
6+
externalLinkUrlOverrides: {
7+
"https://github.com/openedx/docs.openedx.org/": "https://docs.openedx.org/",
8+
}
69
};
710

811
export default config;

example/ExamplePage.jsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@ import { Link } from 'react-router-dom';
55
import { injectIntl, useIntl } from '@edx/frontend-platform/i18n';
66
import { logInfo } from '@edx/frontend-platform/logging';
77
import { AppContext } from '@edx/frontend-platform/react';
8-
import { ensureConfig, mergeConfig, getConfig } from '@edx/frontend-platform';
8+
import {
9+
ensureConfig, mergeConfig, getConfig, getExternalLinkUrl,
10+
} from '@edx/frontend-platform';
911
/* eslint-enable import/no-extraneous-dependencies */
1012
import messages from './messages';
1113

@@ -49,6 +51,8 @@ function ExamplePage() {
4951
<AuthenticatedUser />
5052
<p>EXAMPLE_VAR env var came through: <strong>{getConfig().EXAMPLE_VAR}</strong></p>
5153
<p>JS_FILE_VAR var came through: <strong>{getConfig().JS_FILE_VAR}</strong></p>
54+
<p>External link to <a href={getExternalLinkUrl('https://github.com/openedx/docs.openedx.org/')}>Open edX docs</a> (customized link).</p>
55+
<p>External link to <a href={getExternalLinkUrl('https://open-edx-proposals.readthedocs.io/en/latest/')}>Open edX OEPs</a> (non-customized link).</p>
5256
<p>Visit <Link to="/authenticated">authenticated page</Link>.</p>
5357
<p>Visit <Link to="/error_example">error page</Link>.</p>
5458
</div>

src/config.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,34 @@ export function ensureConfig(keys, requester = 'unspecified application code') {
307307
});
308308
}
309309

310+
/**
311+
* Get an external link URL based on the URL provided. If the passed in URL is overridden in the
312+
* `externalLinkUrlOverrides` object, it will return the overridden URL. Otherwise, it will return
313+
* the provided URL.
314+
*
315+
*
316+
* @param {string} url - The default URL.
317+
* @returns {string} - The external link URL. Defaults to the input URL if not found in the
318+
* `externalLinkUrlOverrides` object. If the input URL is invalid, '#' is returned.
319+
*
320+
* @example
321+
* import { getExternalLinkUrl } from '@edx/frontend-platform';
322+
*
323+
* <Hyperlink
324+
* destination={getExternalLinkUrl(data.helpLink)}
325+
* target="_blank"
326+
* >
327+
*/
328+
export function getExternalLinkUrl(url) {
329+
// Guard against non-strings or whitespace-only strings
330+
if (typeof url !== 'string' || !url.trim()) {
331+
return '#';
332+
}
333+
334+
const overriddenLinkUrls = getConfig().externalLinkUrlOverrides || {};
335+
return overriddenLinkUrls[url] || url;
336+
}
337+
310338
/**
311339
* An object describing the current application configuration.
312340
*

src/getExternalLinkUrl.test.js

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { getExternalLinkUrl, setConfig } from './config';
2+
3+
describe('getExternalLinkUrl', () => {
4+
afterEach(() => {
5+
// Reset config after each test to avoid cross-test pollution
6+
setConfig({});
7+
});
8+
9+
it('should return the url passed in when externalLinkUrlOverrides is not set', () => {
10+
setConfig({});
11+
const url = 'https://foo.example.com';
12+
expect(getExternalLinkUrl(url)).toBe(url);
13+
});
14+
15+
it('should return the url passed in when externalLinkUrlOverrides does not have the url mapping', () => {
16+
setConfig({
17+
externalLinkUrlOverrides: {
18+
'https://bar.example.com': 'https://mapped.example.com',
19+
},
20+
});
21+
const url = 'https://foo.example.com';
22+
expect(getExternalLinkUrl(url)).toBe(url);
23+
});
24+
25+
it('should return the mapped url when externalLinkUrlOverrides has the url mapping', () => {
26+
const url = 'https://foo.example.com';
27+
const mappedUrl = 'https://mapped.example.com';
28+
setConfig({ externalLinkUrlOverrides: { [url]: mappedUrl } });
29+
expect(getExternalLinkUrl(url)).toBe(mappedUrl);
30+
});
31+
32+
it('should handle empty externalLinkUrlOverrides object', () => {
33+
setConfig({ externalLinkUrlOverrides: {} });
34+
const url = 'https://foo.example.com';
35+
expect(getExternalLinkUrl(url)).toBe(url);
36+
});
37+
38+
it('should guard against empty string argument', () => {
39+
const fallbackResult = '#';
40+
setConfig({ externalLinkUrlOverrides: { foo: 'bar' } });
41+
expect(getExternalLinkUrl(undefined)).toBe(fallbackResult);
42+
});
43+
44+
it('should guard against non-string argument', () => {
45+
const fallbackResult = '#';
46+
setConfig({ externalLinkUrlOverrides: { foo: 'bar' } });
47+
expect(getExternalLinkUrl(null)).toBe(fallbackResult);
48+
expect(getExternalLinkUrl(42)).toBe(fallbackResult);
49+
});
50+
51+
it('should not throw if externalLinkUrlOverrides is not an object', () => {
52+
setConfig({ externalLinkUrlOverrides: null });
53+
const url = 'https://foo.example.com';
54+
expect(getExternalLinkUrl(url)).toBe(url);
55+
setConfig({ externalLinkUrlOverrides: 42 });
56+
expect(getExternalLinkUrl(url)).toBe(url);
57+
});
58+
59+
it('should work with multiple mappings', () => {
60+
setConfig({
61+
externalLinkUrlOverrides: {
62+
'https://a.example.com': 'https://mapped-a.example.com',
63+
'https://b.example.com': 'https://mapped-b.example.com',
64+
},
65+
});
66+
expect(getExternalLinkUrl('https://a.example.com')).toBe(
67+
'https://mapped-a.example.com',
68+
);
69+
expect(getExternalLinkUrl('https://b.example.com')).toBe(
70+
'https://mapped-b.example.com',
71+
);
72+
expect(getExternalLinkUrl('https://c.example.com')).toBe(
73+
'https://c.example.com',
74+
);
75+
});
76+
});

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export {
3737
setConfig,
3838
mergeConfig,
3939
ensureConfig,
40+
getExternalLinkUrl,
4041
} from './config';
4142
export {
4243
initializeMockApp,

0 commit comments

Comments
 (0)