diff --git a/Composer/packages/client/src/components/PluginHost/PluginHost.tsx b/Composer/packages/client/src/components/PluginHost/PluginHost.tsx index 6908b6e367..1b801b878e 100644 --- a/Composer/packages/client/src/components/PluginHost/PluginHost.tsx +++ b/Composer/packages/client/src/components/PluginHost/PluginHost.tsx @@ -63,7 +63,7 @@ export const PluginHost: React.FC = (props) => { 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); + injectScript(iframeDocument, pluginScriptId, `/api/extensions/${pluginName}/view/${pluginType}`, false, cb); }); } }; diff --git a/Composer/packages/client/src/pages/setting/SettingsPage.tsx b/Composer/packages/client/src/pages/setting/SettingsPage.tsx index 0ceee729f0..1a1e0e7ad0 100644 --- a/Composer/packages/client/src/pages/setting/SettingsPage.tsx +++ b/Composer/packages/client/src/pages/setting/SettingsPage.tsx @@ -80,7 +80,7 @@ const SettingPage: React.FC = () => { }, { id: 'application', name: settingLabels.appSettings, url: getProjectLink('application') }, { id: 'runtime', name: settingLabels.runtime, url: getProjectLink('runtime', projectId), disabled: !projectId }, - // { id: 'extensions', name: settingLabels.extensions, url: getProjectLink('extensions') }, + { id: 'extensions', name: settingLabels.extensions, url: getProjectLink('extensions') }, { id: 'about', name: settingLabels.about, url: getProjectLink('about') }, ]; diff --git a/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx b/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx new file mode 100644 index 0000000000..98a967477e --- /dev/null +++ b/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx, css } from '@emotion/core'; +import React from 'react'; +import formatMessage from 'format-message'; +import { + DetailsListLayoutMode, + SelectionMode, + IColumn, + CheckboxVisibility, + ConstrainMode, +} from 'office-ui-fabric-react/lib/DetailsList'; +import { ScrollablePane } from 'office-ui-fabric-react/lib/ScrollablePane'; +import { Sticky } from 'office-ui-fabric-react/lib/Sticky'; +import { ShimmeredDetailsList } from 'office-ui-fabric-react/lib/ShimmeredDetailsList'; + +// TODO: extract to shared? +export type ExtensionSearchResult = { + id: string; + keywords: string[]; + version: string; + description: string; + url: string; +}; + +type ExtensionSearchResultsProps = { + results: ExtensionSearchResult[]; + isSearching: boolean; + onSelect: (extension: ExtensionSearchResult) => void; +}; + +const containerStyles = css` + position: relative; + height: 400px; +`; + +const noResultsStyles = css` + display: flex; + align-items: center; + justify-content: center; +`; + +const ExtensionSearchResults: React.FC = (props) => { + const { results, isSearching, onSelect } = props; + + const searchColumns: IColumn[] = [ + { + key: 'name', + name: formatMessage('Name'), + minWidth: 100, + maxWidth: 150, + onRender: (item: ExtensionSearchResult) => { + return {item.id}; + }, + }, + { + key: 'description', + name: formatMessage('Description'), + minWidth: 100, + maxWidth: 300, + isMultiline: true, + onRender: (item: ExtensionSearchResult) => { + return
{item.description}
; + }, + }, + { + key: 'version', + name: formatMessage('Version'), + minWidth: 30, + maxWidth: 100, + onRender: (item: ExtensionSearchResult) => { + return {item.version}; + }, + }, + { + key: 'url', + name: formatMessage('Url'), + minWidth: 100, + maxWidth: 100, + onRender: (item: ExtensionSearchResult) => { + return item.url ? ( + + {formatMessage('View on npm')} + + ) : null; + }, + }, + ]; + + const noResultsFound = !isSearching && results.length === 0; + + return ( +
+ + onSelect(item)} + onRenderDetailsHeader={(headerProps, defaultRender) => { + if (defaultRender) { + return {defaultRender(headerProps)}; + } + + return
; + }} + onRenderRow={(rowProps, defaultRender) => { + // there are no search results + if (!isSearching && results.length === 0) { + return ( +
+

{formatMessage('No search results')}

+
+ ); + } + + if (defaultRender) { + return defaultRender(rowProps); + } + + return null; + }} + /> + +
+ ); +}; + +export { ExtensionSearchResults }; diff --git a/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx b/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx index 3af2af1b22..aa15932931 100644 --- a/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx +++ b/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx @@ -2,160 +2,115 @@ // Licensed under the MIT License. /** @jsx jsx */ -import { jsx } from '@emotion/core'; -import React, { useEffect, useState, useCallback } from 'react'; +import { jsx, css } from '@emotion/core'; +import React, { useEffect, useState, useRef, useCallback } from 'react'; import { RouteComponentProps } from '@reach/router'; import { - DetailsList, DetailsListLayoutMode, + Selection, SelectionMode, IColumn, CheckboxVisibility, + ConstrainMode, + DetailsRow, + IDetailsRowStyles, } from 'office-ui-fabric-react/lib/DetailsList'; -import { DefaultButton, PrimaryButton } from 'office-ui-fabric-react/lib/Button'; +import { Toggle } from 'office-ui-fabric-react/lib/Toggle'; +import { ShimmeredDetailsList } from 'office-ui-fabric-react/lib/ShimmeredDetailsList'; import formatMessage from 'format-message'; -import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; -import { TextField } from 'office-ui-fabric-react/lib/TextField'; -import axios from 'axios'; -import { useRecoilValue } from 'recoil'; +import { useRecoilValue, selector } from 'recoil'; +import { NeutralColors } from '@uifabric/fluent-theme'; import { ExtensionConfig } from '../../../recoilModel/types'; import { Toolbar, IToolbarItem } from '../../../components/Toolbar'; -import httpClient from '../../../utils/httpUtil'; import { dispatcherState, extensionsState } from '../../../recoilModel'; +import { InstallExtensionDialog } from './InstallExtensionDialog'; +import { ExtensionSearchResult } from './ExtensionSearchResults'; + +const remoteExtensionsState = selector({ + key: 'remoteExtensions', + get: ({ get }) => get(extensionsState).filter((e) => !e.builtIn), +}); + +const noExtensionsStyles = css` + display: flex; + align-items: center; + justify-content: center; +`; + const Extensions: React.FC = () => { const { fetchExtensions, toggleExtension, addExtension, removeExtension } = useRecoilValue(dispatcherState); - const extensions = useRecoilValue(extensionsState); + const extensions = useRecoilValue(remoteExtensionsState); + // if a string, its the id of the extension being updated + const [isUpdating, setIsUpdating] = useState(false); const [showNewModal, setShowNewModal] = useState(false); - const [extensionName, setExtensionName] = useState(null); - const [extensionVersion, setExtensionVersion] = useState(null); - const [matchingExtensions, setMatchingExtensions] = useState([]); - const [selectedExtension, setSelectedExtension] = useState(); + const [selectedExtensions, setSelectedExtensions] = useState([]); + const selection = useRef( + new Selection({ + onSelectionChanged: () => { + setSelectedExtensions(selection.getSelection() as ExtensionConfig[]); + }, + }) + ).current; useEffect(() => { fetchExtensions(); }, []); - useEffect(() => { - if (extensionName !== null) { - const source = axios.CancelToken.source(); - - const timer = setTimeout(() => { - httpClient - .get(`/extensions/search?q=${extensionName}`, { cancelToken: source.token }) - .then((res) => { - setMatchingExtensions(res.data); - }) - .catch((err) => { - if (!axios.isCancel(err)) { - console.error(err); - } - }); - }, 200); - - return () => { - source.cancel('User interruption'); - clearTimeout(timer); - }; - } - }, [extensionName]); - const installedColumns: IColumn[] = [ { key: 'name', name: formatMessage('Name'), minWidth: 100, - maxWidth: 150, - onRender: (item: ExtensionConfig) => { - return {item.id}; - }, - }, - { - key: 'version', - name: formatMessage('Version'), - minWidth: 30, - maxWidth: 100, - onRender: (item: ExtensionConfig) => { - return {item.version}; - }, - }, - { - key: 'enabled', - name: formatMessage('Enabled'), - minWidth: 30, - maxWidth: 150, - onRender: (item: ExtensionConfig) => { - const text = item.enabled ? formatMessage('Disable') : formatMessage('Enable'); - return ( - toggleExtension(item.id, !item.enabled)}> - {text} - - ); - }, - }, - { - key: 'remove', - name: formatMessage('Remove'), - minWidth: 30, - maxWidth: 150, - onRender: (item: ExtensionConfig) => { - return ( - removeExtension(item.id)}> - {formatMessage('Remove')} - - ); - }, - }, - ]; - - const matchingColumns: IColumn[] = [ - { - key: 'name', - name: formatMessage('Name'), - minWidth: 100, - maxWidth: 150, - onRender: (item: any) => { - return {item.id}; - }, + maxWidth: 250, + isResizable: true, + fieldName: 'name', }, { key: 'description', name: formatMessage('Description'), - minWidth: 100, - maxWidth: 300, + minWidth: 150, + maxWidth: 500, + isResizable: true, + isCollapsible: true, isMultiline: true, - onRender: (item: any) => { - return
{item.description}
; - }, + fieldName: 'description', }, { key: 'version', name: formatMessage('Version'), - minWidth: 30, + minWidth: 100, maxWidth: 100, - onRender: (item: any) => { - return {item.version}; - }, + isResizable: true, + fieldName: 'version', }, { - key: 'url', - name: formatMessage('Url'), + key: 'enabled', + name: formatMessage('Enabled'), minWidth: 100, - maxWidth: 100, - onRender: (item: any) => { - return item.url ? ( - - View on npm - - ) : null; + maxWidth: 150, + isResizable: true, + onRender: (item: ExtensionConfig) => { + return ( + { + const timeout = setTimeout(() => setIsUpdating(item.id), 200); + await toggleExtension(item.id, !item.enabled); + clearTimeout(timeout); + setIsUpdating(false); + }} + /> + ); }, }, ]; const toolbarItems: IToolbarItem[] = [ - // TODO (toanzian / abrown): re-enable once remote extensions are supported - /*{ + { type: 'action', text: formatMessage('Add'), buttonProps: { @@ -167,61 +122,91 @@ const Extensions: React.FC = () => { }, }, align: 'left', - },*/ + }, + { + type: 'action', + text: formatMessage('Uninstall'), + buttonProps: { + iconProps: { + iconName: 'Trash', + }, + onClick: async () => { + const names = selectedExtensions.map((e) => e.name).join('\n'); + const message = formatMessage('Are you sure you want to uninstall these extensions?'); + if (confirm(`${message}\n\n${names}`)) { + for (const ext of selectedExtensions) { + const timeout = setTimeout(() => setIsUpdating(ext.id), 200); + await removeExtension(ext.id); + clearTimeout(timeout); + setIsUpdating(false); + } + } + }, + }, + disabled: selectedExtensions.length === 0, + align: 'left', + }, ]; - const submit = useCallback(() => { - if (selectedExtension && extensionVersion) { - addExtension(selectedExtension.id, extensionVersion); + const submit = useCallback(async (selectedExtension?: ExtensionSearchResult) => { + if (selectedExtension) { + setIsUpdating(true); setShowNewModal(false); - setExtensionName(null); - setExtensionVersion(null); - setSelectedExtension(null); + await addExtension(selectedExtension.id); + setIsUpdating(false); } - }, [selectedExtension, extensionVersion]); + }, []); + + const shownItems = () => { + if (extensions.length === 0) { + // render no installed message + return [{}]; + } else if (isUpdating === true) { + // extension is being added, render a shimmer row at end of list + return [...extensions, null]; + } else if (typeof isUpdating === 'string') { + // extension is being removed or updated, show shimmer for that row + return extensions.map((e) => (e.id === isUpdating ? null : e)); + } else { + return extensions; + } + }; + + const dismissInstallDialog = useCallback(() => setShowNewModal(false), []); return ( -
+
- - { + if (extensions.length === 0) { + return ( +
+

{formatMessage('No extensions installed')}

+
+ ); + } + + if (defaultRender && rowProps) { + const customStyles: Partial = { + root: { + color: rowProps?.item?.enabled ? undefined : NeutralColors.gray90, + }, + }; + return ; + } + + return null; }} - hidden={!showNewModal} - minWidth="600px" - onDismiss={() => setShowNewModal(false)} - > -
- setExtensionName(val ?? null)} - /> - setSelectedExtension(item)} - /> -
- - setShowNewModal(false)}>Cancel - - {formatMessage('Add')} - - -
+ /> +
); }; diff --git a/Composer/packages/client/src/pages/setting/extensions/InstallExtensionDialog.tsx b/Composer/packages/client/src/pages/setting/extensions/InstallExtensionDialog.tsx new file mode 100644 index 0000000000..9065d92ef0 --- /dev/null +++ b/Composer/packages/client/src/pages/setting/extensions/InstallExtensionDialog.tsx @@ -0,0 +1,107 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { useState, useEffect } from 'react'; +import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { SearchBox } from 'office-ui-fabric-react/lib/SearchBox'; +import { DefaultButton, PrimaryButton } from 'office-ui-fabric-react/lib/Button'; +import axios, { CancelToken } from 'axios'; +import formatMessage from 'format-message'; + +import httpClient from '../../../utils/httpUtil'; + +import { ExtensionSearchResult, ExtensionSearchResults } from './ExtensionSearchResults'; + +type InstallExtensionDialogProps = { + isOpen: boolean; + onDismiss: () => void; + onInstall: (selectedExtension: ExtensionSearchResult) => Promise; +}; + +const InstallExtensionDialog: React.FC = (props) => { + const { isOpen, onDismiss, onInstall } = props; + const [searchQuery, setSearchQuery] = useState(null); + const [selectedExtension, setSelectedExtension] = useState(null); + const [matchingExtensions, setMatchingExtensions] = useState([]); + + const [isSearching, setIsSearching] = useState(false); + + const performSearch = (query: string, cancelToken?: CancelToken) => { + setIsSearching(true); + httpClient + .get(`/extensions/search?q=${query}`, { cancelToken }) + .then((res) => { + setMatchingExtensions(res.data); + setIsSearching(false); + }) + .catch((err) => { + setIsSearching(false); + if (!axios.isCancel(err)) { + // TODO: abrown - what to do on error? + // eslint-disable-next-line no-console + console.error(err); + } + }); + }; + + useEffect(() => { + performSearch(''); + }, []); + + useEffect(() => { + if (searchQuery !== null) { + const source = axios.CancelToken.source(); + + const timer = setTimeout(() => { + performSearch(searchQuery, source.token); + }, 200); + + return () => { + source.cancel('User interruption'); + clearTimeout(timer); + }; + } + }, [searchQuery]); + + const onSubmit = async () => { + if (selectedExtension) { + await onInstall(selectedExtension); + performSearch(searchQuery ?? ''); + } + }; + + return ( + + ); +}; + +export { InstallExtensionDialog }; diff --git a/Composer/packages/client/src/pages/setting/router.tsx b/Composer/packages/client/src/pages/setting/router.tsx index 81deba54fb..6236121241 100644 --- a/Composer/packages/client/src/pages/setting/router.tsx +++ b/Composer/packages/client/src/pages/setting/router.tsx @@ -12,7 +12,7 @@ import { About } from '../about/About'; import { DialogSettings } from './dialog-settings/DialogSettings'; import { AppSettings } from './app-settings/AppSettings'; import { RuntimeSettings } from './runtime-settings/RuntimeSettings'; -// import { Extensions } from './extensions/Extensions'; +import { Extensions } from './extensions/Extensions'; export const SettingsRoutes = React.memo(({ projectId }: { projectId: string }) => { const applicationError = useRecoilValue(applicationErrorState); @@ -34,7 +34,7 @@ export const SettingsRoutes = React.memo(({ projectId }: { projectId: string }) - {/* */} + ); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/extensions.ts b/Composer/packages/client/src/recoilModel/dispatchers/extensions.ts index c545eb03ea..b7196583f5 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/extensions.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/extensions.ts @@ -21,24 +21,23 @@ export const extensionsDispatcher = () => { }); const addExtension = useRecoilCallback( - (callbackHelpers: CallbackInterface) => async (extensionName: string, version: string) => { + (callbackHelpers: CallbackInterface) => async (extensionName: string, version?: string) => { const { set } = callbackHelpers; try { - const res = await httpClient.post('/extensions', { name: extensionName, version }); + const res = await httpClient.post('/extensions', { id: extensionName, version }); const addedExtension: ExtensionConfig = res.data; set(extensionsState, (extensions) => { if (extensions.find((p) => p.id === addedExtension.id)) { - extensions = extensions.map((p) => { + return extensions.map((p) => { if (p.id === addedExtension.id) { return addedExtension; } return p; }); } else { - extensions.push(addedExtension); + return [...extensions, addedExtension]; } - return extensions; }); } catch (err) { console.error(err); diff --git a/Composer/packages/client/src/recoilModel/types.ts b/Composer/packages/client/src/recoilModel/types.ts index 9053bac96f..ed9c639461 100644 --- a/Composer/packages/client/src/recoilModel/types.ts +++ b/Composer/packages/client/src/recoilModel/types.ts @@ -41,10 +41,21 @@ export interface PublishType { }; } +type ExtensionPublishContribution = { + bundleId: string; +}; + +export type ExtensionPageContribution = { + bundleId: string; + label: string; + icon?: string; +}; + // TODO: move this definition to a shared spot export interface ExtensionConfig { id: string; name: string; + description: string; enabled: boolean; version: string; /** Special property only used in the in-memory representation of extensions to flag as a built-in. Not written to disk. */ @@ -52,7 +63,12 @@ export interface ExtensionConfig { /** Path where module is installed */ path: string; bundles: any; // TODO: needed? - contributes: any; // TODO: define this type + contributes?: { + views?: { + publish?: ExtensionPublishContribution; + pages?: ExtensionPageContribution[]; + }; + }; } export interface RuntimeTemplate { diff --git a/Composer/packages/client/src/utils/hooks.ts b/Composer/packages/client/src/utils/hooks.ts index 8df4ab3cd3..8cb4591a9f 100644 --- a/Composer/packages/client/src/utils/hooks.ts +++ b/Composer/packages/client/src/utils/hooks.ts @@ -7,6 +7,8 @@ import replace from 'lodash/replace'; import find from 'lodash/find'; import { useRecoilValue } from 'recoil'; +import { ExtensionPageContribution } from '../recoilModel/types'; + import { designPageLocationState, extensionsState, currentProjectIdState } from './../recoilModel'; import { bottomLinks, topLinks } from './pageLinks'; import routerCache from './routerCache'; @@ -29,12 +31,12 @@ export const useLinks = () => { // add page-contributing extensions const pluginPages = extensions.reduce((pages, p) => { - const pageConfig = p.contributes?.views?.page; - if (pageConfig) { - pages.push({ ...pageConfig, id: p.id }); + const pagesConfig = p.contributes?.views?.pages; + if (Array.isArray(pagesConfig) && pagesConfig.length > 0) { + pages.push(...pagesConfig); } return pages; - }, [] as any[]); + }, [] as ExtensionPageContribution[]); return { topLinks: topLinks(projectId, openedDialogId, pluginPages), bottomLinks }; }; diff --git a/Composer/packages/client/src/utils/pageLinks.ts b/Composer/packages/client/src/utils/pageLinks.ts index 1d90bcef31..4756d92fba 100644 --- a/Composer/packages/client/src/utils/pageLinks.ts +++ b/Composer/packages/client/src/utils/pageLinks.ts @@ -2,11 +2,9 @@ // Licensed under the MIT License. import formatMessage from 'format-message'; -export const topLinks = ( - projectId: string, - openedDialogId: string, - pluginPages: { id: string; label: string; icon?: string; when?: string }[] -) => { +import { ExtensionPageContribution } from '../recoilModel/types'; + +export const topLinks = (projectId: string, openedDialogId: string, pluginPages: ExtensionPageContribution[]) => { const botLoaded = !!projectId; let links = [ { @@ -74,7 +72,7 @@ export const topLinks = ( if (pluginPages.length > 0) { pluginPages.forEach((p) => { links.push({ - to: `page/${p.id}`, + to: `page/${p.bundleId}`, iconName: p.icon ?? 'StatusCircleQuestionMark', labelName: p.label, exact: true, diff --git a/Composer/packages/extension/src/manager/manager.ts b/Composer/packages/extension/src/manager/manager.ts index 15fa510409..e86c5fedb8 100644 --- a/Composer/packages/extension/src/manager/manager.ts +++ b/Composer/packages/extension/src/manager/manager.ts @@ -4,7 +4,7 @@ import path from 'path'; import glob from 'globby'; -import { readJson } from 'fs-extra'; +import { readJson, ensureDir } from 'fs-extra'; import { ExtensionContext } from '../extensionContext'; import logger from '../logger'; @@ -14,6 +14,8 @@ import { npm } from '../utils/npm'; const log = logger.extend('manager'); +const SEARCH_CACHE_TIMEOUT = 5 * 60000; // 5 minutes + function processBundles(extensionPath: string, bundles: ExtensionBundle[]) { return bundles.map((b) => ({ ...b, @@ -25,6 +27,7 @@ function getExtensionMetadata(extensionPath: string, packageJson: PackageJSON): return { id: packageJson.name, name: packageJson.composer?.name ?? packageJson.name, + description: packageJson.description, version: packageJson.version, enabled: true, path: extensionPath, @@ -36,13 +39,14 @@ function getExtensionMetadata(extensionPath: string, packageJson: PackageJSON): class ExtensionManager { private searchCache = new Map(); private _manifest: ExtensionManifestStore | undefined; + private _lastSearchTimestamp: Date | undefined; /** * Returns all extensions currently in the extension manifest */ - public getAll() { + public getAll(): ExtensionMetadata[] { const extensions = this.manifest.getExtensions(); - return Object.values(extensions); + return Object.values(extensions).filter(Boolean) as ExtensionMetadata[]; } /** @@ -58,6 +62,7 @@ class ExtensionManager { */ public async loadAll() { await this.seedBuiltinExtensions(); + await ensureDir(this.remoteDir); const extensions = Object.entries(this.manifest.getExtensions()); @@ -78,7 +83,12 @@ class ExtensionManager { log('Installing %s to %s', packageNameAndVersion, this.remoteDir); try { - const { stdout } = await npm('install', packageNameAndVersion, { '--prefix': this.remoteDir }); + const { stdout } = await npm( + 'install', + packageNameAndVersion, + { '--prefix': this.remoteDir }, + { cwd: this.remoteDir } + ); log('%s', stdout); @@ -147,7 +157,7 @@ class ExtensionManager { log('Removing %s', id); try { - const { stdout } = await npm('uninstall', id, { '--prefix': this.remoteDir }); + const { stdout } = await npm('uninstall', id, { '--prefix': this.remoteDir }, { cwd: this.remoteDir }); log('%s', stdout); @@ -163,30 +173,16 @@ class ExtensionManager { * @param query The search query */ public async search(query: string) { - const { stdout } = await npm('search', `keywords:botframework-composer extension ${query}`, { '--json': '' }); + await this.updateSearchCache(); - try { - const result = JSON.parse(stdout); - if (Array.isArray(result)) { - result.forEach((searchResult) => { - const { name, keywords = [], version, description, links } = searchResult; - if (keywords.includes('botframework-composer') && keywords.includes('extension')) { - const url = links?.npm ?? ''; - this.searchCache.set(name, { - id: name, - version, - description, - keywords, - url, - }); - } - }); - } - } catch (err) { - log('%O', err); - } + const results = Array.from(this.searchCache.values()).filter((result) => { + return ( + !this.find(result.id) && + [result.id, result.description, ...result.keywords].some((target) => target.includes(query)) + ); + }); - return Array.from(this.searchCache.values()); + return results; } /** @@ -286,6 +282,37 @@ class ExtensionManager { return process.env.COMPOSER_REMOTE_EXTENSIONS_DIR; } + + private async updateSearchCache() { + const timeout = new Date(new Date().getTime() - SEARCH_CACHE_TIMEOUT); + if (!this._lastSearchTimestamp || this._lastSearchTimestamp < timeout) { + const { stdout } = await npm('search', '', { + '--json': '', + '--searchopts': '"keywords:botframework-composer extension"', + }); + + try { + const result = JSON.parse(stdout); + if (Array.isArray(result)) { + result.forEach((searchResult) => { + const { name, keywords = [], version, description, links } = searchResult; + if (keywords.includes('botframework-composer') && keywords.includes('extension')) { + const url = links?.npm ?? ''; + this.searchCache.set(name, { + id: name, + version, + description, + keywords, + url, + }); + } + }); + } + } catch (err) { + log('%O', err); + } + } + } } const manager = new ExtensionManager(); diff --git a/Composer/packages/extension/src/types/extension.ts b/Composer/packages/extension/src/types/extension.ts index 8fceeda395..c13ecc9bdb 100644 --- a/Composer/packages/extension/src/types/extension.ts +++ b/Composer/packages/extension/src/types/extension.ts @@ -1,14 +1,19 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +export type ExtensionContributionPage = { + /** Id of cooresponding UI bundle. */ + bundleId: string; + /** Label to dispaly in nav. */ + label: string; + /** Optional icon to use in nav. Available icons [here](https://developer.microsoft.com/en-us/fluentui#/styles/web/icons). */ + icon?: string; + // when?: string; +}; + export type ExtensionContribution = { views?: { - page?: { - id: string; - name: string; - icon?: string; - when?: string; - }[]; + pages?: ExtensionContributionPage[]; publish?: { bundleId?: string; }; @@ -25,6 +30,8 @@ export type ExtensionMetadata = { id: string; /** name field from composer object in package.json, defaults to id */ name: string; + /** description field from package.json */ + description: string; /** currently installed version */ version: string; /** enabled or disabled */ diff --git a/Composer/packages/extension/src/utils/npm.ts b/Composer/packages/extension/src/utils/npm.ts index 043ff76bb4..e0fc6ebb11 100644 --- a/Composer/packages/extension/src/utils/npm.ts +++ b/Composer/packages/extension/src/utils/npm.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { spawn } from 'child_process'; +import { spawn, SpawnOptionsWithoutStdio } from 'child_process'; import logger from '../logger'; @@ -18,7 +18,7 @@ type NpmOptions = { }; function processOptions(opts: NpmOptions) { - return Object.entries({ '--no-fund': '', '--no-audit': '', ...opts }).map(([flag, value]) => { + return Object.entries({ '--no-fund': '', '--no-audit': '', '--quiet': '', ...opts }).map(([flag, value]) => { return value ? `${flag}=${value}` : flag; }); } @@ -28,9 +28,15 @@ function processOptions(opts: NpmOptions) { * @param `command` npm command to execute. * @param `args` cli arguments * @param `opts` cli flags + * @param `spawnOpts` options to pass to spawn command * @returns Object with stdout, stderr, and exit code from command */ -export async function npm(command: NpmCommand, args: string, opts: NpmOptions = {}): Promise { +export async function npm( + command: NpmCommand, + args: string, + opts: NpmOptions = {}, + spawnOpts: SpawnOptionsWithoutStdio = {} +): Promise { return new Promise((resolve, reject) => { const cmdOptions = processOptions(opts); const spawnArgs = [command, ...cmdOptions, args]; @@ -38,7 +44,7 @@ export async function npm(command: NpmCommand, args: string, opts: NpmOptions = let stdout = ''; let stderr = ''; - const proc = spawn('npm', spawnArgs); + const proc = spawn('npm', spawnArgs, { ...spawnOpts, shell: process.platform === 'win32' }); proc.stdout.on('data', (data) => { stdout += data; diff --git a/Composer/packages/server/src/controllers/__tests__/extensions.test.ts b/Composer/packages/server/src/controllers/__tests__/extensions.test.ts index bb220dfea7..bcaca8c5bf 100644 --- a/Composer/packages/server/src/controllers/__tests__/extensions.test.ts +++ b/Composer/packages/server/src/controllers/__tests__/extensions.test.ts @@ -3,6 +3,7 @@ import { Request, Response } from 'express'; import { ExtensionManager } from '@bfc/extension'; +import { ExtensionMetadata } from '@bfc/extension/lib/types/extension'; import * as ExtensionsController from '../extensions'; @@ -31,12 +32,121 @@ beforeEach(() => { } as unknown) as Response; }); +const mockExtension1 = { + id: 'remoteExtension1', + name: 'Extension 1', + version: '1.0.0', + enabled: true, + path: '/some/path/extension1', + description: 'description text', + bundles: [ + { + id: 'page1', + path: 'some/path', + }, + ], + contributes: { + views: { + publish: { + bundleId: '', + }, + pages: [ + { + bundleId: 'page1', + label: 'Page 1', + icon: 'SomeIcon', + }, + ], + }, + }, +}; + +const allExtensions: ExtensionMetadata[] = [ + mockExtension1, + { + id: 'builtinExtension2', + name: 'Extension 2', + version: '1.0.0', + path: '/some/path/extension2', + description: 'description text', + enabled: true, + builtIn: true, + bundles: [ + { + id: 'page2', + path: 'some/path', + }, + ], + contributes: { + views: { + publish: { + bundleId: '', + }, + pages: [ + { + bundleId: 'page2', + label: 'Page 2', + icon: 'SomeOtherIcon', + }, + ], + }, + }, + }, +]; describe('listing all extensions', () => { - it('returns all extensions', () => { - (ExtensionManager.getAll as jest.Mock).mockReturnValue(['list', 'of', 'extensions']); + it('returns all extensions with sensitive properties removed', () => { + (ExtensionManager.getAll as jest.Mock).mockReturnValue(allExtensions); ExtensionsController.listExtensions(req, res); - expect(res.json).toHaveBeenCalledWith(['list', 'of', 'extensions']); + expect(res.json).toHaveBeenCalledWith([ + { + id: 'remoteExtension1', + name: 'Extension 1', + version: '1.0.0', + enabled: true, + description: 'description text', + contributes: { + views: { + publish: { + bundleId: '', + }, + pages: [ + { + bundleId: 'page1', + label: 'Page 1', + icon: 'SomeIcon', + }, + ], + }, + }, + bundles: undefined, + path: undefined, + }, + { + id: 'builtinExtension2', + name: 'Extension 2', + version: '1.0.0', + enabled: true, + builtIn: true, + description: 'description text', + contributes: { + views: { + publish: { + bundleId: '', + }, + pages: [ + { + bundleId: 'page2', + label: 'Page 2', + icon: 'SomeOtherIcon', + }, + ], + }, + }, + bundles: undefined, + path: undefined, + }, + ]); }); }); @@ -63,11 +173,15 @@ describe('adding an extension', () => { }); it('returns the extension', async () => { - (ExtensionManager.find as jest.Mock).mockReturnValue('installed extension'); + (ExtensionManager.find as jest.Mock).mockReturnValue(mockExtension1); await ExtensionsController.addExtension({ body: { id } } as Request, res); expect(ExtensionManager.find).toHaveBeenCalledWith(id); - expect(res.json).toHaveBeenCalledWith('installed extension'); + expect(res.json).toHaveBeenCalledWith({ + ...mockExtension1, + bundles: undefined, + path: undefined, + }); }); }); @@ -91,7 +205,7 @@ describe('toggling an extension', () => { const id = 'extension-id'; beforeEach(() => { - (ExtensionManager.find as jest.Mock).mockReturnValue('found extension'); + (ExtensionManager.find as jest.Mock).mockReturnValue(mockExtension1); }); it('can enable an extension', async () => { @@ -110,7 +224,11 @@ describe('toggling an extension', () => { it('returns the updated extension', async () => { await ExtensionsController.toggleExtension({ body: { id, enabled: true } } as Request, res); - expect(res.json).toBeCalledWith('found extension'); + expect(res.json).toHaveBeenCalledWith({ + ...mockExtension1, + bundles: undefined, + path: undefined, + }); }); }); }); @@ -135,7 +253,7 @@ describe('removing an extension', () => { const id = 'extension-id'; beforeEach(() => { - (ExtensionManager.find as jest.Mock).mockReturnValue('found extension'); + (ExtensionManager.find as jest.Mock).mockReturnValue(mockExtension1); }); it('removes the extension', async () => { @@ -144,10 +262,16 @@ describe('removing an extension', () => { }); it('returns the list of extensions', async () => { - (ExtensionManager.getAll as jest.Mock).mockReturnValue(['list', 'of', 'extensions']); + (ExtensionManager.getAll as jest.Mock).mockReturnValue([mockExtension1]); await ExtensionsController.removeExtension({ body: { id } } as Request, res); - expect(res.json).toHaveBeenCalledWith(['list', 'of', 'extensions']); + expect(res.json).toHaveBeenCalledWith([ + { + ...mockExtension1, + bundles: undefined, + path: undefined, + }, + ]); }); }); }); diff --git a/Composer/packages/server/src/controllers/extensions.ts b/Composer/packages/server/src/controllers/extensions.ts index 3ab9d85644..67aff9aa02 100644 --- a/Composer/packages/server/src/controllers/extensions.ts +++ b/Composer/packages/server/src/controllers/extensions.ts @@ -3,6 +3,7 @@ import { Request, Response } from 'express'; import { ExtensionManager } from '@bfc/extension'; +import { ExtensionMetadata } from '@bfc/extension/lib/types/extension'; interface AddExtensionRequest extends Request { body: { @@ -44,8 +45,11 @@ interface ExtensionFetchRequest extends Request { }; } +const presentExtension = (e?: ExtensionMetadata) => (e ? { ...e, bundles: undefined, path: undefined } : undefined); + export async function listExtensions(req: Request, res: Response) { - res.json(ExtensionManager.getAll()); // might need to have this list all enabled extensions ? + const extensions = ExtensionManager.getAll().map(presentExtension); + res.json(extensions); } export async function addExtension(req: AddExtensionRequest, res: Response) { @@ -58,7 +62,8 @@ export async function addExtension(req: AddExtensionRequest, res: Response) { await ExtensionManager.installRemote(id, version); await ExtensionManager.load(id); - res.json(ExtensionManager.find(id)); + const extension = ExtensionManager.find(id); + res.json(presentExtension(extension)); } export async function toggleExtension(req: ToggleExtensionRequest, res: Response) { @@ -80,7 +85,8 @@ export async function toggleExtension(req: ToggleExtensionRequest, res: Response await ExtensionManager.disable(id); } - res.json(ExtensionManager.find(id)); + const extension = ExtensionManager.find(id); + res.json(presentExtension(extension)); } export async function removeExtension(req: RemoveExtensionRequest, res: Response) { @@ -97,7 +103,7 @@ export async function removeExtension(req: RemoveExtensionRequest, res: Response } await ExtensionManager.remove(id); - res.json(ExtensionManager.getAll()); + res.json(ExtensionManager.getAll().map(presentExtension)); } export async function searchExtensions(req: SearchExtensionsRequest, res: Response) { diff --git a/Composer/plugins/sample-ui-plugin/README.md b/Composer/plugins/sample-ui-plugin/README.md index 30c2abd40d..f9b56db788 100644 --- a/Composer/plugins/sample-ui-plugin/README.md +++ b/Composer/plugins/sample-ui-plugin/README.md @@ -15,20 +15,28 @@ Below, we will explain how you, as a developer, can author your own extension th The first thing that you need to do when authoring a Composer extension is to configure the `package.json` properly. In order to do this, you will need to fill out the following top-level properties in the `package.json`: -**`extendsComposer`** - boolean - -This property must be set to `true` in order for Composer to recognize it as an extension. - --- **`composer`** - object -This property will contain the rest of the configuration options for your Composer extension. This will specify things like what contribution points your extension will host custom UI at, and which bundled JavaScript files (your React applications) to load in order to do so. +This property will contain the configuration options for your Composer extension. This will specify things like what contribution points your extension will host custom UI at, and which bundled JavaScript files (your React applications) to load in order to do so. In the future, we expect the possibilities of this property to grow and change. --- +**`composer.name`** - string + +The name of the plugin. Defaults to the package name. + +--- + +**`composer.enabled`** - boolean + +Only used for builtin extensions. Composer will not load the extension if this is set to false. + +--- + **`composer.bundles`** - array The `composer.bundles` property is an array of objects specifying which bundles your extension will provide to Composer in order to host your custom UI. Each entry in the array will specify an `id` for the bundle, and also a relative `path` to the bundle (relative to your `package.json`). @@ -60,7 +68,7 @@ The `composer.contributes.views` property is an object that allows you to specif Each key inside of the `views` property represents one of the contribution points that an extension can provide custom UI for. -The current valid contribution points are: `page` and `publish`. More will be available in the future. +The current valid contribution points are: `pages` and `publish`. More will be available in the future. `package.json` ``` @@ -69,9 +77,9 @@ The current valid contribution points are: `page` and `publish`. More will be av ... "composer": { "views": { - "page": { + "pages": [{ // specify page contribution point configration here - }, + }], "publish": { // specify publish contribution point configuration here } @@ -80,7 +88,7 @@ The current valid contribution points are: `page` and `publish`. More will be av } ``` -Each `view.` property will specify a `bundleId` property that correlates to the `id` property of the desired React app bundle to display at that contribution point as specified in the `composer.bundles` array. +Each `view.` property will specify an `bundleId` property that correlates to the `id` property of the desired React app bundle to display at that contribution point as specified in the `composer.bundles` array. Adding on to the example that we used in the `composer.bundles` section: @@ -109,10 +117,38 @@ Adding on to the example that we used in the `composer.bundles` section: } ``` -Depending on the contribution point, the `view.` property might also allow other configuration properties besides `bundleId` that will affect each contribution point differently. +Depending on the contribution point, the `view.` property might also allow other configuration properties besides `id` that will affect each contribution point differently. > Look at `/sample-ui-plugin/package.json` as an example +--- + +**`composer.contributes.views.pages`** -- array + +This is an array containing your extension's page contributions. Composer will add a link to the left nav with the route `/page/`. + +Each page must define an `bundleId` and `label` and can optionally define `icon`. Available icons can be found [here](https://developer.microsoft.com/en-us/fluentui#/styles/web/icons). + +```json +{ + "composer": { + "bundles": [{ + "id": "page-id", + "path": "some/path.js" + }], + "contributes": { + "views": { + "pages": [{ + "bundleId": "page-id", + "label": "My Page", + "icon": "Airplane" + }] + } + } + } +} +``` + ## Bundling Extension Client Code Currently, Composer only allows the hosting of bundled React applications. diff --git a/Composer/plugins/sample-ui-plugin/package.json b/Composer/plugins/sample-ui-plugin/package.json index b5942c14de..698723192b 100644 --- a/Composer/plugins/sample-ui-plugin/package.json +++ b/Composer/plugins/sample-ui-plugin/package.json @@ -25,10 +25,12 @@ "publish": { "bundleId": "publish" }, - "page-DISABLED": { - "bundleId": "page", - "label": "Sample UI Plugin" - } + "page-DISABLED": [ + { + "bundleId": "page", + "label": "Sample UI Plugin" + } + ] } } },