diff --git a/.env b/.env
index 2291ec64..951731e6 100644
--- a/.env
+++ b/.env
@@ -41,4 +41,4 @@ ACCOUNT_PROFILE_URL=''
ENABLE_NOTICES=''
CAREER_LINK_URL=''
OPTIMIZELY_FULL_STACK_SDK_KEY=''
-EXPERIMENT_08_23_VAN_PAINTED_DOOR=true
+EXPERIMENT_08_23_VAN_PAINTED_DOOR=false
diff --git a/package-lock.json b/package-lock.json
index 9ce2a24c..7386d0fd 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -23,6 +23,7 @@
"@optimizely/react-sdk": "^2.9.2",
"@redux-beacon/segment": "^1.1.0",
"@reduxjs/toolkit": "^1.6.1",
+ "@testing-library/dom": "^9.3.3",
"@testing-library/user-event": "^13.5.0",
"axios": "^0.21.4",
"classnames": "^2.3.1",
@@ -44,6 +45,7 @@
"query-string": "7.0.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
+ "react-error-boundary": "^4.0.11",
"react-helmet": "^6.1.0",
"react-intl": "^5.20.9",
"react-pdf": "^5.5.0",
@@ -5808,9 +5810,9 @@
}
},
"node_modules/@testing-library/dom": {
- "version": "9.3.1",
- "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.1.tgz",
- "integrity": "sha512-0DGPd9AR3+iDTjGoMpxIkAsUihHZ3Ai6CneU6bRRrffXMgzCdlNk43jTrD2/5LT6CBb3MWTP8v510JzYtahD2w==",
+ "version": "9.3.3",
+ "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-9.3.3.tgz",
+ "integrity": "sha512-fB0R+fa3AUqbLHWyxXa2kGVtf1Fe1ZZFr0Zp6AIbIAzXb2mKbEXl+PCQNUOaq5lbTab5tfctfXRNsWXxa2f7Aw==",
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
@@ -6017,6 +6019,21 @@
}
}
},
+ "node_modules/@testing-library/react-hooks/node_modules/react-error-boundary": {
+ "version": "3.1.4",
+ "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz",
+ "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==",
+ "dependencies": {
+ "@babel/runtime": "^7.12.5"
+ },
+ "engines": {
+ "node": ">=10",
+ "npm": ">=6"
+ },
+ "peerDependencies": {
+ "react": ">=16.13.1"
+ }
+ },
"node_modules/@testing-library/react/node_modules/@testing-library/dom": {
"version": "8.20.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz",
@@ -22642,16 +22659,12 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/react-error-boundary": {
- "version": "3.1.4",
- "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-3.1.4.tgz",
- "integrity": "sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA==",
+ "version": "4.0.11",
+ "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-4.0.11.tgz",
+ "integrity": "sha512-U13ul67aP5DOSPNSCWQ/eO0AQEYzEFkVljULQIjMV0KlffTAhxuDoBKdO0pb/JZ8mDhMKFZ9NZi0BmLGUiNphw==",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
- "engines": {
- "node": ">=10",
- "npm": ">=6"
- },
"peerDependencies": {
"react": ">=16.13.1"
}
diff --git a/package.json b/package.json
index 5b1a497e..a17b1eca 100755
--- a/package.json
+++ b/package.json
@@ -40,6 +40,7 @@
"@optimizely/react-sdk": "^2.9.2",
"@redux-beacon/segment": "^1.1.0",
"@reduxjs/toolkit": "^1.6.1",
+ "@testing-library/dom": "^9.3.3",
"@testing-library/user-event": "^13.5.0",
"axios": "^0.21.4",
"classnames": "^2.3.1",
@@ -61,6 +62,7 @@
"query-string": "7.0.1",
"react": "^17.0.2",
"react-dom": "^17.0.2",
+ "react-error-boundary": "^4.0.11",
"react-helmet": "^6.1.0",
"react-intl": "^5.20.9",
"react-pdf": "^5.5.0",
diff --git a/plugins/Plugin.jsx b/plugins/Plugin.jsx
new file mode 100644
index 00000000..a80e3be7
--- /dev/null
+++ b/plugins/Plugin.jsx
@@ -0,0 +1,93 @@
+'use client';
+
+import React, {
+ useEffect, useMemo, useState,
+} from 'react';
+import PropTypes from 'prop-types';
+import { ErrorBoundary } from 'react-error-boundary';
+import { logError } from '@edx/frontend-platform/logging';
+import { PLUGIN_RESIZE } from './data/constants';
+import {
+ dispatchMountedEvent, dispatchReadyEvent, dispatchUnmountedEvent, useHostEvent,
+} from './data/hooks';
+
+// see example-plugin-app/src/PluginOne.jsx for example of customizing errorFallback
+function errorFallbackDefault() {
+ return (
+
+
+ Oops! An error occurred. Please refresh the screen to try again.
+
+
+ );
+}
+
+// eslint-disable-next-line react/function-component-definition
+export default function Plugin({
+ children, className, style, ready, errorFallbackProp,
+}) {
+ const [dimensions, setDimensions] = useState({
+ width: null,
+ height: null,
+ });
+
+ const finalStyle = useMemo(() => ({
+ ...dimensions,
+ ...style,
+ }), [dimensions, style]);
+
+ const errorFallback = errorFallbackProp || errorFallbackDefault;
+
+ // Error logging function
+ // Need to confirm: When an error is caught here, the logging will be sent to the child MFE's logging service
+ const logErrorToService = (error, info) => {
+ logError(error, { stack: info.componentStack });
+ };
+
+ useHostEvent(PLUGIN_RESIZE, ({ payload }) => {
+ setDimensions({
+ width: payload.width,
+ height: payload.height,
+ });
+ });
+
+ useEffect(() => {
+ dispatchMountedEvent();
+
+ return () => {
+ dispatchUnmountedEvent();
+ };
+ }, []);
+
+ useEffect(() => {
+ if (ready) {
+ dispatchReadyEvent();
+ }
+ }, [ready]);
+
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+Plugin.propTypes = {
+ children: PropTypes.node.isRequired,
+ className: PropTypes.string,
+ errorFallbackProp: PropTypes.func,
+ ready: PropTypes.bool,
+ style: PropTypes.object, // eslint-disable-line
+};
+
+Plugin.defaultProps = {
+ className: null,
+ errorFallbackProp: null,
+ style: {},
+ ready: true,
+};
diff --git a/plugins/PluginContainer.jsx b/plugins/PluginContainer.jsx
new file mode 100644
index 00000000..98f23721
--- /dev/null
+++ b/plugins/PluginContainer.jsx
@@ -0,0 +1,42 @@
+'use client';
+
+import React from 'react';
+
+// eslint-disable-next-line import/no-extraneous-dependencies
+import PluginContainerIframe from './PluginContainerIframe';
+
+import {
+ IFRAME_PLUGIN,
+} from './data/constants';
+import { pluginConfigShape } from './data/shapes';
+
+// eslint-disable-next-line react/function-component-definition
+export default function PluginContainer({ config, ...props }) {
+ if (config === null) {
+ return null;
+ }
+
+ // this will allow for future plugin types to be inserted in the PluginErrorBoundary
+ let renderer = null;
+ switch (config.type) {
+ case IFRAME_PLUGIN:
+ renderer = (
+
+ );
+ break;
+ // istanbul ignore next: default isn't meaningful, just satisfying linter
+ default:
+ }
+
+ return (
+ renderer
+ );
+}
+
+PluginContainer.propTypes = {
+ config: pluginConfigShape,
+};
+
+PluginContainer.defaultProps = {
+ config: null,
+};
diff --git a/plugins/PluginContainerIframe.jsx b/plugins/PluginContainerIframe.jsx
new file mode 100644
index 00000000..6ebfcade
--- /dev/null
+++ b/plugins/PluginContainerIframe.jsx
@@ -0,0 +1,98 @@
+import React, {
+ useEffect, useState,
+} from 'react';
+import PropTypes from 'prop-types';
+import classNames from 'classnames';
+
+import {
+ PLUGIN_MOUNTED,
+ PLUGIN_READY,
+ PLUGIN_RESIZE,
+} from './data/constants';
+import {
+ dispatchPluginEvent,
+ useElementSize,
+ usePluginEvent,
+} from './data/hooks';
+import { pluginConfigShape } from './data/shapes';
+
+/**
+ * Feature policy for iframe, allowing access to certain courseware-related media.
+ *
+ * We must use the wildcard (*) origin for each feature, as courseware content
+ * may be embedded in external iframes. Notably, xblock-lti-consumer is a popular
+ * block that iframes external course content.
+
+ * This policy was selected in conference with the edX Security Working Group.
+ * Changes to it should be vetted by them (security@edx.org).
+ */
+export const IFRAME_FEATURE_POLICY = (
+ 'fullscreen; microphone *; camera *; midi *; geolocation *; encrypted-media *'
+);
+
+export default function PluginContainerIframe({
+ config, fallback, className, ...props
+}) {
+ const { url } = config;
+ const { title, scrolling } = props;
+ const [mounted, setMounted] = useState(false);
+ const [ready, setReady] = useState(false);
+
+ const [iframeRef, iframeElement, width, height] = useElementSize();
+
+ useEffect(() => {
+ if (mounted) {
+ dispatchPluginEvent(iframeElement, {
+ type: PLUGIN_RESIZE,
+ payload: {
+ width,
+ height,
+ },
+ }, url);
+ }
+ }, [iframeElement, mounted, width, height, url]);
+
+ usePluginEvent(iframeElement, PLUGIN_MOUNTED, () => {
+ setMounted(true);
+ });
+
+ usePluginEvent(iframeElement, PLUGIN_READY, () => {
+ setReady(true);
+ });
+
+ return (
+ <>
+
+ {!ready && fallback}
+ >
+ );
+}
+
+PluginContainerIframe.propTypes = {
+ config: pluginConfigShape,
+ fallback: PropTypes.node,
+ scrolling: PropTypes.oneOf(['auto', 'yes', 'no']),
+ title: PropTypes.string,
+ className: PropTypes.string,
+};
+
+PluginContainerIframe.defaultProps = {
+ config: null,
+ fallback: null,
+ scrolling: 'auto',
+ title: null,
+ className: null,
+};
diff --git a/plugins/PluginErrorBoundary.jsx b/plugins/PluginErrorBoundary.jsx
new file mode 100644
index 00000000..f1aaf507
--- /dev/null
+++ b/plugins/PluginErrorBoundary.jsx
@@ -0,0 +1,44 @@
+import React, { Component } from 'react';
+import PropTypes from 'prop-types';
+import { FormattedMessage } from 'react-intl';
+
+import { logError } from '@edx/frontend-platform/logging';
+
+export default class PluginErrorBoundary extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError() {
+ // Update state so the next render will show the fallback UI.
+ return { hasError: true };
+ }
+
+ componentDidCatch(error, info) {
+ logError(error, { stack: info.componentStack });
+ }
+
+ render() {
+ if (this.state.hasError) {
+ // You can render any custom fallback UI
+ return (
+
+ );
+ }
+
+ return this.props.children;
+ }
+}
+
+PluginErrorBoundary.propTypes = {
+ children: PropTypes.node,
+};
+
+PluginErrorBoundary.defaultProps = {
+ children: null,
+};
diff --git a/plugins/PluginSlot.jsx b/plugins/PluginSlot.jsx
new file mode 100644
index 00000000..11899e3c
--- /dev/null
+++ b/plugins/PluginSlot.jsx
@@ -0,0 +1,97 @@
+import React, { forwardRef } from 'react';
+
+import classNames from 'classnames';
+import { Spinner } from '@edx/paragon';
+import PropTypes from 'prop-types';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+
+// import { usePluginSlot } from './data/hooks';
+import {
+ IFRAME_PLUGIN,
+} from './data/constants';
+
+import PluginContainer from './PluginContainer';
+
+const PluginSlot = forwardRef(({
+ as, id, intl, pluginProps, children, ...props
+}, ref) => {
+ /* the plugins below are obtained by the id passed into PluginSlot by the Host MFE. See example/src/PluginsPage.jsx
+ for an example of how PluginSlot is populated, and example/src/index.jsx for a dummy JS config that holds all plugins
+ */
+
+ const dummyConfig = {
+ plugins: {
+ example: {
+ keepDefault: true,
+ plugins: [
+ {
+ url: 'http://localhost:1995/u/edx/plugin',
+ type: IFRAME_PLUGIN,
+ },
+ {
+ url: 'http://localhost:8081/plugin2',
+ type: IFRAME_PLUGIN,
+ },
+ ],
+ },
+ },
+ };
+
+ const { plugins, keepDefault } = dummyConfig.plugins.example;
+
+ const { fallback } = pluginProps;
+
+ // TODO: Add internationalization to the "Loading" text on the spinner.
+ let finalFallback = (
+
+
+
+ );
+ if (fallback !== undefined) {
+ finalFallback = fallback;
+ }
+
+ let finalChildren = [];
+ if (plugins.length > 0) {
+ if (keepDefault) {
+ finalChildren.push(children);
+ }
+ plugins.forEach((pluginConfig) => {
+ finalChildren.push(
+ ,
+ );
+ });
+ } else {
+ finalChildren = children;
+ }
+
+ return React.createElement(
+ as,
+ {
+ ...props,
+ ref,
+ },
+ finalChildren,
+ );
+});
+
+export default injectIntl(PluginSlot);
+
+PluginSlot.propTypes = {
+ as: PropTypes.elementType,
+ children: PropTypes.node,
+ id: PropTypes.string.isRequired,
+ intl: intlShape.isRequired,
+ pluginProps: PropTypes.object, // eslint-disable-line
+};
+
+PluginSlot.defaultProps = {
+ as: 'div',
+ children: null,
+ pluginProps: {},
+};
diff --git a/plugins/data/constants.js b/plugins/data/constants.js
new file mode 100644
index 00000000..9c834a2b
--- /dev/null
+++ b/plugins/data/constants.js
@@ -0,0 +1,8 @@
+// TODO: We expect other plugin types to be added here, such as LTI_PLUGIN and BUILD_TIME_PLUGIN.
+export const IFRAME_PLUGIN = 'IFRAME_PLUGIN'; // loads iframe at the URL, rather than loading a JS file.
+
+// Plugin lifecycle events
+export const PLUGIN_MOUNTED = 'PLUGIN_MOUNTED';
+export const PLUGIN_READY = 'PLUGIN_READY';
+export const PLUGIN_UNMOUNTED = 'PLUGIN_UNMOUNTED';
+export const PLUGIN_RESIZE = 'PLUGIN_RESIZE';
diff --git a/plugins/data/hooks.js b/plugins/data/hooks.js
new file mode 100644
index 00000000..d37c0a91
--- /dev/null
+++ b/plugins/data/hooks.js
@@ -0,0 +1,104 @@
+import {
+ useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
+} from 'react';
+// import { getConfig } from '../../config';
+import { PLUGIN_MOUNTED, PLUGIN_READY, PLUGIN_UNMOUNTED } from './constants';
+
+// export function usePluginSlot(id) {
+// if (getConfig().plugins[id] !== undefined) {
+// return getConfig().plugins[id];
+// }
+// return { keepDefault: true, plugins: [] };
+// }
+
+export function useMessageEvent(srcWindow, type, callback) {
+ useLayoutEffect(() => {
+ const listener = (event) => {
+ // Filter messages to those from our source window.
+ if (event.source === srcWindow) {
+ if (event.data.type === type) {
+ callback({ type, payload: event.data.payload });
+ }
+ }
+ };
+ if (srcWindow !== null) {
+ global.addEventListener('message', listener);
+ }
+ return () => {
+ global.removeEventListener('message', listener);
+ };
+ }, [srcWindow, type, callback]);
+}
+
+export function useHostEvent(type, callback) {
+ useMessageEvent(global.parent, type, callback);
+}
+
+export function usePluginEvent(iframeElement, type, callback) {
+ const contentWindow = iframeElement ? iframeElement.contentWindow : null;
+ useMessageEvent(contentWindow, type, callback);
+}
+
+export function dispatchMessageEvent(targetWindow, message, targetOrigin) {
+ // Checking targetOrigin falsiness here since '', null or undefined would all be reasons not to
+ // try to post a message to the origin.
+ if (targetOrigin) {
+ targetWindow.postMessage(message, targetOrigin);
+ }
+}
+
+export function dispatchPluginEvent(iframeElement, message, targetOrigin) {
+ dispatchMessageEvent(iframeElement.contentWindow, message, targetOrigin);
+}
+
+export function dispatchHostEvent(message) {
+ dispatchMessageEvent(global.parent, message, global.document.referrer);
+}
+
+export function dispatchReadyEvent() {
+ dispatchHostEvent({ type: PLUGIN_READY });
+}
+
+export function dispatchMountedEvent() {
+ dispatchHostEvent({ type: PLUGIN_MOUNTED });
+}
+
+export function dispatchUnmountedEvent() {
+ dispatchHostEvent({ type: PLUGIN_UNMOUNTED });
+}
+
+export function useElementSize() {
+ const observerRef = useRef();
+
+ const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
+ const [offset, setOffset] = useState({ x: 0, y: 0 });
+
+ const [element, setElement] = useState(null);
+
+ const measuredRef = useCallback(_element => {
+ setElement(_element);
+ }, []);
+
+ useEffect(() => {
+ observerRef.current = new ResizeObserver(() => {
+ if (element) {
+ setDimensions({
+ width: element.clientWidth,
+ height: element.clientHeight,
+ });
+ setOffset({
+ x: element.offsetLeft,
+ y: element.offsetTop,
+ });
+ }
+ });
+ if (element) {
+ observerRef.current.observe(element);
+ }
+ }, [element]);
+
+ return useMemo(
+ () => ([measuredRef, element, dimensions.width, dimensions.height, offset.x, offset.y]),
+ [measuredRef, element, dimensions, offset],
+ );
+}
diff --git a/plugins/data/shapes.js b/plugins/data/shapes.js
new file mode 100644
index 00000000..75a206e3
--- /dev/null
+++ b/plugins/data/shapes.js
@@ -0,0 +1,10 @@
+/* eslint-disable import/prefer-default-export */
+import PropTypes from 'prop-types';
+import { IFRAME_PLUGIN } from './constants';
+
+export const pluginConfigShape = PropTypes.shape({
+ url: PropTypes.string.isRequired,
+ type: PropTypes.oneOf([IFRAME_PLUGIN]).isRequired,
+ // This is a place for us to put any generic props we want to pass to the component. We need it.
+ props: PropTypes.object, // eslint-disable-line react/forbid-prop-types
+});
diff --git a/plugins/index.js b/plugins/index.js
new file mode 100644
index 00000000..e7c234ca
--- /dev/null
+++ b/plugins/index.js
@@ -0,0 +1,15 @@
+// export {
+// usePluginSlot,
+// } from './data/hooks';
+export {
+ default as Plugin,
+} from './Plugin';
+export {
+ default as PluginContainer,
+} from './PluginContainer';
+export {
+ default as PluginSlot,
+} from './PluginSlot';
+export {
+ IFRAME_PLUGIN,
+} from './data/constants';
diff --git a/src/containers/LearnerDashboardHeader/CollapsedHeader/CollapseMenuBody.jsx b/src/containers/LearnerDashboardHeader/CollapsedHeader/CollapseMenuBody.jsx
index 0860b0b0..7ca93b7f 100644
--- a/src/containers/LearnerDashboardHeader/CollapsedHeader/CollapseMenuBody.jsx
+++ b/src/containers/LearnerDashboardHeader/CollapsedHeader/CollapseMenuBody.jsx
@@ -6,10 +6,10 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { AppContext } from '@edx/frontend-platform/react';
import { Button, Badge } from '@edx/paragon';
-import WidgetNavbar from 'containers/WidgetContainers/WidgetNavbar';
+// import WidgetNavbar from 'containers/WidgetContainers/WidgetNavbar';
import urls from 'data/services/lms/urls';
import { reduxHooks } from 'hooks';
-import { COLLAPSED_NAVBAR } from 'widgets/RecommendationsPaintedDoorBtn/constants';
+// import { COLLAPSED_NAVBAR } from 'widgets/RecommendationsPaintedDoorBtn/constants';
import { findCoursesNavDropdownClicked } from '../hooks';
import messages from '../messages';
@@ -40,7 +40,7 @@ export const CollapseMenuBody = ({ isOpen }) => {
>
{formatMessage(messages.discoverNew)}
-
+ {/* */}
diff --git a/src/containers/LearnerDashboardHeader/CollapsedHeader/__snapshots__/CollapseMenuBody.test.jsx.snap b/src/containers/LearnerDashboardHeader/CollapsedHeader/__snapshots__/CollapseMenuBody.test.jsx.snap
index 7be3396a..c7694e82 100644
--- a/src/containers/LearnerDashboardHeader/CollapsedHeader/__snapshots__/CollapseMenuBody.test.jsx.snap
+++ b/src/containers/LearnerDashboardHeader/CollapsedHeader/__snapshots__/CollapseMenuBody.test.jsx.snap
@@ -26,9 +26,6 @@ exports[`CollapseMenuBody render 1`] = `
>
Discover New
-
-
-
diff --git a/src/containers/LearnerDashboardHeader/ExpandedHeader/index.jsx b/src/containers/LearnerDashboardHeader/ExpandedHeader/index.jsx
index 250a7756..b2b978ce 100644
--- a/src/containers/LearnerDashboardHeader/ExpandedHeader/index.jsx
+++ b/src/containers/LearnerDashboardHeader/ExpandedHeader/index.jsx
@@ -4,10 +4,10 @@ import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@edx/paragon';
-import WidgetNavbar from 'containers/WidgetContainers/WidgetNavbar';
+// import WidgetNavbar from 'containers/WidgetContainers/WidgetNavbar';
import urls from 'data/services/lms/urls';
import { reduxHooks } from 'hooks';
-import { EXPANDED_NAVBAR } from 'widgets/RecommendationsPaintedDoorBtn/constants';
+// import { EXPANDED_NAVBAR } from 'widgets/RecommendationsPaintedDoorBtn/constants';
import AuthenticatedUserDropdown from './AuthenticatedUserDropdown';
import { useIsCollapsed, findCoursesNavClicked } from '../hooks';
@@ -52,7 +52,7 @@ export const ExpandedHeader = () => {
>
{formatMessage(messages.discoverNew)}
-
+ {/* */}