diff --git a/package-lock.json b/package-lock.json
index c7e2f24853..d6b62e4e1d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -30,6 +30,7 @@
"prop-types": "15.7.2",
"react": "16.14.0",
"react-dom": "16.14.0",
+ "react-helmet": "^6.1.0",
"react-redux": "7.1.3",
"react-responsive": "8.1.0",
"react-router": "5.1.2",
@@ -20127,6 +20128,25 @@
}
}
},
+ "node_modules/react-helmet": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz",
+ "integrity": "sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==",
+ "dependencies": {
+ "object-assign": "^4.1.1",
+ "prop-types": "^15.7.2",
+ "react-fast-compare": "^3.1.1",
+ "react-side-effect": "^2.1.0"
+ },
+ "peerDependencies": {
+ "react": ">=16.3.0"
+ }
+ },
+ "node_modules/react-helmet/node_modules/react-fast-compare": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz",
+ "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA=="
+ },
"node_modules/react-intl": {
"version": "5.25.1",
"resolved": "https://registry.npmjs.org/react-intl/-/react-intl-5.25.1.tgz",
@@ -20347,6 +20367,14 @@
"react": ">=15"
}
},
+ "node_modules/react-side-effect": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz",
+ "integrity": "sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==",
+ "peerDependencies": {
+ "react": "^16.3.0 || ^17.0.0 || ^18.0.0"
+ }
+ },
"node_modules/react-style-singleton": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz",
@@ -40393,6 +40421,24 @@
"use-sidecar": "^1.1.2"
}
},
+ "react-helmet": {
+ "version": "6.1.0",
+ "resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-6.1.0.tgz",
+ "integrity": "sha512-4uMzEY9nlDlgxr61NL3XbKRy1hEkXmKNXhjbAIOVw5vcFrsdYbH2FEwcNyWvWinl103nXgzYNlns9ca+8kFiWw==",
+ "requires": {
+ "object-assign": "^4.1.1",
+ "prop-types": "^15.7.2",
+ "react-fast-compare": "^3.1.1",
+ "react-side-effect": "^2.1.0"
+ },
+ "dependencies": {
+ "react-fast-compare": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.0.tgz",
+ "integrity": "sha512-rtGImPZ0YyLrscKI9xTpV8psd6I8VAtjKCzQDlzyDvqJA8XOW78TXYQwNRNd8g8JZnDu8q9Fu/1v4HPAVwVdHA=="
+ }
+ }
+ },
"react-intl": {
"version": "5.25.1",
"resolved": "https://registry.npmjs.org/react-intl/-/react-intl-5.25.1.tgz",
@@ -40544,6 +40590,12 @@
"tiny-warning": "^1.0.0"
}
},
+ "react-side-effect": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-2.1.2.tgz",
+ "integrity": "sha512-PVjOcvVOyIILrYoyGEpDN3vmYNLdy1CajSFNt4TDsVQC5KpTijDvWVoR+/7Rz2xT978D8/ZtFceXxzsPwZEDvw==",
+ "requires": {}
+ },
"react-style-singleton": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.1.tgz",
diff --git a/package.json b/package.json
index a329d191f7..89e9170b3d 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,7 @@
"prop-types": "15.7.2",
"react": "16.14.0",
"react-dom": "16.14.0",
+ "react-helmet": "^6.1.0",
"react-redux": "7.1.3",
"react-responsive": "8.1.0",
"react-router": "5.1.2",
diff --git a/public/index.html b/public/index.html
index 8a77d75c6f..98e0b7732b 100644
--- a/public/index.html
+++ b/public/index.html
@@ -1,7 +1,7 @@
- Course Authoring | edX
+ Course Authoring | <%= process.env.SITE_NAME %>
diff --git a/src/head/Head.jsx b/src/head/Head.jsx
new file mode 100644
index 0000000000..f093b87514
--- /dev/null
+++ b/src/head/Head.jsx
@@ -0,0 +1,21 @@
+import React from 'react';
+import { Helmet } from 'react-helmet';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import { getConfig } from '@edx/frontend-platform';
+
+import messages from './messages';
+
+const Head = ({ intl }) => (
+
+
+ {intl.formatMessage(messages['course-authoring.page.title'], { siteName: getConfig().SITE_NAME })}
+
+
+
+);
+
+Head.propTypes = {
+ intl: intlShape.isRequired,
+};
+
+export default injectIntl(Head);
diff --git a/src/head/Head.test.jsx b/src/head/Head.test.jsx
new file mode 100644
index 0000000000..cdd54311fe
--- /dev/null
+++ b/src/head/Head.test.jsx
@@ -0,0 +1,17 @@
+import React from 'react';
+import { IntlProvider } from '@edx/frontend-platform/i18n';
+import { Helmet } from 'react-helmet';
+import { mount } from 'enzyme';
+import { getConfig } from '@edx/frontend-platform';
+import Head from './Head';
+
+describe('Head', () => {
+ const props = {};
+ it('should match render title tag and favicon with the site configuration values', () => {
+ mount();
+ const helmet = Helmet.peek();
+ expect(helmet.title).toEqual(`Course Authoring | ${getConfig().SITE_NAME}`);
+ expect(helmet.linkTags[0].rel).toEqual('shortcut icon');
+ expect(helmet.linkTags[0].href).toEqual(getConfig().FAVICON_URL);
+ });
+});
diff --git a/src/head/messages.js b/src/head/messages.js
new file mode 100644
index 0000000000..4fca090383
--- /dev/null
+++ b/src/head/messages.js
@@ -0,0 +1,11 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ 'course-authoring.page.title': {
+ id: 'course-authoring.page.title',
+ defaultMessage: 'Course Authoring | {siteName}',
+ description: 'Title tag',
+ },
+});
+
+export default messages;
diff --git a/src/index.jsx b/src/index.jsx
index 41153d52e9..20efe9de4e 100755
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -16,10 +16,12 @@ import appMessages from './i18n';
import initializeStore from './store';
import './index.scss';
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
+import Head from './head/Head';
subscribe(APP_READY, () => {
ReactDOM.render(
+
node);
+
function renderComponent() {
const wrapper = render(
diff --git a/src/pages-and-resources/live/BbbSettings.test.jsx b/src/pages-and-resources/live/BbbSettings.test.jsx
index 9b66ef77e5..5696f693d7 100644
--- a/src/pages-and-resources/live/BbbSettings.test.jsx
+++ b/src/pages-and-resources/live/BbbSettings.test.jsx
@@ -6,6 +6,7 @@ import {
waitForElementToBeRemoved,
} from '@testing-library/react';
+import ReactDOM from 'react-dom';
import { Switch } from 'react-router-dom';
import { initializeMockApp, history } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
@@ -34,6 +35,9 @@ let container;
let store;
const liveSettingsUrl = `/course/${courseId}/pages-and-resources/live/settings`;
+// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
+ReactDOM.createPortal = jest.fn(node => node);
+
const renderComponent = () => {
const wrapper = render(
diff --git a/src/pages-and-resources/live/Settings.test.jsx b/src/pages-and-resources/live/Settings.test.jsx
index 5d927795cf..7d7ab4aefe 100644
--- a/src/pages-and-resources/live/Settings.test.jsx
+++ b/src/pages-and-resources/live/Settings.test.jsx
@@ -10,6 +10,7 @@ import {
waitForElementToBeRemoved,
} from '@testing-library/react';
+import ReactDOM from 'react-dom';
import { Switch } from 'react-router-dom';
import { initializeMockApp, history } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
@@ -37,6 +38,9 @@ let container;
let store;
const liveSettingsUrl = `/course/${courseId}/pages-and-resources/live/settings`;
+// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
+ReactDOM.createPortal = jest.fn(node => node);
+
const renderComponent = () => {
const wrapper = render(
diff --git a/src/pages-and-resources/live/ZoomSettings.test.jsx b/src/pages-and-resources/live/ZoomSettings.test.jsx
index 59155f6324..039d3c12d5 100644
--- a/src/pages-and-resources/live/ZoomSettings.test.jsx
+++ b/src/pages-and-resources/live/ZoomSettings.test.jsx
@@ -5,6 +5,7 @@ import {
waitForElementToBeRemoved,
} from '@testing-library/react';
+import ReactDOM from 'react-dom';
import { Switch } from 'react-router-dom';
import { initializeMockApp, history } from '@edx/frontend-platform';
import MockAdapter from 'axios-mock-adapter';
@@ -32,6 +33,9 @@ let container;
let store;
const liveSettingsUrl = `/course/${courseId}/pages-and-resources/live/settings`;
+// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
+ReactDOM.createPortal = jest.fn(node => node);
+
const renderComponent = () => {
const wrapper = render(
diff --git a/src/setupTest.js b/src/setupTest.js
index fb84d8e7da..0bfa7618d6 100755
--- a/src/setupTest.js
+++ b/src/setupTest.js
@@ -2,7 +2,6 @@ import 'core-js/stable';
import 'regenerator-runtime/runtime';
import '@testing-library/jest-dom';
import '@testing-library/jest-dom/extend-expect';
-import ReactDOM from 'react-dom';
/* eslint-disable import/no-extraneous-dependencies */
import Enzyme from 'enzyme';
@@ -29,9 +28,6 @@ Object.defineProperty(window, 'matchMedia', {
})),
});
-// Modal creates a portal. Overriding ReactDOM.createPortal allows portals to be tested in jest.
-ReactDOM.createPortal = node => node;
-
// Mock Intersection Observer which is unavailable in the context of a test.
global.IntersectionObserver = jest.fn(function mockIntersectionObserver() {
this.observe = jest.fn();