From bfc29927368bfd2bc47bb796e8e73b72c14ffb54 Mon Sep 17 00:00:00 2001 From: David Joy Date: Mon, 16 Aug 2021 11:09:18 -0400 Subject: [PATCH 01/40] Checking in to transfer machines. --- .eslintrc.js | 1 + docs/extensibility-notes.md | 138 ++++++++++++++++++++++++++++ example/PluginsPage.jsx | 19 ++++ example/{index.jsx => index.js} | 5 +- example/index.scss | 4 + package-lock.json | 3 +- package.json | 1 + public/plugin1.html | 11 +++ src/plugins/Plugin.jsx | 65 +++++++++++++ src/plugins/PluginComponent.jsx | 41 +++++++++ src/plugins/PluginErrorBoundary.jsx | 36 ++++++++ src/plugins/PluginIframe.jsx | 60 ++++++++++++ src/plugins/PluginSlot.jsx | 37 ++++++++ src/plugins/data/constants.js | 7 ++ src/plugins/data/hooks.js | 21 +++++ src/plugins/data/utils.js | 70 ++++++++++++++ src/plugins/index.js | 8 ++ 17 files changed, 524 insertions(+), 3 deletions(-) create mode 100644 docs/extensibility-notes.md create mode 100644 example/PluginsPage.jsx rename example/{index.jsx => index.js} (91%) create mode 100644 public/plugin1.html create mode 100644 src/plugins/Plugin.jsx create mode 100644 src/plugins/PluginComponent.jsx create mode 100644 src/plugins/PluginErrorBoundary.jsx create mode 100644 src/plugins/PluginIframe.jsx create mode 100644 src/plugins/PluginSlot.jsx create mode 100644 src/plugins/data/constants.js create mode 100644 src/plugins/data/hooks.js create mode 100644 src/plugins/data/utils.js create mode 100644 src/plugins/index.js diff --git a/.eslintrc.js b/.eslintrc.js index 8d5d03d00..74dbf6810 100755 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -23,6 +23,7 @@ config.rules = { specialLink: ['to'], aspects: ['noHref', 'invalidHref', 'preferButton'], }], + 'react/react-in-jsx-scope': ['error'], }; module.exports = config; diff --git a/docs/extensibility-notes.md b/docs/extensibility-notes.md new file mode 100644 index 000000000..b085d9209 --- /dev/null +++ b/docs/extensibility-notes.md @@ -0,0 +1,138 @@ +# Frontend extensibility and composability + +# frontend-platform/plugins + +Directory of plugin utilities for loading dynamic plugins + +# module.config.js + +This is all module.config.js! The plugin configuration is a new section in that. The module federation configuration is yet another new section in there. + +module.exports = { + localModules: [ + // { moduleName: '@edx/frontend-platform', dir: '../frontend-platform', dist: 'dist' }, + ], + remoteModules: [ + { name: 'account', url: 'http://localhost:1997/modules.js' } + ], + exposedModules: [ + + ], + plugins: [ + { + slot: 'user-menu', + type: 'component', + url: 'http://localhost:2003/modules.js', + scope: 'plugin', + module: 'UserMenu', + config: { + username: 'djoy' + } + } + ] +}; + + + +## Plugin configuration document + +Created on a per-MFE basis. + +plugin.config.js + +{ + +} + +## Module federation configuration + + + +This is different than a plugin configuration, as the MFE's webpack configuration will dictateas it is merely environment-specific + +Default plugins can be included in the MFE itself and loaded dynamically. This will set up the proper chunks for efficient loading. + +Plugin overrides can be structured however is appropriate for the environment. edx.org may have a frontend-plugins-edx.org deployment with edx.org-specific overrides of defaults + +# frontend-plugins-openedx + +Default set of frontend plugins which may or may not be used by a given environment depending on its configuration. Contains a set of generally useful functionality. Calculator would live here. + +# frontend-lib-headers + +Header-specific organisms would live here, like the user menu. This is intended to be loaded at build-time by consuming applications and plugins. + + +Version the modules.js file for shared dependencies like Paragon and Vendor with a major version number. This means we can manage breaking changes by publishing a new version of the file, and so long as we don't delete the old one, both can co-exist because all the dependent files are versioned. + +So do: modules-v1.js - this isn't a great idea, because it means you can't easily stand up the system again from total failure. It will lose that history when the old files are deleted. Fudge. + +Shared modules are hard problems. It should only be done with significant resources that are large and incur overhead through duplication. It's not worth doing for smaller resources, as every edge we create in the graph between nodes increases complexity significantly. + + + +# Types of cross-deployment sharing + +Third party libraries like React + +Component library like Paragon + +Shared organisms like headers and footers + +Plugins + +Experiments + + +Header + +Header edx.org + +User Menu + +Logo + +Mobile Menu + + + + +
// flexible enough for 80% of cases + + +
belongs to the MFE, but makes use of header sub-organisms from the frontend-lib-headers library. + +The sub-organisms in frontend-lib-headers are configurable and composed into the header. + +Two notes: + +1. PluginSlots must be able to get their own contents, as we don't know where they will be mounted. Don't pass it in. +2. PluginSlots must be nestable - a plugin slot can have a plugin slot inside it. +3. The children inside a PluginSlot are the default plugins, or default implementations. Supplying a plugin config replaces the children and uses that instead. + + +
+ + + + + + + + + + + + +
+
+ +Example: edx.org would configure the 'user-menu' plugin slot to switch out the user-menu implementation to include enterprise links. + + +MFEs should build and version themselves on npm/Github like our libraries do. Then our deployment gunk could just pull down and deploy those named versions. + + +ModuleFederationPlugin + +requiredVersion sets a _range_ to use to check the version. diff --git a/example/PluginsPage.jsx b/example/PluginsPage.jsx new file mode 100644 index 000000000..3975b3c39 --- /dev/null +++ b/example/PluginsPage.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { Plugin, IFRAME_PLUGIN } from '@edx/frontend-platform/plugins'; + +export default function PluginsPage() { + const plugin = { + url: 'http://localhost:8080/plugin1.html', + type: IFRAME_PLUGIN, + }; + + return ( +
+

Plugins Page

+ +
+ +
+
+ ); +} diff --git a/example/index.jsx b/example/index.js similarity index 91% rename from example/index.jsx rename to example/index.js index ff5513e67..5c9f1ebf4 100644 --- a/example/index.jsx +++ b/example/index.js @@ -12,9 +12,11 @@ import { import { APP_INIT_ERROR, APP_READY, initialize } from '@edx/frontend-platform'; import { subscribe } from '@edx/frontend-platform/pubSub'; -import './index.scss'; import ExamplePage from './ExamplePage'; import AuthenticatedPage from './AuthenticatedPage'; +import PluginsPage from './PluginsPage'; + +import './index.scss'; subscribe(APP_READY, () => { ReactDOM.render( @@ -25,6 +27,7 @@ subscribe(APP_READY, () => { path="/error_example" component={() => } /> + , document.getElementById('root'), diff --git a/example/index.scss b/example/index.scss index 845b15a65..6d479c6cd 100644 --- a/example/index.scss +++ b/example/index.scss @@ -2,3 +2,7 @@ @import "@edx/brand/paragon/variables"; @import "@edx/paragon/scss/core/core"; @import "@edx/brand/paragon/overrides"; + +body { + background-color: #ff0000 !important; +} diff --git a/package-lock.json b/package-lock.json index f7eb4e9fd..c1859b9d9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6320,8 +6320,7 @@ "node_modules/classnames": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.3.1.tgz", - "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==", - "dev": true + "integrity": "sha512-OlQdbZ7gLfGarSqxesMesDa5uz7KFbID8Kpq/SxIoNGDqY8lSYs0D+hhtBXhcdB3rcbXArFr7vlHheLk1voeNA==" }, "node_modules/clean-css": { "version": "5.3.2", diff --git a/package.json b/package.json index d86e94db3..68a2554bb 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,7 @@ "@formatjs/intl-relativetimeformat": "10.0.1", "axios": "0.27.2", "axios-cache-interceptor": "0.10.7", + "classnames": "2.3.1", "form-urlencoded": "4.1.4", "glob": "7.2.3", "history": "4.10.1", diff --git a/public/plugin1.html b/public/plugin1.html new file mode 100644 index 000000000..63d067e47 --- /dev/null +++ b/public/plugin1.html @@ -0,0 +1,11 @@ + + + + Plugin One + + + + + Plugin One + + diff --git a/src/plugins/Plugin.jsx b/src/plugins/Plugin.jsx new file mode 100644 index 000000000..cd7352c70 --- /dev/null +++ b/src/plugins/Plugin.jsx @@ -0,0 +1,65 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import { + COMPONENT_PLUGIN, IFRAME_PLUGIN, +} from './data/constants'; +import PluginComponent from './PluginComponent'; +import PluginIframe from './PluginIframe'; +import PluginErrorBoundary from './PluginErrorBoundary'; + +export default function Plugin({ as, plugin, ...props }) { + if (plugin === null) { + return null; + } + + let renderer = null; + switch (plugin.type) { + case COMPONENT_PLUGIN: + renderer = ( + + ); + break; + case IFRAME_PLUGIN: + renderer = ( + + ); + break; + default: + } + + const element = ( + + {renderer} + + ); + + // If we've been asked to wrap this with a particular component or DOM element ('as'), then do so + // and dump our element into it. + if (as) { + return React.createElement( + as, + { + ...props, + }, + element, + ); + } + + // Otherwise just return the element unwrapped. + return element; +} + +Plugin.propTypes = { + plugin: PropTypes.shape({ + scope: PropTypes.string, + module: PropTypes.string, + url: PropTypes.string.isRequired, + type: PropTypes.oneOf([COMPONENT_PLUGIN, IFRAME_PLUGIN]).isRequired, + props: PropTypes.object, + }), +}; + +Plugin.defaultProps = { + plugin: null, +}; diff --git a/src/plugins/PluginComponent.jsx b/src/plugins/PluginComponent.jsx new file mode 100644 index 000000000..c3dc02480 --- /dev/null +++ b/src/plugins/PluginComponent.jsx @@ -0,0 +1,41 @@ +import React, { Suspense } from 'react'; +import PropTypes from 'prop-types'; + +import PluginErrorBoundary from './PluginErrorBoundary'; + +import { COMPONENT_PLUGIN } from './data/constants'; +import { useDynamicPluginComponent } from './data/hooks'; + +function PluginComponent({ plugin, fallback, ...props }) { + if (!plugin) { + return null; + } + + const Component = useDynamicPluginComponent(plugin); + + return ( + + + + + + ); +} + +PluginComponent.propTypes = { + plugin: PropTypes.shape({ + scope: PropTypes.string.isRequired, + module: PropTypes.string.isRequired, + url: PropTypes.string.isRequired, + type: PropTypes.oneOf([COMPONENT_PLUGIN]).isRequired, + props: PropTypes.object, + }), + fallback: PropTypes.node, +}; + +PluginComponent.defaultProps = { + plugin: null, + fallback: null, +}; + +export default PluginComponent; diff --git a/src/plugins/PluginErrorBoundary.jsx b/src/plugins/PluginErrorBoundary.jsx new file mode 100644 index 000000000..0136537b7 --- /dev/null +++ b/src/plugins/PluginErrorBoundary.jsx @@ -0,0 +1,36 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +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, errorInfo) { + // eslint-disable-next-line no-console + console.error(error, errorInfo); + } + + render() { + if (this.state.hasError) { + // You can render any custom fallback UI + return
Plugin failed to load.
; + } + + return this.props.children; + } +} + +PluginErrorBoundary.propTypes = { + children: PropTypes.node, +}; + +PluginErrorBoundary.defaultProps = { + children: null, +}; diff --git a/src/plugins/PluginIframe.jsx b/src/plugins/PluginIframe.jsx new file mode 100644 index 000000000..21f92396b --- /dev/null +++ b/src/plugins/PluginIframe.jsx @@ -0,0 +1,60 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import { IFRAME_PLUGIN, LTI_PLUGIN, SCRIPT_PLUGIN } from './data/constants'; + +/** + * 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). + */ +const IFRAME_FEATURE_POLICY = ( + 'fullscreen; microphone *; camera *; midi *; geolocation *; encrypted-media *' +); + +export default function PluginIframe({ + plugin, fallback, className, ...props +}) { + const { url } = plugin; + const { title, scrolling } = props; + return ( +