From 4a6f8baae914cf24bf550d98c550bc9f4a2d11ea Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Wed, 23 Sep 2020 13:35:26 -0700 Subject: [PATCH 01/20] re-enable extensions page --- .../client/src/pages/setting/SettingsPage.tsx | 2 +- .../pages/setting/extensions/Extensions.tsx | 136 ++------------- .../extensions/InstallExtensionDialog.tsx | 159 ++++++++++++++++++ .../client/src/pages/setting/router.tsx | 4 +- .../src/recoilModel/dispatchers/extensions.ts | 4 +- .../packages/extension/src/manager/manager.ts | 9 +- Composer/packages/extension/src/utils/npm.ts | 14 +- 7 files changed, 196 insertions(+), 132 deletions(-) create mode 100644 Composer/packages/client/src/pages/setting/extensions/InstallExtensionDialog.tsx 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/Extensions.tsx b/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx index 3af2af1b22..751d4dba55 100644 --- a/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx +++ b/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx @@ -3,7 +3,7 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; -import React, { useEffect, useState, useCallback } from 'react'; +import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { RouteComponentProps } from '@reach/router'; import { DetailsList, @@ -12,55 +12,27 @@ import { IColumn, CheckboxVisibility, } from 'office-ui-fabric-react/lib/DetailsList'; -import { DefaultButton, PrimaryButton } from 'office-ui-fabric-react/lib/Button'; +import { DefaultButton } from 'office-ui-fabric-react/lib/Button'; 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 { ExtensionConfig } from '../../../recoilModel/types'; import { Toolbar, IToolbarItem } from '../../../components/Toolbar'; -import httpClient from '../../../utils/httpUtil'; import { dispatcherState, extensionsState } from '../../../recoilModel'; +import { InstallExtensionDialog } from './InstallExtensionDialog'; + const Extensions: React.FC = () => { const { fetchExtensions, toggleExtension, addExtension, removeExtension } = useRecoilValue(dispatcherState); const extensions = useRecoilValue(extensionsState); const [showNewModal, setShowNewModal] = useState(false); - const [extensionName, setExtensionName] = useState(null); - const [extensionVersion, setExtensionVersion] = useState(null); - const [matchingExtensions, setMatchingExtensions] = useState([]); - const [selectedExtension, setSelectedExtension] = useState(); + + const remoteExtensions = useMemo(() => extensions.filter((e) => !e.builtIn), [extensions]); 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', @@ -109,53 +81,8 @@ const Extensions: React.FC = () => { }, ]; - const matchingColumns: IColumn[] = [ - { - key: 'name', - name: formatMessage('Name'), - minWidth: 100, - maxWidth: 150, - onRender: (item: any) => { - return {item.id}; - }, - }, - { - key: 'description', - name: formatMessage('Description'), - minWidth: 100, - maxWidth: 300, - isMultiline: true, - onRender: (item: any) => { - return
{item.description}
; - }, - }, - { - key: 'version', - name: formatMessage('Version'), - minWidth: 30, - maxWidth: 100, - onRender: (item: any) => { - return {item.version}; - }, - }, - { - key: 'url', - name: formatMessage('Url'), - minWidth: 100, - maxWidth: 100, - onRender: (item: any) => { - return item.url ? ( - - View on npm - - ) : null; - }, - }, - ]; - const toolbarItems: IToolbarItem[] = [ - // TODO (toanzian / abrown): re-enable once remote extensions are supported - /*{ + { type: 'action', text: formatMessage('Add'), buttonProps: { @@ -167,61 +94,28 @@ const Extensions: React.FC = () => { }, }, align: 'left', - },*/ + }, ]; - const submit = useCallback(() => { - if (selectedExtension && extensionVersion) { - addExtension(selectedExtension.id, extensionVersion); + const submit = async (selectedExtension) => { + if (selectedExtension) { + await addExtension(selectedExtension.id); setShowNewModal(false); - setExtensionName(null); - setExtensionVersion(null); - setSelectedExtension(null); } - }, [selectedExtension, extensionVersion]); + }; return (
+ {/* only show when extensions are installed */} - + setShowNewModal(false)} onInstall={submit} />
); }; 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..dced6dcca8 --- /dev/null +++ b/Composer/packages/client/src/pages/setting/extensions/InstallExtensionDialog.tsx @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { useState, useEffect, useCallback } from 'react'; +import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { DefaultButton, PrimaryButton } from 'office-ui-fabric-react/lib/Button'; +import { + DetailsList, + DetailsListLayoutMode, + SelectionMode, + IColumn, + CheckboxVisibility, +} from 'office-ui-fabric-react/lib/DetailsList'; +import axios from 'axios'; +import formatMessage from 'format-message'; + +import httpClient from '../../../utils/httpUtil'; + +// TODO: extract to shared? +type ExtensionSearchResult = { + id: string; + keywords: string[]; + version: string; + description: string; + url: string; +}; + +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 [matchingExtensions, setMatchingExtensions] = useState([]); + const [selectedExtension, setSelectedExtension] = useState(null); + + useEffect(() => { + if (searchQuery !== null) { + const source = axios.CancelToken.source(); + + const timer = setTimeout(() => { + httpClient + .get(`/extensions/search?q=${searchQuery}`, { cancelToken: source.token }) + .then((res) => { + setMatchingExtensions(res.data); + }) + .catch((err) => { + if (!axios.isCancel(err)) { + // TODO: abrown - what to do on error? + // eslint-disable-next-line no-console + console.error(err); + } + }); + }, 200); + + return () => { + source.cancel('User interruption'); + clearTimeout(timer); + }; + } + }, [searchQuery]); + + const matchingColumns: 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 ? ( + + View on npm + + ) : null; + }, + }, + ]; + + const onSubmit = async () => { + if (selectedExtension) { + await onInstall(selectedExtension); + setSearchQuery(null); + setMatchingExtensions([]); + setSelectedExtension(null); + } + }; + + 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..a773e1bbcc 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/extensions.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/extensions.ts @@ -21,10 +21,10 @@ 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) => { diff --git a/Composer/packages/extension/src/manager/manager.ts b/Composer/packages/extension/src/manager/manager.ts index 15fa510409..23d60aa48e 100644 --- a/Composer/packages/extension/src/manager/manager.ts +++ b/Composer/packages/extension/src/manager/manager.ts @@ -78,7 +78,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 +152,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); diff --git a/Composer/packages/extension/src/utils/npm.ts b/Composer/packages/extension/src/utils/npm.ts index 043ff76bb4..b72c98e01f 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); proc.stdout.on('data', (data) => { stdout += data; From 7aa168e2539db6f5b429b217d5922ac5d0e9a930 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Wed, 23 Sep 2020 14:43:50 -0700 Subject: [PATCH 02/20] support multiple extension pages --- Composer/packages/client/src/utils/hooks.ts | 6 +- .../packages/client/src/utils/pageLinks.ts | 2 +- Composer/plugins/sample-ui-plugin/README.md | 58 +++++++++++++++---- .../plugins/sample-ui-plugin/package.json | 10 ++-- 4 files changed, 57 insertions(+), 19 deletions(-) diff --git a/Composer/packages/client/src/utils/hooks.ts b/Composer/packages/client/src/utils/hooks.ts index 8df4ab3cd3..31aaa7de03 100644 --- a/Composer/packages/client/src/utils/hooks.ts +++ b/Composer/packages/client/src/utils/hooks.ts @@ -29,9 +29,9 @@ 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[]); diff --git a/Composer/packages/client/src/utils/pageLinks.ts b/Composer/packages/client/src/utils/pageLinks.ts index 1d90bcef31..338ccc806f 100644 --- a/Composer/packages/client/src/utils/pageLinks.ts +++ b/Composer/packages/client/src/utils/pageLinks.ts @@ -5,7 +5,7 @@ import formatMessage from 'format-message'; export const topLinks = ( projectId: string, openedDialogId: string, - pluginPages: { id: string; label: string; icon?: string; when?: string }[] + pluginPages: { id: string; label: string; icon?: string }[] ) => { const botLoaded = !!projectId; let links = [ diff --git a/Composer/plugins/sample-ui-plugin/README.md b/Composer/plugins/sample-ui-plugin/README.md index 30c2abd40d..aae183033a 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 `id` 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: @@ -102,17 +110,45 @@ Adding on to the example that we used in the `composer.bundles` section: // telling Composer to display bundled React app at ./dist/bundle.js // inside of the publish contribution point surface - "bundleId": "my-bundle" + "id": "my-bundle" } } } } ``` -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 `id` 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": [{ + "id": "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" + } + ] } } }, From dbfd671c8e00b632f5fdfb5cf5e630d0f5361804 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Thu, 24 Sep 2020 08:33:33 -0700 Subject: [PATCH 03/20] revert to using bundleId for page config --- .../packages/client/src/recoilModel/types.ts | 17 ++++++++++++++++- Composer/packages/client/src/utils/hooks.ts | 4 +++- Composer/packages/client/src/utils/pageLinks.ts | 10 ++++------ Composer/plugins/sample-ui-plugin/README.md | 10 +++++----- 4 files changed, 28 insertions(+), 13 deletions(-) diff --git a/Composer/packages/client/src/recoilModel/types.ts b/Composer/packages/client/src/recoilModel/types.ts index 38c3748f73..33bedf89f7 100644 --- a/Composer/packages/client/src/recoilModel/types.ts +++ b/Composer/packages/client/src/recoilModel/types.ts @@ -39,6 +39,16 @@ 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; @@ -50,7 +60,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 31aaa7de03..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'; @@ -34,7 +36,7 @@ export const useLinks = () => { 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 338ccc806f..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 }[] -) => { +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/plugins/sample-ui-plugin/README.md b/Composer/plugins/sample-ui-plugin/README.md index aae183033a..f9b56db788 100644 --- a/Composer/plugins/sample-ui-plugin/README.md +++ b/Composer/plugins/sample-ui-plugin/README.md @@ -88,7 +88,7 @@ The current valid contribution points are: `pages` and `publish`. More will be a } ``` -Each `view.` property will specify an `id` 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: @@ -110,7 +110,7 @@ Adding on to the example that we used in the `composer.bundles` section: // telling Composer to display bundled React app at ./dist/bundle.js // inside of the publish contribution point surface - "id": "my-bundle" + "bundleId": "my-bundle" } } } @@ -125,9 +125,9 @@ Depending on the contribution point, the `view.` property mi **`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/`. +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 `id` and `label` and can optionally define `icon`. Available icons can be found [here](https://developer.microsoft.com/en-us/fluentui#/styles/web/icons). +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 { @@ -139,7 +139,7 @@ Each page must define an `id` and `label` and can optionally define `icon`. Avai "contributes": { "views": { "pages": [{ - "id": "page-id", + "bundleId": "page-id", "label": "My Page", "icon": "Airplane" }] From 921d9ab396e610a2d4acd33cce434bf9b5bc4bb2 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Thu, 24 Sep 2020 09:02:58 -0700 Subject: [PATCH 04/20] omit sensitve properties from extension apis --- .../packages/extension/src/manager/manager.ts | 4 +- .../packages/extension/src/types/extension.ts | 17 ++- .../controllers/__tests__/extensions.test.ts | 136 ++++++++++++++++-- .../server/src/controllers/extensions.ts | 14 +- 4 files changed, 149 insertions(+), 22 deletions(-) diff --git a/Composer/packages/extension/src/manager/manager.ts b/Composer/packages/extension/src/manager/manager.ts index 23d60aa48e..cc0995e329 100644 --- a/Composer/packages/extension/src/manager/manager.ts +++ b/Composer/packages/extension/src/manager/manager.ts @@ -40,9 +40,9 @@ class ExtensionManager { /** * 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[]; } /** diff --git a/Composer/packages/extension/src/types/extension.ts b/Composer/packages/extension/src/types/extension.ts index 8fceeda395..b235425812 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; }; diff --git a/Composer/packages/server/src/controllers/__tests__/extensions.test.ts b/Composer/packages/server/src/controllers/__tests__/extensions.test.ts index bb220dfea7..93f7fcd0f5 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,113 @@ beforeEach(() => { } as unknown) as Response; }); +const mockExtension1 = { + id: 'remoteExtension1', + name: 'Extension 1', + version: '1.0.0', + enabled: true, + path: '/some/path/extension1', + 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', + 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, + contributes: { + views: { + publish: { + bundleId: '', + }, + pages: [ + { + bundleId: 'page1', + label: 'Page 1', + icon: 'SomeIcon', + }, + ], + }, + }, + }, + { + id: 'builtinExtension2', + name: 'Extension 2', + version: '1.0.0', + enabled: true, + builtIn: true, + contributes: { + views: { + publish: { + bundleId: '', + }, + pages: [ + { + bundleId: 'page2', + label: 'Page 2', + icon: 'SomeOtherIcon', + }, + ], + }, + }, + }, + ]); }); }); @@ -63,11 +165,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 +197,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 +216,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 +245,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 +254,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) { From 504eb5927eb11c52972d969023a24e79a49789a3 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Thu, 24 Sep 2020 09:23:05 -0700 Subject: [PATCH 05/20] fix adding new extension to state --- .../client/src/recoilModel/dispatchers/extensions.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Composer/packages/client/src/recoilModel/dispatchers/extensions.ts b/Composer/packages/client/src/recoilModel/dispatchers/extensions.ts index a773e1bbcc..b7196583f5 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/extensions.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/extensions.ts @@ -29,16 +29,15 @@ export const extensionsDispatcher = () => { 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); From 9815cd5ffb29626fb8ddfe2f4eb209165ccb079e Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Thu, 24 Sep 2020 10:33:15 -0700 Subject: [PATCH 06/20] add shimmer list when doing remote calls --- .../pages/setting/extensions/Extensions.tsx | 38 +++++++++++-------- .../extensions/InstallExtensionDialog.tsx | 14 +++++-- 2 files changed, 33 insertions(+), 19 deletions(-) diff --git a/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx b/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx index 751d4dba55..67743231ae 100644 --- a/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx +++ b/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx @@ -3,18 +3,18 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; -import React, { useEffect, useState, useCallback, useMemo } from 'react'; +import React, { useEffect, useState } from 'react'; import { RouteComponentProps } from '@reach/router'; import { - DetailsList, DetailsListLayoutMode, SelectionMode, IColumn, CheckboxVisibility, } from 'office-ui-fabric-react/lib/DetailsList'; +import { ShimmeredDetailsList } from 'office-ui-fabric-react/lib/ShimmeredDetailsList'; import { DefaultButton } from 'office-ui-fabric-react/lib/Button'; import formatMessage from 'format-message'; -import { useRecoilValue } from 'recoil'; +import { useRecoilValue, selector } from 'recoil'; import { ExtensionConfig } from '../../../recoilModel/types'; import { Toolbar, IToolbarItem } from '../../../components/Toolbar'; @@ -22,13 +22,17 @@ import { dispatcherState, extensionsState } from '../../../recoilModel'; import { InstallExtensionDialog } from './InstallExtensionDialog'; +const remoteExtensionsState = selector({ + key: 'remoteExtensions', + get: ({ get }) => get(extensionsState).filter((e) => !e.builtIn), +}); + const Extensions: React.FC = () => { const { fetchExtensions, toggleExtension, addExtension, removeExtension } = useRecoilValue(dispatcherState); - const extensions = useRecoilValue(extensionsState); + const extensions = useRecoilValue(remoteExtensionsState); + const [isAdding, setIsAdding] = useState(false); const [showNewModal, setShowNewModal] = useState(false); - const remoteExtensions = useMemo(() => extensions.filter((e) => !e.builtIn), [extensions]); - useEffect(() => { fetchExtensions(); }, []); @@ -99,22 +103,26 @@ const Extensions: React.FC = () => { const submit = async (selectedExtension) => { if (selectedExtension) { - await addExtension(selectedExtension.id); + setIsAdding(true); setShowNewModal(false); + await addExtension(selectedExtension.id); + setIsAdding(false); } }; return (
- {/* only show when extensions are installed */} - + {(isAdding || extensions.length > 0) && ( + + )} setShowNewModal(false)} onInstall={submit} />
); diff --git a/Composer/packages/client/src/pages/setting/extensions/InstallExtensionDialog.tsx b/Composer/packages/client/src/pages/setting/extensions/InstallExtensionDialog.tsx index dced6dcca8..ec9f17115c 100644 --- a/Composer/packages/client/src/pages/setting/extensions/InstallExtensionDialog.tsx +++ b/Composer/packages/client/src/pages/setting/extensions/InstallExtensionDialog.tsx @@ -3,17 +3,17 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; -import React, { useState, useEffect, useCallback } from 'react'; +import React, { useState, useEffect } from 'react'; import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; import { TextField } from 'office-ui-fabric-react/lib/TextField'; import { DefaultButton, PrimaryButton } from 'office-ui-fabric-react/lib/Button'; import { - DetailsList, DetailsListLayoutMode, SelectionMode, IColumn, CheckboxVisibility, } from 'office-ui-fabric-react/lib/DetailsList'; +import { ShimmeredDetailsList } from 'office-ui-fabric-react/lib/ShimmeredDetailsList'; import axios from 'axios'; import formatMessage from 'format-message'; @@ -39,18 +39,22 @@ const InstallExtensionDialog: React.FC = (props) => const [searchQuery, setSearchQuery] = useState(null); const [matchingExtensions, setMatchingExtensions] = useState([]); const [selectedExtension, setSelectedExtension] = useState(null); + const [isSearching, setIsSearching] = useState(false); useEffect(() => { if (searchQuery !== null) { const source = axios.CancelToken.source(); const timer = setTimeout(() => { + setIsSearching(true); httpClient .get(`/extensions/search?q=${searchQuery}`, { cancelToken: source.token }) .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 @@ -135,13 +139,15 @@ const InstallExtensionDialog: React.FC = (props) => value={searchQuery ?? ''} onChange={(_e, val) => setSearchQuery(val ?? null)} /> - {matchingExtensions.length > 0 && ( - 0 || isSearching) && ( + setSelectedExtension(item)} /> )} From 5b08eb57c51810241e9e12a5e9d39c699c08d385 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Thu, 24 Sep 2020 11:00:43 -0700 Subject: [PATCH 07/20] show extension display name --- .../packages/client/src/pages/setting/extensions/Extensions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx b/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx index 67743231ae..b00e09e547 100644 --- a/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx +++ b/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx @@ -44,7 +44,7 @@ const Extensions: React.FC = () => { minWidth: 100, maxWidth: 150, onRender: (item: ExtensionConfig) => { - return {item.id}; + return {item.name}; }, }, { From e5a1082fb74bf42651014a8eaf137e1353529d34 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Thu, 24 Sep 2020 11:02:01 -0700 Subject: [PATCH 08/20] update search mechanics Update cache with all results of keyword search and then match on query in memory. --- .../packages/extension/src/manager/manager.ts | 64 ++++++++++++------- 1 file changed, 42 insertions(+), 22 deletions(-) diff --git a/Composer/packages/extension/src/manager/manager.ts b/Composer/packages/extension/src/manager/manager.ts index cc0995e329..5f10145bf8 100644 --- a/Composer/packages/extension/src/manager/manager.ts +++ b/Composer/packages/extension/src/manager/manager.ts @@ -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, @@ -36,6 +38,7 @@ 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 @@ -168,30 +171,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; } /** @@ -291,6 +280,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(); From 67e40eeab4dc5317d482902700cd3d9368e30dd8 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Thu, 24 Sep 2020 14:33:23 -0700 Subject: [PATCH 09/20] ensure remote extensions dir exists --- Composer/packages/extension/src/manager/manager.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Composer/packages/extension/src/manager/manager.ts b/Composer/packages/extension/src/manager/manager.ts index 5f10145bf8..a361eb4427 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, existsSync, mkdir } from 'fs-extra'; import { ExtensionContext } from '../extensionContext'; import logger from '../logger'; @@ -61,6 +61,7 @@ class ExtensionManager { */ public async loadAll() { await this.seedBuiltinExtensions(); + await this.ensureRemoteDir(); const extensions = Object.entries(this.manifest.getExtensions()); @@ -311,6 +312,12 @@ class ExtensionManager { } } } + + private async ensureRemoteDir() { + if (!existsSync(this.remoteDir)) { + await mkdir(this.remoteDir); + } + } } const manager = new ExtensionManager(); From 9af004d2440ef8aa3244b09a8e346806c6db3407 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Thu, 24 Sep 2020 16:41:52 -0700 Subject: [PATCH 10/20] fix api call to get plugin bundle --- .../packages/client/src/components/PluginHost/PluginHost.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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); }); } }; From 9097571110f5e45d6940604cc10c1da6b075c352 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Thu, 24 Sep 2020 16:44:44 -0700 Subject: [PATCH 11/20] spawn npm with shell on windows platforms --- Composer/packages/extension/src/utils/npm.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Composer/packages/extension/src/utils/npm.ts b/Composer/packages/extension/src/utils/npm.ts index b72c98e01f..e0fc6ebb11 100644 --- a/Composer/packages/extension/src/utils/npm.ts +++ b/Composer/packages/extension/src/utils/npm.ts @@ -44,7 +44,7 @@ export async function npm( let stdout = ''; let stderr = ''; - const proc = spawn('npm', spawnArgs, spawnOpts); + const proc = spawn('npm', spawnArgs, { ...spawnOpts, shell: process.platform === 'win32' }); proc.stdout.on('data', (data) => { stdout += data; From 191b4057215f65b415e4846f53e8dca2de529083 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Fri, 25 Sep 2020 10:06:13 -0700 Subject: [PATCH 12/20] improve searching UX --- .../extensions/ExtensionSearchResults.tsx | 134 ++++++++++++++++++ .../extensions/InstallExtensionDialog.tsx | 130 +++++------------ 2 files changed, 170 insertions(+), 94 deletions(-) create mode 100644 Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx 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..b351cd8f73 --- /dev/null +++ b/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx @@ -0,0 +1,134 @@ +// 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, +} 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 ? ( + + 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 ( +
+

No search results

+
+ ); + } + + if (defaultRender) { + return defaultRender(rowProps); + } + + return null; + }} + /> + +
+ ); +}; + +export { ExtensionSearchResults }; diff --git a/Composer/packages/client/src/pages/setting/extensions/InstallExtensionDialog.tsx b/Composer/packages/client/src/pages/setting/extensions/InstallExtensionDialog.tsx index ec9f17115c..9065d92ef0 100644 --- a/Composer/packages/client/src/pages/setting/extensions/InstallExtensionDialog.tsx +++ b/Composer/packages/client/src/pages/setting/extensions/InstallExtensionDialog.tsx @@ -5,28 +5,14 @@ import { jsx } from '@emotion/core'; import React, { useState, useEffect } from 'react'; import { Dialog, DialogType, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; -import { TextField } from 'office-ui-fabric-react/lib/TextField'; +import { SearchBox } from 'office-ui-fabric-react/lib/SearchBox'; import { DefaultButton, PrimaryButton } from 'office-ui-fabric-react/lib/Button'; -import { - DetailsListLayoutMode, - SelectionMode, - IColumn, - CheckboxVisibility, -} from 'office-ui-fabric-react/lib/DetailsList'; -import { ShimmeredDetailsList } from 'office-ui-fabric-react/lib/ShimmeredDetailsList'; -import axios from 'axios'; +import axios, { CancelToken } from 'axios'; import formatMessage from 'format-message'; import httpClient from '../../../utils/httpUtil'; -// TODO: extract to shared? -type ExtensionSearchResult = { - id: string; - keywords: string[]; - version: string; - description: string; - url: string; -}; +import { ExtensionSearchResult, ExtensionSearchResults } from './ExtensionSearchResults'; type InstallExtensionDialogProps = { isOpen: boolean; @@ -37,30 +23,39 @@ type InstallExtensionDialogProps = { const InstallExtensionDialog: React.FC = (props) => { const { isOpen, onDismiss, onInstall } = props; const [searchQuery, setSearchQuery] = useState(null); - const [matchingExtensions, setMatchingExtensions] = useState([]); 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(() => { - setIsSearching(true); - httpClient - .get(`/extensions/search?q=${searchQuery}`, { cancelToken: source.token }) - .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); - } - }); + performSearch(searchQuery, source.token); }, 200); return () => { @@ -70,56 +65,10 @@ const InstallExtensionDialog: React.FC = (props) => } }, [searchQuery]); - const matchingColumns: 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 ? ( - - View on npm - - ) : null; - }, - }, - ]; - const onSubmit = async () => { if (selectedExtension) { await onInstall(selectedExtension); - setSearchQuery(null); - setMatchingExtensions([]); - setSelectedExtension(null); + performSearch(searchQuery ?? ''); } }; @@ -134,23 +83,16 @@ const InstallExtensionDialog: React.FC = (props) => onDismiss={onDismiss} >
- setSearchQuery(val ?? null)} /> - {(matchingExtensions.length > 0 || isSearching) && ( - setSelectedExtension(item)} - /> - )} + setSelectedExtension(e)} + />
Cancel From 03f489b3dc14682ac0a3f8dc4fc6727b1154f700 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Fri, 25 Sep 2020 13:18:27 -0700 Subject: [PATCH 13/20] show message when no extensions or search results --- .../extensions/ExtensionSearchResults.tsx | 2 + .../pages/setting/extensions/Extensions.tsx | 124 +++++++++++++----- .../packages/client/src/recoilModel/types.ts | 1 + 3 files changed, 94 insertions(+), 33 deletions(-) diff --git a/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx b/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx index b351cd8f73..a8bf884046 100644 --- a/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx +++ b/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx @@ -10,6 +10,7 @@ import { 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'; @@ -96,6 +97,7 @@ const ExtensionSearchResults: React.FC = (props) => 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(remoteExtensionsState); - const [isAdding, setIsAdding] = useState(false); + // if a string, its the id of the extension being updated + const [isUpdating, setIsUpdating] = useState(false); const [showNewModal, setShowNewModal] = useState(false); useEffect(() => { @@ -42,44 +51,65 @@ const Extensions: React.FC = () => { key: 'name', name: formatMessage('Name'), minWidth: 100, - maxWidth: 150, - onRender: (item: ExtensionConfig) => { - return {item.name}; - }, + maxWidth: 250, + isResizable: true, + isRowHeader: true, + fieldName: 'name', + }, + { + key: 'description', + name: formatMessage('Description'), + minWidth: 150, + maxWidth: 500, + isResizable: true, + isCollapsible: true, + isMultiline: true, + fieldName: 'description', }, { key: 'version', name: formatMessage('Version'), - minWidth: 30, + minWidth: 100, maxWidth: 100, - onRender: (item: ExtensionConfig) => { - return {item.version}; - }, + isResizable: true, + fieldName: 'version', }, { key: 'enabled', name: formatMessage('Enabled'), - minWidth: 30, + minWidth: 100, maxWidth: 150, + isResizable: true, onRender: (item: ExtensionConfig) => { - const text = item.enabled ? formatMessage('Disable') : formatMessage('Enable'); return ( - toggleExtension(item.id, !item.enabled)}> - {text} - + toggleExtension(item.id, !item.enabled)} + /> ); }, }, { key: 'remove', name: formatMessage('Remove'), - minWidth: 30, + minWidth: 100, maxWidth: 150, + isResizable: true, onRender: (item: ExtensionConfig) => { return ( - removeExtension(item.id)}> - {formatMessage('Remove')} - + { + if (confirm(formatMessage('Are you sure you want to uninstall {extension}?', { extension: item.name }))) { + setIsUpdating(item.id); + await removeExtension(item.id); + setIsUpdating(false); + } + }} + /> ); }, }, @@ -103,26 +133,54 @@ const Extensions: React.FC = () => { const submit = async (selectedExtension) => { if (selectedExtension) { - setIsAdding(true); + setIsUpdating(true); setShowNewModal(false); await addExtension(selectedExtension.id); - setIsAdding(false); + setIsUpdating(false); + } + }; + + 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; } }; return ( -
+
- {(isAdding || extensions.length > 0) && ( - - )} + { + if (extensions.length === 0) { + return ( +
+

No extensions installed

+
+ ); + } + + if (defaultRender) { + return defaultRender(rowProps); + } + + return null; + }} + /> setShowNewModal(false)} onInstall={submit} />
); diff --git a/Composer/packages/client/src/recoilModel/types.ts b/Composer/packages/client/src/recoilModel/types.ts index aab6a33b95..ed9c639461 100644 --- a/Composer/packages/client/src/recoilModel/types.ts +++ b/Composer/packages/client/src/recoilModel/types.ts @@ -55,6 +55,7 @@ export type ExtensionPageContribution = { 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. */ From 681e666a1960a229616ae160fed405057ecd6d30 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Fri, 25 Sep 2020 13:19:44 -0700 Subject: [PATCH 14/20] add description to extension db --- Composer/packages/extension/src/manager/manager.ts | 1 + Composer/packages/extension/src/types/extension.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/Composer/packages/extension/src/manager/manager.ts b/Composer/packages/extension/src/manager/manager.ts index a361eb4427..7cd679f555 100644 --- a/Composer/packages/extension/src/manager/manager.ts +++ b/Composer/packages/extension/src/manager/manager.ts @@ -27,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, diff --git a/Composer/packages/extension/src/types/extension.ts b/Composer/packages/extension/src/types/extension.ts index b235425812..c13ecc9bdb 100644 --- a/Composer/packages/extension/src/types/extension.ts +++ b/Composer/packages/extension/src/types/extension.ts @@ -30,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 */ From 43a02a9157d8d00f782cfdc4ae76657add789c73 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Tue, 29 Sep 2020 10:00:29 -0700 Subject: [PATCH 15/20] move uninstall action to toolbar --- .../pages/setting/extensions/Extensions.tsx | 82 ++++++++++++------- 1 file changed, 52 insertions(+), 30 deletions(-) diff --git a/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx b/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx index cc3fe40455..4bc3c07a08 100644 --- a/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx +++ b/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx @@ -3,20 +3,23 @@ /** @jsx jsx */ import { jsx, css } from '@emotion/core'; -import React, { useEffect, useState } from 'react'; +import React, { useEffect, useState, useRef } from 'react'; import { RouteComponentProps } from '@reach/router'; import { DetailsListLayoutMode, + Selection, SelectionMode, IColumn, CheckboxVisibility, ConstrainMode, + DetailsRow, + IDetailsRowStyles, } from 'office-ui-fabric-react/lib/DetailsList'; import { Toggle } from 'office-ui-fabric-react/lib/Toggle'; import { ShimmeredDetailsList } from 'office-ui-fabric-react/lib/ShimmeredDetailsList'; -import { IconButton } from 'office-ui-fabric-react/lib/Button'; import formatMessage from 'format-message'; import { useRecoilValue, selector } from 'recoil'; +import { NeutralColors } from '@uifabric/fluent-theme'; import { ExtensionConfig } from '../../../recoilModel/types'; import { Toolbar, IToolbarItem } from '../../../components/Toolbar'; @@ -41,6 +44,14 @@ const Extensions: React.FC = () => { // if a string, its the id of the extension being updated const [isUpdating, setIsUpdating] = useState(false); const [showNewModal, setShowNewModal] = useState(false); + const [selectedExtensions, setSelectedExtensions] = useState([]); + const selection = useRef( + new Selection({ + onSelectionChanged: () => { + setSelectedExtensions(selection.getSelection() as ExtensionConfig[]); + }, + }) + ).current; useEffect(() => { fetchExtensions(); @@ -53,7 +64,6 @@ const Extensions: React.FC = () => { minWidth: 100, maxWidth: 250, isResizable: true, - isRowHeader: true, fieldName: 'name', }, { @@ -85,29 +95,12 @@ const Extensions: React.FC = () => { toggleExtension(item.id, !item.enabled)} - /> - ); - }, - }, - { - key: 'remove', - name: formatMessage('Remove'), - minWidth: 100, - maxWidth: 150, - isResizable: true, - onRender: (item: ExtensionConfig) => { - return ( - { - if (confirm(formatMessage('Are you sure you want to uninstall {extension}?', { extension: item.name }))) { - setIsUpdating(item.id); - await removeExtension(item.id); - setIsUpdating(false); - } + styles={{ root: { marginBottom: 0 } }} + onChange={async () => { + const timeout = setTimeout(() => setIsUpdating(item.id), 200); + await toggleExtension(item.id, !item.enabled); + clearTimeout(timeout); + setIsUpdating(false); }} /> ); @@ -129,6 +122,29 @@ 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 = async (selectedExtension) => { @@ -159,12 +175,13 @@ const Extensions: React.FC = () => {
{ if (extensions.length === 0) { return ( @@ -174,8 +191,13 @@ const Extensions: React.FC = () => { ); } - if (defaultRender) { - return defaultRender(rowProps); + if (defaultRender && rowProps) { + const customStyles: Partial = { + root: { + color: rowProps?.item?.enabled ? undefined : NeutralColors.gray90, + }, + }; + return ; } return null; From f30b5ca0fe7a980ab380de87a3a5af59b7b058af Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Tue, 29 Sep 2020 10:01:59 -0700 Subject: [PATCH 16/20] use ensureDir api from fs-extra --- Composer/packages/extension/src/manager/manager.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/Composer/packages/extension/src/manager/manager.ts b/Composer/packages/extension/src/manager/manager.ts index 7cd679f555..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, existsSync, mkdir } from 'fs-extra'; +import { readJson, ensureDir } from 'fs-extra'; import { ExtensionContext } from '../extensionContext'; import logger from '../logger'; @@ -62,7 +62,7 @@ class ExtensionManager { */ public async loadAll() { await this.seedBuiltinExtensions(); - await this.ensureRemoteDir(); + await ensureDir(this.remoteDir); const extensions = Object.entries(this.manifest.getExtensions()); @@ -313,12 +313,6 @@ class ExtensionManager { } } } - - private async ensureRemoteDir() { - if (!existsSync(this.remoteDir)) { - await mkdir(this.remoteDir); - } - } } const manager = new ExtensionManager(); From 33de4b343967b2f00f3d7a45490c7748bdff4d41 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Tue, 29 Sep 2020 10:05:23 -0700 Subject: [PATCH 17/20] memoize callback functions --- .../setting/extensions/ExtensionSearchResults.tsx | 2 +- .../src/pages/setting/extensions/Extensions.tsx | 11 +++++++---- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx b/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx index a8bf884046..63ec736992 100644 --- a/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx +++ b/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx @@ -82,7 +82,7 @@ const ExtensionSearchResults: React.FC = (props) => onRender: (item: ExtensionSearchResult) => { return item.url ? ( - View on npm + {formatMessage('View on npm')} ) : null; }, diff --git a/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx b/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx index 4bc3c07a08..1c0f041bd6 100644 --- a/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx +++ b/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx @@ -3,7 +3,7 @@ /** @jsx jsx */ import { jsx, css } from '@emotion/core'; -import React, { useEffect, useState, useRef } from 'react'; +import React, { useEffect, useState, useRef, useCallback } from 'react'; import { RouteComponentProps } from '@reach/router'; import { DetailsListLayoutMode, @@ -26,6 +26,7 @@ import { Toolbar, IToolbarItem } from '../../../components/Toolbar'; import { dispatcherState, extensionsState } from '../../../recoilModel'; import { InstallExtensionDialog } from './InstallExtensionDialog'; +import { ExtensionSearchResult } from './ExtensionSearchResults'; const remoteExtensionsState = selector({ key: 'remoteExtensions', @@ -147,14 +148,14 @@ const Extensions: React.FC = () => { }, ]; - const submit = async (selectedExtension) => { + const submit = useCallback(async (selectedExtension?: ExtensionSearchResult) => { if (selectedExtension) { setIsUpdating(true); setShowNewModal(false); await addExtension(selectedExtension.id); setIsUpdating(false); } - }; + }, []); const shownItems = () => { if (extensions.length === 0) { @@ -171,6 +172,8 @@ const Extensions: React.FC = () => { } }; + const dismissInstallDialog = useCallback(() => setShowNewModal(false), []); + return (
@@ -203,7 +206,7 @@ const Extensions: React.FC = () => { return null; }} /> - setShowNewModal(false)} onInstall={submit} /> +
); }; From 70f455f2ffbf842755fdac3de7aeb9842e736ef0 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Tue, 29 Sep 2020 11:32:06 -0700 Subject: [PATCH 18/20] fix type error in test --- .../server/src/controllers/__tests__/extensions.test.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Composer/packages/server/src/controllers/__tests__/extensions.test.ts b/Composer/packages/server/src/controllers/__tests__/extensions.test.ts index 93f7fcd0f5..d6ce16c019 100644 --- a/Composer/packages/server/src/controllers/__tests__/extensions.test.ts +++ b/Composer/packages/server/src/controllers/__tests__/extensions.test.ts @@ -38,6 +38,7 @@ const mockExtension1 = { version: '1.0.0', enabled: true, path: '/some/path/extension1', + description: 'description text', bundles: [ { id: 'page1', @@ -67,6 +68,7 @@ const allExtensions: ExtensionMetadata[] = [ name: 'Extension 2', version: '1.0.0', path: '/some/path/extension2', + description: 'description text', enabled: true, builtIn: true, bundles: [ @@ -102,6 +104,7 @@ describe('listing all extensions', () => { name: 'Extension 1', version: '1.0.0', enabled: true, + description: 'description text', contributes: { views: { publish: { From 4fe78c3775ac7148cb75351128b20c2aa12f6146 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Tue, 29 Sep 2020 13:27:47 -0700 Subject: [PATCH 19/20] fix failing tests --- .../server/src/controllers/__tests__/extensions.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Composer/packages/server/src/controllers/__tests__/extensions.test.ts b/Composer/packages/server/src/controllers/__tests__/extensions.test.ts index d6ce16c019..bcaca8c5bf 100644 --- a/Composer/packages/server/src/controllers/__tests__/extensions.test.ts +++ b/Composer/packages/server/src/controllers/__tests__/extensions.test.ts @@ -119,6 +119,8 @@ describe('listing all extensions', () => { ], }, }, + bundles: undefined, + path: undefined, }, { id: 'builtinExtension2', @@ -126,6 +128,7 @@ describe('listing all extensions', () => { version: '1.0.0', enabled: true, builtIn: true, + description: 'description text', contributes: { views: { publish: { @@ -140,6 +143,8 @@ describe('listing all extensions', () => { ], }, }, + bundles: undefined, + path: undefined, }, ]); }); From fc593eae21f3372424a81620e6ae8dcdfa2c5e68 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Tue, 29 Sep 2020 15:02:53 -0700 Subject: [PATCH 20/20] localize some strings --- .../src/pages/setting/extensions/ExtensionSearchResults.tsx | 2 +- .../packages/client/src/pages/setting/extensions/Extensions.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx b/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx index 63ec736992..98a967477e 100644 --- a/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx +++ b/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx @@ -116,7 +116,7 @@ const ExtensionSearchResults: React.FC = (props) => if (!isSearching && results.length === 0) { return (
-

No search results

+

{formatMessage('No search results')}

); } diff --git a/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx b/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx index 1c0f041bd6..aa15932931 100644 --- a/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx +++ b/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx @@ -189,7 +189,7 @@ const Extensions: React.FC = () => { if (extensions.length === 0) { return (
-

No extensions installed

+

{formatMessage('No extensions installed')}

); }