From 7dab87b5135b6245dacce3df58f2fe5503f86185 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Wed, 30 Sep 2020 08:58:38 -0700 Subject: [PATCH 1/9] use plugin id for page links --- Composer/packages/client/src/recoilModel/types.ts | 2 ++ Composer/packages/client/src/utils/hooks.ts | 2 +- Composer/packages/client/src/utils/pageLinks.ts | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Composer/packages/client/src/recoilModel/types.ts b/Composer/packages/client/src/recoilModel/types.ts index ed9c639461..63ddebe513 100644 --- a/Composer/packages/client/src/recoilModel/types.ts +++ b/Composer/packages/client/src/recoilModel/types.ts @@ -46,6 +46,8 @@ type ExtensionPublishContribution = { }; export type ExtensionPageContribution = { + /** plugin id */ + id: string; bundleId: string; label: string; icon?: string; diff --git a/Composer/packages/client/src/utils/hooks.ts b/Composer/packages/client/src/utils/hooks.ts index 8cb4591a9f..6e4aa3a1f3 100644 --- a/Composer/packages/client/src/utils/hooks.ts +++ b/Composer/packages/client/src/utils/hooks.ts @@ -33,7 +33,7 @@ export const useLinks = () => { const pluginPages = extensions.reduce((pages, p) => { const pagesConfig = p.contributes?.views?.pages; if (Array.isArray(pagesConfig) && pagesConfig.length > 0) { - pages.push(...pagesConfig); + pages.push(...pagesConfig.map((page) => ({ ...page, id: p.id }))); } return pages; }, [] as ExtensionPageContribution[]); diff --git a/Composer/packages/client/src/utils/pageLinks.ts b/Composer/packages/client/src/utils/pageLinks.ts index 4756d92fba..3573c6993a 100644 --- a/Composer/packages/client/src/utils/pageLinks.ts +++ b/Composer/packages/client/src/utils/pageLinks.ts @@ -72,7 +72,7 @@ export const topLinks = (projectId: string, openedDialogId: string, pluginPages: if (pluginPages.length > 0) { pluginPages.forEach((p) => { links.push({ - to: `page/${p.bundleId}`, + to: `page/${p.id}`, iconName: p.icon ?? 'StatusCircleQuestionMark', labelName: p.label, exact: true, From 3c9ab0457c9a21c374d7af6a0641e2c53d257653 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Wed, 30 Sep 2020 09:00:49 -0700 Subject: [PATCH 2/9] add api to install local extension --- .../packages/extension/src/manager/manager.ts | 36 ++++++++++++++++++- Composer/packages/extension/src/utils/npm.ts | 2 +- .../server/src/controllers/extensions.ts | 33 ++++++++++++----- 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/Composer/packages/extension/src/manager/manager.ts b/Composer/packages/extension/src/manager/manager.ts index e86c5fedb8..7be3937d06 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, ensureDir } from 'fs-extra'; +import { readJson, ensureDir, existsSync } from 'fs-extra'; import { ExtensionContext } from '../extensionContext'; import logger from '../logger'; @@ -77,6 +77,7 @@ class ExtensionManager { * Installs a remote extension via NPM * @param name The name of the extension to install * @param version The version of the extension to install + * @returns id of installed package */ public async installRemote(name: string, version?: string) { const packageNameAndVersion = version ? `${name}@${version}` : `${name}@latest`; @@ -97,6 +98,8 @@ class ExtensionManager { if (packageJson) { const extensionPath = path.resolve(this.remoteDir, 'node_modules', name); this.manifest.updateExtensionConfig(name, getExtensionMetadata(extensionPath, packageJson)); + + return packageJson.name; } else { throw new Error(`Unable to install ${packageNameAndVersion}`); } @@ -108,6 +111,37 @@ class ExtensionManager { } } + /** + * Installs a local extension at path + * @param path Path of directory where extension is + */ + public async installLocal(extPath: string) { + try { + const packageJsonPath = path.join(extPath, 'package.json'); + + if (!existsSync(packageJsonPath)) { + throw new Error(`Extension not found at path: ${extPath}`); + } + + const packageJson = await readJson(packageJsonPath); + + log('Linking %s', packageJson.name); + await npm('link', '.', {}, { cwd: extPath }); + + log('Installing %s@local to %s', packageJson.name, this.remoteDir); + await npm('link', packageJson.name, { '--prefix': this.remoteDir }, { cwd: this.remoteDir }); + + const extensionPath = path.resolve(this.remoteDir, 'node_modules', packageJson.name); + this.manifest.updateExtensionConfig(packageJson.name, getExtensionMetadata(extensionPath, packageJson)); + + return packageJson.name; + } catch (err) { + log('%s', err.msg ?? err.stderr ?? err); + // eslint-disable-next-line no-console + console.error(err); + } + } + public async load(id: string) { const metadata = this.manifest.getExtensionConfig(id); try { diff --git a/Composer/packages/extension/src/utils/npm.ts b/Composer/packages/extension/src/utils/npm.ts index e0fc6ebb11..60685b7c98 100644 --- a/Composer/packages/extension/src/utils/npm.ts +++ b/Composer/packages/extension/src/utils/npm.ts @@ -12,7 +12,7 @@ type NpmOutput = { stderr: string; code: number; }; -type NpmCommand = 'install' | 'uninstall' | 'search'; +type NpmCommand = 'install' | 'uninstall' | 'search' | 'link'; type NpmOptions = { [key: string]: string; }; diff --git a/Composer/packages/server/src/controllers/extensions.ts b/Composer/packages/server/src/controllers/extensions.ts index 67aff9aa02..0695bc8240 100644 --- a/Composer/packages/server/src/controllers/extensions.ts +++ b/Composer/packages/server/src/controllers/extensions.ts @@ -9,6 +9,7 @@ interface AddExtensionRequest extends Request { body: { id?: string; version?: string; + path?: string; }; } @@ -53,17 +54,33 @@ export async function listExtensions(req: Request, res: Response) { } export async function addExtension(req: AddExtensionRequest, res: Response) { - const { id, version } = req.body; + const { id, version, path } = req.body; - if (!id) { - res.status(400).json({ error: '`id` is missing from body' }); - return; + let installedId: string | null = null; + + if (path) { + if (id) { + res.status(400).json({ error: '`id` and `path` cannot be used together.' }); + return; + } + + installedId = await ExtensionManager.installLocal(path); + } else { + if (!id) { + res.status(400).json({ error: '`id` is missing from body' }); + return; + } + + installedId = await ExtensionManager.installRemote(id, version); } - await ExtensionManager.installRemote(id, version); - await ExtensionManager.load(id); - const extension = ExtensionManager.find(id); - res.json(presentExtension(extension)); + if (installedId) { + await ExtensionManager.load(installedId); + const extension = ExtensionManager.find(installedId); + res.json(presentExtension(extension)); + } else { + res.status(500).json({ error: 'Unable to install extension.' }); + } } export async function toggleExtension(req: ToggleExtensionRequest, res: Response) { From 857e283feffb8bc0118cf84403127cd3f548d5de Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Wed, 30 Sep 2020 09:15:50 -0700 Subject: [PATCH 3/9] disable add button when no extension selected --- .../setting/extensions/ExtensionSearchResults.tsx | 12 ++++++++++-- .../setting/extensions/InstallExtensionDialog.tsx | 4 +++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx b/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx index 98a967477e..b7e9c748b4 100644 --- a/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx +++ b/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx @@ -3,7 +3,7 @@ /** @jsx jsx */ import { jsx, css } from '@emotion/core'; -import React from 'react'; +import React, { useRef } from 'react'; import formatMessage from 'format-message'; import { DetailsListLayoutMode, @@ -11,6 +11,7 @@ import { IColumn, CheckboxVisibility, ConstrainMode, + Selection, } 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'; @@ -44,6 +45,13 @@ const noResultsStyles = css` const ExtensionSearchResults: React.FC = (props) => { const { results, isSearching, onSelect } = props; + const selection = useRef( + new Selection({ + onSelectionChanged: () => { + onSelect(selection.getSelection()[0] as ExtensionConfig); + }, + }) + ).current; const searchColumns: IColumn[] = [ { @@ -101,9 +109,9 @@ const ExtensionSearchResults: React.FC = (props) => enableShimmer={isSearching} items={noResultsFound ? [{}] : results} layoutMode={DetailsListLayoutMode.justified} + selection={selection} selectionMode={SelectionMode.single} shimmerLines={8} - onActiveItemChanged={(item) => onSelect(item)} onRenderDetailsHeader={(headerProps, defaultRender) => { if (defaultRender) { return {defaultRender(headerProps)}; diff --git a/Composer/packages/client/src/pages/setting/extensions/InstallExtensionDialog.tsx b/Composer/packages/client/src/pages/setting/extensions/InstallExtensionDialog.tsx index 9065d92ef0..a0c2494a78 100644 --- a/Composer/packages/client/src/pages/setting/extensions/InstallExtensionDialog.tsx +++ b/Composer/packages/client/src/pages/setting/extensions/InstallExtensionDialog.tsx @@ -7,6 +7,7 @@ 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 { Separator } from 'office-ui-fabric-react/lib/Separator'; import axios, { CancelToken } from 'axios'; import formatMessage from 'format-message'; @@ -83,6 +84,7 @@ const InstallExtensionDialog: React.FC = (props) => onDismiss={onDismiss} >
+ {formatMessage('OR')} = (props) =>
Cancel - + {formatMessage('Add')} From d5958caa3bee654df3f9eba71d00f72027c06130 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Wed, 30 Sep 2020 09:34:35 -0700 Subject: [PATCH 4/9] fix settings page redirect issue --- Composer/packages/client/src/pages/setting/SettingsPage.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/Composer/packages/client/src/pages/setting/SettingsPage.tsx b/Composer/packages/client/src/pages/setting/SettingsPage.tsx index 1a1e0e7ad0..350fa2ed46 100644 --- a/Composer/packages/client/src/pages/setting/SettingsPage.tsx +++ b/Composer/packages/client/src/pages/setting/SettingsPage.tsx @@ -89,8 +89,6 @@ const SettingPage: React.FC = () => { useEffect(() => { if (!projectId && location.pathname.indexOf('/settings/bot/') !== -1) { navigate('/settings/application'); - } else { - navigate(links[0].url); } }, [projectId]); From 12d00a65cc5bfe71814c208b0c17681818f76698 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Wed, 30 Sep 2020 10:25:01 -0700 Subject: [PATCH 5/9] normalize search query for extensions --- Composer/packages/extension/src/manager/manager.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Composer/packages/extension/src/manager/manager.ts b/Composer/packages/extension/src/manager/manager.ts index 7be3937d06..1fd4578297 100644 --- a/Composer/packages/extension/src/manager/manager.ts +++ b/Composer/packages/extension/src/manager/manager.ts @@ -208,11 +208,14 @@ class ExtensionManager { */ public async search(query: string) { await this.updateSearchCache(); + const normalizedQuery = query.toLowerCase(); 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)) + [result.id, result.description, ...result.keywords].some((target) => + target.toLowerCase().includes(normalizedQuery) + ) ); }); From dd1df7d02d4acc7679722e0b3099f1178821da8c Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Thu, 1 Oct 2020 10:40:39 -0700 Subject: [PATCH 6/9] revert api to install local extension --- .../server/src/controllers/extensions.ts | 29 ++++++------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/Composer/packages/server/src/controllers/extensions.ts b/Composer/packages/server/src/controllers/extensions.ts index 0695bc8240..f007179bfd 100644 --- a/Composer/packages/server/src/controllers/extensions.ts +++ b/Composer/packages/server/src/controllers/extensions.ts @@ -54,29 +54,18 @@ export async function listExtensions(req: Request, res: Response) { } export async function addExtension(req: AddExtensionRequest, res: Response) { - const { id, version, path } = req.body; + const { id, version } = req.body; - let installedId: string | null = null; - - if (path) { - if (id) { - res.status(400).json({ error: '`id` and `path` cannot be used together.' }); - return; - } - - installedId = await ExtensionManager.installLocal(path); - } else { - if (!id) { - res.status(400).json({ error: '`id` is missing from body' }); - return; - } - - installedId = await ExtensionManager.installRemote(id, version); + if (!id) { + res.status(400).json({ error: '`id` is missing from body' }); + return; } - if (installedId) { - await ExtensionManager.load(installedId); - const extension = ExtensionManager.find(installedId); + const extensionId = await ExtensionManager.installRemote(id, version); + + if (extensionId) { + await ExtensionManager.load(extensionId); + const extension = ExtensionManager.find(extensionId); res.json(presentExtension(extension)); } else { res.status(500).json({ error: 'Unable to install extension.' }); From d096645a97558f943419aaebaee3124f7ba87557 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Thu, 1 Oct 2020 10:51:51 -0700 Subject: [PATCH 7/9] fix shimmer row when no extensions installed --- .../pages/setting/extensions/Extensions.tsx | 31 +++++++++++-------- .../extensions/InstallExtensionDialog.tsx | 2 -- 2 files changed, 18 insertions(+), 15 deletions(-) diff --git a/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx b/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx index aa15932931..fc15946802 100644 --- a/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx +++ b/Composer/packages/client/src/pages/setting/extensions/Extensions.tsx @@ -158,15 +158,15 @@ const Extensions: React.FC = () => { }, []); const shownItems = () => { - if (extensions.length === 0) { - // render no installed message - return [{}]; - } else if (isUpdating === true) { + 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 if (extensions.length === 0) { + // render no installed message + return [{}]; } else { return extensions; } @@ -186,20 +186,25 @@ const Extensions: React.FC = () => { selection={selection} selectionMode={SelectionMode.multiple} onRenderRow={(rowProps, defaultRender) => { - if (extensions.length === 0) { - return ( -
-

{formatMessage('No extensions installed')}

-
- ); - } + if (rowProps && defaultRender) { + if (isUpdating) { + return defaultRender(rowProps); + } + + if (extensions.length === 0) { + return ( +
+

{formatMessage('No extensions installed')}

+
+ ); + } - if (defaultRender && rowProps) { const customStyles: Partial = { root: { - color: rowProps?.item?.enabled ? undefined : NeutralColors.gray90, + color: rowProps.item?.enabled ? undefined : NeutralColors.gray90, }, }; + return ; } diff --git a/Composer/packages/client/src/pages/setting/extensions/InstallExtensionDialog.tsx b/Composer/packages/client/src/pages/setting/extensions/InstallExtensionDialog.tsx index a0c2494a78..0f4e721be4 100644 --- a/Composer/packages/client/src/pages/setting/extensions/InstallExtensionDialog.tsx +++ b/Composer/packages/client/src/pages/setting/extensions/InstallExtensionDialog.tsx @@ -7,7 +7,6 @@ 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 { Separator } from 'office-ui-fabric-react/lib/Separator'; import axios, { CancelToken } from 'axios'; import formatMessage from 'format-message'; @@ -84,7 +83,6 @@ const InstallExtensionDialog: React.FC = (props) => onDismiss={onDismiss} >
- {formatMessage('OR')} Date: Thu, 1 Oct 2020 11:01:20 -0700 Subject: [PATCH 8/9] add test for unsuccessul install --- .../controllers/__tests__/extensions.test.ts | 41 ++++++++++++++----- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/Composer/packages/server/src/controllers/__tests__/extensions.test.ts b/Composer/packages/server/src/controllers/__tests__/extensions.test.ts index bcaca8c5bf..021e4dbeaa 100644 --- a/Composer/packages/server/src/controllers/__tests__/extensions.test.ts +++ b/Composer/packages/server/src/controllers/__tests__/extensions.test.ts @@ -166,21 +166,40 @@ describe('adding an extension', () => { expect(ExtensionManager.installRemote).toHaveBeenCalledWith(id, 'some-version'); }); - it('loads the extension', async () => { - await ExtensionsController.addExtension({ body: { id } } as Request, res); + describe('installed successfully', () => { + beforeEach(() => { + (ExtensionManager.installRemote as jest.Mock).mockResolvedValue(id); + }); + + it('loads the extension', async () => { + await ExtensionsController.addExtension({ body: { id } } as Request, res); + + expect(ExtensionManager.load).toHaveBeenCalledWith(id); + }); - expect(ExtensionManager.load).toHaveBeenCalledWith(id); + it('returns the extension', async () => { + (ExtensionManager.find as jest.Mock).mockReturnValue(mockExtension1); + await ExtensionsController.addExtension({ body: { id } } as Request, res); + + expect(ExtensionManager.find).toHaveBeenCalledWith(id); + expect(res.json).toHaveBeenCalledWith({ + ...mockExtension1, + bundles: undefined, + path: undefined, + }); + }); }); - it('returns the extension', async () => { - (ExtensionManager.find as jest.Mock).mockReturnValue(mockExtension1); - await ExtensionsController.addExtension({ body: { id } } as Request, res); + describe('install fails', () => { + beforeEach(() => { + (ExtensionManager.installRemote as jest.Mock).mockResolvedValue(undefined); + }); - expect(ExtensionManager.find).toHaveBeenCalledWith(id); - expect(res.json).toHaveBeenCalledWith({ - ...mockExtension1, - bundles: undefined, - path: undefined, + it('returns an error', async () => { + await ExtensionsController.addExtension({ body: { id } } as Request, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ error: expect.any(String) }); }); }); }); From 727e0cca6ac8c85fd4fd3aa2007ec567b8d541b2 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Thu, 1 Oct 2020 12:29:21 -0700 Subject: [PATCH 9/9] fix type error --- .../src/pages/setting/extensions/ExtensionSearchResults.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx b/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx index b7e9c748b4..e687032207 100644 --- a/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx +++ b/Composer/packages/client/src/pages/setting/extensions/ExtensionSearchResults.tsx @@ -48,7 +48,7 @@ const ExtensionSearchResults: React.FC = (props) => const selection = useRef( new Selection({ onSelectionChanged: () => { - onSelect(selection.getSelection()[0] as ExtensionConfig); + onSelect(selection.getSelection()[0] as ExtensionSearchResult); }, }) ).current;