diff --git a/.vscode/launch.json b/.vscode/launch.json index b7c8245d2b..a9e017d4c1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -44,11 +44,13 @@ "type": "node", "request": "launch", "name": "Server: Launch", - "args": ["./build/server.js"], - "preLaunchTask": "server: build", + "args": ["./build/init.js"], + "env": { + "DEBUG": "" + }, "restart": true, "outFiles": ["./build/*"], - "envFile": "${workspaceFolder}/Composer/packages/server/.env", + "preLaunchTask": "server: build", "outputCapture": "std", "cwd": "${workspaceFolder}/Composer/packages/server" }, @@ -77,9 +79,13 @@ "request": "launch", "name": "Electron Main Process", "runtimeExecutable": "${workspaceRoot}/Composer/node_modules/.bin/electron", + "windows": { + "runtimeExecutable": "${workspaceRoot}/Composer/node_modules/.bin/electron.cmd" + }, "args": ["${workspaceRoot}/Composer/packages/electron-server"], "env": { - "NODE_ENV": "development" + "NODE_ENV": "development", + "DEBUG": "composer*" }, "outputCapture": "std" }, @@ -92,9 +98,7 @@ "${workspaceRoot}/Composer/node_modules/jest/bin/jest", "--runInBand" ], - "args": [ - "${fileBasename}", - ], + "args": ["${fileBasename}"], "console": "integratedTerminal", "internalConsoleOptions": "neverOpen", "port": 9229 diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 04e7393d24..a8f3da49b7 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -5,9 +5,11 @@ "tasks": [ { "label": "server: build", - "type": "npm", - "script": "build", - "path": "Composer/packages/server/", + "type": "shell", + "command": "yarn build", + "options": { + "cwd": "Composer/packages/server" + }, "problemMatcher": [] } ] diff --git a/Composer/package.json b/Composer/package.json index ca6b7578cd..d130ea2e8d 100644 --- a/Composer/package.json +++ b/Composer/package.json @@ -22,7 +22,6 @@ "packages/extensions/*", "packages/lib", "packages/lib/*", - "packages/plugin-loader", "packages/server", "packages/test-utils", "packages/tools", @@ -38,7 +37,7 @@ "build:test": "yarn workspace @bfc/test-utils build", "build:lib": "yarn workspace @bfc/libs build:all", "build:electron": "yarn workspace @bfc/electron-server build", - "build:extensions": "wsrun -lt -p @bfc/plugin-loader @bfc/intellisense @bfc/extension @bfc/adaptive-form @bfc/adaptive-flow @bfc/ui-plugin-* -c build", + "build:extensions": "wsrun -lt -p @bfc/plugin-loader @bfc/intellisense @bfc/extension @bfc/adaptive-form @bfc/adaptive-flow @bfc/ui-plugin-* @bfc/client-plugin-lib -c build", "build:server": "yarn workspace @bfc/server build", "build:client": "yarn workspace @bfc/client build", "build:tools": "yarn workspace @bfc/tools build:all", diff --git a/Composer/packages/client/.gitignore b/Composer/packages/client/.gitignore index 567609b123..de820129fc 100644 --- a/Composer/packages/client/.gitignore +++ b/Composer/packages/client/.gitignore @@ -1 +1,2 @@ build/ +public/*-bundle.js diff --git a/Composer/packages/client/config/paths.js b/Composer/packages/client/config/paths.js index 7346475c97..13b4627e5e 100644 --- a/Composer/packages/client/config/paths.js +++ b/Composer/packages/client/config/paths.js @@ -7,7 +7,7 @@ const url = require('url'); // Make sure any symlinks in the project folder are resolved: // https://github.com/facebook/create-react-app/issues/637 const appDirectory = fs.realpathSync(process.cwd()); -const resolveApp = relativePath => path.resolve(appDirectory, relativePath); +const resolveApp = (relativePath) => path.resolve(appDirectory, relativePath); const envPublicUrl = process.env.PUBLIC_URL; @@ -22,7 +22,7 @@ function ensureSlash(inputPath, needsSlash) { } } -const getPublicUrl = appPackageJson => envPublicUrl || require(appPackageJson).homepage; +const getPublicUrl = (appPackageJson) => envPublicUrl || require(appPackageJson).homepage; // We use `PUBLIC_URL` environment variable or "homepage" field to infer // "public path" at which the app is served. @@ -52,7 +52,7 @@ const moduleFileExtensions = [ // Resolve file paths in the same order as webpack const resolveModule = (resolveFn, filePath) => { - const extension = moduleFileExtensions.find(extension => fs.existsSync(resolveFn(`${filePath}.${extension}`))); + const extension = moduleFileExtensions.find((extension) => fs.existsSync(resolveFn(`${filePath}.${extension}`))); if (extension) { return resolveFn(`${filePath}.${extension}`); diff --git a/Composer/packages/client/config/webpack-react-dom.config.js b/Composer/packages/client/config/webpack-react-dom.config.js new file mode 100644 index 0000000000..4e325e745c --- /dev/null +++ b/Composer/packages/client/config/webpack-react-dom.config.js @@ -0,0 +1,22 @@ +const { resolve } = require('path'); + +module.exports = { + entry: { + 'react-dom-bundle': 'react-dom', + }, + mode: 'production', + // export react-dom globally under a variable named ReactDOM + output: { + path: resolve(__dirname, '../public'), + library: 'ReactDOM', + libraryTarget: 'var', + }, + externals: { + // ReactDOM depends on React, but we need this to resolve to the globally-exposed React variable in react-bundle.js (created by webpack-react.config.js). + // If we don't do this, ReactDom will bundle its own copy of React and we will have 2 copies which breaks hooks. + react: 'React', + }, + resolve: { + extensions: ['.js'], + }, +}; diff --git a/Composer/packages/client/config/webpack-react.config.js b/Composer/packages/client/config/webpack-react.config.js new file mode 100644 index 0000000000..711737994a --- /dev/null +++ b/Composer/packages/client/config/webpack-react.config.js @@ -0,0 +1,17 @@ +const { resolve } = require('path'); + +module.exports = { + entry: { + 'react-bundle': 'react', + }, + mode: 'production', + // export react globally under a variable named React + output: { + path: resolve(__dirname, '../public'), + library: 'React', + libraryTarget: 'var', + }, + resolve: { + extensions: ['.js'], + }, +}; diff --git a/Composer/packages/client/package.json b/Composer/packages/client/package.json index b1fe205426..e83d49570a 100644 --- a/Composer/packages/client/package.json +++ b/Composer/packages/client/package.json @@ -8,8 +8,9 @@ "node": ">=12" }, "scripts": { - "start": "node scripts/start.js", + "start": "yarn build:react-bundles && node scripts/start.js", "build": "node --max_old_space_size=4096 scripts/build.js", + "build:react-bundles": "webpack --config ./config/webpack-react.config.js && webpack --config ./config/webpack-react-dom.config.js", "clean": "rimraf build", "test": "jest", "lint": "eslint --quiet --ext .js,.jsx,.ts,.tsx ./src ./__tests__", diff --git a/Composer/packages/client/public/extensionContainer.html b/Composer/packages/client/public/extensionContainer.html deleted file mode 100644 index 56d4f64b40..0000000000 --- a/Composer/packages/client/public/extensionContainer.html +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - - - - - - React App - - <% if (process.env.NODE_ENV === 'production') { %> - - - - <% } %> - - - - -
- - - diff --git a/Composer/packages/client/public/plugin-host-preload.js b/Composer/packages/client/public/plugin-host-preload.js new file mode 100644 index 0000000000..688fa7b79f --- /dev/null +++ b/Composer/packages/client/public/plugin-host-preload.js @@ -0,0 +1,29 @@ +// add default doc styles +if (!document.getElementById('plugin-host-default-styles')) { + const styles = document.createElement('style'); + styles.id = 'plugin-host-default-styles'; + styles.type = 'text/css'; + styles.appendChild( + document.createTextNode(` + html, body { padding: 0; margin: 0; } + #plugin-root { + display: flex; + flex-flow: column nowrap; + height: 100%; + } + `) + ); + document.head.appendChild(styles); +} +// add the react mount point +if (!document.getElementById('plugin-root')) { + const root = document.createElement('div'); + root.id = 'plugin-root'; + document.body.appendChild(root); +} +// initialize the API object +window.Composer = {}; +// init the render function +window.Composer['render'] = function (component) { + ReactDOM.render(component, document.getElementById('plugin-root')); +}; diff --git a/Composer/packages/client/src/App.tsx b/Composer/packages/client/src/App.tsx index 03b44d12f5..59661e7adf 100644 --- a/Composer/packages/client/src/App.tsx +++ b/Composer/packages/client/src/App.tsx @@ -1,16 +1,23 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import React, { Fragment } from 'react'; +import React, { Fragment, useEffect } from 'react'; import { initializeIcons } from 'office-ui-fabric-react/lib/Icons'; +import { useRecoilValue } from 'recoil'; import { Header } from './components/Header'; import { Announcement } from './components/AppComponents/Announcement'; import { MainContainer } from './components/AppComponents/MainContainer'; +import { dispatcherState } from './recoilModel/DispatcherWrapper'; initializeIcons(undefined, { disableWarnings: true }); export const App: React.FC = () => { + const { fetchPlugins } = useRecoilValue(dispatcherState); + useEffect(() => { + fetchPlugins(); + }); + return ( diff --git a/Composer/packages/client/src/components/PluginHost/PluginHost.tsx b/Composer/packages/client/src/components/PluginHost/PluginHost.tsx new file mode 100644 index 0000000000..6908b6e367 --- /dev/null +++ b/Composer/packages/client/src/components/PluginHost/PluginHost.tsx @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx, SerializedStyles } from '@emotion/core'; +import * as React from 'react'; +import { useEffect, useRef } from 'react'; + +import { PluginAPI } from '../../plugins/api'; +import { PluginType } from '../../plugins/types'; + +import { iframeStyle } from './styles'; + +interface PluginHostProps { + extraIframeStyles?: SerializedStyles[]; + pluginName?: string; + pluginType?: PluginType; +} + +/** Binds closures around Composer client code to plugin iframe's window object */ +function attachPluginAPI(win: Window, type: PluginType) { + const api = { ...PluginAPI[type], ...PluginAPI.auth }; + for (const method in api) { + win.Composer[method] = (...args) => api[method](...args); + } +} + +function injectScript(doc: Document, id: string, src: string, async: boolean, onload?: () => any) { + if (!doc.getElementById(id)) { + const script = document.createElement('script'); + Object.assign(script, { id, src, async, onload }); + doc.body.appendChild(script); + } +} + +/** Abstraction that will render an iframe injected with all the necessary UI plugin scripts, + * and then serve the plugin's client bundle. + */ +export const PluginHost: React.FC = (props) => { + const targetRef = useRef(null); + const { extraIframeStyles = [] } = props; + + useEffect(() => { + const { pluginName, pluginType } = props; + // renders the plugin's UI inside of the iframe + const renderPluginView = async () => { + if (pluginName && pluginType) { + const iframeWindow = targetRef.current?.contentWindow as Window; + const iframeDocument = targetRef.current?.contentDocument as Document; + + // inject the react / react-dom bundles + injectScript(iframeDocument, 'react-bundle', '/react-bundle.js', false); + injectScript(iframeDocument, 'react-dom-bundle', '/react-dom-bundle.js', false); + // // load the preload script to setup the plugin API + injectScript(iframeDocument, 'preload-bundle', '/plugin-host-preload.js', false, () => { + attachPluginAPI(iframeWindow, pluginType); + }); + + //load the bundle for the specified plugin + const pluginScriptId = `plugin-${pluginType}-${pluginName}`; + await new Promise((resolve) => { + const cb = () => { + resolve(); + }; + // If plugin bundles end up being too large and block the client thread due to the load, enable the async flag on this call + injectScript(iframeDocument, pluginScriptId, `/api/plugins/${pluginName}/view/${pluginType}`, false, cb); + }); + } + }; + renderPluginView(); + }, [props.pluginName, props.pluginType, targetRef]); + + return ; +}; diff --git a/Composer/packages/client/src/components/PluginHost/styles.ts b/Composer/packages/client/src/components/PluginHost/styles.ts new file mode 100644 index 0000000000..0d5314c988 --- /dev/null +++ b/Composer/packages/client/src/components/PluginHost/styles.ts @@ -0,0 +1,10 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { css } from '@emotion/core'; + +export const iframeStyle = css` + height: 100%; + width: 100%; + border: 0; +`; diff --git a/Composer/packages/client/src/pages/plugin/pluginPageContainer.tsx b/Composer/packages/client/src/pages/plugin/pluginPageContainer.tsx new file mode 100644 index 0000000000..662b0ca484 --- /dev/null +++ b/Composer/packages/client/src/pages/plugin/pluginPageContainer.tsx @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; +import { RouteComponentProps } from '@reach/router'; + +import { PluginHost } from '../../components/PluginHost/PluginHost'; + +const PluginPageContainer: React.FC> = (props) => { + const { pluginId } = props; + + return ; +}; + +export { PluginPageContainer }; diff --git a/Composer/packages/client/src/pages/publish/createPublishTarget.tsx b/Composer/packages/client/src/pages/publish/createPublishTarget.tsx index 9d411567bb..cc9fe43042 100644 --- a/Composer/packages/client/src/pages/publish/createPublishTarget.tsx +++ b/Composer/packages/client/src/pages/publish/createPublishTarget.tsx @@ -7,7 +7,7 @@ import formatMessage from 'format-message'; import { Dropdown, IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; import { TextField } from 'office-ui-fabric-react/lib/TextField'; import { DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; -import { Fragment, useState, useMemo } from 'react'; +import { Fragment, useState, useMemo, useEffect } from 'react'; import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; import { JsonEditor } from '@bfc/code-editor'; import { useRecoilValue } from 'recoil'; @@ -15,8 +15,10 @@ import { PublishTarget } from '@bfc/shared'; import { PublishType } from '../../recoilModel/types'; import { userSettingsState } from '../../recoilModel'; +import { PluginHost } from '../../components/PluginHost/PluginHost'; +import { PluginAPI } from '../../plugins/api'; -import { label } from './styles'; +import { label, customPublishUISurface } from './styles'; interface CreatePublishTargetProps { closeDialog: () => void; @@ -27,10 +29,12 @@ interface CreatePublishTargetProps { } const CreatePublishTarget: React.FC = (props) => { - const [targetType, setTargetType] = useState(props.current?.type); - const [name, setName] = useState(props.current ? props.current.name : ''); - const [config, setConfig] = useState(props.current ? JSON.parse(props.current.configuration) : undefined); + const { current } = props; + const [targetType, setTargetType] = useState(current?.type); + const [name, setName] = useState(current ? current.name : ''); + const [config, setConfig] = useState(current ? JSON.parse(current.configuration) : undefined); const [errorMessage, setErrorMsg] = useState(''); + const [pluginConfigIsValid, setPluginConfigIsValid] = useState(false); const userSettings = useRecoilValue(userSettingsState); const targetTypes = useMemo(() => { @@ -69,27 +73,68 @@ const CreatePublishTarget: React.FC = (props) => { return targetType ? props.types.find((t) => t.name === targetType)?.schema : undefined; }, [props.targets, targetType]); + const hasView = useMemo(() => { + return targetType ? props.types.find((t) => t.name === targetType)?.hasView : undefined; + }, [props.targets, targetType]); + const updateName = (e, newName) => { setErrorMsg(''); setName(newName); isNameValid(newName); }; - const isDisable = () => { - if (!targetType || !name || errorMessage) { - return true; - } else { - return false; + const saveDisabled = useMemo(() => { + const disabled = !targetType || !name || !!errorMessage; + if (hasView) { + // plugin config must also be valid + return disabled || !pluginConfigIsValid; } - }; + return disabled; + }, [errorMessage, name, pluginConfigIsValid, targetType]); - const submit = async () => { + // setup plugin APIs + useEffect(() => { + PluginAPI.publish.setPublishConfig = (config) => updateConfig(config); + PluginAPI.publish.setConfigIsValid = (valid) => setPluginConfigIsValid(valid); + PluginAPI.publish.useConfigBeingEdited = () => [current ? JSON.parse(current.configuration) : undefined]; + }, [current, targetType, name]); + + const submit = async (_e) => { if (targetType) { await props.updateSettings(name, targetType, JSON.stringify(config) || '{}'); props.closeDialog(); } }; + const publishTargetContent = useMemo(() => { + if (hasView && targetType) { + // render custom plugin view + return ( + + ); + } + // render default instruction / schema editor view + return ( + + {instructions &&

{instructions}

} +
{formatMessage('Publish Configuration')}
+ +