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 ( + <> +