From cadcccbe5d82efc46ba2cc83f802c1d2bbcad4ac Mon Sep 17 00:00:00 2001 From: Zhixiang Zhan Date: Tue, 22 Sep 2020 17:30:41 +0800 Subject: [PATCH 1/9] fix: New LG template not sync to other locale files (#4230) * fix call update do create not sync to other locale * also apply to lu * update test --- .../src/recoilModel/dispatchers/__tests__/lg.test.tsx | 3 +++ .../packages/client/src/recoilModel/dispatchers/lg.ts | 7 +++++++ .../packages/client/src/recoilModel/dispatchers/lu.ts | 8 ++++++++ 3 files changed, 18 insertions(+) diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/lg.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/lg.test.tsx index 8d2f221ae7..34b42bf1d0 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/lg.test.tsx +++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/lg.test.tsx @@ -40,6 +40,9 @@ const lgFiles = [ { id: 'common.en-us', content: `\r\n# Hello\r\n-hi`, + templates: [{ name: 'Hello', body: '-hi', parameters: [] }], + diagnostics: [], + allTemplates: [{ name: 'Hello', body: '-hi', parameters: [] }], }, ] as LgFile[]; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/lg.ts b/Composer/packages/client/src/recoilModel/dispatchers/lg.ts index cc77bf45a5..dd4d866f25 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/lg.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/lg.ts @@ -187,6 +187,13 @@ export const lgDispatcher = () => { if (!lgFile) return lgFiles; const sameIdOtherLocaleFiles = lgFiles.filter((file) => getBaseName(file.id) === getBaseName(id)); + // create need sync to multi locale file. + const originTemplate = lgFile.templates.find(({ name }) => name === templateName); + if (!originTemplate) { + await createLgTemplate({ id, template, projectId }); + return; + } + try { if (template.name !== templateName) { // name change, need update cross multi locale file. diff --git a/Composer/packages/client/src/recoilModel/dispatchers/lu.ts b/Composer/packages/client/src/recoilModel/dispatchers/lu.ts index 665bcb97e1..249f6cf38f 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/lu.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/lu.ts @@ -153,6 +153,14 @@ export const luDispatcher = () => { const luFiles = await snapshot.getPromise(luFilesState(projectId)); const luFile = luFiles.find((temp) => temp.id === id); if (!luFile) return luFiles; + + // create need sync to multi locale file. + const originIntent = luFile.intents.find(({ Name }) => Name === intentName); + if (!originIntent) { + await createLuIntent({ id, intent, projectId }); + return; + } + try { const sameIdOtherLocaleFiles = luFiles.filter((file) => getBaseName(file.id) === getBaseName(id)); From b3614dbf59ab67ffcc974fb29b7acdc25bd377f3 Mon Sep 17 00:00:00 2001 From: TJ Durnford Date: Tue, 22 Sep 2020 07:40:34 -1000 Subject: [PATCH 2/9] feat: Added fieldSets to UIOptions (#4231) * feat: Added fieldSets to UIOptions * fix tests * fixed test description --- .../src/components/CollapseField.tsx | 12 +- .../src/components/fields/FieldSets.tsx | 29 +++++ .../fields/__tests__/FieldSets.test.tsx | 59 +++++++++ .../src/components/fields/index.ts | 1 + .../src/utils/__tests__/getFieldSets.test.ts | 120 ++++++++++++++++++ .../adaptive-form/src/utils/getFieldSets.ts | 41 ++++++ .../packages/adaptive-form/src/utils/index.ts | 1 + .../src/utils/resolveFieldWidget.ts | 6 +- .../extension-client/src/types/formSchema.ts | 8 ++ .../src/Fields/CollapsedField.tsx | 23 ---- .../ui-plugins/schema-editor/src/uiOptions.ts | 4 +- 11 files changed, 272 insertions(+), 32 deletions(-) create mode 100644 Composer/packages/adaptive-form/src/components/fields/FieldSets.tsx create mode 100644 Composer/packages/adaptive-form/src/components/fields/__tests__/FieldSets.test.tsx create mode 100644 Composer/packages/adaptive-form/src/utils/__tests__/getFieldSets.test.ts create mode 100644 Composer/packages/adaptive-form/src/utils/getFieldSets.ts delete mode 100644 Composer/packages/ui-plugins/schema-editor/src/Fields/CollapsedField.tsx diff --git a/Composer/packages/adaptive-form/src/components/CollapseField.tsx b/Composer/packages/adaptive-form/src/components/CollapseField.tsx index ef82efa768..9a0cf377a2 100644 --- a/Composer/packages/adaptive-form/src/components/CollapseField.tsx +++ b/Composer/packages/adaptive-form/src/components/CollapseField.tsx @@ -24,19 +24,19 @@ const styles = { }; interface CollapseField { - defaultCollapsed?: boolean; - label?: string | boolean; + defaultExpanded?: boolean; + title?: string | boolean; } -export const CollapseField: React.FC = ({ children, defaultCollapsed, label }) => { - const [isOpen, setIsOpen] = useState(!!defaultCollapsed); +export const CollapseField: React.FC = ({ children, defaultExpanded, title }) => { + const [isOpen, setIsOpen] = useState(!!defaultExpanded); return (
{ @@ -47,7 +47,7 @@ export const CollapseField: React.FC = ({ children, defaultCollap iconProps={{ iconName: isOpen ? 'ChevronDown' : 'ChevronRight' }} styles={{ root: { color: NeutralColors.gray150 } }} /> - {label && } + {title && }
diff --git a/Composer/packages/adaptive-form/src/components/fields/FieldSets.tsx b/Composer/packages/adaptive-form/src/components/fields/FieldSets.tsx new file mode 100644 index 0000000000..a608c7d4dc --- /dev/null +++ b/Composer/packages/adaptive-form/src/components/fields/FieldSets.tsx @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; +import { FieldProps } from '@bfc/extension-client'; + +import { CollapseField } from '../CollapseField'; +import { getFieldSets } from '../../utils'; + +import { ObjectField } from './ObjectField'; + +export const FieldSets: React.FC> = (props) => { + const { schema, uiOptions: baseUiOptions, value } = props; + const { fieldSets: _, ...uiOptions } = baseUiOptions; + + const fieldSets = getFieldSets(schema, baseUiOptions, value); + + return ( + + {fieldSets.map(({ fields, schema, ...rest }, key) => ( + + + + ))} + + ); +}; + +export default FieldSets; diff --git a/Composer/packages/adaptive-form/src/components/fields/__tests__/FieldSets.test.tsx b/Composer/packages/adaptive-form/src/components/fields/__tests__/FieldSets.test.tsx new file mode 100644 index 0000000000..6a95fb28d4 --- /dev/null +++ b/Composer/packages/adaptive-form/src/components/fields/__tests__/FieldSets.test.tsx @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; +import { render, fireEvent, act } from '@bfc/test-utils'; +import assign from 'lodash/assign'; + +import { FieldSets } from '../FieldSets'; + +import { fieldProps } from './testUtils'; + +const schema = { + type: 'object', + properties: { + name: { type: 'string' }, + city: { type: 'string' }, + }, +}; + +function renderSubject(overrides = {}) { + const props = assign({}, fieldProps(), overrides); + return render(); +} + +describe('', () => { + it('renders an object with two field sets', async () => { + const onChange = jest.fn(); + const uiOptions = { + fieldSets: [ + { + title: 'set 1', + fields: ['name'], + }, + { + title: 'set 2', + }, + ], + }; + + const { getByLabelText, findByText } = renderSubject({ schema, uiOptions, onChange, value: {} }); + + await findByText('set 1'); + await findByText('set 2'); + + const nameField = getByLabelText('Name'); + act(() => { + fireEvent.change(nameField, { target: { value: 'name' } }); + }); + + expect(onChange).toHaveBeenLastCalledWith({ name: 'name' }); + + const cityField = getByLabelText('City'); + act(() => { + fireEvent.change(cityField, { target: { value: 'Seattle' } }); + }); + + expect(onChange).toHaveBeenLastCalledWith({ city: 'Seattle' }); + }); +}); diff --git a/Composer/packages/adaptive-form/src/components/fields/index.ts b/Composer/packages/adaptive-form/src/components/fields/index.ts index 64713348f8..fdb9c1c115 100644 --- a/Composer/packages/adaptive-form/src/components/fields/index.ts +++ b/Composer/packages/adaptive-form/src/components/fields/index.ts @@ -4,6 +4,7 @@ export * from './ArrayField'; export * from './BooleanField'; export * from './EditableField'; export * from './ExpressionField/ExpressionField'; +export * from './FieldSets'; export * from './IntentField'; export * from './JsonField'; export * from './NumberField'; diff --git a/Composer/packages/adaptive-form/src/utils/__tests__/getFieldSets.test.ts b/Composer/packages/adaptive-form/src/utils/__tests__/getFieldSets.test.ts new file mode 100644 index 0000000000..bcc1f0cbe9 --- /dev/null +++ b/Composer/packages/adaptive-form/src/utils/__tests__/getFieldSets.test.ts @@ -0,0 +1,120 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { JSONSchema7 } from 'json-schema'; + +import { getFieldSets } from '../getFieldSets'; + +const schema = { + properties: { + one: { type: 'string' }, + two: { type: 'string' }, + three: { type: 'number' }, + four: { type: 'object' }, + five: { type: 'object' }, + six: { type: 'object' }, + seven: { type: 'boolean' }, + $kind: { type: 'string' }, + }, +} as JSONSchema7; + +describe('getFieldSets', () => { + it('should return a single field set containing all the properties', () => { + const uiOptions = { + fieldSets: [{ title: 'set1' }], + }; + + const result = getFieldSets(schema, uiOptions, {}); + + expect(result).toEqual([ + expect.objectContaining({ + fields: ['one', 'two', 'three', 'four', 'five', 'six', 'seven'], + schema: { + properties: { + one: { type: 'string' }, + two: { type: 'string' }, + three: { type: 'number' }, + four: { type: 'object' }, + five: { type: 'object' }, + six: { type: 'object' }, + seven: { type: 'boolean' }, + }, + }, + title: 'set1', + }), + ]); + }); + + it('should return two sets', () => { + const uiOptions = { + fieldSets: [ + { + title: 'set1', + fields: ['two', 'four', 'six'], + }, + { + title: 'set2', + fields: ['*'], + }, + ], + }; + + const result = getFieldSets(schema, uiOptions, {}); + + expect(result).toEqual([ + expect.objectContaining({ + fields: ['two', 'four', 'six'], + title: 'set1', + schema: { + properties: { + two: { type: 'string' }, + four: { type: 'object' }, + six: { type: 'object' }, + }, + }, + }), + expect.objectContaining({ + fields: ['one', 'three', 'five', 'seven'], + title: 'set2', + schema: { + properties: { + one: { type: 'string' }, + three: { type: 'number' }, + five: { type: 'object' }, + seven: { type: 'boolean' }, + }, + }, + }), + ]); + }); + + it('should throw an error for multiple wildcards', () => { + const uiOptions = { + fieldSets: [{ title: 'set1', fields: ['two', '*', 'six'] }, { title: 'set2' }], + }; + + expect(() => getFieldSets(schema, uiOptions, {})).toThrow('multiple wildcards'); + }); + + it('should throw an error for missing fields', () => { + const uiOptions = { + fieldSets: [ + { title: 'set1', fields: ['two', 'four', 'six'] }, + { title: 'set2', fields: ['one'] }, + ], + }; + + expect(() => getFieldSets(schema, uiOptions, {})).toThrow('missing fields'); + }); + + it('should throw an error for duplicate fields', () => { + const uiOptions = { + fieldSets: [ + { title: 'set1', fields: ['two', 'four', 'six'] }, + { title: 'set2', fields: ['two', '*'] }, + ], + }; + + expect(() => getFieldSets(schema, uiOptions, {})).toThrow('duplicate fields'); + }); +}); diff --git a/Composer/packages/adaptive-form/src/utils/getFieldSets.ts b/Composer/packages/adaptive-form/src/utils/getFieldSets.ts new file mode 100644 index 0000000000..2ebf01bf32 --- /dev/null +++ b/Composer/packages/adaptive-form/src/utils/getFieldSets.ts @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { JSONSchema7, UIOptions } from '@bfc/extension-client'; +import formatMessage from 'format-message'; +import difference from 'lodash/difference'; +import flatten from 'lodash/flatten'; +import keys from 'lodash/keys'; +import pick from 'lodash/pick'; + +import { getHiddenProperties } from './getHiddenProperties'; + +export const getFieldSets = (schema: JSONSchema7, uiOptions: UIOptions, value: any) => { + const { fieldSets: baseFieldSets = [] } = uiOptions; + const { properties } = schema; + + const hiddenProperties = getHiddenProperties(uiOptions, value); + const allFields: string[] = keys(properties).filter( + (field) => !(field.startsWith('$') || hiddenProperties.has(field)) + ); + const fields: string[] = flatten(baseFieldSets.map(({ fields = ['*'] }) => fields)); + const restFields = difference(allFields, fields); + + if (fields.filter((field) => field === '*').length > 1) { + throw new Error(formatMessage('multiple wildcards')); + } else if (!fields.includes('*') && allFields.some((field) => !fields.includes(field))) { + throw new Error(formatMessage('missing fields')); + } else if (fields.length !== new Set(fields).size) { + throw new Error(formatMessage('duplicate fields')); + } + + return baseFieldSets.map(({ fields = ['*'], ...rest }) => { + const restIdx = fields.indexOf('*'); + + if (restIdx > -1) { + fields.splice(restIdx, 1, ...restFields); + } + + return { ...rest, fields, schema: { ...schema, properties: pick(schema.properties, fields) } as JSONSchema7 }; + }); +}; diff --git a/Composer/packages/adaptive-form/src/utils/index.ts b/Composer/packages/adaptive-form/src/utils/index.ts index 087351f409..a83f95e3c4 100644 --- a/Composer/packages/adaptive-form/src/utils/index.ts +++ b/Composer/packages/adaptive-form/src/utils/index.ts @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. export * from './arrayUtils'; +export * from './getFieldSets'; export * from './getHiddenProperties'; export * from './getOrderedProperties'; export * from './getUIOptions'; diff --git a/Composer/packages/adaptive-form/src/utils/resolveFieldWidget.ts b/Composer/packages/adaptive-form/src/utils/resolveFieldWidget.ts index ca56b7a2ce..07d67b1134 100644 --- a/Composer/packages/adaptive-form/src/utils/resolveFieldWidget.ts +++ b/Composer/packages/adaptive-form/src/utils/resolveFieldWidget.ts @@ -70,7 +70,11 @@ export function resolveFieldWidget( return DefaultFields.ArrayField; } case 'object': - return schema.additionalProperties ? DefaultFields.OpenObjectField : DefaultFields.ObjectField; + if (schema.additionalProperties) { + return DefaultFields.OpenObjectField; + } else { + return uiOptions?.fieldSets ? DefaultFields.FieldSets : DefaultFields.ObjectField; + } } } diff --git a/Composer/packages/extension-client/src/types/formSchema.ts b/Composer/packages/extension-client/src/types/formSchema.ts index 0beb4304e6..c07a175cb3 100644 --- a/Composer/packages/extension-client/src/types/formSchema.ts +++ b/Composer/packages/extension-client/src/types/formSchema.ts @@ -9,6 +9,12 @@ type UIOptionValue = R | UIOptionFunc; // eslint-disable-next-line @typescript-eslint/no-explicit-any type UIOptionFunc = (data: D) => R; +interface FieldSet { + title: string; + fields?: string[]; + defaultExpanded?: boolean; +} + export interface UIOptions { /** Description override. */ description?: UIOptionValue; @@ -28,6 +34,8 @@ export interface UIOptions { * } */ hidden?: UIOptionValue; + /** Organizes fields into collapsible sets */ + fieldSets?: FieldSet[]; /** Label override. */ label?: UIOptionValue; /** Set order of fields. Use * for all other fields. */ diff --git a/Composer/packages/ui-plugins/schema-editor/src/Fields/CollapsedField.tsx b/Composer/packages/ui-plugins/schema-editor/src/Fields/CollapsedField.tsx deleted file mode 100644 index 38d71a97b4..0000000000 --- a/Composer/packages/ui-plugins/schema-editor/src/Fields/CollapsedField.tsx +++ /dev/null @@ -1,23 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -/** @jsx jsx */ -import { css, jsx } from '@emotion/core'; -import React from 'react'; -import { CollapseField as Collapse, SchemaField } from '@bfc/adaptive-form'; -import { FieldProps } from '@bfc/extension-client'; -import formatMessage from 'format-message'; - -const style = { - schemaField: css` - margin: 0; - `, -}; - -export const CollapsedField: React.FC = ({ label, uiOptions, ...rest }) => { - return ( - - - - ); -}; diff --git a/Composer/packages/ui-plugins/schema-editor/src/uiOptions.ts b/Composer/packages/ui-plugins/schema-editor/src/uiOptions.ts index ef8b2febf8..6f3c23e03a 100644 --- a/Composer/packages/ui-plugins/schema-editor/src/uiOptions.ts +++ b/Composer/packages/ui-plugins/schema-editor/src/uiOptions.ts @@ -2,9 +2,9 @@ // Licensed under the MIT License. import { UIOptions } from '@bfc/extension-client'; +import formatMessage from 'format-message'; import startCase from 'lodash/startCase'; -import { CollapsedField } from './Fields/CollapsedField'; import { ValueRefField } from './Fields/ValueRefField'; const objectSerializer = { @@ -17,7 +17,7 @@ const objectSerializer = { export const uiOptions: UIOptions = { label: false, - field: CollapsedField, + fieldSets: [{ title: formatMessage('Dialog Interface') }], properties: { dialogValue: { serializer: objectSerializer, From 889d3efdb0904cbc1f7f08caf16a059d48fb7f4f Mon Sep 17 00:00:00 2001 From: TJ Durnford Date: Tue, 22 Sep 2020 08:29:48 -1000 Subject: [PATCH 3/9] fix: refactored select skill ui-plugin (#4207) * fix: refactored select skill ui-plugin * small fixed * removed redundant fields from order --- .../packages/client/src/shell/useShell.ts | 8 +- .../packages/lib/shared/src/types/shell.ts | 6 +- .../src/BeginSkillDialogField.tsx | 137 +----------------- .../src/SelectSkillDialogField.tsx | 42 ++++-- .../src/SkillEndpointField.tsx | 70 +++++---- .../__tests__/BeginSkillDialogField.test.tsx | 108 -------------- .../src/__tests__/SelectSkillDialog.test.tsx | 84 +++-------- .../src/__tests__/SkillEndpointField.test.tsx | 65 +++++++++ .../src/__tests__/constants.ts | 43 +----- .../select-skill-dialog/src/index.ts | 10 +- 10 files changed, 172 insertions(+), 401 deletions(-) delete mode 100644 Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/BeginSkillDialogField.test.tsx create mode 100644 Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/SkillEndpointField.test.tsx diff --git a/Composer/packages/client/src/shell/useShell.ts b/Composer/packages/client/src/shell/useShell.ts index de01dcbee3..cbb8b68da1 100644 --- a/Composer/packages/client/src/shell/useShell.ts +++ b/Composer/packages/client/src/shell/useShell.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { useMemo, useRef } from 'react'; -import { ShellApi, ShellData, Shell, fetchFromSettings, DialogSchemaFile, SkillSetting } from '@bfc/shared'; +import { ShellApi, ShellData, Shell, DialogSchemaFile } from '@bfc/shared'; import { useRecoilValue } from 'recoil'; import formatMessage from 'format-message'; @@ -203,10 +203,7 @@ export function useShell(source: EventSource, projectId: string): Shell { updateDialogSchema: async (dialogSchema: DialogSchemaFile) => { updateDialogSchema(dialogSchema, projectId); }, - skillsSettings: { - get: (path: string) => fetchFromSettings(path, settings), - set: (id: string, skill: SkillSetting) => updateSkill(projectId, id, skill), - }, + updateSkillSetting: (...params) => updateSkill(projectId, ...params), }; const currentDialog = useMemo(() => dialogs.find((d) => d.id === dialogId), [dialogs, dialogId]); @@ -240,6 +237,7 @@ export function useShell(source: EventSource, projectId: string): Shell { clipboardActions, hosted: !!isAbsHosted(), skills, + skillsSettings: settings.skill || {}, } : ({} as ShellData); diff --git a/Composer/packages/lib/shared/src/types/shell.ts b/Composer/packages/lib/shared/src/types/shell.ts index acfb580fb0..03c5753076 100644 --- a/Composer/packages/lib/shared/src/types/shell.ts +++ b/Composer/packages/lib/shared/src/types/shell.ts @@ -58,6 +58,7 @@ export interface ShellData { qnaFiles: QnAFile[]; userSettings: UserSettings; skills: any[]; + skillsSettings: Record; // TODO: remove schemas: BotSchemas; } @@ -100,10 +101,7 @@ export interface ShellApi { displayManifestModal: (manifestId: string) => void; updateDialogSchema: (_: DialogSchemaFile) => Promise; createTrigger: (id: string, formData, url?: string) => void; - skillsSettings: { - get: (path: string) => any; - set: (skillId: string, skillsData: SkillSetting) => Promise; - }; + updateSkillSetting: (skillId: string, skillsData: SkillSetting) => Promise; } export interface Shell { diff --git a/Composer/packages/ui-plugins/select-skill-dialog/src/BeginSkillDialogField.tsx b/Composer/packages/ui-plugins/select-skill-dialog/src/BeginSkillDialogField.tsx index 5e46060a83..a2b919c38e 100644 --- a/Composer/packages/ui-plugins/select-skill-dialog/src/BeginSkillDialogField.tsx +++ b/Composer/packages/ui-plugins/select-skill-dialog/src/BeginSkillDialogField.tsx @@ -1,147 +1,18 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import React, { useMemo, useState, useEffect } from 'react'; -import { FieldProps, JSONSchema7, useShellApi } from '@bfc/extension-client'; -import { Link } from 'office-ui-fabric-react/lib/Link'; +import React from 'react'; +import { FieldProps } from '@bfc/extension-client'; import { ObjectField } from '@bfc/adaptive-form'; -import formatMessage from 'format-message'; -import { Skill, getSkillNameFromSetting } from '@bfc/shared'; -import { IComboBoxOption } from 'office-ui-fabric-react/lib/ComboBox'; import { SelectSkillDialog } from './SelectSkillDialogField'; -import { SkillEndpointField } from './SkillEndpointField'; - -const referBySettings = (skillName: string, property: string) => { - return `=settings.skill['${skillName}'].${property}`; -}; - -const settingReferences = (skillName: string) => ({ - skillEndpoint: referBySettings(skillName, 'endpointUrl'), - skillAppId: referBySettings(skillName, 'msAppId'), -}); - -const handleBackwardCompatibility = (skills: Skill[], value): { name: string; endpointName: string } | undefined => { - const { skillEndpoint } = value; - const foundSkill = skills.find(({ manifestUrl }) => manifestUrl === value.id); - if (foundSkill) { - const matchedEndpoint: any = foundSkill.endpoints.find(({ endpointUrl }) => endpointUrl === skillEndpoint); - return { - name: foundSkill?.name, - endpointName: matchedEndpoint ? matchedEndpoint.name : '', - }; - } -}; export const BeginSkillDialogField: React.FC = (props) => { - const { depth, id, schema, uiOptions, value, onChange, definitions } = props; - const { projectId, shellApi, skills = [] } = useShellApi(); - const { displayManifestModal, skillsSettings } = shellApi; - const [selectedSkill, setSelectedSkill] = useState(''); - const [oldEndpoint, loadEndpointForOldBots] = useState(''); - - useEffect(() => { - const { skillEndpoint } = value; - const skill = skills.find(({ name }) => name === getSkillNameFromSetting(skillEndpoint)); - - if (skill) { - setSelectedSkill(skill.name); - } else { - const result = handleBackwardCompatibility(skills, value); - if (result) { - setSelectedSkill(result.name); - if (result.endpointName) { - loadEndpointForOldBots(result.endpointName); - } - } - } - }, []); - - const matchedSkill = useMemo(() => { - return skills.find(({ id }) => id === selectedSkill) || ({} as Skill); - }, [skills, selectedSkill]); - - const endpointOptions = useMemo(() => { - return (matchedSkill.endpoints || []).map(({ name }) => name); - }, [matchedSkill]); - - const handleEndpointChange = async (skillEndpoint) => { - if (matchedSkill.id) { - const { msAppId, endpointUrl } = - (matchedSkill.endpoints || []).find(({ name }) => name === skillEndpoint) || ({} as any); - const schemaUpdate: any = {}; - const settingsUpdate: any = { ...matchedSkill }; - if (endpointUrl) { - schemaUpdate.skillEndpoint = referBySettings(matchedSkill.name, 'endpointUrl'); - settingsUpdate.endpointUrl = endpointUrl; - } - if (msAppId) { - schemaUpdate.skillAppId = referBySettings(matchedSkill.name, 'msAppId'); - settingsUpdate.msAppId = msAppId; - } - skillsSettings.set(matchedSkill.id, { ...settingsUpdate }); - onChange({ - ...value, - ...schemaUpdate, - }); - } - }; - - useEffect(() => { - if (oldEndpoint) { - handleEndpointChange(oldEndpoint); - } - }, [oldEndpoint]); - - const handleShowManifestClick = () => { - matchedSkill && displayManifestModal(matchedSkill.manifestUrl); - }; - - const skillEndpointUiSchema = uiOptions.properties?.skillEndpoint || {}; - skillEndpointUiSchema.serializer = { - get: (value) => { - const url: any = skillsSettings.get(value); - const endpoint = (matchedSkill?.endpoints || []).find(({ endpointUrl }) => endpointUrl === url); - return endpoint?.name; - }, - set: (value) => { - const endpoint = (matchedSkill?.endpoints || []).find(({ name }) => name === value); - return endpoint?.endpointUrl; - }, - }; - - const onSkillSelectionChange = (option: IComboBoxOption | null) => { - if (option?.text) { - setSelectedSkill(option.text); - onChange({ ...value, ...settingReferences(option.text) }); - } - }; + const { value, onChange } = props; return ( - - - {formatMessage('Show skill manifest')} - - - - {formatMessage('Open Skills page for configuration details')} - + ); diff --git a/Composer/packages/ui-plugins/select-skill-dialog/src/SelectSkillDialogField.tsx b/Composer/packages/ui-plugins/select-skill-dialog/src/SelectSkillDialogField.tsx index c7b2159041..ef0cca7244 100644 --- a/Composer/packages/ui-plugins/select-skill-dialog/src/SelectSkillDialogField.tsx +++ b/Composer/packages/ui-plugins/select-skill-dialog/src/SelectSkillDialogField.tsx @@ -8,24 +8,39 @@ import { IComboBoxOption, SelectableOptionMenuItemType } from 'office-ui-fabric- import { useShellApi } from '@bfc/extension-client'; import formatMessage from 'format-message'; import { schemaField } from '@bfc/adaptive-form'; +import { getSkillNameFromSetting, Skill } from '@bfc/shared'; +import { Link } from 'office-ui-fabric-react/lib/components/Link/Link'; import { ComboBoxField } from './ComboBoxField'; const ADD_DIALOG = 'ADD_DIALOG'; +const referBySettings = (skillName: string, property: string) => { + return `=settings.skill['${skillName}'].${property}`; +}; + +export const settingReferences = (skillName: string) => ({ + skillEndpoint: referBySettings(skillName, 'endpointUrl'), + skillAppId: referBySettings(skillName, 'msAppId'), +}); + export const SelectSkillDialog: React.FC<{ - value: string; - onChange: (option: IComboBoxOption | null) => void; + value: any; + onChange: (value: any) => void; }> = (props) => { const { value, onChange } = props; const { shellApi, skills = [] } = useShellApi(); - const { addSkillDialog } = shellApi; + const { addSkillDialog, displayManifestModal } = shellApi; const [comboboxTitle, setComboboxTitle] = useState(null); - const options: IComboBoxOption[] = skills.map(({ name }) => ({ + const skillId = getSkillNameFromSetting(value.skillEndpoint); + const { content, manifestUrl, name } = skills.find(({ id }) => id === skillId) || ({} as Skill); + + const options: IComboBoxOption[] = skills.map(({ id, name }) => ({ key: name, text: name, - isSelected: value === name, + data: settingReferences(id), + isSelected: id === skillId, })); options.push( @@ -41,21 +56,19 @@ export const SelectSkillDialog: React.FC<{ options.push({ key: 'customTitle', text: comboboxTitle }); } - const handleChange = (_, option) => { + const handleChange = (_, option: IComboBoxOption) => { if (option) { if (option.key === ADD_DIALOG) { setComboboxTitle(formatMessage('Add a new Skill Dialog')); addSkillDialog().then((skill) => { if (skill?.manifestUrl && skill?.name) { - onChange({ key: skill?.manifestUrl, text: skill?.name }); + onChange({ ...value, ...settingReferences(skill.name) }); } setComboboxTitle(null); }); } else { - onChange(option); + onChange({ ...value, ...option.data }); } - } else { - onChange(null); } }; @@ -67,9 +80,16 @@ export const SelectSkillDialog: React.FC<{ id={'SkillDialogName'} label={formatMessage('Skill Dialog Name')} options={options} - value={value} + value={skillId} onChange={handleChange} /> + manifestUrl && displayManifestModal(manifestUrl)} + > + {formatMessage('Show skill manifest')} +
); }; diff --git a/Composer/packages/ui-plugins/select-skill-dialog/src/SkillEndpointField.tsx b/Composer/packages/ui-plugins/select-skill-dialog/src/SkillEndpointField.tsx index 0787695d5e..3e21d71a9a 100644 --- a/Composer/packages/ui-plugins/select-skill-dialog/src/SkillEndpointField.tsx +++ b/Composer/packages/ui-plugins/select-skill-dialog/src/SkillEndpointField.tsx @@ -3,50 +3,48 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; -import React from 'react'; -import { FieldProps, useFormConfig } from '@bfc/extension-client'; -import { - getUiLabel, - getUIOptions, - getUiPlaceholder, - getUiDescription, - schemaField, - SelectField, -} from '@bfc/adaptive-form'; +import React, { useMemo } from 'react'; +import { Dropdown, IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; +import { FieldProps, useShellApi } from '@bfc/extension-client'; +import { FieldLabel } from '@bfc/adaptive-form'; +import { getSkillNameFromSetting, Skill } from '@bfc/shared'; export const SkillEndpointField: React.FC = (props) => { - const { depth, schema, uiOptions: baseUIOptions, value, onChange } = props; - const formUIOptions = useFormConfig(); + const { description, label, required, uiOptions, value } = props; + const { shellApi, skillsSettings, skills = [] } = useShellApi(); + const { updateSkillSetting } = shellApi; - const uiOptions = { - ...getUIOptions(schema, formUIOptions), - ...baseUIOptions, - }; + const id = getSkillNameFromSetting(value); + const skill = skills.find(({ id: skillId }) => skillId === id) || ({} as Skill); + const { endpointUrl, msAppId } = skillsSettings[id] || {}; + + const { endpoints = [] } = skill; - const deserializedValue = typeof uiOptions?.serializer?.get === 'function' ? uiOptions.serializer.get(value) : value; + const options = useMemo( + () => + endpoints.map(({ name, endpointUrl, msAppId }, key) => ({ + key, + text: name, + data: { + endpointUrl, + msAppId, + }, + })), + [endpoints] + ); - const handleChange = (newValue: any) => { - const serializedValue = newValue; - if (typeof uiOptions?.serializer?.set === 'function') { - uiOptions.serializer.set(newValue); + const { key } = options.find(({ data }) => data.endpointUrl === endpointUrl && data.msAppId === msAppId) || {}; + + const handleChange = (_: React.FormEvent, option?: IDropdownOption) => { + if (option) { + updateSkillSetting(skill.id, { ...skill, ...option.data }); } - onChange(serializedValue); }; - const label = getUiLabel({ ...props, uiOptions }); - const placeholder = getUiPlaceholder({ ...props, uiOptions }); - const description = getUiDescription({ ...props, uiOptions }); - return ( -
- -
+ + + + ); }; diff --git a/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/BeginSkillDialogField.test.tsx b/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/BeginSkillDialogField.test.tsx deleted file mode 100644 index 3caaab819c..0000000000 --- a/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/BeginSkillDialogField.test.tsx +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -/* eslint-disable @typescript-eslint/no-explicit-any */ - -import React from 'react'; -import { fireEvent, getAllByRole, render } from '@bfc/test-utils'; -import { EditorExtension, JSONSchema7 } from '@bfc/extension-client'; -import { SDKKinds, convertSkillsToDictionary, fetchFromSettings } from '@bfc/shared'; -import { act } from '@bfc/test-utils/lib/hooks'; - -import { BeginSkillDialogField } from '../BeginSkillDialogField'; -import pluginConfig from '..'; - -import { schema } from './constants'; - -const skills: any = [ - { - id: 'yuesuemailskill0207', - content: {}, - manifestUrl: 'https://yuesuemailskill0207-gjvga67.azurewebsites.net/manifest/manifest-1.0.json', - name: 'yuesuemailskill0207', - endpoints: [ - { - name: 'production', - protocol: 'BotFrameworkV3', - description: 'Production endpoint for the Email Skill', - endpointUrl: 'https://yuesuemailskill0207-gjvga67.azurewebsites.net/api/messages', - msAppId: '79432da8-0f7e-4a16-8c23-ddbba30ae85d', - }, - ], - }, -]; - -const renderBeginSkillDialog = ({ value = {}, onChange = jest.fn() } = {}) => { - const addSkillDialog = jest.fn().mockResolvedValue({ manifestUrl: 'https://' }); - - const props = { - depth: 1, - id: 'select.skillDialog', - schema: (schema?.[SDKKinds.BeginSkill] || {}) as JSONSchema7, - uiOptions: pluginConfig.uiSchema?.[SDKKinds.BeginSkill]?.form || {}, - value, - onChange, - definitions: {}, - name: 'select.skillDialog', - }; - - const shellData: any = { - skills, - }; - const setting: any = { - skill: convertSkillsToDictionary(skills), - }; - - const shell: any = { - addSkillDialog, - skillsSettings: { - get: (path: string) => fetchFromSettings(path, setting), - set: () => {}, - }, - }; - - return render( - - - - ); -}; - -describe('Begin Skill Dialog', () => { - beforeEach(() => { - jest.useFakeTimers(); - }); - - it('should add a new skill', async () => { - const onChange = jest.fn(); - const value = { skillEndpoint: `=settings.skill['yuesuemailskill0207'].endpointUrl` }; - const { baseElement, findByRole } = renderBeginSkillDialog({ value, onChange }); - - const listbox = await findByRole('listbox'); - fireEvent.click(listbox); - - const endpoints = getAllByRole(baseElement, 'option'); - act(() => { - fireEvent.click(endpoints[endpoints.length - 1]); - }); - - expect(onChange).toHaveBeenCalledWith({ - skillAppId: "=settings.skill['yuesuemailskill0207'].msAppId", - skillEndpoint: "=settings.skill['yuesuemailskill0207'].endpointUrl", - }); - }); - - it('should be backwards compatible', async () => { - const onChange = jest.fn(); - const value = { - id: `https://yuesuemailskill0207-gjvga67.azurewebsites.net/manifest/manifest-1.0.json`, - skillEndpoint: 'https://yuesuemailskill0207-gjvga67.azurewebsites.net/api/messages', - }; - renderBeginSkillDialog({ value, onChange }); - - expect(onChange).toHaveBeenCalledWith({ - id: `https://yuesuemailskill0207-gjvga67.azurewebsites.net/manifest/manifest-1.0.json`, - skillAppId: "=settings.skill['yuesuemailskill0207'].msAppId", - skillEndpoint: "=settings.skill['yuesuemailskill0207'].endpointUrl", - }); - }); -}); diff --git a/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/SelectSkillDialog.test.tsx b/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/SelectSkillDialog.test.tsx index 09db7f078d..f30715afc5 100644 --- a/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/SelectSkillDialog.test.tsx +++ b/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/SelectSkillDialog.test.tsx @@ -1,61 +1,22 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -// @ts-nocheck - import React from 'react'; -import { fireEvent, getAllByRole, render } from '@bfc/test-utils'; +import { act, fireEvent, getAllByRole, render } from '@bfc/test-utils'; import { EditorExtension } from '@bfc/extension-client'; -import { fetchFromSettings, convertSkillsToDictionary } from '@bfc/shared'; -import { SelectSkillDialog } from '../SelectSkillDialogField'; +import { SelectSkillDialog, settingReferences } from '../SelectSkillDialogField'; -const skills = [ - { - manifestUrl: 'https://yuesuemailskill0207-gjvga67.azurewebsites.net/manifest/manifest-1.0.json', - name: 'Email Skill', - endpoints: [ - { - name: 'production', - protocol: 'BotFrameworkV3', - description: 'Production endpoint for the Email Skill', - endpointUrl: 'https://yuesuemailskill0207-gjvga67.azurewebsites.net/api/messages', - msAppId: '79432da8-0f7e-4a16-8c23-ddbba30ae85d', - }, - ], - }, - { - manifestUrl: 'https://ericv3skillssimplesandwichbot.azurewebsites.net/wwwroot/sandwich-bot-manifest.json', - name: 'Sandwich Skill Bot', - endpoints: [ - { - name: 'YourSandwichBotName', - protocol: 'BotFrameworkV3', - description: 'Default endpoint for the skill', - endpointUrl: 'https://ericv3skillssimplesandwichbot.azurewebsites.net/api/messages', - msAppId: '94e29d0f-3f0d-46f0-aa78-00aed83698cf', - }, - ], - }, -]; +import { skills } from './constants'; -const renderSelectSkillDialog = ({ addSkillDialog, onChange } = {}) => { +const renderSelectSkillDialog = ({ addSkillDialog = jest.fn(), onChange = jest.fn() } = {}) => { const props = { - description: 'Name of the skill to call.', - id: 'select.skillDialog', - label: 'Skill Dialog Name', + value: {}, onChange, }; const shell = { addSkillDialog, - skillsSettings: { - get: (path: string) => - fetchFromSettings(path, { - skill: convertSkillsToDictionary(skills), - }), - set: () => {}, - }, }; const shellData = { @@ -74,7 +35,12 @@ describe('Select Skill Dialog', () => { const addSkillDialog = jest.fn().mockImplementation(() => { return { then: (cb) => { - cb({ manifestUrl: 'https://', name: 'test-skill' }); + cb({ + manifestUrl: 'https://skill', + name: 'test-skill', + msAppId: '0000-0000', + endpointUrl: 'https://skill/api/messafes', + }); }, }; }); @@ -82,31 +48,17 @@ describe('Select Skill Dialog', () => { const { baseElement, findByRole } = renderSelectSkillDialog({ addSkillDialog, onChange }); const combobox = await findByRole('combobox'); - fireEvent.click(combobox); + act(() => { + fireEvent.click(combobox); + }); const dialogs = getAllByRole(baseElement, 'option'); - fireEvent.click(dialogs[dialogs.length - 1]); + act(() => { + fireEvent.click(dialogs[dialogs.length - 1]); + }); expect(addSkillDialog).toHaveBeenCalled(); - expect(onChange).toHaveBeenCalledWith({ key: 'https://', text: 'test-skill' }); - }); - - it('should select skill', async () => { - const onChange = jest.fn(); - - const { baseElement, findByRole } = renderSelectSkillDialog({ onChange }); - const combobox = await findByRole('combobox'); - fireEvent.click(combobox); - - const [skill] = getAllByRole(baseElement, 'option'); - fireEvent.click(skill); - - expect(onChange).toHaveBeenCalledWith({ - index: 0, - isSelected: false, - key: 'Email Skill', - text: 'Email Skill', - }); + expect(onChange).toHaveBeenCalledWith({ ...settingReferences('test-skill') }); }); it('should display label', async () => { diff --git a/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/SkillEndpointField.test.tsx b/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/SkillEndpointField.test.tsx new file mode 100644 index 0000000000..05a21d8cce --- /dev/null +++ b/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/SkillEndpointField.test.tsx @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import React from 'react'; +import { fireEvent, getAllByRole, render } from '@bfc/test-utils'; +import { EditorExtension } from '@bfc/extension-client'; +import { convertSkillsToDictionary, Skill } from '@bfc/shared'; +import { act } from '@bfc/test-utils/lib/hooks'; + +import { SkillEndpointField } from '../SkillEndpointField'; + +import { skills } from './constants'; + +const projectId = '123.abc'; + +const renderSkillEndpointField = ({ value = '', updateSkillSetting = jest.fn() } = {}) => { + const props = { + label: 'Skill endpoint', + value, + } as any; + + const shellData: any = { + skillsSettings: convertSkillsToDictionary(skills as Skill[]), + skills, + }; + + const shell: any = { + updateSkillSetting, + }; + + return render( + + + + ); +}; + +describe('Begin Skill Dialog', () => { + it('should update the skill settings', async () => { + const updateSkillSetting = jest.fn(); + const { baseElement, findByRole } = renderSkillEndpointField({ + updateSkillSetting, + value: `=settings.skill['${skills[0].id}'].endpointUrl`, + }); + + const listbox = await findByRole('listbox'); + act(() => { + fireEvent.click(listbox); + }); + + const endpoints = getAllByRole(baseElement, 'option'); + act(() => { + fireEvent.click(endpoints[endpoints.length - 1]); + }); + + expect(updateSkillSetting).toHaveBeenCalledWith( + skills[0].id, + expect.objectContaining({ + endpointUrl: skills[0].endpoints[0].endpointUrl, + msAppId: skills[0].endpoints[0].msAppId, + }) + ); + }); +}); diff --git a/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/constants.ts b/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/constants.ts index 2202c4bf12..7b8d4f479f 100644 --- a/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/constants.ts +++ b/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/constants.ts @@ -3,8 +3,8 @@ export const skills = [ { + id: 'yuesuemailskill0207', manifestUrl: 'https://yuesuemailskill0207-gjvga67.azurewebsites.net/manifest/manifest-1.0.json', - name: 'Email Skill', endpoints: [ { name: 'production', @@ -14,8 +14,12 @@ export const skills = [ msAppId: '79432da8-0f7e-4a16-8c23-ddbba30ae85d', }, ], + name: 'Email Skill', + description: 'Production endpoint for the Email Skill', + content: {}, }, { + id: 'sandwich', manifestUrl: 'https://ericv3skillssimplesandwichbot.azurewebsites.net/wwwroot/sandwich-bot-manifest.json', name: 'Sandwich Skill Bot', endpoints: [ @@ -27,40 +31,7 @@ export const skills = [ msAppId: '94e29d0f-3f0d-46f0-aa78-00aed83698cf', }, ], + description: 'Default endpoint for the skill', + content: {}, }, ]; - -export const schema = { - 'Microsoft.BeginSkill': { - $role: 'implements(Microsoft.IDialog)', - title: 'Begin a skill dialog', - description: 'Begin a remote skill dialog.', - type: 'object', - properties: { - $kind: { - title: '$kind', - description: 'Defines the valid properties for the component you are configuring (from a dialog .schema file)', - type: 'string', - pattern: '^[a-zA-Z][a-zA-Z0-9.]*$', - const: 'Microsoft.BeginSkill', - }, - id: { - type: 'string', - title: 'Id', - description: 'Optional id for the skill dialog', - }, - skillAppId: { - $role: 'expression', - type: 'string', - title: 'Skill App ID', - description: 'The Microsoft App ID for the skill.', - }, - skillEndpoint: { - type: 'string', - title: 'Skill endpoint ', - description: 'The /api/messages endpoint for the skill.', - examples: ['https://myskill.contoso.com/api/messages/'], - }, - }, - }, -}; diff --git a/Composer/packages/ui-plugins/select-skill-dialog/src/index.ts b/Composer/packages/ui-plugins/select-skill-dialog/src/index.ts index 6dcd1ca222..3240a8bf28 100644 --- a/Composer/packages/ui-plugins/select-skill-dialog/src/index.ts +++ b/Composer/packages/ui-plugins/select-skill-dialog/src/index.ts @@ -5,14 +5,20 @@ import { PluginConfig } from '@bfc/extension-client'; import { SDKKinds } from '@bfc/shared'; import { BeginSkillDialogField } from './BeginSkillDialogField'; +import { SkillEndpointField } from './SkillEndpointField'; const config: PluginConfig = { uiSchema: { [SDKKinds.BeginSkill]: { form: { - order: ['skillAppId', '*', 'resultProperty', 'disabled', 'activityProcessed'], - hidden: ['botId', 'skillEndpoint', 'skillAppId', 'skillHostEndpoint'], + order: ['skillEndpoint', '*', 'resultProperty', 'activityProcessed'], + hidden: ['botId', 'skillAppId', 'skillHostEndpoint'], field: BeginSkillDialogField, + properties: { + skillEndpoint: { + field: SkillEndpointField, + }, + }, }, }, }, From 46552aae3627c4f04af2ce12abc9dc55fec36954 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Tue, 22 Sep 2020 17:41:11 -0700 Subject: [PATCH 4/9] feat: install remote extensions from npm (#4224) * move server tests into local __tests__ directories * add tests for extensions controller * set up jest for extension package * add build config * add test for manifest store * change logger prefix * extract npm command to helper util * add missing dependency * rename env variables * use different manifest for tests * rename parameter for clarity * load all extensions on server start * more name refactoring * fix test mocks * set up tests better * load all extensions on server start * refactor npm signature * further scope npm search keywords * apply code review feedback * disable format message warnings for test * reduce console noise in tests * make console override safer * load plugins from correct directory in test Co-authored-by: Tony Anziano --- Composer/.gitignore | 3 + Composer/packages/electron-server/src/main.ts | 4 +- Composer/packages/extension/.gitignore | 3 + Composer/packages/extension/jest.config.js | 8 + Composer/packages/extension/package.json | 8 +- .../extension/src/__tests__/setupEnv.ts | 8 + .../pluginLoader.ts => extensionContext.ts} | 24 +- ...gistration.ts => extensionRegistration.ts} | 71 +++--- Composer/packages/extension/src/index.ts | 3 +- .../packages/extension/src/loader/index.ts | 5 - Composer/packages/extension/src/logger.ts | 2 +- .../src/manager/__tests__/manager.test.ts | 79 ++++++ .../packages/extension/src/manager/manager.ts | 225 ++++++++---------- .../__tests__/extensionManifestStore.test.ts | 113 +++++++++ .../src/storage/extensionManifestStore.ts | 79 +++--- .../packages/extension/src/types/extension.ts | 7 +- .../packages/extension/src/types/types.ts | 4 +- Composer/packages/extension/src/utils/npm.ts | 59 +++++ .../packages/extension/tsconfig.build.json | 8 + Composer/packages/server/.gitignore | 3 +- Composer/packages/server/jest.config.js | 4 +- Composer/packages/server/package.json | 5 +- .../asset/projects/SampleBot/bot1.dialog | 0 .../projects/SampleBot/dialogs/a/a.dialog | 0 .../a/knowledge-base/en-us/a.en-us.qna | 0 .../a/language-generation/en-us/a.en-us.lg | 0 .../a/language-understanding/en-us/a.en-us.lu | 0 .../projects/SampleBot/dialogs/b/b.dialog | 0 .../b/knowledge-base/en-us/b.en-us.qna | 0 .../b/language-generation/en-us/b.en-us.lg | 0 .../b/language-understanding/en-us/b.en-us.lu | 0 .../knowledge-base/en-us/bot1.en-us.qna | 0 .../language-generation/en-us/bot1.en-us.lg | 0 .../language-generation/en-us/common.en-us.lg | 0 .../en-us/bot1.en-us.lu | 0 .../SampleBot/settings/appsettings.json | 0 .../__mocks__}/runtimes/CSharp/readme.md | 0 .../__mocks__}/runtimes/dotnet/readme.md | 0 .../__mocks__}/samplebots/bot1/bot1.dialog | 0 .../samplebots/bot1/dialogs/a/a.dialog | 0 .../a/language-generation/en-us/a.en-us.lg | 0 .../a/language-understanding/en-us/a.en-us.lu | 0 .../samplebots/bot1/dialogs/b/b.dialog | 0 .../b/language-generation/en-us/b.en-us.lg | 0 .../b/language-understanding/en-us/b.en-us.lu | 0 .../language-generation/en-us/bot1.en-us.lg | 0 .../language-generation/en-us/common.en-us.lg | 0 .../en-us/bot1.en-us.lu | 0 .../samplebots/bot1/settings/appsettings.json | 0 .../server/{ => src}/__tests__/setupEnv.ts | 0 .../controllers/__tests__}/asset.test.ts | 2 +- .../controllers/__tests__}/eject.test.ts | 18 +- .../controllers/__tests__/extensions.test.ts | 224 +++++++++++++++++ .../controllers/__tests__}/project.test.ts | 34 ++- .../controllers/__tests__}/publisher.test.ts | 33 ++- .../controllers/__tests__}/storage.test.ts | 6 +- .../packages/server/src/controllers/eject.ts | 8 +- .../server/src/controllers/extensions.ts | 43 ++-- .../server/src/controllers/formDialog.ts | 4 +- .../server/src/controllers/project.ts | 30 +-- .../server/src/controllers/publisher.ts | 75 ++---- .../server/src/controllers/storage.ts | 4 +- .../asset/__tests__}/assetManager.test.ts | 14 +- .../server/src/models/asset/assetManager.ts | 12 +- .../models/bot/__tests__}/botProject.test.ts | 8 +- .../bot/__tests__}/botStructure.test.ts | 2 +- .../server/src/models/bot/botProject.ts | 10 +- .../__tests__}/BootstrapSampler.test.ts | 2 +- .../__tests__}/ReservoirSampler.test.ts | 2 +- .../__tests__}/fileSettingManager.test.ts | 4 +- .../mocks/bonus/settings/appsettings.json | 0 .../integration/settings/appsettings.json | 0 .../production/settings/appsettings.json | 0 .../mocks/settings/appsettings.json | 0 .../src/models/storage/storageFactory.ts | 6 +- Composer/packages/server/src/server.ts | 15 +- .../services/__tests__}/project.test.ts | 10 +- .../services/__tests__}/storage.test.ts | 6 +- Composer/packages/server/tsconfig.build.json | 2 +- Composer/packages/server/tsconfig.json | 2 +- .../test-utils/src/base/jest.config.ts | 2 +- .../packages/test-utils/src/base/setup.ts | 14 -- .../packages/test-utils/src/base/setupEnv.ts | 34 +++ Composer/plugins/README.md | 8 +- .../plugins/mockRemotePublish/src/index.ts | 8 +- Composer/plugins/sample-ui-plugin/README.md | 2 +- .../plugins/sample-ui-plugin/package.json | 2 +- Composer/yarn.lock | 52 ++-- 88 files changed, 966 insertions(+), 457 deletions(-) create mode 100644 Composer/packages/extension/jest.config.js create mode 100644 Composer/packages/extension/src/__tests__/setupEnv.ts rename Composer/packages/extension/src/{loader/pluginLoader.ts => extensionContext.ts} (85%) rename Composer/packages/extension/src/{loader/composerPluginRegistration.ts => extensionRegistration.ts} (72%) delete mode 100644 Composer/packages/extension/src/loader/index.ts create mode 100644 Composer/packages/extension/src/manager/__tests__/manager.test.ts create mode 100644 Composer/packages/extension/src/storage/__tests__/extensionManifestStore.test.ts create mode 100644 Composer/packages/extension/src/utils/npm.ts create mode 100644 Composer/packages/extension/tsconfig.build.json rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/asset/projects/SampleBot/bot1.dialog (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/asset/projects/SampleBot/dialogs/a/a.dialog (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/asset/projects/SampleBot/dialogs/a/knowledge-base/en-us/a.en-us.qna (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/asset/projects/SampleBot/dialogs/a/language-generation/en-us/a.en-us.lg (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/asset/projects/SampleBot/dialogs/a/language-understanding/en-us/a.en-us.lu (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/asset/projects/SampleBot/dialogs/b/b.dialog (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/asset/projects/SampleBot/dialogs/b/knowledge-base/en-us/b.en-us.qna (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/asset/projects/SampleBot/dialogs/b/language-generation/en-us/b.en-us.lg (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/asset/projects/SampleBot/dialogs/b/language-understanding/en-us/b.en-us.lu (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/asset/projects/SampleBot/knowledge-base/en-us/bot1.en-us.qna (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/asset/projects/SampleBot/language-generation/en-us/bot1.en-us.lg (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/asset/projects/SampleBot/language-generation/en-us/common.en-us.lg (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/asset/projects/SampleBot/language-understanding/en-us/bot1.en-us.lu (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/asset/projects/SampleBot/settings/appsettings.json (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/runtimes/CSharp/readme.md (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/runtimes/dotnet/readme.md (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/samplebots/bot1/bot1.dialog (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/samplebots/bot1/dialogs/a/a.dialog (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/samplebots/bot1/dialogs/a/language-generation/en-us/a.en-us.lg (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/samplebots/bot1/dialogs/a/language-understanding/en-us/a.en-us.lu (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/samplebots/bot1/dialogs/b/b.dialog (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/samplebots/bot1/dialogs/b/language-generation/en-us/b.en-us.lg (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/samplebots/bot1/dialogs/b/language-understanding/en-us/b.en-us.lu (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/samplebots/bot1/language-generation/en-us/bot1.en-us.lg (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/samplebots/bot1/language-generation/en-us/common.en-us.lg (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/samplebots/bot1/language-understanding/en-us/bot1.en-us.lu (100%) rename Composer/packages/server/{__tests__/mocks => src/__mocks__}/samplebots/bot1/settings/appsettings.json (100%) rename Composer/packages/server/{ => src}/__tests__/setupEnv.ts (100%) rename Composer/packages/server/{__tests__/controllers => src/controllers/__tests__}/asset.test.ts (90%) rename Composer/packages/server/{__tests__/controllers => src/controllers/__tests__}/eject.test.ts (85%) create mode 100644 Composer/packages/server/src/controllers/__tests__/extensions.test.ts rename Composer/packages/server/{__tests__/controllers => src/controllers/__tests__}/project.test.ts (91%) rename Composer/packages/server/{__tests__/controllers => src/controllers/__tests__}/publisher.test.ts (77%) rename Composer/packages/server/{__tests__/controllers => src/controllers/__tests__}/storage.test.ts (89%) rename Composer/packages/server/{__tests__/models/asset => src/models/asset/__tests__}/assetManager.test.ts (76%) rename Composer/packages/server/{__tests__/models/bot => src/models/bot/__tests__}/botProject.test.ts (97%) rename Composer/packages/server/{__tests__/models/bot => src/models/bot/__tests__}/botStructure.test.ts (98%) rename Composer/packages/server/{__tests__/models/bot/sampler => src/models/bot/sampler/__tests__}/BootstrapSampler.test.ts (91%) rename Composer/packages/server/{__tests__/models/bot/sampler => src/models/bot/sampler/__tests__}/ReservoirSampler.test.ts (93%) rename Composer/packages/server/{__tests__/models/settings => src/models/settings/__tests__}/fileSettingManager.test.ts (85%) rename Composer/packages/server/{__tests__/models/settings => src/models/settings/__tests__}/mocks/bonus/settings/appsettings.json (100%) rename Composer/packages/server/{__tests__/models/settings => src/models/settings/__tests__}/mocks/integration/settings/appsettings.json (100%) rename Composer/packages/server/{__tests__/models/settings => src/models/settings/__tests__}/mocks/production/settings/appsettings.json (100%) rename Composer/packages/server/{__tests__/models/settings => src/models/settings/__tests__}/mocks/settings/appsettings.json (100%) rename Composer/packages/server/{__tests__/services => src/services/__tests__}/project.test.ts (85%) rename Composer/packages/server/{__tests__/services => src/services/__tests__}/storage.test.ts (90%) delete mode 100644 Composer/packages/test-utils/src/base/setup.ts create mode 100644 Composer/packages/test-utils/src/base/setupEnv.ts diff --git a/Composer/.gitignore b/Composer/.gitignore index 756c18e624..4a121af957 100644 --- a/Composer/.gitignore +++ b/Composer/.gitignore @@ -13,3 +13,6 @@ packages/server/schemas/*.schema packages/server/schemas/*.uischema !packages/server/schemas/sdk.schema !packages/server/schemas/sdk.uischema + +# remote extensions +.composer diff --git a/Composer/packages/electron-server/src/main.ts b/Composer/packages/electron-server/src/main.ts index 8e86f112b8..3887eda3b0 100644 --- a/Composer/packages/electron-server/src/main.ts +++ b/Composer/packages/electron-server/src/main.ts @@ -63,7 +63,7 @@ async function createAppDataDir() { const azurePublishPath: string = join(composerAppDataPath, 'publishBots'); process.env.COMPOSER_APP_DATA = join(composerAppDataPath, 'data.json'); // path to the actual data file process.env.COMPOSER_EXTENSION_DATA = join(composerAppDataPath, 'extensions.json'); - process.env.COMPOSER_REMOTE_PLUGINS_DIR = join(composerAppDataPath, '.composer'); + process.env.COMPOSER_REMOTE_EXTENSIONS_DIR = join(composerAppDataPath, '.composer'); log('creating composer app data path at: ', composerAppDataPath); @@ -142,7 +142,7 @@ async function loadServer() { // only change paths if packaged electron app const unpackedDir = getUnpackedAsarPath(); process.env.COMPOSER_RUNTIME_FOLDER = join(unpackedDir, 'runtime'); - process.env.COMPOSER_BUILTIN_PLUGINS_DIR = join(unpackedDir, 'build', 'plugins'); + process.env.COMPOSER_BUILTIN_EXTENSIONS_DIR = join(unpackedDir, 'build', 'plugins'); } // only create a new data directory if packaged electron app diff --git a/Composer/packages/extension/.gitignore b/Composer/packages/extension/.gitignore index c3af857904..0b59fe24ac 100644 --- a/Composer/packages/extension/.gitignore +++ b/Composer/packages/extension/.gitignore @@ -1 +1,4 @@ lib/ +src/__tests__/__manifest__.json +src/__tests__/__builtin__ +src/__tests__/__remote__ diff --git a/Composer/packages/extension/jest.config.js b/Composer/packages/extension/jest.config.js new file mode 100644 index 0000000000..81af8fc3ab --- /dev/null +++ b/Composer/packages/extension/jest.config.js @@ -0,0 +1,8 @@ +const path = require('path'); + +const { createConfig } = require('@bfc/test-utils'); + +module.exports = createConfig('extension', 'node', { + setupFiles: [path.resolve(__dirname, 'src/__tests__/setupEnv.ts')], + testPathIgnorePatterns: ['src/__tests__/setupEnv.ts'], +}); diff --git a/Composer/packages/extension/package.json b/Composer/packages/extension/package.json index c48d6b1f87..cd99329ead 100644 --- a/Composer/packages/extension/package.json +++ b/Composer/packages/extension/package.json @@ -7,12 +7,15 @@ "private": true, "scripts": { "build": "yarn build:clean && yarn build:ts", - "build:ts": "tsc", + "build:ts": "tsc -p tsconfig.build.json", "build:clean": "rimraf lib && rimraf build", - "lint": "eslint --quiet ./src" + "lint": "eslint --quiet ./src", + "test": "jest" }, "devDependencies": { + "@bfc/test-utils": "*", "@types/express": "^4.17.6", + "@types/fs-extra": "^9.0.1", "@types/passport": "^1.0.3", "@types/path-to-regexp": "^1.7.0", "json-schema": "^0.2.5", @@ -21,6 +24,7 @@ }, "dependencies": { "debug": "^4.1.1", + "fs-extra": "^9.0.1", "globby": "^11.0.0", "passport": "^0.4.1", "path-to-regexp": "^6.1.0" diff --git a/Composer/packages/extension/src/__tests__/setupEnv.ts b/Composer/packages/extension/src/__tests__/setupEnv.ts new file mode 100644 index 0000000000..42911b1eac --- /dev/null +++ b/Composer/packages/extension/src/__tests__/setupEnv.ts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import path from 'path'; + +process.env.COMPOSER_EXTENSION_DATA = path.resolve(__dirname, '__manifest__.json'); +process.env.COMPOSER_BUILTIN_EXTENSIONS_DIR = path.resolve(__dirname, '__builtin__'); +process.env.COMPOSER_REMOTE_EXTENSIONS_DIR = path.resolve(__dirname, '__remote__'); diff --git a/Composer/packages/extension/src/loader/pluginLoader.ts b/Composer/packages/extension/src/extensionContext.ts similarity index 85% rename from Composer/packages/extension/src/loader/pluginLoader.ts rename to Composer/packages/extension/src/extensionContext.ts index 5ccc65929e..3ece1f7681 100644 --- a/Composer/packages/extension/src/loader/pluginLoader.ts +++ b/Composer/packages/extension/src/extensionContext.ts @@ -10,12 +10,13 @@ import { pathToRegexp } from 'path-to-regexp'; import glob from 'globby'; import formatMessage from 'format-message'; -import { UserIdentity, ExtensionCollection, RuntimeTemplate, DEFAULT_RUNTIME } from '../types/types'; -import log from '../logger'; +import { UserIdentity, ExtensionCollection, RuntimeTemplate, DEFAULT_RUNTIME } from './types/types'; +import logger from './logger'; +import { ExtensionRegistration } from './extensionRegistration'; -import { ComposerPluginRegistration } from './composerPluginRegistration'; +const log = logger.extend('extension-context'); -export class PluginLoader { +class ExtensionContext { private _passport: passport.PassportStatic; private _webserver: Express | undefined; public loginUri = '/login'; @@ -67,7 +68,8 @@ export class PluginLoader { } public async loadPlugin(name: string, description: string, thisPlugin: any) { - const pluginRegistration = new ComposerPluginRegistration(this, name, description); + log('Loading extension: %s', name); + const pluginRegistration = new ExtensionRegistration(this, name, description); if (typeof thisPlugin.default === 'function') { // the module exported just an init function thisPlugin.default.call(null, pluginRegistration); @@ -82,12 +84,12 @@ export class PluginLoader { } } - public async loadPluginFromFile(path: string) { - const packageJSON = fs.readFileSync(path, 'utf8'); + public async loadPluginFromFile(packageJsonPath: string) { + const packageJSON = fs.readFileSync(packageJsonPath, 'utf8'); const json = JSON.parse(packageJSON); if (json.extendsComposer) { - const modulePath = path.replace(/package\.json$/, ''); + const modulePath = path.dirname(packageJsonPath); try { // eslint-disable-next-line security/detect-non-literal-require, @typescript-eslint/no-var-requires const thisPlugin = require(modulePath); @@ -131,10 +133,10 @@ export class PluginLoader { } } - static async getUserFromRequest(req): Promise { + public async getUserFromRequest(req): Promise { return req.user || undefined; } } -export const pluginLoader = new PluginLoader(); -export default pluginLoader; +const context = new ExtensionContext(); +export { context as ExtensionContext }; diff --git a/Composer/packages/extension/src/loader/composerPluginRegistration.ts b/Composer/packages/extension/src/extensionRegistration.ts similarity index 72% rename from Composer/packages/extension/src/loader/composerPluginRegistration.ts rename to Composer/packages/extension/src/extensionRegistration.ts index 1297eb2af1..8a3d90de86 100644 --- a/Composer/packages/extension/src/loader/composerPluginRegistration.ts +++ b/Composer/packages/extension/src/extensionRegistration.ts @@ -4,26 +4,27 @@ import { RequestHandler } from 'express-serve-static-core'; import { Debugger } from 'debug'; -import log from '../logger'; -import { PublishPlugin, RuntimeTemplate, BotTemplate } from '../types/types'; +import logger from './logger'; +import { PublishPlugin, RuntimeTemplate, BotTemplate } from './types/types'; +import { ExtensionContext } from './extensionContext'; -import { PluginLoader } from './pluginLoader'; +const log = logger.extend('extension-registration'); -export class ComposerPluginRegistration { - public loader: PluginLoader; +export class ExtensionRegistration { + public context: typeof ExtensionContext; private _name: string; private _description: string; private _log: Debugger; - constructor(loader: PluginLoader, name: string, description: string) { - this.loader = loader; + constructor(context: typeof ExtensionContext, name: string, description: string) { + this.context = context; this._name = name; this._description = description; this._log = log.extend(name); } public get passport() { - return this.loader.passport; + return this.context.passport; } public get name(): string { @@ -46,8 +47,8 @@ export class ComposerPluginRegistration { * Storage related features *************************************************************************************/ public async useStorage(customStorageClass: any) { - if (!this.loader.extensions.storage.customStorageClass) { - this.loader.extensions.storage.customStorageClass = customStorageClass; + if (!this.context.extensions.storage.customStorageClass) { + this.context.extensions.storage.customStorageClass = customStorageClass; } else { throw new Error('Cannot redefine storage driver once set.'); } @@ -58,7 +59,7 @@ export class ComposerPluginRegistration { *************************************************************************************/ public async addPublishMethod(plugin: PublishPlugin) { log('registering publish method', this.name); - this.loader.extensions.publish[plugin.customName || this.name] = { + this.context.extensions.publish[plugin.customName || this.name] = { plugin: { name: plugin.customName || this.name, description: plugin.customDescription || this.description, @@ -90,56 +91,56 @@ export class ComposerPluginRegistration { * ``` */ public addRuntimeTemplate(plugin: RuntimeTemplate) { - this.loader.extensions.runtimeTemplates.push(plugin); + this.context.extensions.runtimeTemplates.push(plugin); } /************************************************************************************** * Get current runtime from project *************************************************************************************/ public getRuntimeByProject(project): RuntimeTemplate { - return this.loader.getRuntimeByProject(project); + return this.context.getRuntimeByProject(project); } /************************************************************************************** * Get current runtime by type *************************************************************************************/ public getRuntime(type: string | undefined): RuntimeTemplate { - return this.loader.getRuntime(type); + return this.context.getRuntime(type); } /************************************************************************************** * Add Bot Template (aka, SampleBot) *************************************************************************************/ public addBotTemplate(template: BotTemplate) { - this.loader.extensions.botTemplates.push(template); + this.context.extensions.botTemplates.push(template); } /************************************************************************************** * Add Base Template (aka, BoilerPlate) *************************************************************************************/ public addBaseTemplate(template: BotTemplate) { - this.loader.extensions.baseTemplates.push(template); + this.context.extensions.baseTemplates.push(template); } /************************************************************************************** * Express/web related features *************************************************************************************/ public addWebMiddleware(middleware: RequestHandler) { - if (!this.loader.webserver) { + if (!this.context.webserver) { throw new Error('Plugin loaded in context without webserver. Cannot add web middleware.'); } else { - this.loader.webserver.use(middleware); + this.context.webserver.use(middleware); } } public addWebRoute(type: string, url: string, ...handlers: RequestHandler[]) { - if (!this.loader.webserver) { + if (!this.context.webserver) { throw new Error('Plugin loaded in context without webserver. Cannot add web route.'); } else { - const method = this.loader.webserver[type.toLowerCase()]; + const method = this.context.webserver[type.toLowerCase()]; if (typeof method === 'function') { - method.call(this.loader.webserver, url, ...handlers); + method.call(this.context.webserver, url, ...handlers); } else { throw new Error(`Unhandled web route type ${type}`); } @@ -151,55 +152,55 @@ export class ComposerPluginRegistration { *************************************************************************************/ public usePassportStrategy(passportStrategy) { // set up the passport strategy to be used - this.loader.passport.use(passportStrategy); + this.context.passport.use(passportStrategy); // bind a basic auth middleware. this can be overridden. see setAuthMiddleware below - this.loader.extensions.authentication.middleware = (req, res, next) => { + this.context.extensions.authentication.middleware = (req, res, next) => { if (req.isAuthenticated()) { next(); } else { log('Rejecting access to ', req.url); - res.redirect(this.loader.loginUri); + res.redirect(this.context.loginUri); } }; // set up default serializer, takes entire object and json encodes - this.loader.extensions.authentication.serializeUser = (user, done) => { + this.context.extensions.authentication.serializeUser = (user, done) => { done(null, JSON.stringify(user)); }; // set up default deserializer. - this.loader.extensions.authentication.deserializeUser = (user, done) => { + this.context.extensions.authentication.deserializeUser = (user, done) => { done(null, JSON.parse(user)); }; // use a wrapper on the serializer that calls configured serializer this.passport.serializeUser((user, done) => { - if (this.loader.extensions.authentication.serializeUser) { - this.loader.extensions.authentication.serializeUser(user, done); + if (this.context.extensions.authentication.serializeUser) { + this.context.extensions.authentication.serializeUser(user, done); } }); // use a wrapper on the deserializer that calls configured deserializer this.passport.deserializeUser((user, done) => { - if (this.loader.extensions.authentication.deserializeUser) { - this.loader.extensions.authentication.deserializeUser(user, done); + if (this.context.extensions.authentication.deserializeUser) { + this.context.extensions.authentication.deserializeUser(user, done); } }); } public useAuthMiddleware(middleware: RequestHandler) { - this.loader.extensions.authentication.middleware = middleware; + this.context.extensions.authentication.middleware = middleware; } public useUserSerializers(serialize, deserialize) { - this.loader.extensions.authentication.serializeUser = serialize; - this.loader.extensions.authentication.deserializeUser = deserialize; + this.context.extensions.authentication.serializeUser = serialize; + this.context.extensions.authentication.deserializeUser = deserialize; } public addAllowedUrl(url: string) { - if (this.loader.extensions.authentication.allowedUrls.indexOf(url) < 0) { - this.loader.extensions.authentication.allowedUrls.push(url); + if (this.context.extensions.authentication.allowedUrls.indexOf(url) < 0) { + this.context.extensions.authentication.allowedUrls.push(url); } } } diff --git a/Composer/packages/extension/src/index.ts b/Composer/packages/extension/src/index.ts index 2abf54ebfa..1c381ecc22 100644 --- a/Composer/packages/extension/src/index.ts +++ b/Composer/packages/extension/src/index.ts @@ -3,7 +3,8 @@ export { JSONSchema7 } from 'json-schema'; -export * from './loader'; export * from './manager'; export * from './storage'; export * from './types/types'; +export * from './extensionContext'; +export * from './extensionRegistration'; diff --git a/Composer/packages/extension/src/loader/index.ts b/Composer/packages/extension/src/loader/index.ts deleted file mode 100644 index ae6fcecc77..0000000000 --- a/Composer/packages/extension/src/loader/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -export * from './composerPluginRegistration'; -export * from './pluginLoader'; diff --git a/Composer/packages/extension/src/logger.ts b/Composer/packages/extension/src/logger.ts index 8656206e45..0f4dfdf1ac 100644 --- a/Composer/packages/extension/src/logger.ts +++ b/Composer/packages/extension/src/logger.ts @@ -3,4 +3,4 @@ import debug from 'debug'; -export default debug('composer:plugins'); +export default debug('composer:extensions'); diff --git a/Composer/packages/extension/src/manager/__tests__/manager.test.ts b/Composer/packages/extension/src/manager/__tests__/manager.test.ts new file mode 100644 index 0000000000..2f80727d89 --- /dev/null +++ b/Composer/packages/extension/src/manager/__tests__/manager.test.ts @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { writeJsonSync } from 'fs-extra'; + +import { ExtensionManager } from '../manager'; + +const mockManifest = { + extension1: { + id: 'extension1', + builtIn: true, + enabled: true, + }, + extension2: { + id: 'extension2', + enabled: true, + }, + extension3: { + id: 'extension3', + enabled: false, + }, +}; + +beforeEach(() => { + writeJsonSync(process.env.COMPOSER_EXTENSION_DATA as string, mockManifest); + ExtensionManager.reloadManifest(); +}); + +describe('#getAll', () => { + it('return an array of all extensions', () => { + expect(ExtensionManager.getAll()).toEqual([ + { + id: 'extension1', + builtIn: true, + enabled: true, + }, + { + id: 'extension2', + enabled: true, + }, + { + id: 'extension3', + enabled: false, + }, + ]); + }); +}); + +describe('#find', () => { + it('returns extension metadata for id', () => { + expect(ExtensionManager.find('extension1')).toEqual({ id: 'extension1', builtIn: true, enabled: true }); + expect(ExtensionManager.find('does-not-exist')).toBeUndefined(); + }); +}); + +describe('#loadAll', () => { + it('loads built-in extensions and remote extensions that are enabled', async () => { + const loadSpy = jest.spyOn(ExtensionManager, 'load'); + + loadSpy.mockReturnValue(Promise.resolve()); + + await ExtensionManager.loadAll(); + + expect(loadSpy).toHaveBeenCalledTimes(2); + expect(loadSpy).toHaveBeenCalledWith('extension1'); + expect(loadSpy).toHaveBeenCalledWith('extension2'); + }); +}); + +// describe('#installRemote', () => {}); +// describe('#loadBuiltinExtensions', () => {}); +// describe('#loadRemotePlugins', () => {}); +// describe('#load', () => {}); +// describe('#enable', () => {}); +// describe('#disable', () => {}); +// describe('#remove', () => {}); +// describe('#search', () => {}); +// describe('#getAllBundles', () => {}); +// describe('#getBundle', () => {}); diff --git a/Composer/packages/extension/src/manager/manager.ts b/Composer/packages/extension/src/manager/manager.ts index d62c7f1e57..29c80a0ade 100644 --- a/Composer/packages/extension/src/manager/manager.ts +++ b/Composer/packages/extension/src/manager/manager.ts @@ -2,48 +2,22 @@ // Licensed under the MIT License. import path from 'path'; -import { spawn } from 'child_process'; import glob from 'globby'; import { readJson } from 'fs-extra'; -import { pluginLoader } from '../loader'; +import { ExtensionContext } from '../extensionContext'; import logger from '../logger'; import { ExtensionManifestStore } from '../storage/extensionManifestStore'; import { ExtensionBundle, PackageJSON, ExtensionMetadata, ExtensionSearchResult } from '../types/extension'; +import { npm } from '../utils/npm'; -const log = logger.extend('plugins'); +const log = logger.extend('manager'); -/** - * Used to safely execute commands that include user input - */ -async function runNpm(command: string): Promise<{ stdout: string; stderr: string }> { - return new Promise((resolve) => { - log('npm %s', command); - const cmdArgs = command.split(' '); - let stdout = ''; - let stderr = ''; - - const proc = spawn('npm', cmdArgs); - - proc.stdout.on('data', (data) => { - stdout += data; - }); - - proc.stderr.on('data', (data) => { - stderr += data; - }); - - proc.on('close', () => { - resolve({ stdout, stderr }); - }); - }); -} - -function processBundles(pluginPath: string, bundles: ExtensionBundle[]) { +function processBundles(extensionPath: string, bundles: ExtensionBundle[]) { return bundles.map((b) => ({ ...b, - path: path.resolve(pluginPath, b.path), + path: path.resolve(extensionPath, b.path), })); } @@ -68,7 +42,7 @@ class ExtensionManager { */ public getAll() { const extensions = this.manifest.getExtensions(); - return Object.keys(extensions).map((extId) => extensions[extId]); + return Object.values(extensions); } /** @@ -76,139 +50,127 @@ class ExtensionManager { * @param id Id of the extension to search for */ public find(id: string) { - return this.manifest.getExtensions()[id]; + return this.manifest.getExtensionConfig(id); } /** - * Installs a remote plugin via NPM - * @param name The name of the plugin to install - * @param version The version of the plugin to install + * Loads all builtin extensions and remote extensions. */ - public async installRemote(name: string, version?: string) { - const packageNameAndVersion = version ? `${name}@${version}` : name; - const cmd = `install --no-audit --prefix ${this.remotePluginsDir} ${packageNameAndVersion}`; - log('Installing %s@%s to %s', name, version, this.remotePluginsDir); - - const { stdout } = await runNpm(cmd); - - log('%s', stdout); + public async loadAll() { + await this.seedBuiltinExtensions(); - const packageJson = await this.getPackageJson(name); + const extensions = Object.entries(this.manifest.getExtensions()); - if (packageJson) { - const pluginPath = path.resolve(this.remotePluginsDir, 'node_modules', name); - this.manifest.updateExtensionConfig(name, getExtensionMetadata(pluginPath, packageJson)); - } else { - throw new Error(`Unable to install ${packageNameAndVersion}`); + for (const [id, metadata] of extensions) { + if (metadata?.enabled) { + await this.load(id); + } } } /** - * Loads all the plugins that are checked into the Composer project (1P plugins) + * Installs a remote extension via NPM + * @param name The name of the extension to install + * @param version The version of the extension to install */ - public async loadBuiltinPlugins() { - log('Loading inherent plugins from: ', this.builtinPluginsDir); - - // get all plugins with a package.json in the plugins dir - const plugins = await glob('*/package.json', { cwd: this.builtinPluginsDir, dot: true }); - for (const p in plugins) { - // go through each plugin, make sure to add it to the manager store then load it as usual - const pluginPackageJsonPath = plugins[p]; - const fullPath = path.join(this.builtinPluginsDir, pluginPackageJsonPath); - const pluginInstallPath = path.dirname(fullPath); - const packageJson = (await readJson(fullPath)) as PackageJSON; - if (packageJson && (!!packageJson.composer || !!packageJson.extendsComposer)) { - const metadata = getExtensionMetadata(pluginInstallPath, packageJson); - this.manifest.updateExtensionConfig(packageJson.name, { - ...metadata, - builtIn: true, - }); - await pluginLoader.loadPluginFromFile(fullPath); + public async installRemote(name: string, version?: string) { + const packageNameAndVersion = version ? `${name}@${version}` : `${name}@latest`; + log('Installing %s to %s', packageNameAndVersion, this.remoteDir); + + try { + const { stdout } = await npm('install', packageNameAndVersion, { '--prefix': this.remoteDir }); + + log('%s', stdout); + + const packageJson = await this.getPackageJson(name, this.remoteDir); + + if (packageJson) { + const extensionPath = path.resolve(this.remoteDir, 'node_modules', name); + this.manifest.updateExtensionConfig(name, getExtensionMetadata(extensionPath, packageJson)); + } else { + throw new Error(`Unable to install ${packageNameAndVersion}`); } + } catch (err) { + if (err?.stderr) { + log('%s', err.stderr); + } + throw new Error(`Unable to install ${packageNameAndVersion}`); } } - /** - * Loads all installed remote plugins - * TODO (toanzian / abrown): Needs to be implemented - */ - public async loadRemotePlugins() { - // should perform the same function as loadBuiltInPlugins but from the - // location that remote / 3P plugins are installed - } - public async load(id: string) { + const metadata = this.manifest.getExtensionConfig(id); try { - const modulePath = require.resolve(id, { - paths: [`${this.remotePluginsDir}/node_modules`], - }); // eslint-disable-next-line @typescript-eslint/no-var-requires, security/detect-non-literal-require - const plugin = require(modulePath); - log('got plugin: ', plugin); + const extension = metadata?.path && require(metadata.path); - if (!plugin) { - throw new Error('Plugin not found'); + if (!extension) { + throw new Error(`Extension not found: ${id}`); } - await pluginLoader.loadPlugin(id, '', plugin); + await ExtensionContext.loadPlugin(id, '', extension); } catch (err) { - log('Unable to load plugin `%s`', id); + log('Unable to load extension `%s`', id); log('%O', err); - await this.remove(id); + if (!metadata?.builtIn) { + await this.remove(id); + } throw err; } } /** - * Enables a plugin - * @param id Id of the plugin to be enabled + * Enables an extension + * @param id Id of the extension to be enabled */ public async enable(id: string) { this.manifest.updateExtensionConfig(id, { enabled: true }); - // re-load plugin + await this.load(id); } /** - * Disables a plugin - * @param id Id of the plugin to be disabled + * Disables an extension + * @param id Id of the extension to be disabled */ public async disable(id: string) { this.manifest.updateExtensionConfig(id, { enabled: false }); - // tear down plugin? + // TODO: tear down extension? } /** - * Removes a remote plugin via NPM - * @param id Id of the plugin to be removed + * Removes a remote extension via NPM + * @param id Id of the extension to be removed */ public async remove(id: string) { - const cmd = `uninstall --no-audit --prefix ${this.remotePluginsDir} ${id}`; log('Removing %s', id); - const { stdout } = await runNpm(cmd); + try { + const { stdout } = await npm('uninstall', id, { '--prefix': this.remoteDir }); - log('%s', stdout); + log('%s', stdout); - this.manifest.removeExtension(id); + this.manifest.removeExtension(id); + } catch (err) { + log('%s', err); + throw new Error(`Unable to remove extension: ${id}`); + } } /** - * Searches for a plugin via NPM's search function + * Searches for an extension via NPM's search function * @param query The search query */ public async search(query: string) { - const cmd = `search --json keywords:botframework-composer ${query}`; - - const { stdout } = await runNpm(cmd); + const { stdout } = await npm('search', `keywords:botframework-composer extension ${query}`, { '--json': '' }); 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')) { + if (keywords.includes('botframework-composer') && keywords.includes('extension')) { const url = links?.npm ?? ''; this.searchCache.set(name, { id: name, @@ -235,7 +197,7 @@ class ExtensionManager { const info = this.find(id); if (!info) { - throw new Error('plugin not found'); + throw new Error('extension not found'); } return info.bundles ?? []; @@ -250,7 +212,7 @@ class ExtensionManager { const info = this.find(id); if (!info) { - throw new Error('plugin not found'); + throw new Error('extension not found'); } const bundle = info.bundles.find((b) => b.id === bundleId); @@ -262,41 +224,64 @@ class ExtensionManager { return bundle.path; } - private async getPackageJson(id: string): Promise { + private async getPackageJson(id: string, dir: string): Promise { try { - const pluginPackagePath = path.resolve(this.remotePluginsDir, 'node_modules', id, 'package.json'); - log('fetching package.json for %s at %s', id, pluginPackagePath); - const packageJson = await readJson(pluginPackagePath); + const extensionPackagePath = path.resolve(dir, 'node_modules', id, 'package.json'); + log('fetching package.json for %s at %s', id, extensionPackagePath); + const packageJson = await readJson(extensionPackagePath); return packageJson as PackageJSON; } catch (err) { log('Error getting package json for %s', id); + // eslint-disable-next-line no-console console.error(err); } } + public async seedBuiltinExtensions() { + const extensions = await glob('*/package.json', { cwd: this.builtinDir, dot: true }); + for (const extensionPackageJsonPath of extensions) { + // go through each extension, make sure to add it to the manager store then load it as usual + const fullPath = path.join(this.builtinDir, extensionPackageJsonPath); + const extensionInstallPath = path.dirname(fullPath); + const packageJson = (await readJson(fullPath)) as PackageJSON; + const isEnabled = packageJson?.composer && packageJson.composer.enabled !== false; + if (packageJson && (isEnabled || packageJson.extendsComposer === true)) { + const metadata = getExtensionMetadata(extensionInstallPath, packageJson); + this.manifest.updateExtensionConfig(packageJson.name, { + ...metadata, + builtIn: true, + }); + } + } + } + + public reloadManifest() { + this._manifest = undefined; + } + private get manifest() { if (this._manifest) { return this._manifest; } - this._manifest = new ExtensionManifestStore(); + this._manifest = new ExtensionManifestStore(process.env.COMPOSER_EXTENSION_DATA as string); return this._manifest; } - private get builtinPluginsDir() { - if (!process.env.COMPOSER_BUILTIN_PLUGINS_DIR) { - throw new Error('COMPOSER_BUILTIN_PLUGINS_DIR must be set.'); + private get builtinDir() { + if (!process.env.COMPOSER_BUILTIN_EXTENSIONS_DIR) { + throw new Error('COMPOSER_BUILTIN_EXTENSIONS_DIR must be set.'); } - return process.env.COMPOSER_BUILTIN_PLUGINS_DIR; + return process.env.COMPOSER_BUILTIN_EXTENSIONS_DIR; } - private get remotePluginsDir() { - if (!process.env.COMPOSER_REMOTE_PLUGINS_DIR) { - throw new Error('COMPOSER_REMOTE_PLUGINS_DIR must be set.'); + private get remoteDir() { + if (!process.env.COMPOSER_REMOTE_EXTENSIONS_DIR) { + throw new Error('COMPOSER_REMOTE_EXTENSIONS_DIR must be set.'); } - return process.env.COMPOSER_REMOTE_PLUGINS_DIR; + return process.env.COMPOSER_REMOTE_EXTENSIONS_DIR; } } diff --git a/Composer/packages/extension/src/storage/__tests__/extensionManifestStore.test.ts b/Composer/packages/extension/src/storage/__tests__/extensionManifestStore.test.ts new file mode 100644 index 0000000000..3b67b42bb5 --- /dev/null +++ b/Composer/packages/extension/src/storage/__tests__/extensionManifestStore.test.ts @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import fs from 'fs'; +import path from 'path'; + +import { existsSync, writeJsonSync, readJsonSync } from 'fs-extra'; + +import { ExtensionManifestStore } from '../extensionManifestStore'; + +jest.mock('fs-extra', () => ({ + existsSync: jest.fn(), + readJsonSync: jest.fn(), + writeJsonSync: jest.fn(), +})); + +const manifestPath = path.resolve(__dirname, '../../../__manifest__.json'); + +afterAll(() => { + if (fs.existsSync(manifestPath)) { + fs.unlinkSync(manifestPath); + } +}); + +describe('when the manifest does not exist', () => { + it('creates one with the default data', () => { + (existsSync as jest.Mock).mockReturnValue(false); + new ExtensionManifestStore(manifestPath); + expect(writeJsonSync).toHaveBeenCalledWith(manifestPath, {}, { spaces: 2 }); + }); +}); + +describe('when the manifest already exists', () => { + const currentManifest = { + extension1: { + id: 'extension1', + }, + }; + + beforeAll(() => { + if (fs.existsSync(manifestPath)) { + fs.unlinkSync(manifestPath); + } + + fs.writeFileSync(manifestPath, JSON.stringify({})); + }); + + beforeEach(() => { + (existsSync as jest.Mock).mockReturnValue(true); + (readJsonSync as jest.Mock).mockImplementation((path) => { + if (path === manifestPath) { + return { ...currentManifest }; + } + }); + }); + + it('reads from the manifest', () => { + new ExtensionManifestStore(manifestPath); + expect(readJsonSync).toHaveBeenCalledWith(manifestPath); + }); + + describe('#getExtensionConfig', () => { + it('returns the extension metadata', () => { + const store = new ExtensionManifestStore(manifestPath); + + expect(store.getExtensionConfig('extension1')).toEqual(currentManifest.extension1); + }); + }); + + describe('#getExtensions', () => { + it('returns all extension metadata', () => { + const store = new ExtensionManifestStore(manifestPath); + + expect(store.getExtensions()).toEqual(currentManifest); + }); + }); + + describe('#updateExtensionConfig', () => { + it('creates a new entry if config does not exist', () => { + const newExtension = { id: 'newExtension' }; + const store = new ExtensionManifestStore(manifestPath); + store.updateExtensionConfig('newExtension', newExtension); + + expect(writeJsonSync).toHaveBeenCalledWith( + manifestPath, + { ...currentManifest, newExtension }, + expect.any(Object) + ); + }); + + it('updates the entry if config exist', () => { + const store = new ExtensionManifestStore(manifestPath); + store.updateExtensionConfig('extension1', { name: 'new name' }); + + expect(writeJsonSync).toHaveBeenCalledWith( + manifestPath, + { extension1: { id: 'extension1', name: 'new name' } }, + expect.any(Object) + ); + }); + }); + + describe('#removeExtension', () => { + it('removes the extension from the manifest', () => { + const store = new ExtensionManifestStore(manifestPath); + store.removeExtension('extension1'); + + expect(writeJsonSync).toHaveBeenCalledWith(manifestPath, {}, expect.any(Object)); + expect(store.getExtensionConfig('extension1')).toBeUndefined(); + expect(store.getExtensions()).toEqual({}); + }); + }); +}); diff --git a/Composer/packages/extension/src/storage/extensionManifestStore.ts b/Composer/packages/extension/src/storage/extensionManifestStore.ts index 99f2079dee..4483d9555c 100644 --- a/Composer/packages/extension/src/storage/extensionManifestStore.ts +++ b/Composer/packages/extension/src/storage/extensionManifestStore.ts @@ -8,79 +8,80 @@ import { ExtensionMap, ExtensionMetadata } from '../types/extension'; const log = logger.extend('plugins'); -export interface ExtensionManifest { - extensions: ExtensionMap; -} - -const DEFAULT_MANIFEST: ExtensionManifest = { - extensions: {}, -}; +export type ExtensionManifest = ExtensionMap; -function omitBuiltinProperty(key: string, value: string) { - if (key && key === 'builtIn') { - return undefined; - } - return value; -} +const DEFAULT_MANIFEST: ExtensionManifest = {}; /** In-memory representation of extensions.json as well as reads / writes data to disk. */ export class ExtensionManifestStore { private manifest: ExtensionManifest = DEFAULT_MANIFEST; - private manifestPath: string; - constructor() { - this.manifestPath = process.env.COMPOSER_EXTENSION_DATA as string; + constructor(private manifestPath: string) { // create extensions.json if it doesn't exist + if (!existsSync(this.manifestPath)) { log('extensions.json does not exist yet. Writing file to path: %s', this.manifestPath); writeJsonSync(this.manifestPath, DEFAULT_MANIFEST, { spaces: 2 }); } - this.readManifestFromDisk(); // load manifest into memory - } - // load manifest into memory - private readManifestFromDisk() { - try { - const manifest: ExtensionManifest = readJsonSync(this.manifestPath); - this.manifest = manifest; - } catch (e) { - log('Error reading %s: %s', this.manifestPath, e); - } - } + this.readManifestFromDisk(); // load manifest into memory - // write manifest from memory to disk - private writeManifestToDisk() { - try { - writeJsonSync(this.manifestPath, this.manifest, { replacer: omitBuiltinProperty, spaces: 2 }); - } catch (e) { - log('Error writing %s: %s', this.manifestPath, e); + // remove extensions key from existing manifests + // TODO: remove in the future + /* istanbul ignore next */ + if (this.manifest && this.manifest.extensions) { + this.manifest = (this.manifest.extensions as unknown) as ExtensionMap; + this.writeManifestToDisk(); } } public getExtensionConfig(id: string) { - return this.manifest.extensions[id]; + return this.manifest[id]; } public getExtensions() { - return this.manifest.extensions; + return this.manifest; } public removeExtension(id: string) { - delete this.manifest.extensions[id]; + delete this.manifest[id]; // sync changes to disk this.writeManifestToDisk(); } // update extension config public updateExtensionConfig(id: string, newConfig: Partial) { - const currentConfig = this.manifest.extensions[id]; + const currentConfig = this.manifest[id]; if (currentConfig) { - this.manifest.extensions[id] = Object.assign(currentConfig, newConfig); + this.manifest[id] = Object.assign({}, currentConfig, newConfig); } else { - this.manifest.extensions[id] = Object.assign({} as ExtensionMetadata, newConfig); + this.manifest[id] = Object.assign({} as ExtensionMetadata, newConfig); } // sync changes to disk this.writeManifestToDisk(); } + + public reload() { + this.readManifestFromDisk(); + } + + // load manifest into memory + private readManifestFromDisk() { + try { + const manifest: ExtensionManifest = readJsonSync(this.manifestPath); + this.manifest = manifest; + } catch (e) { + log('Error reading %s: %s', this.manifestPath, e); + } + } + + // write manifest from memory to disk + private writeManifestToDisk() { + try { + writeJsonSync(this.manifestPath, this.manifest, { spaces: 2 }); + } catch (e) { + log('Error writing %s: %s', this.manifestPath, e); + } + } } diff --git a/Composer/packages/extension/src/types/extension.ts b/Composer/packages/extension/src/types/extension.ts index c0ae780e5a..8fceeda395 100644 --- a/Composer/packages/extension/src/types/extension.ts +++ b/Composer/packages/extension/src/types/extension.ts @@ -20,7 +20,7 @@ export type ExtensionBundle = { path: string; }; -export interface ExtensionMetadata { +export type ExtensionMetadata = { /** name field from package.json */ id: string; /** name field from composer object in package.json, defaults to id */ @@ -35,10 +35,10 @@ export interface ExtensionMetadata { builtIn?: boolean; bundles: ExtensionBundle[]; contributes: ExtensionContribution; -} +}; export interface ExtensionMap { - [id: string]: ExtensionMetadata; + [id: string]: ExtensionMetadata | undefined; } /** Info about a plugin returned from an NPM search query */ @@ -58,6 +58,7 @@ export interface PackageJSON { extendsComposer: boolean; composer?: { name?: string; + enabled?: boolean; contributes?: ExtensionContribution; bundles?: ExtensionBundle[]; }; diff --git a/Composer/packages/extension/src/types/types.ts b/Composer/packages/extension/src/types/types.ts index 76854b0fbe..888ce72c08 100644 --- a/Composer/packages/extension/src/types/types.ts +++ b/Composer/packages/extension/src/types/types.ts @@ -53,7 +53,7 @@ export interface PublishPlugin { instructions?: string; customName?: string; customDescription?: string; - hasView: boolean; + hasView?: boolean; [key: string]: any; } @@ -117,7 +117,7 @@ export interface ExtensionCollection { /** (Optional) Schema for publishing configuration. */ schema?: JSONSchema7; /** Whether or not the plugin has custom UI to host in the publish surface */ - hasView: boolean; + hasView?: boolean; }; methods: PublishPlugin; }; diff --git a/Composer/packages/extension/src/utils/npm.ts b/Composer/packages/extension/src/utils/npm.ts new file mode 100644 index 0000000000..043ff76bb4 --- /dev/null +++ b/Composer/packages/extension/src/utils/npm.ts @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { spawn } from 'child_process'; + +import logger from '../logger'; + +const log = logger.extend('npm'); + +type NpmOutput = { + stdout: string; + stderr: string; + code: number; +}; +type NpmCommand = 'install' | 'uninstall' | 'search'; +type NpmOptions = { + [key: string]: string; +}; + +function processOptions(opts: NpmOptions) { + return Object.entries({ '--no-fund': '', '--no-audit': '', ...opts }).map(([flag, value]) => { + return value ? `${flag}=${value}` : flag; + }); +} + +/** + * Executes npm commands that include user input safely + * @param `command` npm command to execute. + * @param `args` cli arguments + * @param `opts` cli flags + * @returns Object with stdout, stderr, and exit code from command + */ +export async function npm(command: NpmCommand, args: string, opts: NpmOptions = {}): Promise { + return new Promise((resolve, reject) => { + const cmdOptions = processOptions(opts); + const spawnArgs = [command, ...cmdOptions, args]; + log('npm %s', spawnArgs.join(' ')); + let stdout = ''; + let stderr = ''; + + const proc = spawn('npm', spawnArgs); + + proc.stdout.on('data', (data) => { + stdout += data; + }); + + proc.stderr.on('data', (data) => { + stderr += data; + }); + + proc.on('close', (code) => { + if (code > 0) { + reject({ stdout, stderr, code }); + } else { + resolve({ stdout, stderr, code }); + } + }); + }); +} diff --git a/Composer/packages/extension/tsconfig.build.json b/Composer/packages/extension/tsconfig.build.json new file mode 100644 index 0000000000..3d2578d39e --- /dev/null +++ b/Composer/packages/extension/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + /* Options used for building production code (tests excluded) */ + "extends": "./tsconfig.json", + "exclude": [ + "**/__tests__/**", + "**/__mocks__/**" + ] +} diff --git a/Composer/packages/server/.gitignore b/Composer/packages/server/.gitignore index 46e4d8f37b..3a2f30f54c 100644 --- a/Composer/packages/server/.gitignore +++ b/Composer/packages/server/.gitignore @@ -1,7 +1,6 @@ build/ data.json -__tests__/__data__.json +src/__tests__/__data__.json schemas/locales/en-US-pseudo/ src/locales/en-US-pseudo.json -.composer extensions.json diff --git a/Composer/packages/server/jest.config.js b/Composer/packages/server/jest.config.js index af5aea9fbf..96ddb74adc 100644 --- a/Composer/packages/server/jest.config.js +++ b/Composer/packages/server/jest.config.js @@ -3,6 +3,6 @@ const path = require('path'); const { createConfig } = require('@bfc/test-utils'); module.exports = createConfig('server', 'node', { - setupFiles: [path.resolve(__dirname, '__tests__/setupEnv.ts')], - testPathIgnorePatterns: ['__tests__/setupEnv.ts'], + setupFiles: [path.resolve(__dirname, 'src/__tests__/setupEnv.ts')], + testPathIgnorePatterns: ['src/__tests__/setupEnv.ts'], }); diff --git a/Composer/packages/server/package.json b/Composer/packages/server/package.json index 1d3f0e8dd8..aa25dff4b3 100644 --- a/Composer/packages/server/package.json +++ b/Composer/packages/server/package.json @@ -17,14 +17,15 @@ "test": "jest", "test:watch": "jest --watch", "typecheck": "tsc --noEmit", - "lint": "eslint --quiet ./src ./__tests__", + "lint": "eslint --quiet ./src", "lint:fix": "yarn lint --fix" }, "author": "", "nodemonConfig": { "exec": "cross-env TS_NODE_FILES=true node -r ts-node/register src/init.ts", "watch": [ - "src" + "src", + "../extension/lib" ], "ext": "ts", "delay": 2 diff --git a/Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/bot1.dialog b/Composer/packages/server/src/__mocks__/asset/projects/SampleBot/bot1.dialog similarity index 100% rename from Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/bot1.dialog rename to Composer/packages/server/src/__mocks__/asset/projects/SampleBot/bot1.dialog diff --git a/Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/dialogs/a/a.dialog b/Composer/packages/server/src/__mocks__/asset/projects/SampleBot/dialogs/a/a.dialog similarity index 100% rename from Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/dialogs/a/a.dialog rename to Composer/packages/server/src/__mocks__/asset/projects/SampleBot/dialogs/a/a.dialog diff --git a/Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/dialogs/a/knowledge-base/en-us/a.en-us.qna b/Composer/packages/server/src/__mocks__/asset/projects/SampleBot/dialogs/a/knowledge-base/en-us/a.en-us.qna similarity index 100% rename from Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/dialogs/a/knowledge-base/en-us/a.en-us.qna rename to Composer/packages/server/src/__mocks__/asset/projects/SampleBot/dialogs/a/knowledge-base/en-us/a.en-us.qna diff --git a/Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/dialogs/a/language-generation/en-us/a.en-us.lg b/Composer/packages/server/src/__mocks__/asset/projects/SampleBot/dialogs/a/language-generation/en-us/a.en-us.lg similarity index 100% rename from Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/dialogs/a/language-generation/en-us/a.en-us.lg rename to Composer/packages/server/src/__mocks__/asset/projects/SampleBot/dialogs/a/language-generation/en-us/a.en-us.lg diff --git a/Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/dialogs/a/language-understanding/en-us/a.en-us.lu b/Composer/packages/server/src/__mocks__/asset/projects/SampleBot/dialogs/a/language-understanding/en-us/a.en-us.lu similarity index 100% rename from Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/dialogs/a/language-understanding/en-us/a.en-us.lu rename to Composer/packages/server/src/__mocks__/asset/projects/SampleBot/dialogs/a/language-understanding/en-us/a.en-us.lu diff --git a/Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/dialogs/b/b.dialog b/Composer/packages/server/src/__mocks__/asset/projects/SampleBot/dialogs/b/b.dialog similarity index 100% rename from Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/dialogs/b/b.dialog rename to Composer/packages/server/src/__mocks__/asset/projects/SampleBot/dialogs/b/b.dialog diff --git a/Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/dialogs/b/knowledge-base/en-us/b.en-us.qna b/Composer/packages/server/src/__mocks__/asset/projects/SampleBot/dialogs/b/knowledge-base/en-us/b.en-us.qna similarity index 100% rename from Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/dialogs/b/knowledge-base/en-us/b.en-us.qna rename to Composer/packages/server/src/__mocks__/asset/projects/SampleBot/dialogs/b/knowledge-base/en-us/b.en-us.qna diff --git a/Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/dialogs/b/language-generation/en-us/b.en-us.lg b/Composer/packages/server/src/__mocks__/asset/projects/SampleBot/dialogs/b/language-generation/en-us/b.en-us.lg similarity index 100% rename from Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/dialogs/b/language-generation/en-us/b.en-us.lg rename to Composer/packages/server/src/__mocks__/asset/projects/SampleBot/dialogs/b/language-generation/en-us/b.en-us.lg diff --git a/Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/dialogs/b/language-understanding/en-us/b.en-us.lu b/Composer/packages/server/src/__mocks__/asset/projects/SampleBot/dialogs/b/language-understanding/en-us/b.en-us.lu similarity index 100% rename from Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/dialogs/b/language-understanding/en-us/b.en-us.lu rename to Composer/packages/server/src/__mocks__/asset/projects/SampleBot/dialogs/b/language-understanding/en-us/b.en-us.lu diff --git a/Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/knowledge-base/en-us/bot1.en-us.qna b/Composer/packages/server/src/__mocks__/asset/projects/SampleBot/knowledge-base/en-us/bot1.en-us.qna similarity index 100% rename from Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/knowledge-base/en-us/bot1.en-us.qna rename to Composer/packages/server/src/__mocks__/asset/projects/SampleBot/knowledge-base/en-us/bot1.en-us.qna diff --git a/Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/language-generation/en-us/bot1.en-us.lg b/Composer/packages/server/src/__mocks__/asset/projects/SampleBot/language-generation/en-us/bot1.en-us.lg similarity index 100% rename from Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/language-generation/en-us/bot1.en-us.lg rename to Composer/packages/server/src/__mocks__/asset/projects/SampleBot/language-generation/en-us/bot1.en-us.lg diff --git a/Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/language-generation/en-us/common.en-us.lg b/Composer/packages/server/src/__mocks__/asset/projects/SampleBot/language-generation/en-us/common.en-us.lg similarity index 100% rename from Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/language-generation/en-us/common.en-us.lg rename to Composer/packages/server/src/__mocks__/asset/projects/SampleBot/language-generation/en-us/common.en-us.lg diff --git a/Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/language-understanding/en-us/bot1.en-us.lu b/Composer/packages/server/src/__mocks__/asset/projects/SampleBot/language-understanding/en-us/bot1.en-us.lu similarity index 100% rename from Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/language-understanding/en-us/bot1.en-us.lu rename to Composer/packages/server/src/__mocks__/asset/projects/SampleBot/language-understanding/en-us/bot1.en-us.lu diff --git a/Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/settings/appsettings.json b/Composer/packages/server/src/__mocks__/asset/projects/SampleBot/settings/appsettings.json similarity index 100% rename from Composer/packages/server/__tests__/mocks/asset/projects/SampleBot/settings/appsettings.json rename to Composer/packages/server/src/__mocks__/asset/projects/SampleBot/settings/appsettings.json diff --git a/Composer/packages/server/__tests__/mocks/runtimes/CSharp/readme.md b/Composer/packages/server/src/__mocks__/runtimes/CSharp/readme.md similarity index 100% rename from Composer/packages/server/__tests__/mocks/runtimes/CSharp/readme.md rename to Composer/packages/server/src/__mocks__/runtimes/CSharp/readme.md diff --git a/Composer/packages/server/__tests__/mocks/runtimes/dotnet/readme.md b/Composer/packages/server/src/__mocks__/runtimes/dotnet/readme.md similarity index 100% rename from Composer/packages/server/__tests__/mocks/runtimes/dotnet/readme.md rename to Composer/packages/server/src/__mocks__/runtimes/dotnet/readme.md diff --git a/Composer/packages/server/__tests__/mocks/samplebots/bot1/bot1.dialog b/Composer/packages/server/src/__mocks__/samplebots/bot1/bot1.dialog similarity index 100% rename from Composer/packages/server/__tests__/mocks/samplebots/bot1/bot1.dialog rename to Composer/packages/server/src/__mocks__/samplebots/bot1/bot1.dialog diff --git a/Composer/packages/server/__tests__/mocks/samplebots/bot1/dialogs/a/a.dialog b/Composer/packages/server/src/__mocks__/samplebots/bot1/dialogs/a/a.dialog similarity index 100% rename from Composer/packages/server/__tests__/mocks/samplebots/bot1/dialogs/a/a.dialog rename to Composer/packages/server/src/__mocks__/samplebots/bot1/dialogs/a/a.dialog diff --git a/Composer/packages/server/__tests__/mocks/samplebots/bot1/dialogs/a/language-generation/en-us/a.en-us.lg b/Composer/packages/server/src/__mocks__/samplebots/bot1/dialogs/a/language-generation/en-us/a.en-us.lg similarity index 100% rename from Composer/packages/server/__tests__/mocks/samplebots/bot1/dialogs/a/language-generation/en-us/a.en-us.lg rename to Composer/packages/server/src/__mocks__/samplebots/bot1/dialogs/a/language-generation/en-us/a.en-us.lg diff --git a/Composer/packages/server/__tests__/mocks/samplebots/bot1/dialogs/a/language-understanding/en-us/a.en-us.lu b/Composer/packages/server/src/__mocks__/samplebots/bot1/dialogs/a/language-understanding/en-us/a.en-us.lu similarity index 100% rename from Composer/packages/server/__tests__/mocks/samplebots/bot1/dialogs/a/language-understanding/en-us/a.en-us.lu rename to Composer/packages/server/src/__mocks__/samplebots/bot1/dialogs/a/language-understanding/en-us/a.en-us.lu diff --git a/Composer/packages/server/__tests__/mocks/samplebots/bot1/dialogs/b/b.dialog b/Composer/packages/server/src/__mocks__/samplebots/bot1/dialogs/b/b.dialog similarity index 100% rename from Composer/packages/server/__tests__/mocks/samplebots/bot1/dialogs/b/b.dialog rename to Composer/packages/server/src/__mocks__/samplebots/bot1/dialogs/b/b.dialog diff --git a/Composer/packages/server/__tests__/mocks/samplebots/bot1/dialogs/b/language-generation/en-us/b.en-us.lg b/Composer/packages/server/src/__mocks__/samplebots/bot1/dialogs/b/language-generation/en-us/b.en-us.lg similarity index 100% rename from Composer/packages/server/__tests__/mocks/samplebots/bot1/dialogs/b/language-generation/en-us/b.en-us.lg rename to Composer/packages/server/src/__mocks__/samplebots/bot1/dialogs/b/language-generation/en-us/b.en-us.lg diff --git a/Composer/packages/server/__tests__/mocks/samplebots/bot1/dialogs/b/language-understanding/en-us/b.en-us.lu b/Composer/packages/server/src/__mocks__/samplebots/bot1/dialogs/b/language-understanding/en-us/b.en-us.lu similarity index 100% rename from Composer/packages/server/__tests__/mocks/samplebots/bot1/dialogs/b/language-understanding/en-us/b.en-us.lu rename to Composer/packages/server/src/__mocks__/samplebots/bot1/dialogs/b/language-understanding/en-us/b.en-us.lu diff --git a/Composer/packages/server/__tests__/mocks/samplebots/bot1/language-generation/en-us/bot1.en-us.lg b/Composer/packages/server/src/__mocks__/samplebots/bot1/language-generation/en-us/bot1.en-us.lg similarity index 100% rename from Composer/packages/server/__tests__/mocks/samplebots/bot1/language-generation/en-us/bot1.en-us.lg rename to Composer/packages/server/src/__mocks__/samplebots/bot1/language-generation/en-us/bot1.en-us.lg diff --git a/Composer/packages/server/__tests__/mocks/samplebots/bot1/language-generation/en-us/common.en-us.lg b/Composer/packages/server/src/__mocks__/samplebots/bot1/language-generation/en-us/common.en-us.lg similarity index 100% rename from Composer/packages/server/__tests__/mocks/samplebots/bot1/language-generation/en-us/common.en-us.lg rename to Composer/packages/server/src/__mocks__/samplebots/bot1/language-generation/en-us/common.en-us.lg diff --git a/Composer/packages/server/__tests__/mocks/samplebots/bot1/language-understanding/en-us/bot1.en-us.lu b/Composer/packages/server/src/__mocks__/samplebots/bot1/language-understanding/en-us/bot1.en-us.lu similarity index 100% rename from Composer/packages/server/__tests__/mocks/samplebots/bot1/language-understanding/en-us/bot1.en-us.lu rename to Composer/packages/server/src/__mocks__/samplebots/bot1/language-understanding/en-us/bot1.en-us.lu diff --git a/Composer/packages/server/__tests__/mocks/samplebots/bot1/settings/appsettings.json b/Composer/packages/server/src/__mocks__/samplebots/bot1/settings/appsettings.json similarity index 100% rename from Composer/packages/server/__tests__/mocks/samplebots/bot1/settings/appsettings.json rename to Composer/packages/server/src/__mocks__/samplebots/bot1/settings/appsettings.json diff --git a/Composer/packages/server/__tests__/setupEnv.ts b/Composer/packages/server/src/__tests__/setupEnv.ts similarity index 100% rename from Composer/packages/server/__tests__/setupEnv.ts rename to Composer/packages/server/src/__tests__/setupEnv.ts diff --git a/Composer/packages/server/__tests__/controllers/asset.test.ts b/Composer/packages/server/src/controllers/__tests__/asset.test.ts similarity index 90% rename from Composer/packages/server/__tests__/controllers/asset.test.ts rename to Composer/packages/server/src/controllers/__tests__/asset.test.ts index e4cd1671ac..85415fe0cd 100644 --- a/Composer/packages/server/__tests__/controllers/asset.test.ts +++ b/Composer/packages/server/src/controllers/__tests__/asset.test.ts @@ -3,7 +3,7 @@ import { Request, Response } from 'express'; -import { AssetController } from '../../src/controllers/asset'; +import { AssetController } from '../asset'; let mockRes: Response; diff --git a/Composer/packages/server/__tests__/controllers/eject.test.ts b/Composer/packages/server/src/controllers/__tests__/eject.test.ts similarity index 85% rename from Composer/packages/server/__tests__/controllers/eject.test.ts rename to Composer/packages/server/src/controllers/__tests__/eject.test.ts index cc8983562a..eb621a17d9 100644 --- a/Composer/packages/server/__tests__/controllers/eject.test.ts +++ b/Composer/packages/server/src/controllers/__tests__/eject.test.ts @@ -3,22 +3,20 @@ import { Request, Response } from 'express'; import rimraf from 'rimraf'; -import { pluginLoader } from '@bfc/extension'; +import { ExtensionContext } from '@bfc/extension'; -import { BotProjectService } from '../../src/services/project'; -import { Path } from '../../src/utility/path'; -import { EjectController } from '../../src/controllers/eject'; +import { BotProjectService } from '../../services/project'; +import { Path } from '../../utility/path'; +import { EjectController } from '../eject'; jest.mock('@bfc/extension', () => { return { - pluginLoader: { + ExtensionContext: { extensions: { botTemplates: [], baseTemplates: [], runtimeTemplates: [], }, - }, - PluginLoader: { getUserFromRequest: jest.fn(), }, }; @@ -26,8 +24,8 @@ jest.mock('@bfc/extension', () => { let mockRes: Response; -const useFortest = Path.resolve(__dirname, '../mocks/samplebots/testEject'); -const bot1 = Path.resolve(__dirname, '../mocks/samplebots/bot1'); +const useFortest = Path.resolve(__dirname, '../../__mocks__/samplebots/testEject'); +const bot1 = Path.resolve(__dirname, '../../__mocks__/samplebots/bot1'); const location1 = { storageId: 'default', @@ -48,7 +46,7 @@ beforeEach(() => { }); beforeAll(async () => { - pluginLoader.extensions.runtimeTemplates.push({ + ExtensionContext.extensions.runtimeTemplates.push({ key: 'azurewebapp', name: 'C#', startCommand: 'dotnet run --project azurewebapp', diff --git a/Composer/packages/server/src/controllers/__tests__/extensions.test.ts b/Composer/packages/server/src/controllers/__tests__/extensions.test.ts new file mode 100644 index 0000000000..bb220dfea7 --- /dev/null +++ b/Composer/packages/server/src/controllers/__tests__/extensions.test.ts @@ -0,0 +1,224 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Request, Response } from 'express'; +import { ExtensionManager } from '@bfc/extension'; + +import * as ExtensionsController from '../extensions'; + +jest.mock('@bfc/extension', () => ({ + ExtensionManager: { + disable: jest.fn(), + enable: jest.fn(), + find: jest.fn(), + getAll: jest.fn(), + getBundle: jest.fn(), + installRemote: jest.fn(), + load: jest.fn(), + remove: jest.fn(), + search: jest.fn(), + }, +})); + +const req: Request = {} as Request; +let res: Response = {} as Response; + +beforeEach(() => { + res = ({ + json: jest.fn(), + status: jest.fn().mockReturnThis(), + sendFile: jest.fn().mockReturnThis(), + } as unknown) as Response; +}); + +describe('listing all extensions', () => { + it('returns all extensions', () => { + (ExtensionManager.getAll as jest.Mock).mockReturnValue(['list', 'of', 'extensions']); + + ExtensionsController.listExtensions(req, res); + expect(res.json).toHaveBeenCalledWith(['list', 'of', 'extensions']); + }); +}); + +describe('adding an extension', () => { + const id = 'new-extension'; + + it('validates id parameter', async () => { + await ExtensionsController.addExtension({ body: { id: '' } } as Request, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: expect.any(String) }); + }); + + it('installs a remote extension', async () => { + await ExtensionsController.addExtension({ body: { id, version: 'some-version' } } as Request, res); + + expect(ExtensionManager.installRemote).toHaveBeenCalledWith(id, 'some-version'); + }); + + it('loads the extension', async () => { + await ExtensionsController.addExtension({ body: { id } } as Request, res); + + expect(ExtensionManager.load).toHaveBeenCalledWith(id); + }); + + it('returns the extension', async () => { + (ExtensionManager.find as jest.Mock).mockReturnValue('installed extension'); + await ExtensionsController.addExtension({ body: { id } } as Request, res); + + expect(ExtensionManager.find).toHaveBeenCalledWith(id); + expect(res.json).toHaveBeenCalledWith('installed extension'); + }); +}); + +describe('toggling an extension', () => { + it('validates id parameter', async () => { + await ExtensionsController.toggleExtension({ body: { id: '' } } as Request, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: expect.any(String) }); + }); + + it('returns a 404 if the extension is not found', async () => { + (ExtensionManager.find as jest.Mock).mockReturnValue(null); + await ExtensionsController.toggleExtension({ body: { id: 'does-not-exist' } } as Request, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: expect.any(String) }); + }); + + describe('when extension is found', () => { + const id = 'extension-id'; + + beforeEach(() => { + (ExtensionManager.find as jest.Mock).mockReturnValue('found extension'); + }); + + it('can enable an extension', async () => { + await ExtensionsController.toggleExtension({ body: { id, enabled: true } } as Request, res); + expect(ExtensionManager.enable).toBeCalledWith(id); + }); + + it('can disable an extension', async () => { + await ExtensionsController.toggleExtension({ body: { id, enabled: false } } as Request, res); + await ExtensionsController.toggleExtension({ body: { id, enabled: 'true' } } as Request, res); + await ExtensionsController.toggleExtension({ body: { id, enabled: '' } } as Request, res); + await ExtensionsController.toggleExtension({ body: { id, enabled: 1 } } as Request, res); + expect(ExtensionManager.disable).toBeCalledTimes(4); + }); + + it('returns the updated extension', async () => { + await ExtensionsController.toggleExtension({ body: { id, enabled: true } } as Request, res); + + expect(res.json).toBeCalledWith('found extension'); + }); + }); +}); + +describe('removing an extension', () => { + it('validates id parameter', async () => { + await ExtensionsController.removeExtension({ body: { id: '' } } as Request, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: expect.any(String) }); + }); + + it('returns a 404 if the extension is not found', async () => { + (ExtensionManager.find as jest.Mock).mockReturnValue(null); + await ExtensionsController.removeExtension({ body: { id: 'does-not-exist' } } as Request, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: expect.any(String) }); + }); + + describe('when extension is found', () => { + const id = 'extension-id'; + + beforeEach(() => { + (ExtensionManager.find as jest.Mock).mockReturnValue('found extension'); + }); + + it('removes the extension', async () => { + await ExtensionsController.removeExtension({ body: { id } } as Request, res); + expect(ExtensionManager.remove).toHaveBeenCalledWith(id); + }); + + it('returns the list of extensions', async () => { + (ExtensionManager.getAll as jest.Mock).mockReturnValue(['list', 'of', 'extensions']); + + await ExtensionsController.removeExtension({ body: { id } } as Request, res); + expect(res.json).toHaveBeenCalledWith(['list', 'of', 'extensions']); + }); + }); +}); + +describe('searching extensions', () => { + it('returns the search result', async () => { + (ExtensionManager.search as jest.Mock).mockReturnValue(['search', 'results']); + await ExtensionsController.searchExtensions({ query: { q: 'search query' } } as Request, res); + + expect(ExtensionManager.search).toHaveBeenCalledWith('search query'); + expect(res.json).toHaveBeenCalledWith(['search', 'results']); + }); +}); + +describe('getting a view bundle', () => { + it('validates id parameter', async () => { + await ExtensionsController.getBundleForView({ params: { id: '' } } as Request, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: expect.any(String) }); + }); + + it('validates view parameter', async () => { + await ExtensionsController.getBundleForView({ params: { id: 'some-id', view: '' } } as Request, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: expect.any(String) }); + }); + + it('returns 404 if extension not found', async () => { + (ExtensionManager.find as jest.Mock).mockReturnValue(null); + await ExtensionsController.getBundleForView({ params: { id: 'does-not-exist', view: 'some-id' } } as Request, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: expect.any(String) }); + }); + + describe('when extension found', () => { + const id = 'extension-id'; + const viewId = 'view-id'; + const bundleId = 'bundle-id'; + + beforeEach(() => { + (ExtensionManager.find as jest.Mock).mockReturnValue({ + contributes: { + views: { + [viewId]: { + bundleId, + }, + }, + }, + }); + }); + + it('returns a 404 if bundle not found', async () => { + (ExtensionManager.getBundle as jest.Mock).mockReturnValue(null); + await ExtensionsController.getBundleForView({ params: { id, view: viewId } } as Request, res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: expect.any(String) }); + }); + + it('sends the javascript bundle', async () => { + (ExtensionManager.getBundle as jest.Mock).mockReturnValue('js bundle path'); + await ExtensionsController.getBundleForView({ params: { id, view: viewId } } as Request, res); + + expect(res.sendFile).toHaveBeenCalledWith('js bundle path'); + }); + }); +}); + +describe('proxying extension requests', () => { + it.todo('proxies requests from extensions'); +}); diff --git a/Composer/packages/server/__tests__/controllers/project.test.ts b/Composer/packages/server/src/controllers/__tests__/project.test.ts similarity index 91% rename from Composer/packages/server/__tests__/controllers/project.test.ts rename to Composer/packages/server/src/controllers/__tests__/project.test.ts index 5ed15daf4c..78d7fc3675 100644 --- a/Composer/packages/server/__tests__/controllers/project.test.ts +++ b/Composer/packages/server/src/controllers/__tests__/project.test.ts @@ -3,35 +3,33 @@ import { Request, Response } from 'express'; import rimraf from 'rimraf'; -import { pluginLoader } from '@bfc/extension'; +import { ExtensionContext } from '@bfc/extension'; -import { BotProjectService } from '../../src/services/project'; -import { ProjectController } from '../../src/controllers/project'; -import { Path } from '../../src/utility/path'; +import { BotProjectService } from '../../services/project'; +import { ProjectController } from '../../controllers/project'; +import { Path } from '../../utility/path'; jest.mock('@bfc/extension', () => { return { - pluginLoader: { + ExtensionContext: { extensions: { botTemplates: [], baseTemplates: [], publish: [], }, - }, - PluginLoader: { getUserFromRequest: jest.fn(), }, }; }); -const mockSampleBotPath = Path.join(__dirname, '../mocks/asset/projects/SampleBot'); +const mockSampleBotPath = Path.join(__dirname, '../../__mocks__/asset/projects/SampleBot'); let mockRes: Response; -const newBot = Path.resolve(__dirname, '../mocks/samplebots/newBot'); -const saveAsDir = Path.resolve(__dirname, '../mocks/samplebots/saveAsBot'); -const useFortest = Path.resolve(__dirname, '../mocks/samplebots/test'); -const bot1 = Path.resolve(__dirname, '../mocks/samplebots/bot1'); +const newBot = Path.resolve(__dirname, '../../__mocks__/samplebots/newBot'); +const saveAsDir = Path.resolve(__dirname, '../../__mocks__/samplebots/saveAsBot'); +const useFortest = Path.resolve(__dirname, '../../__mocks__/samplebots/test'); +const bot1 = Path.resolve(__dirname, '../../__mocks__/samplebots/bot1'); const location1 = { storageId: 'default', @@ -52,7 +50,7 @@ beforeEach(() => { }); beforeAll(async () => { - pluginLoader.extensions.botTemplates.push({ + ExtensionContext.extensions.botTemplates.push({ id: 'SampleBot', name: 'Sample Bot', description: 'Sample Bot', @@ -92,7 +90,7 @@ describe('get bot project', () => { const mockReq = { params: {}, query: {}, - body: { storageId: 'default', path: Path.resolve(__dirname, '../mocks/samplebots/bot1') }, + body: { storageId: 'default', path: Path.resolve(__dirname, '../../__mocks__/samplebots/bot1') }, } as Request; await ProjectController.openProject(mockReq, mockRes); expect(mockRes.status).toHaveBeenCalledWith(200); @@ -104,7 +102,7 @@ describe('get all projects', () => { const mockReq = { params: {}, query: {}, - body: { storageId: 'default', path: Path.resolve(__dirname, '../mocks/samplebots') }, + body: { storageId: 'default', path: Path.resolve(__dirname, '../../__mocks__/samplebots') }, } as Request; await ProjectController.getAllProjects(mockReq, mockRes); expect(mockRes.status).toHaveBeenCalledWith(200); @@ -129,7 +127,7 @@ describe('open bot operation', () => { const mockReq = { params: {}, query: {}, - body: { storageId: 'default', path: Path.resolve(__dirname, '../mocks/samplebots/bot1') }, + body: { storageId: 'default', path: Path.resolve(__dirname, '../../__mocks__/samplebots/bot1') }, } as Request; await ProjectController.openProject(mockReq, mockRes); expect(mockRes.status).toHaveBeenCalledWith(200); @@ -137,7 +135,7 @@ describe('open bot operation', () => { }); describe('should save as bot', () => { - const saveAsDir = Path.resolve(__dirname, '../mocks/samplebots/'); + const saveAsDir = Path.resolve(__dirname, '../../__mocks__/samplebots/'); it('saveProjectAs', async () => { const projectId = await BotProjectService.openProject(location1); const schemaUrl = 'http://json-schema.org/draft-07/schema#'; @@ -166,7 +164,7 @@ describe('should get recent projects', () => { describe('create a Empty Bot project', () => { it('should create a new project', async () => { - const newBotDir = Path.resolve(__dirname, '../mocks/samplebots/'); + const newBotDir = Path.resolve(__dirname, '../../__mocks__/samplebots/'); const name = 'newBot'; const mockReq = { params: {}, diff --git a/Composer/packages/server/__tests__/controllers/publisher.test.ts b/Composer/packages/server/src/controllers/__tests__/publisher.test.ts similarity index 77% rename from Composer/packages/server/__tests__/controllers/publisher.test.ts rename to Composer/packages/server/src/controllers/__tests__/publisher.test.ts index 33d7c44329..9fcfc8ccb4 100644 --- a/Composer/packages/server/__tests__/controllers/publisher.test.ts +++ b/Composer/packages/server/src/controllers/__tests__/publisher.test.ts @@ -5,18 +5,18 @@ import path from 'path'; import { Request, Response } from 'express'; import rimraf from 'rimraf'; -import { pluginLoader } from '@bfc/extension'; +import { ExtensionContext } from '@bfc/extension'; -import { BotProjectService } from '../../src/services/project'; -import { Path } from '../../src/utility/path'; -import { PublishController } from '../../src/controllers/publisher'; +import { BotProjectService } from '../../services/project'; +import { Path } from '../../utility/path'; +import { PublishController } from '../../controllers/publisher'; -const pluginDir = path.resolve(__dirname, '../../../../plugins'); +const pluginDir = path.resolve(__dirname, '../../../../../plugins'); let mockRes: Response; -const useFortest = Path.resolve(__dirname, '../mocks/samplebots/testPublish'); -const bot1 = Path.resolve(__dirname, '../mocks/samplebots/bot1'); +const useFortest = Path.resolve(__dirname, '../../__mocks__/samplebots/testPublish'); +const bot1 = Path.resolve(__dirname, '../../__mocks__/samplebots/bot1'); const location1 = { storageId: 'default', @@ -28,22 +28,23 @@ const location2 = { path: useFortest, }; -beforeEach(() => { +beforeEach(async () => { mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis(), send: jest.fn().mockReturnThis(), } as any; -}); -beforeAll(async () => { const currentProjectId = await BotProjectService.openProject(location1); const currentProject = await BotProjectService.getProjectById(currentProjectId); await BotProjectService.saveProjectAs(currentProject, location2); - await pluginLoader.loadPluginsFromFolder(pluginDir); }); -afterAll(() => { +beforeAll(async () => { + await ExtensionContext.loadPluginsFromFolder(pluginDir); +}); + +afterEach(() => { // remove the new bot files try { rimraf.sync(useFortest); @@ -65,13 +66,11 @@ describe('get types', () => { }); describe('status', () => { - let projectId = ''; const target = 'default'; - beforeEach(async () => { - projectId = await BotProjectService.openProject(location2); - }); - it('should get status', async () => { + it.only('should get status', async () => { + const projectId = await BotProjectService.openProject(location2); + const mockReq = { params: { projectId, target }, query: {}, diff --git a/Composer/packages/server/__tests__/controllers/storage.test.ts b/Composer/packages/server/src/controllers/__tests__/storage.test.ts similarity index 89% rename from Composer/packages/server/__tests__/controllers/storage.test.ts rename to Composer/packages/server/src/controllers/__tests__/storage.test.ts index 3fe4172f49..61e243c244 100644 --- a/Composer/packages/server/__tests__/controllers/storage.test.ts +++ b/Composer/packages/server/src/controllers/__tests__/storage.test.ts @@ -3,10 +3,10 @@ import { Request, Response } from 'express'; -import StorageService from '../../src/services/storage'; -import { StorageController } from '../../src/controllers/storage'; +import StorageService from '../../services/storage'; +import { StorageController } from '../../controllers/storage'; -jest.mock('../../src/services/storage', () => ({ +jest.mock('../../services/storage', () => ({ getBlob: jest.fn(), })); diff --git a/Composer/packages/server/src/controllers/eject.ts b/Composer/packages/server/src/controllers/eject.ts index ca997a23dc..0098c2d467 100644 --- a/Composer/packages/server/src/controllers/eject.ts +++ b/Composer/packages/server/src/controllers/eject.ts @@ -1,21 +1,21 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { pluginLoader, PluginLoader } from '@bfc/extension'; +import { ExtensionContext } from '@bfc/extension'; import { BotProjectService } from '../services/project'; import { LocalDiskStorage } from '../models/storage/localDiskStorage'; export const EjectController = { getTemplates: async (req, res) => { - res.json(pluginLoader.extensions.runtimeTemplates); + res.json(ExtensionContext.extensions.runtimeTemplates); }, eject: async (req, res) => { - const user = await PluginLoader.getUserFromRequest(req); + const user = await ExtensionContext.getUserFromRequest(req); const projectId = req.params.projectId; const currentProject = await BotProjectService.getProjectById(projectId, user); - const template = pluginLoader.extensions.runtimeTemplates.find((i) => i.key === req.params.template); + const template = ExtensionContext.extensions.runtimeTemplates.find((i) => i.key === req.params.template); if (template) { let runtimePath; try { diff --git a/Composer/packages/server/src/controllers/extensions.ts b/Composer/packages/server/src/controllers/extensions.ts index 833ed092ee..3ab9d85644 100644 --- a/Composer/packages/server/src/controllers/extensions.ts +++ b/Composer/packages/server/src/controllers/extensions.ts @@ -6,7 +6,7 @@ import { ExtensionManager } from '@bfc/extension'; interface AddExtensionRequest extends Request { body: { - name?: string; + id?: string; version?: string; }; } @@ -49,16 +49,16 @@ export async function listExtensions(req: Request, res: Response) { } export async function addExtension(req: AddExtensionRequest, res: Response) { - const { name, version } = req.body; + const { id, version } = req.body; - if (!name) { - res.status(400).send({ error: '`name` is missing from body' }); + if (!id) { + res.status(400).json({ error: '`id` is missing from body' }); return; } - await ExtensionManager.installRemote(name, version); - await ExtensionManager.load(name); - res.json(ExtensionManager.find(name)); + await ExtensionManager.installRemote(id, version); + await ExtensionManager.load(id); + res.json(ExtensionManager.find(id)); } export async function toggleExtension(req: ToggleExtensionRequest, res: Response) { @@ -87,7 +87,7 @@ export async function removeExtension(req: RemoveExtensionRequest, res: Response const { id } = req.body; if (!id) { - res.status(400).send({ error: '`id` is missing from body' }); + res.status(400).json({ error: '`id` is missing from body' }); return; } @@ -109,14 +109,29 @@ export async function searchExtensions(req: SearchExtensionsRequest, res: Respon export async function getBundleForView(req: ExtensionViewBundleRequest, res: Response) { const { id, view } = req.params; + + if (!id) { + res.status(400).json({ error: '`id` is missing from body' }); + return; + } + + if (!view) { + res.status(400).json({ error: '`view` is missing from body' }); + return; + } + const extension = ExtensionManager.find(id); - const bundleId = extension.contributes.views?.[view].bundleId as string; - const bundle = ExtensionManager.getBundle(id, bundleId); - if (bundle) { - res.sendFile(bundle); - } else { - res.status(404); + + if (extension) { + const bundleId = extension.contributes.views?.[view].bundleId as string; + const bundle = ExtensionManager.getBundle(id, bundleId); + if (bundle) { + res.sendFile(bundle); + return; + } } + + res.status(404).json({ error: 'extension or bundle not found' }); } export async function performExtensionFetch(req: ExtensionFetchRequest, res: Response) { diff --git a/Composer/packages/server/src/controllers/formDialog.ts b/Composer/packages/server/src/controllers/formDialog.ts index 19c8f4e38e..2cf3fc987e 100644 --- a/Composer/packages/server/src/controllers/formDialog.ts +++ b/Composer/packages/server/src/controllers/formDialog.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { Request, Response } from 'express'; -import { PluginLoader } from '@bfc/extension'; +import { ExtensionContext } from '@bfc/extension'; import { schemas, expandPropertyDefinition } from '@microsoft/bf-generate-library'; import { BotProjectService } from '../services/project'; @@ -34,7 +34,7 @@ const getTemplateSchemas = async (req: Request, res: Response) => { const generate = async (req: Request, res: Response) => { const projectId = req.params.projectId; - const user = await PluginLoader.getUserFromRequest(req); + const user = await ExtensionContext.getUserFromRequest(req); const currentProject = await BotProjectService.getProjectById(projectId, user); if (currentProject !== undefined) { diff --git a/Composer/packages/server/src/controllers/project.ts b/Composer/packages/server/src/controllers/project.ts index 61956e4727..c609ec934e 100644 --- a/Composer/packages/server/src/controllers/project.ts +++ b/Composer/packages/server/src/controllers/project.ts @@ -5,7 +5,7 @@ import * as fs from 'fs'; import { Request, Response } from 'express'; import { Archiver } from 'archiver'; -import { PluginLoader } from '@bfc/extension'; +import { ExtensionContext } from '@bfc/extension'; import log from '../logger'; import { BotProjectService } from '../services/project'; @@ -20,7 +20,7 @@ import { Path } from './../utility/path'; async function createProject(req: Request, res: Response) { let { templateId } = req.body; const { name, description, storageId, location, schemaUrl, locale } = req.body; - const user = await PluginLoader.getUserFromRequest(req); + const user = await ExtensionContext.getUserFromRequest(req); if (templateId === '') { templateId = 'EmptyBot'; } @@ -76,7 +76,7 @@ async function createProject(req: Request, res: Response) { async function getProjectById(req: Request, res: Response) { const projectId = req.params.projectId; - const user = await PluginLoader.getUserFromRequest(req); + const user = await ExtensionContext.getUserFromRequest(req); try { const currentProject = await BotProjectService.getProjectById(projectId, user); @@ -129,7 +129,7 @@ async function openProject(req: Request, res: Response) { return; } - const user = await PluginLoader.getUserFromRequest(req); + const user = await ExtensionContext.getUserFromRequest(req); const location: LocationRef = { storageId: req.body.storageId, @@ -167,7 +167,7 @@ async function saveProjectAs(req: Request, res: Response) { } const projectId = req.params.projectId; - const user = await PluginLoader.getUserFromRequest(req); + const user = await ExtensionContext.getUserFromRequest(req); const originalProject = await BotProjectService.getProjectById(projectId, user); const { name, description, location, storageId } = req.body; @@ -201,7 +201,7 @@ async function saveProjectAs(req: Request, res: Response) { } async function getRecentProjects(req: Request, res: Response) { - const user = await PluginLoader.getUserFromRequest(req); + const user = await ExtensionContext.getUserFromRequest(req); const projects = await BotProjectService.getRecentBotProjects(user); return res.status(200).json(projects); @@ -209,7 +209,7 @@ async function getRecentProjects(req: Request, res: Response) { async function updateFile(req: Request, res: Response) { const projectId = req.params.projectId; - const user = await PluginLoader.getUserFromRequest(req); + const user = await ExtensionContext.getUserFromRequest(req); const currentProject = await BotProjectService.getProjectById(projectId, user); if (currentProject !== undefined) { const lastModified = await currentProject.updateFile(req.body.name, req.body.content); @@ -223,7 +223,7 @@ async function updateFile(req: Request, res: Response) { async function createFile(req: Request, res: Response) { const projectId = req.params.projectId; - const user = await PluginLoader.getUserFromRequest(req); + const user = await ExtensionContext.getUserFromRequest(req); const currentProject = await BotProjectService.getProjectById(projectId, user); if (currentProject !== undefined) { @@ -241,7 +241,7 @@ async function createFile(req: Request, res: Response) { async function removeFile(req: Request, res: Response) { const projectId = req.params.projectId; - const user = await PluginLoader.getUserFromRequest(req); + const user = await ExtensionContext.getUserFromRequest(req); const currentProject = await BotProjectService.getProjectById(projectId, user); if (currentProject !== undefined) { @@ -254,7 +254,7 @@ async function removeFile(req: Request, res: Response) { async function getSkill(req: Request, res: Response) { const projectId = req.params.projectId; - const user = await PluginLoader.getUserFromRequest(req); + const user = await ExtensionContext.getUserFromRequest(req); const currentProject = await BotProjectService.getProjectById(projectId, user); if (currentProject !== undefined) { @@ -288,7 +288,7 @@ async function exportProject(req: Request, res: Response) { async function setQnASettings(req: Request, res: Response) { const projectId = req.params.projectId; - const user = await PluginLoader.getUserFromRequest(req); + const user = await ExtensionContext.getUserFromRequest(req); const currentProject = await BotProjectService.getProjectById(projectId, user); if (currentProject !== undefined) { @@ -309,7 +309,7 @@ async function setQnASettings(req: Request, res: Response) { async function build(req: Request, res: Response) { const projectId = req.params.projectId; - const user = await PluginLoader.getUserFromRequest(req); + const user = await ExtensionContext.getUserFromRequest(req); const currentProject = await BotProjectService.getProjectById(projectId, user); if (currentProject !== undefined) { @@ -338,7 +338,7 @@ async function build(req: Request, res: Response) { async function getAllProjects(req: Request, res: Response) { const storageId = 'default'; const folderPath = Path.resolve(settings.botsFolder); - const user = await PluginLoader.getUserFromRequest(req); + const user = await ExtensionContext.getUserFromRequest(req); try { res.status(200).json(await StorageService.getBlob(storageId, folderPath, user)); @@ -351,7 +351,7 @@ async function getAllProjects(req: Request, res: Response) { async function checkBoilerplateVersion(req: Request, res: Response) { const projectId = req.params.projectId; - const user = await PluginLoader.getUserFromRequest(req); + const user = await ExtensionContext.getUserFromRequest(req); const currentProject = await BotProjectService.getProjectById(projectId, user); if (currentProject !== undefined) { @@ -375,7 +375,7 @@ async function checkBoilerplateVersion(req: Request, res: Response) { async function updateBoilerplate(req: Request, res: Response) { const projectId = req.params.projectId; - const user = await PluginLoader.getUserFromRequest(req); + const user = await ExtensionContext.getUserFromRequest(req); const currentProject = await BotProjectService.getProjectById(projectId, user); diff --git a/Composer/packages/server/src/controllers/publisher.ts b/Composer/packages/server/src/controllers/publisher.ts index 6d788483c2..fb5e03d566 100644 --- a/Composer/packages/server/src/controllers/publisher.ts +++ b/Composer/packages/server/src/controllers/publisher.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import merge from 'lodash/merge'; -import { pluginLoader, PluginLoader } from '@bfc/extension'; +import { ExtensionContext } from '@bfc/extension'; import { defaultPublishConfig } from '@bfc/shared'; import { BotProjectService } from '../services/project'; @@ -10,7 +10,7 @@ import { BotProjectService } from '../services/project'; export const PublishController = { getTypes: async (req, res) => { res.json( - Object.values(pluginLoader.extensions.publish) + Object.values(ExtensionContext.extensions.publish) .filter((extension) => extension.plugin.name !== defaultPublishConfig.type) .map((extension) => { const { plugin, methods } = extension; @@ -33,7 +33,7 @@ export const PublishController = { }, publish: async (req, res) => { const target = req.params.target; - const user = await PluginLoader.getUserFromRequest(req); + const user = await ExtensionContext.getUserFromRequest(req); const { metadata, sensitiveSettings } = req.body; const projectId = req.params.projectId; const currentProject = await BotProjectService.getProjectById(projectId, user); @@ -46,7 +46,7 @@ export const PublishController = { const profile = profiles.length ? profiles[0] : undefined; const method = profile ? profile.type : undefined; // get the publish plugin key - if (profile && method && pluginLoader?.extensions?.publish[method]?.methods?.publish) { + if (profile && method && ExtensionContext?.extensions?.publish[method]?.methods?.publish) { // append config from client(like sensitive settings) const configuration = { profileName: profile.name, @@ -55,7 +55,7 @@ export const PublishController = { }; // get the externally defined method - const pluginMethod = pluginLoader.extensions.publish[method].methods.publish; + const pluginMethod = ExtensionContext.extensions.publish[method].methods.publish; try { // call the method @@ -84,7 +84,7 @@ export const PublishController = { }, status: async (req, res) => { const target = req.params.target; - const user = await PluginLoader.getUserFromRequest(req); + const user = await ExtensionContext.getUserFromRequest(req); const projectId = req.params.projectId; const currentProject = await BotProjectService.getProjectById(projectId, user); @@ -95,15 +95,9 @@ export const PublishController = { const profile = profiles.length ? profiles[0] : undefined; // get the publish plugin key const method = profile ? profile.type : undefined; - if ( - profile && - method && - pluginLoader.extensions.publish[method] && - pluginLoader.extensions.publish[method].methods && - pluginLoader.extensions.publish[method].methods.getStatus - ) { + if (profile && method && ExtensionContext.extensions.publish[method]?.methods?.getStatus) { // get the externally defined method - const pluginMethod = pluginLoader.extensions.publish[method].methods.getStatus; + const pluginMethod = ExtensionContext.extensions.publish[method].methods.getStatus; if (typeof pluginMethod === 'function') { const configuration = { @@ -131,7 +125,7 @@ export const PublishController = { }, history: async (req, res) => { const target = req.params.target; - const user = await PluginLoader.getUserFromRequest(req); + const user = await ExtensionContext.getUserFromRequest(req); const projectId = req.params.projectId; const currentProject = await BotProjectService.getProjectById(projectId, user); @@ -143,15 +137,9 @@ export const PublishController = { // get the publish plugin key const method = profile ? profile.type : undefined; - if ( - profile && - method && - pluginLoader.extensions.publish[method] && - pluginLoader.extensions.publish[method].methods && - pluginLoader.extensions.publish[method].methods.history - ) { + if (profile && method && ExtensionContext.extensions.publish[method]?.methods?.history) { // get the externally defined method - const pluginMethod = pluginLoader.extensions.publish[method].methods.history; + const pluginMethod = ExtensionContext.extensions.publish[method].methods.history; if (typeof pluginMethod === 'function') { const configuration = { profileName: profile.name, @@ -173,7 +161,7 @@ export const PublishController = { }, rollback: async (req, res) => { const target = req.params.target; - const user = await PluginLoader.getUserFromRequest(req); + const user = await ExtensionContext.getUserFromRequest(req); const { version, sensitiveSettings } = req.body; const projectId = req.params.projectId; const currentProject = await BotProjectService.getProjectById(projectId, user); @@ -187,13 +175,7 @@ export const PublishController = { // get the publish plugin key const method = profile ? profile.type : undefined; - if ( - profile && - method && - pluginLoader.extensions.publish[method] && - pluginLoader.extensions.publish[method].methods && - pluginLoader.extensions.publish[method].methods.rollback - ) { + if (profile && method && ExtensionContext.extensions.publish[method]?.methods?.rollback) { // append config from client(like sensitive settings) const configuration = { profileName: profile.name, @@ -201,7 +183,7 @@ export const PublishController = { ...JSON.parse(profile.configuration), }; // get the externally defined method - const pluginMethod = pluginLoader.extensions.publish[method].methods.rollback; + const pluginMethod = ExtensionContext.extensions.publish[method].methods.rollback; if (typeof pluginMethod === 'function') { try { // call the method @@ -233,14 +215,8 @@ export const PublishController = { const projectId = req.params.projectId; const profile = defaultPublishConfig; const method = profile.type; - if ( - profile && - method && - pluginLoader.extensions.publish[method] && - pluginLoader.extensions.publish[method].methods && - pluginLoader.extensions.publish[method].methods.stopBot - ) { - const pluginMethod = pluginLoader.extensions.publish[method].methods.stopBot; + if (profile && method && ExtensionContext.extensions.publish[method]?.methods?.stopBot) { + const pluginMethod = ExtensionContext.extensions.publish[method].methods.stopBot; if (typeof pluginMethod === 'function') { try { await pluginMethod.call(null, projectId); @@ -252,13 +228,8 @@ export const PublishController = { } } } - if ( - profile && - pluginLoader.extensions.publish[method] && - pluginLoader.extensions.publish[method].methods && - pluginLoader.extensions.publish[method].methods.removeRuntimeData - ) { - const pluginMethod = pluginLoader.extensions.publish[method].methods.removeRuntimeData; + if (profile && ExtensionContext.extensions.publish[method]?.methods?.removeRuntimeData) { + const pluginMethod = ExtensionContext.extensions.publish[method].methods.removeRuntimeData; if (typeof pluginMethod === 'function') { try { const result = await pluginMethod.call(null, projectId); @@ -281,14 +252,8 @@ export const PublishController = { const projectId = req.params.projectId; const profile = defaultPublishConfig; const method = profile.type; - if ( - profile && - method && - pluginLoader.extensions.publish[method] && - pluginLoader.extensions.publish[method].methods && - pluginLoader.extensions.publish[method].methods.stopBot - ) { - const pluginMethod = pluginLoader.extensions.publish[method].methods.stopBot; + if (profile && method && ExtensionContext.extensions.publish[method]?.methods?.stopBot) { + const pluginMethod = ExtensionContext.extensions.publish[method].methods.stopBot; if (typeof pluginMethod === 'function') { try { await pluginMethod.call(null, projectId); diff --git a/Composer/packages/server/src/controllers/storage.ts b/Composer/packages/server/src/controllers/storage.ts index a404c983d4..ac2dece498 100644 --- a/Composer/packages/server/src/controllers/storage.ts +++ b/Composer/packages/server/src/controllers/storage.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { Request, Response } from 'express'; -import { PluginLoader } from '@bfc/extension'; +import { ExtensionContext } from '@bfc/extension'; import StorageService from '../services/storage'; import { Path } from '../utility/path'; @@ -45,7 +45,7 @@ async function updateFolder(req: Request, res: Response) { async function getBlob(req: Request, res: Response) { const storageId = req.params.storageId; - const user = await PluginLoader.getUserFromRequest(req); + const user = await ExtensionContext.getUserFromRequest(req); try { if (!req.query.path) { diff --git a/Composer/packages/server/__tests__/models/asset/assetManager.test.ts b/Composer/packages/server/src/models/asset/__tests__/assetManager.test.ts similarity index 76% rename from Composer/packages/server/__tests__/models/asset/assetManager.test.ts rename to Composer/packages/server/src/models/asset/__tests__/assetManager.test.ts index bd87f44055..a29937baf3 100644 --- a/Composer/packages/server/__tests__/models/asset/assetManager.test.ts +++ b/Composer/packages/server/src/models/asset/__tests__/assetManager.test.ts @@ -2,10 +2,10 @@ // Licensed under the MIT License. import rimraf from 'rimraf'; -import { pluginLoader } from '@bfc/extension'; +import { ExtensionContext } from '@bfc/extension'; -import { Path } from '../../../src/utility/path'; -import { AssetManager } from '../../../src/models/asset/assetManager'; +import { Path } from '../../../utility/path'; +import { AssetManager } from '../assetManager'; jest.mock('azure-storage', () => { return {}; }); @@ -13,7 +13,7 @@ jest.mock('azure-storage', () => { jest.mock('@bfc/extension', () => { //const p = require('path'); return { - pluginLoader: { + ExtensionContext: { extensions: { botTemplates: [], }, @@ -21,15 +21,15 @@ jest.mock('@bfc/extension', () => { }; }); -const mockSampleBotPath = Path.join(__dirname, '../../mocks/asset/projects/SampleBot'); -const mockCopyToPath = Path.join(__dirname, '../../mocks/new'); +const mockSampleBotPath = Path.join(__dirname, '../../../__mocks__/asset/projects/SampleBot'); +const mockCopyToPath = Path.join(__dirname, '../../../__mocks__/new'); const locationRef = { storageId: 'default', path: mockCopyToPath, }; beforeAll(() => { - pluginLoader.extensions.botTemplates.push({ + ExtensionContext.extensions.botTemplates.push({ id: 'SampleBot', name: 'Sample Bot', description: 'Sample Bot', diff --git a/Composer/packages/server/src/models/asset/assetManager.ts b/Composer/packages/server/src/models/asset/assetManager.ts index 36eb0b0b95..0df9b9ca04 100644 --- a/Composer/packages/server/src/models/asset/assetManager.ts +++ b/Composer/packages/server/src/models/asset/assetManager.ts @@ -5,7 +5,7 @@ import fs from 'fs'; import path from 'path'; import find from 'lodash/find'; -import { UserIdentity, pluginLoader } from '@bfc/extension'; +import { UserIdentity, ExtensionContext } from '@bfc/extension'; import log from '../../logger'; import { LocalDiskStorage } from '../storage/localDiskStorage'; @@ -24,7 +24,7 @@ export class AssetManager { } public async getProjectTemplates() { - return pluginLoader.extensions.botTemplates; + return ExtensionContext.extensions.botTemplates; } public async copyProjectTemplateTo( @@ -45,7 +45,7 @@ export class AssetManager { } private async copyDataFilesTo(templateId: string, dstDir: string, dstStorage: IFileStorage, locale?: string) { - const template = find(pluginLoader.extensions.botTemplates, { id: templateId }); + const template = find(ExtensionContext.extensions.botTemplates, { id: templateId }); if (template === undefined || template.path === undefined) { throw new Error(`no such template with id ${templateId}`); } @@ -66,7 +66,7 @@ export class AssetManager { // Copy material from the boilerplate into the project // This is used to copy shared content into every new project public async copyBoilerplate(dstDir: string, dstStorage: IFileStorage) { - for (const boilerplate of pluginLoader.extensions.baseTemplates) { + for (const boilerplate of ExtensionContext.extensions.baseTemplates) { const boilerplatePath = boilerplate.path; if (await this.templateStorage.exists(boilerplatePath)) { await copyDir(boilerplatePath, this.templateStorage, dstDir, dstStorage); @@ -97,10 +97,10 @@ export class AssetManager { // return the current version of the boilerplate content, if one exists so specified // this is based off of the first boilerplate template added to the app. public getBoilerplateCurrentVersion(): string | undefined { - if (!pluginLoader.extensions.baseTemplates.length) { + if (!ExtensionContext.extensions.baseTemplates.length) { return undefined; } - const boilerplate = pluginLoader.extensions.baseTemplates[0]; + const boilerplate = ExtensionContext.extensions.baseTemplates[0]; const location = Path.join(boilerplate.path, 'scripts', 'package.json'); try { if (fs.existsSync(location)) { diff --git a/Composer/packages/server/__tests__/models/bot/botProject.test.ts b/Composer/packages/server/src/models/bot/__tests__/botProject.test.ts similarity index 97% rename from Composer/packages/server/__tests__/models/bot/botProject.test.ts rename to Composer/packages/server/src/models/bot/__tests__/botProject.test.ts index 56ef84919d..6dcf62c385 100644 --- a/Composer/packages/server/__tests__/models/bot/botProject.test.ts +++ b/Composer/packages/server/src/models/bot/__tests__/botProject.test.ts @@ -6,15 +6,15 @@ import fs from 'fs'; import rimraf from 'rimraf'; import { DialogFactory, SDKKinds } from '@bfc/shared'; -import { Path } from '../../../src/utility/path'; -import { BotProject } from '../../../src/models/bot/botProject'; -import { LocationRef } from '../../../src/models/bot/interface'; +import { Path } from '../../../utility/path'; +import { BotProject } from '../botProject'; +import { LocationRef } from '../interface'; jest.mock('azure-storage', () => { return {}; }); -const botDir = '../../mocks/samplebots/bot1'; +const botDir = '../../../__mocks__/samplebots/bot1'; const mockLocationRef: LocationRef = { storageId: 'default', diff --git a/Composer/packages/server/__tests__/models/bot/botStructure.test.ts b/Composer/packages/server/src/models/bot/__tests__/botStructure.test.ts similarity index 98% rename from Composer/packages/server/__tests__/models/bot/botStructure.test.ts rename to Composer/packages/server/src/models/bot/__tests__/botStructure.test.ts index 7e56c70ad7..afa9a14dae 100644 --- a/Composer/packages/server/__tests__/models/bot/botStructure.test.ts +++ b/Composer/packages/server/src/models/bot/__tests__/botStructure.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { defaultFilePath, parseFileName } from '../../../src/models/bot/botStructure'; +import { defaultFilePath, parseFileName } from '../../../models/bot/botStructure'; const botName = 'Mybot'; const defaultLocale = 'en-us'; diff --git a/Composer/packages/server/src/models/bot/botProject.ts b/Composer/packages/server/src/models/bot/botProject.ts index abc5938af2..2dacd7b9c4 100644 --- a/Composer/packages/server/src/models/bot/botProject.ts +++ b/Composer/packages/server/src/models/bot/botProject.ts @@ -8,7 +8,7 @@ import axios from 'axios'; import { autofixReferInDialog } from '@bfc/indexers'; import { getNewDesigner, FileInfo, Skill, Diagnostic, IBotProject, DialogSetting, FileExtensions } from '@bfc/shared'; import merge from 'lodash/merge'; -import { UserIdentity, pluginLoader } from '@bfc/extension'; +import { UserIdentity, ExtensionContext } from '@bfc/extension'; import { FeedbackType, generate } from '@microsoft/bf-generate-library'; import { Path } from '../../utility/path'; @@ -548,14 +548,14 @@ export class BotProject implements IBotProject { private async removeLocalRuntimeData(projectId) { const method = 'localpublish'; - if (pluginLoader.extensions.publish[method]?.methods?.stopBot) { - const pluginMethod = pluginLoader.extensions.publish[method].methods.stopBot; + if (ExtensionContext.extensions.publish[method]?.methods?.stopBot) { + const pluginMethod = ExtensionContext.extensions.publish[method].methods.stopBot; if (typeof pluginMethod === 'function') { await pluginMethod.call(null, projectId); } } - if (pluginLoader.extensions.publish[method]?.methods?.removeRuntimeData) { - const pluginMethod = pluginLoader.extensions.publish[method].methods.removeRuntimeData; + if (ExtensionContext.extensions.publish[method]?.methods?.removeRuntimeData) { + const pluginMethod = ExtensionContext.extensions.publish[method].methods.removeRuntimeData; if (typeof pluginMethod === 'function') { await pluginMethod.call(null, projectId); } diff --git a/Composer/packages/server/__tests__/models/bot/sampler/BootstrapSampler.test.ts b/Composer/packages/server/src/models/bot/sampler/__tests__/BootstrapSampler.test.ts similarity index 91% rename from Composer/packages/server/__tests__/models/bot/sampler/BootstrapSampler.test.ts rename to Composer/packages/server/src/models/bot/sampler/__tests__/BootstrapSampler.test.ts index c0d26af350..94d0577fbb 100644 --- a/Composer/packages/server/__tests__/models/bot/sampler/BootstrapSampler.test.ts +++ b/Composer/packages/server/src/models/bot/sampler/__tests__/BootstrapSampler.test.ts @@ -1,6 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { ComposerBootstrapSampler } from './../../../../src/models/bot/sampler/BootstrapSampler'; +import { ComposerBootstrapSampler } from '../BootstrapSampler'; describe('BootstrapSampler', () => { it('balence the utterances ratio in intents after bootstrap sampling', async () => { diff --git a/Composer/packages/server/__tests__/models/bot/sampler/ReservoirSampler.test.ts b/Composer/packages/server/src/models/bot/sampler/__tests__/ReservoirSampler.test.ts similarity index 93% rename from Composer/packages/server/__tests__/models/bot/sampler/ReservoirSampler.test.ts rename to Composer/packages/server/src/models/bot/sampler/__tests__/ReservoirSampler.test.ts index e6e8269119..072b0c4b9f 100644 --- a/Composer/packages/server/__tests__/models/bot/sampler/ReservoirSampler.test.ts +++ b/Composer/packages/server/src/models/bot/sampler/__tests__/ReservoirSampler.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { ComposerReservoirSampler } from './../../../../src/models/bot/sampler/ReservoirSampler'; +import { ComposerReservoirSampler } from './../../../../models/bot/sampler/ReservoirSampler'; describe('BootstrapSampler', () => { it('down size the number of utterances reservoir sampling', async () => { diff --git a/Composer/packages/server/__tests__/models/settings/fileSettingManager.test.ts b/Composer/packages/server/src/models/settings/__tests__/fileSettingManager.test.ts similarity index 85% rename from Composer/packages/server/__tests__/models/settings/fileSettingManager.test.ts rename to Composer/packages/server/src/models/settings/__tests__/fileSettingManager.test.ts index 1aa933d10c..67d78c9289 100644 --- a/Composer/packages/server/__tests__/models/settings/fileSettingManager.test.ts +++ b/Composer/packages/server/src/models/settings/__tests__/fileSettingManager.test.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { FileSettingManager } from '../../../src/models/settings/fileSettingManager'; -import { Path } from '../../../src/utility/path'; +import { FileSettingManager } from '../../../models/settings/fileSettingManager'; +import { Path } from '../../../utility/path'; const dir = './mocks'; const defaultDir = Path.join(__dirname, dir); diff --git a/Composer/packages/server/__tests__/models/settings/mocks/bonus/settings/appsettings.json b/Composer/packages/server/src/models/settings/__tests__/mocks/bonus/settings/appsettings.json similarity index 100% rename from Composer/packages/server/__tests__/models/settings/mocks/bonus/settings/appsettings.json rename to Composer/packages/server/src/models/settings/__tests__/mocks/bonus/settings/appsettings.json diff --git a/Composer/packages/server/__tests__/models/settings/mocks/integration/settings/appsettings.json b/Composer/packages/server/src/models/settings/__tests__/mocks/integration/settings/appsettings.json similarity index 100% rename from Composer/packages/server/__tests__/models/settings/mocks/integration/settings/appsettings.json rename to Composer/packages/server/src/models/settings/__tests__/mocks/integration/settings/appsettings.json diff --git a/Composer/packages/server/__tests__/models/settings/mocks/production/settings/appsettings.json b/Composer/packages/server/src/models/settings/__tests__/mocks/production/settings/appsettings.json similarity index 100% rename from Composer/packages/server/__tests__/models/settings/mocks/production/settings/appsettings.json rename to Composer/packages/server/src/models/settings/__tests__/mocks/production/settings/appsettings.json diff --git a/Composer/packages/server/__tests__/models/settings/mocks/settings/appsettings.json b/Composer/packages/server/src/models/settings/__tests__/mocks/settings/appsettings.json similarity index 100% rename from Composer/packages/server/__tests__/models/settings/mocks/settings/appsettings.json rename to Composer/packages/server/src/models/settings/__tests__/mocks/settings/appsettings.json diff --git a/Composer/packages/server/src/models/storage/storageFactory.ts b/Composer/packages/server/src/models/storage/storageFactory.ts index 8c2a32c055..cabb6ab2e3 100644 --- a/Composer/packages/server/src/models/storage/storageFactory.ts +++ b/Composer/packages/server/src/models/storage/storageFactory.ts @@ -1,15 +1,15 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { pluginLoader, UserIdentity } from '@bfc/extension'; +import { ExtensionContext, UserIdentity } from '@bfc/extension'; import { LocalDiskStorage } from './localDiskStorage'; import { StorageConnection, IFileStorage } from './interface'; export class StorageFactory { public static createStorageClient(conn: StorageConnection, user?: UserIdentity): IFileStorage { - if (pluginLoader.extensions.storage && pluginLoader.extensions.storage.customStorageClass) { - const customStorageClass = pluginLoader.extensions.storage.customStorageClass; + if (ExtensionContext.extensions.storage && ExtensionContext.extensions.storage.customStorageClass) { + const customStorageClass = ExtensionContext.extensions.storage.customStorageClass; if (customStorageClass) { return new customStorageClass(conn, user) as IFileStorage; } diff --git a/Composer/packages/server/src/server.ts b/Composer/packages/server/src/server.ts index 25f79a702a..2d004377de 100644 --- a/Composer/packages/server/src/server.ts +++ b/Composer/packages/server/src/server.ts @@ -17,7 +17,7 @@ import { IntellisenseServer } from '@bfc/intellisense-languageserver'; import { LGServer } from '@bfc/lg-languageserver'; import { LUServer } from '@bfc/lu-languageserver'; import chalk from 'chalk'; -import { pluginLoader, ExtensionManager } from '@bfc/extension'; +import { ExtensionContext, ExtensionManager } from '@bfc/extension'; import { BotProjectService } from './services/project'; import { getAuthProvider } from './router/auth'; @@ -40,18 +40,17 @@ export async function start(): Promise { app.use(bodyParser.json({ limit: '50mb' })); app.use(bodyParser.urlencoded({ extended: false })); app.use(session({ secret: 'bot-framework-composer' })); - app.use(pluginLoader.passport.initialize()); - app.use(pluginLoader.passport.session()); + app.use(ExtensionContext.passport.initialize()); + app.use(ExtensionContext.passport.session()); // make sure plugin has access to our express... - pluginLoader.useExpress(app); + ExtensionContext.useExpress(app); // load all installed plugins setEnvDefault('COMPOSER_EXTENSION_DATA', path.resolve(__dirname, '../extensions.json')); - setEnvDefault('COMPOSER_BUILTIN_PLUGINS_DIR', path.resolve(__dirname, '../../../plugins')); - setEnvDefault('COMPOSER_REMOTE_PLUGINS_DIR', path.resolve(__dirname, '../../../.composer')); - await ExtensionManager.loadBuiltinPlugins(); - // TODO (toanzian / abrown): load 3P plugins + setEnvDefault('COMPOSER_BUILTIN_EXTENSIONS_DIR', path.resolve(__dirname, '../../../plugins')); + setEnvDefault('COMPOSER_REMOTE_EXTENSIONS_DIR', path.resolve(__dirname, '../../../.composer')); + await ExtensionManager.loadAll(); const { login, authorize } = getAuthProvider(); diff --git a/Composer/packages/server/__tests__/services/project.test.ts b/Composer/packages/server/src/services/__tests__/project.test.ts similarity index 85% rename from Composer/packages/server/__tests__/services/project.test.ts rename to Composer/packages/server/src/services/__tests__/project.test.ts index 088212e577..eab70305bc 100644 --- a/Composer/packages/server/__tests__/services/project.test.ts +++ b/Composer/packages/server/src/services/__tests__/project.test.ts @@ -3,11 +3,11 @@ import rimraf from 'rimraf'; -import { Path } from '../../src/utility/path'; -import { BotProjectService } from '../../src/services/project'; +import { Path } from '../../utility/path'; +import { BotProjectService } from '../project'; // offer a bot project ref which to open -jest.mock('../../src/store/store', () => { +jest.mock('../../store/store', () => { const data = { storageConnections: [ { @@ -35,9 +35,9 @@ jest.mock('../../src/store/store', () => { jest.mock('azure-storage', () => {}); -const projPath = Path.resolve(__dirname, '../mocks/samplebots/bot1'); +const projPath = Path.resolve(__dirname, '../../__mocks__/samplebots/bot1'); -const saveAsDir = Path.resolve(__dirname, '../mocks/samplebots/saveas'); +const saveAsDir = Path.resolve(__dirname, '../../__mocks__/samplebots/saveas'); const location1 = { storageId: 'default', diff --git a/Composer/packages/server/__tests__/services/storage.test.ts b/Composer/packages/server/src/services/__tests__/storage.test.ts similarity index 90% rename from Composer/packages/server/__tests__/services/storage.test.ts rename to Composer/packages/server/src/services/__tests__/storage.test.ts index b93b445482..e54739f4ab 100644 --- a/Composer/packages/server/__tests__/services/storage.test.ts +++ b/Composer/packages/server/src/services/__tests__/storage.test.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { Path } from '../../src/utility/path'; -import StorageService from '../../src/services/storage'; +import { Path } from '../../utility/path'; +import StorageService from '../../services/storage'; jest.mock('azure-storage', () => { return { createBlobService: (_account: string, _key: string) => { @@ -17,7 +17,7 @@ jest.mock('azure-storage', () => { }, }; }); -jest.mock('../../src/store/store', () => { +jest.mock('../../store/store', () => { const data = [ { id: 'default', diff --git a/Composer/packages/server/tsconfig.build.json b/Composer/packages/server/tsconfig.build.json index 5c27bc8c34..3d2578d39e 100644 --- a/Composer/packages/server/tsconfig.build.json +++ b/Composer/packages/server/tsconfig.build.json @@ -2,7 +2,7 @@ /* Options used for building production code (tests excluded) */ "extends": "./tsconfig.json", "exclude": [ - "__tests__", + "**/__tests__/**", "**/__mocks__/**" ] } diff --git a/Composer/packages/server/tsconfig.json b/Composer/packages/server/tsconfig.json index 991f443d52..aea331b403 100644 --- a/Composer/packages/server/tsconfig.json +++ b/Composer/packages/server/tsconfig.json @@ -5,5 +5,5 @@ "sourceMap": true, "target": "es6" }, - "include": ["src/**/*.ts", "__tests__/**/*"] + "include": ["src/**/*.ts"] } diff --git a/Composer/packages/test-utils/src/base/jest.config.ts b/Composer/packages/test-utils/src/base/jest.config.ts index e94f02436d..a041eb0921 100644 --- a/Composer/packages/test-utils/src/base/jest.config.ts +++ b/Composer/packages/test-utils/src/base/jest.config.ts @@ -20,7 +20,7 @@ const base: Partial = { transformIgnorePatterns: ['/node_modules/'], - setupFilesAfterEnv: [path.resolve(__dirname, 'setup.js')], + setupFiles: [path.resolve(__dirname, 'setupEnv.js')], }; export default base; diff --git a/Composer/packages/test-utils/src/base/setup.ts b/Composer/packages/test-utils/src/base/setup.ts deleted file mode 100644 index a40322e27d..0000000000 --- a/Composer/packages/test-utils/src/base/setup.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -/* eslint-disable @typescript-eslint/no-var-requires */ - -try { - // Not all packages will require format-message, so just swallow the error - const formatMessage = require('format-message'); - - formatMessage.setup({ - missingTranslation: 'ignore', - }); -} catch { - // ignore -} diff --git a/Composer/packages/test-utils/src/base/setupEnv.ts b/Composer/packages/test-utils/src/base/setupEnv.ts new file mode 100644 index 0000000000..a0d11aa5cd --- /dev/null +++ b/Composer/packages/test-utils/src/base/setupEnv.ts @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/* eslint-disable @typescript-eslint/no-var-requires */ + +// try { +// // Not all packages will require format-message, so just swallow the error +// const formatMessage = require('format-message'); + +// formatMessage.setup({ +// missingTranslation: 'ignore', +// }); +// } catch { +// // ignore +// } + +// eslint-disable-next-line no-console +const oldWarn = console.warn; +const oldError = console.error; + +console.warn = (...args) => { + if (args.some((msg) => typeof msg === 'string' && msg.startsWith('Translation for'))) { + return; + } + + oldWarn(...args); +}; + +console.error = (...args) => { + if (args.some((msg) => typeof msg === 'string' && msg.startsWith('Warning: Cannot update a component'))) { + return; + } + + oldError(...args); +}; diff --git a/Composer/plugins/README.md b/Composer/plugins/README.md index 87e1971d9d..984a4f0438 100644 --- a/Composer/plugins/README.md +++ b/Composer/plugins/README.md @@ -42,7 +42,7 @@ Plugin modules must come in one of the following forms: Currently, plugins can be loaded into Composer using 1 of 2 methods: * The plugin is placed in the /plugins/ folder, and contains a package.json file with `extendsComposer` set to `true` -* The plugin is loaded directly via changes to Composer code, using `pluginLoader.loadPlugin(name, plugin)` +* The plugin is loaded directly via changes to Composer code, using `ExtensionContext.loadPlugin(name, plugin)` The simplest form of a plugin module is below: @@ -122,9 +122,9 @@ This value is used by the built-in authentication middleware to redirect the use Note that if you specify an alternate URI for the login page, you must use `addAllowedUrl` to whitelist it. -#### PluginLoader.getUserFromRequest(req)` +#### ExtensionContext.getUserFromRequest(req)` -This is a static method on the PluginLoader class that extracts the user identity information provided by Passport. +This is a static method on the ExtensionContext class that extracts the user identity information provided by Passport. This is for use in the web route implementations to get user and provide it to other components of Composer. For example: @@ -132,7 +132,7 @@ For example: ```ts const RequestHandlerX = async (req, res) => { - const user = await PluginLoader.getUserFromRequest(req); + const user = await ExtensionContext.getUserFromRequest(req); // ... do some stuff diff --git a/Composer/plugins/mockRemotePublish/src/index.ts b/Composer/plugins/mockRemotePublish/src/index.ts index 3a113c7f2d..1ee87e60c2 100644 --- a/Composer/plugins/mockRemotePublish/src/index.ts +++ b/Composer/plugins/mockRemotePublish/src/index.ts @@ -8,7 +8,7 @@ */ import { v4 as uuid } from 'uuid'; -import { ComposerPluginRegistration, PublishResponse, PublishPlugin, JSONSchema7 } from '@bfc/extension'; +import { ExtensionRegistration, PublishResponse, PublishPlugin, JSONSchema7 } from '@bfc/extension'; import schema from './schema'; @@ -23,9 +23,9 @@ interface PublishConfig { class LocalPublisher implements PublishPlugin { private data: { [botId: string]: LocalPublishData }; - private composer: ComposerPluginRegistration; + private composer: ExtensionRegistration; public schema: JSONSchema7; - constructor(composer: ComposerPluginRegistration) { + constructor(composer: ExtensionRegistration) { this.data = {}; this.composer = composer; this.schema = schema; @@ -134,7 +134,7 @@ class LocalPublisher implements PublishPlugin { }; } -export default async (composer: ComposerPluginRegistration): Promise => { +export default async (composer: ExtensionRegistration): Promise => { const publisher = new LocalPublisher(composer); // pass in the custom storage class that will override the default await composer.addPublishMethod(publisher); diff --git a/Composer/plugins/sample-ui-plugin/README.md b/Composer/plugins/sample-ui-plugin/README.md index a39e185054..30c2abd40d 100644 --- a/Composer/plugins/sample-ui-plugin/README.md +++ b/Composer/plugins/sample-ui-plugin/README.md @@ -214,7 +214,7 @@ When Composer tries to load your extension, it will use this `main` property to ## Sample -To see a working sample in action, just navigate to `/sample-ui-plugin/package.json` in this directory and change the `extendsComposer` property to `true`, and the `composer.contributes.views.page-DISABLED` key to `page`. +To see a working sample in action, just navigate to `/sample-ui-plugin/package.json` in this directory and change the `composer.enabled` property to `true`, and the `composer.contributes.views.page-DISABLED` key to `page`. Then restart the Composer server. diff --git a/Composer/plugins/sample-ui-plugin/package.json b/Composer/plugins/sample-ui-plugin/package.json index 8cc70507a4..b5942c14de 100644 --- a/Composer/plugins/sample-ui-plugin/package.json +++ b/Composer/plugins/sample-ui-plugin/package.json @@ -9,6 +9,7 @@ "clean": "rimraf dist" }, "composer": { + "enabled": false, "bundles": [ { "id": "publish", @@ -31,7 +32,6 @@ } } }, - "extendsComposer": false, "main": "dist/index.js", "dependencies": { "@bfc/extension-client": "file:../../packages/extension-client", diff --git a/Composer/yarn.lock b/Composer/yarn.lock index ba3bde2608..3e8beffec5 100644 --- a/Composer/yarn.lock +++ b/Composer/yarn.lock @@ -865,9 +865,9 @@ js-tokens "^4.0.0" "@babel/parser@^7.0.0", "@babel/parser@^7.1.0", "@babel/parser@^7.10.4", "@babel/parser@^7.10.5", "@babel/parser@^7.11.3", "@babel/parser@^7.2.2", "@babel/parser@^7.3.4", "@babel/parser@^7.4.0", "@babel/parser@^7.7.0", "@babel/parser@^7.7.4", "@babel/parser@^7.7.5", "@babel/parser@^7.8.6", "@babel/parser@^7.9.0", "@babel/parser@^7.9.6": - version "7.11.3" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.11.3.tgz#9e1eae46738bcd08e23e867bab43e7b95299a8f9" - integrity sha512-REo8xv7+sDxkKvoxEywIdsNFiZLybwdI7hcT5uEPyQrSMB4YQ973BfC9OOrD/81MaIjh6UxdulIQXkjmiH3PcA== + version "7.11.5" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.11.5.tgz#c7ff6303df71080ec7a4f5b8c003c58f1cf51037" + integrity sha512-X9rD8qqm695vgmeaQ4fvz/o3+Wk4ZzQvSHkDBgpYKxpD4qTAUm88ZKtHkVqIOsYFFbIQ6wQYhC6q7pjqVK0E0Q== "@babel/plugin-proposal-async-generator-functions@^7.2.0": version "7.2.0" @@ -3528,6 +3528,13 @@ dependencies: "@types/node" "*" +"@types/fs-extra@^9.0.1": + version "9.0.1" + resolved "https://registry.yarnpkg.com/@types/fs-extra/-/fs-extra-9.0.1.tgz#91c8fc4c51f6d5dbe44c2ca9ab09310bd00c7918" + integrity sha512-B42Sxuaz09MhC3DDeW5kubRcQ5by4iuVQ0cRRWM2lggLzAa/KVom0Aft/208NgMvNQQZ86s5rVcqDdn/SH0/mg== + dependencies: + "@types/node" "*" + "@types/glob@*", "@types/glob@^7.1.1": version "7.1.1" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-7.1.1.tgz#aa59a1c6e3fbc421e07ccd31a944c30eba521575" @@ -8522,8 +8529,8 @@ elegant-spinner@^1.0.1: elliptic@^6.0.0, elliptic@^6.5.3: version "6.5.3" - resolved "https://botbuilder.myget.org/F/botbuilder-v4-js-daily/npm/elliptic/-/elliptic-6.5.3.tgz#cb59eb2efdaf73a0bd78ccd7015a62ad6e0f93d6" - integrity sha1-y1nrLv2vc6C9eMzXAVpirW4Pk9Y= + resolved "https://registry.yarnpkg.com/elliptic/-/elliptic-6.5.3.tgz#cb59eb2efdaf73a0bd78ccd7015a62ad6e0f93d6" + integrity sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw== dependencies: bn.js "^4.4.0" brorand "^1.0.1" @@ -9961,8 +9968,8 @@ fs-extra@^9.0.0: fs-extra@^9.0.1: version "9.0.1" - resolved "https://botbuilder.myget.org/F/botframework-cli/npm/fs-extra/-/fs-extra-9.0.1.tgz#910da0062437ba4c39fedd863f1675ccfefcb9fc" - integrity sha1-kQ2gBiQ3ukw5/t2GPxZ1zP78ufw= + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.0.1.tgz#910da0062437ba4c39fedd863f1675ccfefcb9fc" + integrity sha512-h2iAoN838FqAFJY2/qVpzFXy+EBxfVE220PalAqQLDVsFOHLJrZvut5puAbCdNv6WJk+B8ihI+k0c7JK5erwqQ== dependencies: at-least-node "^1.0.0" graceful-fs "^4.2.0" @@ -12503,8 +12510,8 @@ killable@^1.0.1: kind-of@^2.0.1, kind-of@^3.0.2, kind-of@^3.0.3, kind-of@^3.2.0, kind-of@^4.0.0, kind-of@^5.0.0, kind-of@^6.0.0, kind-of@^6.0.2, kind-of@^6.0.3: version "6.0.3" - resolved "https://botbuilder.myget.org/F/botbuilder-v4-js-daily/npm/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" - integrity sha1-B8BQNKbDSfoG4k+jWqdttFgM5N0= + resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd" + integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw== kleur@^3.0.2: version "3.0.2" @@ -12918,8 +12925,8 @@ lodash.uniq@^4.5.0: "lodash@>=3.5 <5", lodash@^4.0.0, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.3, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.3.0: version "4.17.20" - resolved "https://botbuilder.myget.org/F/botbuilder-v4-js-daily/npm/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" - integrity sha1-tEqbYpe8tpjxxRo1RaKzs2jVnFI= + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" + integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== log-driver@^1.2.7: version "1.2.7" @@ -13439,8 +13446,8 @@ mixin-object@^2.0.1: mkdirp@0.5.1, mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.2, mkdirp@^0.5.3, mkdirp@^0.5.4, mkdirp@~0.5.1: version "0.5.5" - resolved "https://botbuilder.myget.org/F/botbuilder-v4-js-daily/npm/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" - integrity sha1-2Rzv1i0UNsoPQWIOJRKI1CAJne8= + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def" + integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ== dependencies: minimist "^1.2.5" @@ -16044,7 +16051,7 @@ read-text-file@^1.1.0: iconv-lite "^0.4.17" jschardet "^1.4.2" -"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: +"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.3.3, readable-stream@^2.3.6, readable-stream@~2.3.6: version "2.3.6" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" integrity sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw== @@ -16057,6 +16064,19 @@ read-text-file@^1.1.0: string_decoder "~1.1.1" util-deprecate "~1.0.1" +readable-stream@^2.3.5: + version "2.3.7" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" + integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + readable-stream@^3.0.6, readable-stream@^3.1.1: version "3.2.0" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.2.0.tgz#de17f229864c120a9f56945756e4f32c4045245d" @@ -16995,8 +17015,8 @@ set-blocking@^2.0.0, set-blocking@~2.0.0: set-value@^0.4.3, set-value@^2.0.0, set-value@^3.0.2: version "3.0.2" - resolved "https://botbuilder.myget.org/F/botbuilder-v4-js-daily/npm/set-value/-/set-value-3.0.2.tgz#74e8ecd023c33d0f77199d415409a40f21e61b90" - integrity sha1-dOjs0CPDPQ93GZ1BVAmkDyHmG5A= + resolved "https://registry.yarnpkg.com/set-value/-/set-value-3.0.2.tgz#74e8ecd023c33d0f77199d415409a40f21e61b90" + integrity sha512-npjkVoz+ank0zjlV9F47Fdbjfj/PfXyVhZvGALWsyIYU/qrMzpi6avjKW3/7KeSU2Df3I46BrN1xOI1+6vW0hA== dependencies: is-plain-object "^2.0.4" From db6972cb73015f1bac1cc7190ff12ed913270a8e Mon Sep 17 00:00:00 2001 From: Eric Dahlvang Date: Wed, 23 Sep 2020 08:36:08 -0700 Subject: [PATCH 5/9] fix: Change http to https for petstore.swagger.io calls (#4238) I was not receiving a response from http://petstore.swagger.io Co-authored-by: Chris Whitten --- .../ActionsSample/dialogs/httprequest/httprequest.dialog | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Composer/plugins/samples/assets/projects/ActionsSample/dialogs/httprequest/httprequest.dialog b/Composer/plugins/samples/assets/projects/ActionsSample/dialogs/httprequest/httprequest.dialog index 1cf129ef09..89c023ee1e 100644 --- a/Composer/plugins/samples/assets/projects/ActionsSample/dialogs/httprequest/httprequest.dialog +++ b/Composer/plugins/samples/assets/projects/ActionsSample/dialogs/httprequest/httprequest.dialog @@ -47,7 +47,7 @@ "id": "845107" }, "method": "POST", - "url": "http://petstore.swagger.io/v2/pet", + "url": "https://petstore.swagger.io/v2/pet", "body": { "id": "${user.petid}", "category": { @@ -96,7 +96,7 @@ "id": "705959" }, "method": "GET", - "url": "http://petstore.swagger.io/v2/pet/${user.id}", + "url": "https://petstore.swagger.io/v2/pet/${user.id}", "resultProperty": "user.getResponse" }, { From f269719b8db70f1770b68ae9859dfea4335ced90 Mon Sep 17 00:00:00 2001 From: zeye Date: Thu, 24 Sep 2020 00:18:38 +0800 Subject: [PATCH 6/9] feat: UI Schema - Recognizer (#4135) * chore: turn RecognizerField to folder * extract 'useMigrationEffect' * remove dup & no ref file 'defaultRecognizers' * move out complicated selectedType func && var names * use 'dropdownOption' to replace 'isCustomType' which is anti-pattern * add 'default' and 'disabled' to RecognizerSchema * set CrossTrain to default, disable Luis * impl 'getRecognizerDefinition()' * move DefaultRecognizers to separated file * rename: 'editor' -> 'intentEditor' * impl 'recognizerEditor' in Recognizer schema * adapt to schema's 'disabled' property * sort recognizer options * make renameIntent optional * make handleRecognizerChange optional & impl fallback submit func * impl mappers to map among 'schema', 'dropdown', 'value' * provide 'findRecognizer' as hook's buitin func * apply findRecognizer to RecognizerField * mark isSelected optional * add a todo * merge 'recognizers' to uiSchema * migrate to new RecognizerUISchema * don't show non-recognizer $kinds as dropdown * adjust dropdown order * make the 'Custom Rec' with JSON editor as fallback option * fix tslint error * omi RegexRecognizer's `isSelected` option * resolve intentEditor logic leaks * fix UT * fix UT * impl getDefaultRecognizer() * create default recognizer when seeding new dialog * fix UT * copyright * del 'default' opt from crosstrain plugin Due to uischema merge priority, plugin settings are hard to be overrided. CrossTrain is already set to default in 'useRecongizerConfig.ts/getDefaultRecognizer' * migrate handleRecognizerChange to seedNewRecognizer * mark a todo * update `seedNewRecongnizer` interface & apply to dialog modal * Fix wording issue in comments Co-authored-by: Andy Brown * minor fixes on code style & comments * type the 'disabled' field strictly * more strict `isSelected()` method in CrossTrainReocognizer shema * update default recognizer value * provide 'current' and 'default' recognizer schema in useRecognizerConfig() * fix UTs * remove custom recognizer template * write `displayName` as function to support multi-locale Co-authored-by: Ben Yackley <61990921+beyackle@users.noreply.github.com> Co-authored-by: Andy Brown --- .../packages/adaptive-form/jest.config.js | 2 +- .../src/components/FormTitle.tsx | 17 ++- .../fields/CustomRecognizerField.tsx | 19 +++ .../src/components/fields/IntentField.tsx | 20 +-- .../src/components/fields/RecognizerField.tsx | 129 ------------------ .../RecognizerField/RecognizerField.tsx | 55 ++++++++ .../RecognizerField/defaultRecognizerOrder.ts | 11 ++ .../RecognizerField/getDropdownOptions.ts | 26 ++++ .../fields/RecognizerField/index.ts | 4 + .../fields/RecognizerField/mappers.ts | 15 ++ .../RecognizerField/useMigrationEffect.ts | 29 ++++ .../fields/__tests__/IntentField.test.tsx | 52 +++---- .../fields/__tests__/RecognizerField.test.tsx | 20 ++- .../src/components/fields/index.ts | 1 + .../adaptive-form/src/defaultRecognizers.ts | 66 --------- .../packages/client/__tests__/plugins.test.ts | 25 +++- .../client/src/pages/design/DesignPage.tsx | 30 ++-- .../src/pages/design/createDialogModal.tsx | 39 +++++- Composer/packages/client/src/plugins.ts | 1 - .../packages/client/src/utils/dialogUtil.ts | 1 - .../__tests__/useRecognizerConfig.test.tsx | 17 ++- .../src/hooks/useRecognizerConfig.ts | 83 ++++++++++- .../extension-client/src/types/extension.ts | 5 +- .../extension-client/src/types/formSchema.ts | 24 ++-- .../extension-client/src/types/index.ts | 1 + .../src/types/recognizerSchema.ts | 11 ++ .../__tests__/mergePluginConfigs.test.ts | 28 ++-- .../src/utils/mergePluginConfigs.ts | 1 - Composer/packages/lib/shared/src/types/sdk.ts | 4 +- .../composer/src/defaultRecognizerSchema.ts | 25 ++++ .../packages/ui-plugins/composer/src/index.ts | 86 ++---------- .../ui-plugins/cross-trained/src/index.ts | 38 +++--- .../packages/ui-plugins/luis/src/index.ts | 53 +++---- .../prompts/src/PromptField/UserInput.tsx | 14 +- 34 files changed, 508 insertions(+), 444 deletions(-) create mode 100644 Composer/packages/adaptive-form/src/components/fields/CustomRecognizerField.tsx delete mode 100644 Composer/packages/adaptive-form/src/components/fields/RecognizerField.tsx create mode 100644 Composer/packages/adaptive-form/src/components/fields/RecognizerField/RecognizerField.tsx create mode 100644 Composer/packages/adaptive-form/src/components/fields/RecognizerField/defaultRecognizerOrder.ts create mode 100644 Composer/packages/adaptive-form/src/components/fields/RecognizerField/getDropdownOptions.ts create mode 100644 Composer/packages/adaptive-form/src/components/fields/RecognizerField/index.ts create mode 100644 Composer/packages/adaptive-form/src/components/fields/RecognizerField/mappers.ts create mode 100644 Composer/packages/adaptive-form/src/components/fields/RecognizerField/useMigrationEffect.ts delete mode 100644 Composer/packages/adaptive-form/src/defaultRecognizers.ts create mode 100644 Composer/packages/extension-client/src/types/recognizerSchema.ts create mode 100644 Composer/packages/ui-plugins/composer/src/defaultRecognizerSchema.ts diff --git a/Composer/packages/adaptive-form/jest.config.js b/Composer/packages/adaptive-form/jest.config.js index 2c019947a0..b5a508c259 100644 --- a/Composer/packages/adaptive-form/jest.config.js +++ b/Composer/packages/adaptive-form/jest.config.js @@ -5,5 +5,5 @@ const { createConfig } = require('@bfc/test-utils'); module.exports = createConfig('adaptive-form', 'react', { - coveragePathIgnorePatterns: ['defaultRecognizers.ts', 'defaultRoleSchema.ts', 'defaultUiSchema.ts'], + coveragePathIgnorePatterns: ['defaultRoleSchema.ts', 'defaultUiSchema.ts'], }); diff --git a/Composer/packages/adaptive-form/src/components/FormTitle.tsx b/Composer/packages/adaptive-form/src/components/FormTitle.tsx index 9fe7ba4b38..5946955bbc 100644 --- a/Composer/packages/adaptive-form/src/components/FormTitle.tsx +++ b/Composer/packages/adaptive-form/src/components/FormTitle.tsx @@ -52,9 +52,7 @@ interface FormTitleProps { const FormTitle: React.FC = (props) => { const { description, schema, formData, uiOptions = {} } = props; const { shellApi, ...shellData } = useShellApi(); - const { currentDialog } = shellData; - const recognizers = useRecognizerConfig(); - const selectedRecognizer = recognizers.find((r) => r.isSelected(currentDialog?.content?.recognizer)); + const { currentRecognizer: selectedRecognizer } = useRecognizerConfig(); // use a ref because the syncIntentName is debounced and we need the most current version to invoke the api const shell = useRef({ data: shellData, @@ -69,12 +67,13 @@ const FormTitle: React.FC = (props) => { debounce(async (newIntentName?: string, data?: any) => { if (newIntentName && selectedRecognizer) { const normalizedIntentName = newIntentName?.replace(/[^a-zA-Z0-9-_]+/g, ''); - await selectedRecognizer.renameIntent( - data?.intent, - normalizedIntentName, - shell.current.data, - shell.current.api - ); + typeof selectedRecognizer.renameIntent === 'function' && + (await selectedRecognizer.renameIntent( + data?.intent, + normalizedIntentName, + shell.current.data, + shell.current.api + )); } }, 400), [] diff --git a/Composer/packages/adaptive-form/src/components/fields/CustomRecognizerField.tsx b/Composer/packages/adaptive-form/src/components/fields/CustomRecognizerField.tsx new file mode 100644 index 0000000000..464e6524f3 --- /dev/null +++ b/Composer/packages/adaptive-form/src/components/fields/CustomRecognizerField.tsx @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; +import { FieldProps } from '@bfc/extension-client'; +import { JsonEditor } from '@bfc/code-editor'; + +export const CustomRecognizerField: React.FC = (props) => { + const { value, onChange } = props; + return ( + + ); +}; diff --git a/Composer/packages/adaptive-form/src/components/fields/IntentField.tsx b/Composer/packages/adaptive-form/src/components/fields/IntentField.tsx index 60fb592079..e9bb3a877c 100644 --- a/Composer/packages/adaptive-form/src/components/fields/IntentField.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/IntentField.tsx @@ -2,37 +2,29 @@ // Licensed under the MIT License. import React from 'react'; -import { FieldProps, useShellApi, useRecognizerConfig, FieldWidget } from '@bfc/extension-client'; +import { FieldProps, useRecognizerConfig } from '@bfc/extension-client'; import formatMessage from 'format-message'; -import { SDKKinds } from '@bfc/shared'; import { FieldLabel } from '../FieldLabel'; const IntentField: React.FC = (props) => { const { id, description, uiOptions, value, required, onChange } = props; - const { currentDialog } = useShellApi(); - const recognizers = useRecognizerConfig(); + const { currentRecognizer } = useRecognizerConfig(); + + const Editor = currentRecognizer?.intentEditor; + const label = formatMessage('Trigger phrases (intent: #{intentName})', { intentName: value }); const handleChange = () => { onChange(value); }; - const recognizer = recognizers.find((r) => r.isSelected(currentDialog?.content?.recognizer)); - let Editor: FieldWidget | undefined; - if (recognizer && recognizer.id === SDKKinds.CrossTrainedRecognizerSet) { - Editor = recognizers.find((r) => r.id === SDKKinds.LuisRecognizer)?.editor; - } else { - Editor = recognizer?.editor; - } - const label = formatMessage('Trigger phrases (intent: #{intentName})', { intentName: value }); - return ( {Editor ? ( ) : ( - formatMessage('No Editor for {type}', { type: recognizer?.id }) + formatMessage('No Editor for {type}', { type: currentRecognizer?.id }) )} ); diff --git a/Composer/packages/adaptive-form/src/components/fields/RecognizerField.tsx b/Composer/packages/adaptive-form/src/components/fields/RecognizerField.tsx deleted file mode 100644 index 4cb714dd7f..0000000000 --- a/Composer/packages/adaptive-form/src/components/fields/RecognizerField.tsx +++ /dev/null @@ -1,129 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. -/** @jsx jsx */ -import { jsx } from '@emotion/core'; -import React, { useMemo, useState, useEffect } from 'react'; -import { FieldProps, useShellApi, useRecognizerConfig } from '@bfc/extension-client'; -import { MicrosoftIRecognizer, SDKKinds } from '@bfc/shared'; -import { Dropdown, ResponsiveMode, IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; -import formatMessage from 'format-message'; -import { JsonEditor } from '@bfc/code-editor'; - -import { FieldLabel } from '../FieldLabel'; - -const RecognizerField: React.FC> = (props) => { - const { value, id, label, description, uiOptions, required, onChange } = props; - const { shellApi, ...shellData } = useShellApi(); - const recognizers = useRecognizerConfig(); - const { qnaFiles, luFiles, currentDialog, locale } = shellData; - const [isCustomType, setIsCustomType] = useState(false); - - useEffect(() => { - // this logic is for handling old bot with `recognizer = undefined' - if (value === undefined) { - const qnaFile = qnaFiles.find((f) => f.id === `${currentDialog.id}.${locale}`); - const luFile = luFiles.find((f) => f.id === `${currentDialog.id}.${locale}`); - if (qnaFile && luFile) { - onChange(`${currentDialog.id}.lu.qna`); - } - } - - // transform lu recognizer to crosstrained for old bot - if (value === `${currentDialog.id}.lu`) { - onChange(`${currentDialog.id}.lu.qna`); - } - }, [value]); - - const options = useMemo(() => { - // filter luisRecognizer for dropdown options - return recognizers - .filter((r) => r.id !== SDKKinds.LuisRecognizer) - .map((r) => ({ - key: r.id, - text: typeof r.displayName === 'function' ? r.displayName(value) : r.displayName, - })); - }, [recognizers]); - - const selectedType = useMemo(() => { - if (isCustomType) { - return SDKKinds.CustomRecognizer; - } - const selected = - value === undefined - ? recognizers.length > 0 - ? [recognizers[0].id] - : [] - : recognizers.filter((r) => r.isSelected(value)).map((r) => r.id); - - const involvedCustomItem = selected.find((item) => item !== SDKKinds.CustomRecognizer); - if (involvedCustomItem) { - return involvedCustomItem; - } - if (selected.length < 1) { - /* istanbul ignore next */ - if (process.env.NODE_ENV === 'development') { - console.error( - `Unable to determine selected recognizer.\n - Value: ${JSON.stringify(value)}.\n - Selected Recognizers: [${selected.join(', ')}]` - ); - } - return; - } - - // transform luis recognizer to crosss trained recognizer for old bot. - if (selected[0] === SDKKinds.LuisRecognizer) { - selected[0] = SDKKinds.CrossTrainedRecognizerSet; - } - return selected[0]; - }, [value, isCustomType]); - - const handleChangeRecognizerType = (_, option?: IDropdownOption): void => { - if (option) { - if (option.key === SDKKinds.CustomRecognizer) { - setIsCustomType(true); - return; - } - - setIsCustomType(false); - const handler = recognizers.find((r) => r.id === option.key)?.handleRecognizerChange; - - if (handler) { - handler(props, shellData, shellApi); - } - } - }; - - const handleCustomChange = (value: string): void => { - setIsCustomType(true); - onChange(value); - }; - return ( - - - {selectedType ? ( - - ) : ( - formatMessage('Unable to determine recognizer type from data: {value}', { value }) - )} - {selectedType === SDKKinds.CustomRecognizer && ( - - )} - - ); -}; - -export { RecognizerField }; diff --git a/Composer/packages/adaptive-form/src/components/fields/RecognizerField/RecognizerField.tsx b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/RecognizerField.tsx new file mode 100644 index 0000000000..51c1955f6b --- /dev/null +++ b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/RecognizerField.tsx @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { useMemo } from 'react'; +import { FieldProps, useShellApi, useRecognizerConfig } from '@bfc/extension-client'; +import { MicrosoftIRecognizer } from '@bfc/shared'; +import { Dropdown, ResponsiveMode, IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; +import formatMessage from 'format-message'; + +import { FieldLabel } from '../../FieldLabel'; + +import { useMigrationEffect } from './useMigrationEffect'; +import { mapDropdownOptionToRecognizerSchema } from './mappers'; +import { getDropdownOptions } from './getDropdownOptions'; + +export const RecognizerField: React.FC> = (props) => { + const { value, id, label, description, uiOptions, required, onChange } = props; + const { shellApi, ...shellData } = useShellApi(); + + useMigrationEffect(value, onChange); + const { recognizers: recognizerConfigs, currentRecognizer } = useRecognizerConfig(); + const dropdownOptions = useMemo(() => getDropdownOptions(recognizerConfigs), [recognizerConfigs]); + + const RecognizerEditor = currentRecognizer?.recognizerEditor; + const widget = RecognizerEditor ? : null; + + const submit = (_, option?: IDropdownOption): void => { + if (!option) return; + + const recognizerDefinition = mapDropdownOptionToRecognizerSchema(option, recognizerConfigs); + + const seedNewRecognizer = recognizerDefinition?.seedNewRecognizer; + const recognizerInstance = + typeof seedNewRecognizer === 'function' + ? seedNewRecognizer(shellData, shellApi) + : { $kind: option.key as string, intents: [] }; // fallback to default Recognizer instance; + onChange(recognizerInstance); + }; + + return ( + + + + {widget} + + ); +}; diff --git a/Composer/packages/adaptive-form/src/components/fields/RecognizerField/defaultRecognizerOrder.ts b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/defaultRecognizerOrder.ts new file mode 100644 index 0000000000..7836432d4a --- /dev/null +++ b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/defaultRecognizerOrder.ts @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { SDKKinds } from '@bfc/shared'; + +export const defaultRecognizerOrder = [SDKKinds.CrossTrainedRecognizerSet, SDKKinds.RegexRecognizer]; + +export const recognizerOrderMap: { [$kind: string]: number } = defaultRecognizerOrder.reduce((result, $kind, index) => { + result[$kind] = index; + return result; +}, {}); diff --git a/Composer/packages/adaptive-form/src/components/fields/RecognizerField/getDropdownOptions.ts b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/getDropdownOptions.ts new file mode 100644 index 0000000000..eaca2691be --- /dev/null +++ b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/getDropdownOptions.ts @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RecognizerSchema, FallbackRecognizerKey } from '@bfc/extension-client'; + +import { recognizerOrderMap } from './defaultRecognizerOrder'; +import { mapRecognizerSchemaToDropdownOption } from './mappers'; + +const getRankScore = (r: RecognizerSchema) => { + // Always put disabled recognizer behind. Handle 'disabled' before 'default'. + if (r.disabled) return Number.MAX_VALUE; + // Always put default recognzier ahead. + if (r.default) return -1; + // Put fallback recognizer behind. + if (r.id === FallbackRecognizerKey) return Number.MAX_VALUE - 1; + return recognizerOrderMap[r.id] ?? Number.MAX_VALUE - 1; +}; + +export const getDropdownOptions = (recognizerConfigs: RecognizerSchema[]) => { + return recognizerConfigs + .filter((r) => !r.disabled) + .sort((r1, r2) => { + return getRankScore(r1) - getRankScore(r2); + }) + .map(mapRecognizerSchemaToDropdownOption); +}; diff --git a/Composer/packages/adaptive-form/src/components/fields/RecognizerField/index.ts b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/index.ts new file mode 100644 index 0000000000..24608a9eca --- /dev/null +++ b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/index.ts @@ -0,0 +1,4 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export { RecognizerField } from './RecognizerField'; diff --git a/Composer/packages/adaptive-form/src/components/fields/RecognizerField/mappers.ts b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/mappers.ts new file mode 100644 index 0000000000..47c629af22 --- /dev/null +++ b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/mappers.ts @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RecognizerSchema } from '@bfc/extension-client'; +import { IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; + +export const mapDropdownOptionToRecognizerSchema = (option: IDropdownOption, recognizerConfigs: RecognizerSchema[]) => { + return recognizerConfigs.find((r) => r.id === option.key); +}; + +export const mapRecognizerSchemaToDropdownOption = (recognizerSchema: RecognizerSchema): IDropdownOption => { + const { id, displayName } = recognizerSchema; + const recognizerName = typeof displayName === 'function' ? displayName({}) : displayName; + return { key: id, text: recognizerName || id }; +}; diff --git a/Composer/packages/adaptive-form/src/components/fields/RecognizerField/useMigrationEffect.ts b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/useMigrationEffect.ts new file mode 100644 index 0000000000..e17a19e57c --- /dev/null +++ b/Composer/packages/adaptive-form/src/components/fields/RecognizerField/useMigrationEffect.ts @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useShellApi, ChangeHandler } from '@bfc/extension-client'; +import { useEffect } from 'react'; +import { MicrosoftIRecognizer } from '@bfc/shared'; + +export const useMigrationEffect = ( + recognizer: MicrosoftIRecognizer | undefined, + onChangeRecognizer: ChangeHandler +) => { + const { qnaFiles, luFiles, currentDialog, locale } = useShellApi(); + + useEffect(() => { + // this logic is for handling old bot with `recognizer = undefined' + if (recognizer === undefined) { + const qnaFile = qnaFiles.find((f) => f.id === `${currentDialog.id}.${locale}`); + const luFile = luFiles.find((f) => f.id === `${currentDialog.id}.${locale}`); + if (qnaFile && luFile) { + onChangeRecognizer(`${currentDialog.id}.lu.qna`); + } + } + + // transform lu recognizer to crosstrained for old bot + if (recognizer === `${currentDialog.id}.lu`) { + onChangeRecognizer(`${currentDialog.id}.lu.qna`); + } + }, [recognizer]); +}; diff --git a/Composer/packages/adaptive-form/src/components/fields/__tests__/IntentField.test.tsx b/Composer/packages/adaptive-form/src/components/fields/__tests__/IntentField.test.tsx index 73366dc54e..4f1571e169 100644 --- a/Composer/packages/adaptive-form/src/components/fields/__tests__/IntentField.test.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/__tests__/IntentField.test.tsx @@ -4,7 +4,7 @@ import React from 'react'; import { render, fireEvent } from '@bfc/test-utils'; import assign from 'lodash/assign'; -import { useShellApi, useRecognizerConfig } from '@bfc/extension-client'; +import { useRecognizerConfig } from '@bfc/extension-client'; import { IntentField } from '../IntentField'; @@ -20,28 +20,27 @@ function renderSubject(overrides = {}) { return render(); } -describe('', () => { - beforeEach(() => { - (useRecognizerConfig as jest.Mock).mockReturnValue([ - { - id: 'TestRecognizer', - isSelected: (data) => data?.$kind === 'TestRecognizer', - editor: ({ id, onChange }) => ( -
- Test Recognizer -
- ), - }, - { - id: 'OtherRecognizer', - isSelected: (data) => data?.$kind === 'OtherRecognizer', - }, - ]); - }); +const recognizers = [ + { + id: 'TestRecognizer', + displayName: 'TestRecognizer', + intentEditor: ({ id, onChange }) => ( +
+ Test Recognizer +
+ ), + }, + { + id: 'OtherRecognizer', + displayName: 'OtherRecognizer', + }, +]; +describe('', () => { it('uses a custom label', () => { - (useShellApi as jest.Mock).mockReturnValue({ - currentDialog: { content: { recognizer: { $kind: 'TestRecognizer' } } }, + (useRecognizerConfig as jest.Mock).mockReturnValue({ + recognizers, + currentRecognizer: recognizers[0], }); const { getByLabelText } = renderSubject({ value: 'MyIntent' }); @@ -49,9 +48,11 @@ describe('', () => { }); it('invokes change handler with intent name', () => { - (useShellApi as jest.Mock).mockReturnValue({ - currentDialog: { content: { recognizer: { $kind: 'TestRecognizer' } } }, + (useRecognizerConfig as jest.Mock).mockReturnValue({ + recognizers, + currentRecognizer: recognizers[0], }); + const onChange = jest.fn(); const { getByText } = renderSubject({ onChange, value: 'MyIntent' }); @@ -60,8 +61,9 @@ describe('', () => { }); it('renders message when editor not defined', () => { - (useShellApi as jest.Mock).mockReturnValue({ - currentDialog: { content: { recognizer: { $kind: 'OtherRecognizer' } } }, + (useRecognizerConfig as jest.Mock).mockReturnValue({ + recognizers, + currentRecognizer: recognizers[1], }); const { container } = renderSubject({ value: 'MyIntent' }); diff --git a/Composer/packages/adaptive-form/src/components/fields/__tests__/RecognizerField.test.tsx b/Composer/packages/adaptive-form/src/components/fields/__tests__/RecognizerField.test.tsx index 7d0e79090a..f27b62a2dd 100644 --- a/Composer/packages/adaptive-form/src/components/fields/__tests__/RecognizerField.test.tsx +++ b/Composer/packages/adaptive-form/src/components/fields/__tests__/RecognizerField.test.tsx @@ -31,29 +31,27 @@ describe('', () => { }); }); - it('renders error message when no recognizer matched', () => { - (useRecognizerConfig as jest.Mock).mockReturnValue([]); - const { container } = renderSubject(); - expect(container).toHaveTextContent(/Unable to determine recognizer type from data:/); - }); - it('renders a dropdown when recognizer matches', () => { const handleChange = jest.fn(); - (useRecognizerConfig as jest.Mock).mockReturnValue([ + const recognizers = [ { id: 'one', displayName: 'One Recognizer', isSelected: () => false, - handleRecognizerChange: handleChange, + seedNewRecognizer: handleChange, }, { id: 'two', displayName: 'Two Recognizer', isSelected: () => true, - handleRecognizerChange: jest.fn(), + seedNewRecognizer: jest.fn(), }, - ]); - const { getByTestId } = renderSubject({ value: 'one' }); + ]; + (useRecognizerConfig as jest.Mock).mockReturnValue({ + recognizers, + currentRecognizer: recognizers[1], + }); + const { getByTestId } = renderSubject({ value: { $kind: 'two' } }); const dropdown = getByTestId('recognizerTypeDropdown'); expect(dropdown).toHaveTextContent('Two Recognizer'); fireEvent.click(dropdown); diff --git a/Composer/packages/adaptive-form/src/components/fields/index.ts b/Composer/packages/adaptive-form/src/components/fields/index.ts index fdb9c1c115..24246f3a97 100644 --- a/Composer/packages/adaptive-form/src/components/fields/index.ts +++ b/Composer/packages/adaptive-form/src/components/fields/index.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. export * from './ArrayField'; export * from './BooleanField'; +export * from './CustomRecognizerField'; export * from './EditableField'; export * from './ExpressionField/ExpressionField'; export * from './FieldSets'; diff --git a/Composer/packages/adaptive-form/src/defaultRecognizers.ts b/Composer/packages/adaptive-form/src/defaultRecognizers.ts deleted file mode 100644 index c4a245d1e2..0000000000 --- a/Composer/packages/adaptive-form/src/defaultRecognizers.ts +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { RecognizerSchema } from '@bfc/extension-client'; -import { SDKKinds } from '@bfc/shared'; -import formatMessage from 'format-message'; - -import { RegexIntentField } from './components/fields/RegexIntentField'; - -const DefaultRecognizers: RecognizerSchema[] = [ - { - id: SDKKinds.RegexRecognizer, - displayName: () => formatMessage('Regular Expression'), - editor: RegexIntentField, - isSelected: (data) => { - return typeof data === 'object' && data.$kind === SDKKinds.RegexRecognizer; - }, - handleRecognizerChange: (props) => { - props.onChange({ $kind: SDKKinds.RegexRecognizer, intents: [] }); - }, - renameIntent: async (intentName, newIntentName, shellData, shellApi) => { - const { currentDialog } = shellData; - await shellApi.renameRegExIntent(currentDialog.id, intentName, newIntentName); - }, - }, - { - id: SDKKinds.CustomRecognizer, - displayName: () => formatMessage('Custom recognizer'), - isSelected: (data) => typeof data === 'object', - handleRecognizerChange: (props) => - props.onChange({ - $kind: 'Microsoft.MultiLanguageRecognizer', - recognizers: { - 'en-us': { - $kind: 'Microsoft.RegexRecognizer', - intents: [ - { - intent: 'greeting', - pattern: 'hello', - }, - { - intent: 'test', - pattern: 'test', - }, - ], - }, - 'zh-cn': { - $kind: 'Microsoft.RegexRecognizer', - intents: [ - { - intent: 'greeting', - pattern: '你好', - }, - { - intent: 'test', - pattern: '测试', - }, - ], - }, - }, - }), - renameIntent: () => {}, - }, -]; - -export default DefaultRecognizers; diff --git a/Composer/packages/client/__tests__/plugins.test.ts b/Composer/packages/client/__tests__/plugins.test.ts index ea15956780..102d9536ea 100644 --- a/Composer/packages/client/__tests__/plugins.test.ts +++ b/Composer/packages/client/__tests__/plugins.test.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { SDKKinds } from '@bfc/shared'; + import { mergePluginConfigs } from '../src/plugins'; describe('mergePluginConfigs', () => { @@ -43,22 +45,35 @@ describe('mergePluginConfigs', () => { }, }, }, - recognizers: [], flowWidgets: {}, }); }); it('adds recognizers', () => { const config1 = { - recognizers: ['recognizer 1'], + uiSchema: { + [SDKKinds.RegexRecognizer]: { + recognizer: { displayName: 'recognizer1' }, + }, + }, }; const config2 = { - recognizers: ['recognizer 2'], + uiSchema: { + [SDKKinds.LuisRecognizer]: { + recognizer: { displayName: 'recognizer2' }, + }, + }, }; - // @ts-expect-error - expect(mergePluginConfigs(config1, config2).recognizers).toEqual(['recognizer 2', 'recognizer 1']); + expect(mergePluginConfigs(config1, config2).uiSchema).toEqual({ + [SDKKinds.RegexRecognizer]: { + recognizer: { displayName: 'recognizer1' }, + }, + [SDKKinds.LuisRecognizer]: { + recognizer: { displayName: 'recognizer2' }, + }, + }); }); it('replaces other arrays', () => { diff --git a/Composer/packages/client/src/pages/design/DesignPage.tsx b/Composer/packages/client/src/pages/design/DesignPage.tsx index 09754e1e46..15e51eb8a0 100644 --- a/Composer/packages/client/src/pages/design/DesignPage.tsx +++ b/Composer/packages/client/src/pages/design/DesignPage.tsx @@ -8,7 +8,7 @@ import { Breadcrumb, IBreadcrumbItem } from 'office-ui-fabric-react/lib/Breadcru import formatMessage from 'format-message'; import { globalHistory, RouteComponentProps } from '@reach/router'; import get from 'lodash/get'; -import { DialogFactory, SDKKinds, DialogInfo, PromptTab, getEditorAPI, registerEditorAPI } from '@bfc/shared'; +import { DialogInfo, PromptTab, getEditorAPI, registerEditorAPI } from '@bfc/shared'; import { ActionButton } from 'office-ui-fabric-react/lib/Button'; import { JsonEditor } from '@bfc/code-editor'; import { EditorExtension, useTriggerApi, PluginConfig } from '@bfc/extension-client'; @@ -46,7 +46,6 @@ import { focusPathState, showCreateDialogModalState, showAddSkillDialogModalState, - actionsSeedState, localeState, } from '../../recoilModel'; import ImportQnAFromUrlModal from '../knowledge-base/ImportQnAFromUrlModal'; @@ -117,7 +116,6 @@ const DesignPage: React.FC }> {showCreateDialogModal && ( - createDialogCancel(projectId)} - onSubmit={handleCreateDialogSubmit} - /> + + createDialogCancel(projectId)} + onSubmit={handleCreateDialogSubmit} + /> + )} {showAddSkillDialogModal && ( void; + onSubmit: (dialogName: string, dialogContent) => void; onDismiss: () => void; onCurrentPathUpdate?: (newPath?: string, storageId?: string) => void; focusedStorageFolder?: StorageFolder; @@ -32,7 +34,14 @@ interface CreateDialogModalProps { export const CreateDialogModal: React.FC = (props) => { const { onSubmit, onDismiss, isOpen, projectId } = props; + + const schemas = useRecoilValue(schemasState(projectId)); const dialogs = useRecoilValue(validateDialogSelectorFamily(projectId)); + const actionsSeed = useRecoilValue(actionsSeedState(projectId)); + + const { shellApi, ...shellData } = useShellApi(); + const { defaultRecognizer } = useRecognizerConfig(); + const formConfig: FieldConfig = { name: { required: true, @@ -54,6 +63,26 @@ export const CreateDialogModal: React.FC = (props) => { const { formData, formErrors, hasErrors, updateField } = useForm(formConfig); + const seedNewRecognizer = (recognizerSchema?: RecognizerSchema) => { + if (recognizerSchema && typeof recognizerSchema.seedNewRecognizer === 'function') { + return recognizerSchema.seedNewRecognizer(shellData, shellApi); + } + return { $kind: recognizerSchema?.id }; + }; + + const seedNewDialog = (formData: DialogFormData) => { + const seededContent = new DialogFactory(schemas.sdk?.content).create(SDKKinds.AdaptiveDialog, { + $designer: { name: formData.name, description: formData.description }, + generator: `${formData.name}.lg`, + recognizer: seedNewRecognizer(defaultRecognizer), + }); + if (seededContent.triggers?.[0]) { + seededContent.triggers[0].actions = actionsSeed; + } + + return seededContent; + }; + const handleSubmit = useCallback( (e) => { e.preventDefault(); @@ -61,9 +90,9 @@ export const CreateDialogModal: React.FC = (props) => { return; } - onSubmit({ - ...formData, - }); + const dialogData = seedNewDialog(formData); + + onSubmit(formData.name, dialogData); }, [hasErrors, formData] ); diff --git a/Composer/packages/client/src/plugins.ts b/Composer/packages/client/src/plugins.ts index 9a67db6574..cd8b8f9dc4 100644 --- a/Composer/packages/client/src/plugins.ts +++ b/Composer/packages/client/src/plugins.ts @@ -29,7 +29,6 @@ const mergeArrays: MergeWithCustomizer = (objValue, srcValue, key) => { const defaultPlugin: Required = { uiSchema: {}, - recognizers: [], flowWidgets: {}, }; diff --git a/Composer/packages/client/src/utils/dialogUtil.ts b/Composer/packages/client/src/utils/dialogUtil.ts index e890e019bb..235868bd23 100644 --- a/Composer/packages/client/src/utils/dialogUtil.ts +++ b/Composer/packages/client/src/utils/dialogUtil.ts @@ -52,7 +52,6 @@ export const intentTypeKey: string = SDKKinds.OnIntent; export const qnaTypeKey: string = SDKKinds.OnQnAMatch; export const activityTypeKey: string = SDKKinds.OnActivity; export const regexRecognizerKey: string = SDKKinds.RegexRecognizer; -export const crossTrainedRecognizerSetKey: string = SDKKinds.CrossTrainedRecognizerSet; export const customEventKey = 'OnCustomEvent'; export const qnaMatcherKey: string = SDKKinds.OnQnAMatch; export const onChooseIntentKey: string = SDKKinds.OnChooseIntent; diff --git a/Composer/packages/extension-client/src/hooks/__tests__/useRecognizerConfig.test.tsx b/Composer/packages/extension-client/src/hooks/__tests__/useRecognizerConfig.test.tsx index efea8491c1..f13b03d3b9 100644 --- a/Composer/packages/extension-client/src/hooks/__tests__/useRecognizerConfig.test.tsx +++ b/Composer/packages/extension-client/src/hooks/__tests__/useRecognizerConfig.test.tsx @@ -7,29 +7,40 @@ import { renderHook } from '@bfc/test-utils/lib/hooks'; import { useRecognizerConfig } from '../useRecognizerConfig'; import { EditorExtensionContext } from '../../EditorExtensionContext'; +const shellData = { currentDialog: { content: {} } }; const plugins = { uiSchema: { foo: { form: {}, menu: {}, + recognizer: { displayName: 'recognizer 1' }, }, bar: { form: {}, menu: {}, + recognizer: { displayName: 'recognizer 2' }, }, }, - recognizers: ['recognizer 1', 'recognizer 2'], }; const wrapper: React.FC = ({ children }) => ( // @ts-expect-error - {children} + {children} ); describe('useRecognizerConfig', () => { it('returns the configured recognizers', () => { const { result } = renderHook(() => useRecognizerConfig(), { wrapper }); - expect(result.current).toEqual(['recognizer 1', 'recognizer 2']); + expect(result.current.recognizers).toEqual([ + { + id: 'foo', + displayName: 'recognizer 1', + }, + { + id: 'bar', + displayName: 'recognizer 2', + }, + ]); }); }); diff --git a/Composer/packages/extension-client/src/hooks/useRecognizerConfig.ts b/Composer/packages/extension-client/src/hooks/useRecognizerConfig.ts index 1d28715cff..a90127aa55 100644 --- a/Composer/packages/extension-client/src/hooks/useRecognizerConfig.ts +++ b/Composer/packages/extension-client/src/hooks/useRecognizerConfig.ts @@ -1,12 +1,87 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { useContext } from 'react'; +import { useContext, useMemo } from 'react'; +import { MicrosoftIRecognizer, SDKKinds } from '@bfc/shared'; +import get from 'lodash/get'; import { EditorExtensionContext } from '../EditorExtensionContext'; +import { RecognizerOptions, RecognizerSchema } from '../types'; -export function useRecognizerConfig() { - const { plugins } = useContext(EditorExtensionContext); +export const FallbackRecognizerKey = 'fallback'; - return plugins.recognizers ?? []; +// TODO: (ze) remove this logic after the ui widget PR. [issue #4167] +const reuseLuisIntentEditor = (recognizers: RecognizerSchema[]) => { + const crosstrainRecognizer = recognizers.find((x) => x.id === SDKKinds.CrossTrainedRecognizerSet); + const luisRecognizer = recognizers.find((x) => x.id === SDKKinds.LuisRecognizer); + if (crosstrainRecognizer && luisRecognizer) { + crosstrainRecognizer.intentEditor = luisRecognizer.intentEditor; + } +}; + +const getDefaultRecognizer = (recognizers: RecognizerSchema[]) => { + const defaultRecognizer = recognizers.find((r) => r.default && !r.disabled); + if (defaultRecognizer) return defaultRecognizer; + + // TODO: (ze) remove this logic after recognizer config is port to SDK component schema. + const crosstrainRecognizer = recognizers.find((r) => r.id === SDKKinds.CrossTrainedRecognizerSet); + if (crosstrainRecognizer) return crosstrainRecognizer; + + const firstAvailableRecognizer = recognizers.find((r) => !r.disabled); + return firstAvailableRecognizer; +}; + +const getFallbackRecognizer = (recognizers: RecognizerSchema[]) => { + return recognizers.find((r) => r.id === FallbackRecognizerKey); +}; + +const findRecognizerByValue = (recognizers: RecognizerSchema[], recognizerValue?: MicrosoftIRecognizer) => { + const matchedRecognizer = recognizers.find((r) => { + if (typeof r.isSelected === 'function') { + return r.isSelected(recognizerValue); + } + return r.id === get(recognizerValue, '$kind'); + }); + return matchedRecognizer; +}; + +export interface RecognizerSchemaConfig { + /** All recognizer definitions from uischema. */ + recognizers: RecognizerSchema[]; + /** Current dialog's in-use recognizer definition. */ + currentRecognizer?: RecognizerSchema; + /** Default recognizer's definition, used when creating new dialog. */ + defaultRecognizer?: RecognizerSchema; +} + +export function useRecognizerConfig(): RecognizerSchemaConfig { + const { plugins, shellData } = useContext(EditorExtensionContext); + + const recognizers: RecognizerSchema[] = useMemo(() => { + if (!plugins.uiSchema) return []; + + const schemas = Object.entries(plugins.uiSchema) + .filter(([_, uiOptions]) => uiOptions && uiOptions.recognizer) + .map(([$kind, uiOptions]) => { + const recognizerOptions = uiOptions?.recognizer as RecognizerOptions; + return { + id: $kind, + ...recognizerOptions, + } as RecognizerSchema; + }); + reuseLuisIntentEditor(schemas); + return schemas; + }, [plugins.uiSchema]); + + const defaultRecognizer = getDefaultRecognizer(recognizers); + const fallbackRecognizer = getFallbackRecognizer(recognizers); + + const currentRecognizerValue = shellData.currentDialog?.content?.recognizer; + const currentRecognizer = findRecognizerByValue(recognizers, currentRecognizerValue) ?? fallbackRecognizer; + + return { + recognizers, + currentRecognizer, + defaultRecognizer, + }; } diff --git a/Composer/packages/extension-client/src/types/extension.ts b/Composer/packages/extension-client/src/types/extension.ts index 7032159e6b..d17d56df10 100644 --- a/Composer/packages/extension-client/src/types/extension.ts +++ b/Composer/packages/extension-client/src/types/extension.ts @@ -3,12 +3,12 @@ import { SDKKinds } from '@bfc/shared'; -import { RecognizerSchema, UIOptions } from './formSchema'; +import { UIOptions } from './formSchema'; import { FlowEditorWidgetMap, FlowWidget } from './flowSchema'; import { MenuOptions } from './menuSchema'; +import { RecognizerOptions } from './recognizerSchema'; export interface PluginConfig { - recognizers?: RecognizerSchema[]; uiSchema?: UISchema; flowWidgets?: FlowEditorWidgetMap; } @@ -18,5 +18,6 @@ export type UISchema = { flow?: FlowWidget; form?: UIOptions; menu?: MenuOptions; + recognizer?: RecognizerOptions; }; }; diff --git a/Composer/packages/extension-client/src/types/formSchema.ts b/Composer/packages/extension-client/src/types/formSchema.ts index c07a175cb3..805475717e 100644 --- a/Composer/packages/extension-client/src/types/formSchema.ts +++ b/Composer/packages/extension-client/src/types/formSchema.ts @@ -1,8 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { SDKKinds, SDKRoles, ShellApi, ShellData } from '@bfc/shared'; +import { MicrosoftIRecognizer, SDKKinds, SDKRoles, ShellApi, ShellData } from '@bfc/shared'; -import { FieldProps, FieldWidget } from './form'; +import { FieldWidget } from './form'; // eslint-disable-next-line @typescript-eslint/no-explicit-any type UIOptionValue = R | UIOptionFunc; @@ -63,16 +63,24 @@ export type FormUISchema = { [key in SDKKinds]?: UIOptions }; export type RecognizerSchema = { /** Unique id to identify recognizer (SDK $kind) */ id: string; - /** Display name used in the UI */ + /** If is default, will be used as dropdown's default selection */ + default?: boolean; + /** If disabled, cannot be selected from dropdown */ + disabled?: boolean; + /** Display name used in the UI. Recommended to use function over static string to enable multi-locale feature. */ displayName: UIOptionValue; /** An inline editor to edit an intent. If none provided, users will not be able to edit. */ - editor?: FieldWidget; + intentEditor?: FieldWidget; /** A function invoked with the form data to determine if this is the currently selected recognizer */ - isSelected: (data: any) => boolean; - /** Invoked when changing the recognizer type */ - handleRecognizerChange: (fieldProps: FieldProps, shellData: ShellData, shellApi: ShellApi) => void; + isSelected?: (data: any) => boolean; + /** Invoked when constructing a new recognizer instance. + * Make sure the instance can be recognized either by $kind or isSelected(). + */ + seedNewRecognizer?: (shellData: ShellData, shellApi: ShellApi) => MicrosoftIRecognizer | any; + /** An inline editor to edit recognizer value. If none provided, users will not be able to edit its value. */ + recognizerEditor?: FieldWidget; /** Function to rename an intent */ - renameIntent: ( + renameIntent?: ( intentName: string, newIntentName: string, shellData: ShellData, diff --git a/Composer/packages/extension-client/src/types/index.ts b/Composer/packages/extension-client/src/types/index.ts index d51cd232e0..a44ce84f99 100644 --- a/Composer/packages/extension-client/src/types/index.ts +++ b/Composer/packages/extension-client/src/types/index.ts @@ -6,3 +6,4 @@ export * from './form'; export * from './formSchema'; export * from './flowSchema'; export * from './menuSchema'; +export * from './recognizerSchema'; diff --git a/Composer/packages/extension-client/src/types/recognizerSchema.ts b/Composer/packages/extension-client/src/types/recognizerSchema.ts new file mode 100644 index 0000000000..e1585ec68e --- /dev/null +++ b/Composer/packages/extension-client/src/types/recognizerSchema.ts @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { SDKKinds } from '@bfc/shared'; + +import { RecognizerSchema } from './formSchema'; + +// Omit the 'id' field because it can be inferred from $kind. +export type RecognizerOptions = Omit; + +export type RecognizerUISchema = { [key in SDKKinds]?: RecognizerOptions }; diff --git a/Composer/packages/extension-client/src/utils/__tests__/mergePluginConfigs.test.ts b/Composer/packages/extension-client/src/utils/__tests__/mergePluginConfigs.test.ts index 93763a0fd4..125e0006c1 100644 --- a/Composer/packages/extension-client/src/utils/__tests__/mergePluginConfigs.test.ts +++ b/Composer/packages/extension-client/src/utils/__tests__/mergePluginConfigs.test.ts @@ -56,23 +56,25 @@ describe('mergePluginConfigs', () => { it('merges recognizers into single list', () => { const plugins: Partial[] = [ { - recognizers: [ - { - id: 'default', - displayName: 'Default', - isSelected: () => false, - handleRecognizerChange: jest.fn(), + uiSchema: { + [SDKKinds.LuisRecognizer]: { + recognizer: { + displayName: 'Default', + isSelected: () => false, + seedNewRecognizer: jest.fn(), + }, }, - { - id: 'new', - displayName: 'New Recognizer', - isSelected: () => false, - handleRecognizerChange: jest.fn(), + [SDKKinds.RegexRecognizer]: { + recognizer: { + displayName: 'New Recognizer', + isSelected: () => false, + seedNewRecognizer: jest.fn(), + }, }, - ], + }, }, ]; - expect(mergePluginConfigs(...plugins).recognizers).toHaveLength(2); + expect(Object.keys(mergePluginConfigs(...plugins).uiSchema)).toHaveLength(2); }); }); diff --git a/Composer/packages/extension-client/src/utils/mergePluginConfigs.ts b/Composer/packages/extension-client/src/utils/mergePluginConfigs.ts index 3b08f6dfca..69870f06b4 100644 --- a/Composer/packages/extension-client/src/utils/mergePluginConfigs.ts +++ b/Composer/packages/extension-client/src/utils/mergePluginConfigs.ts @@ -22,7 +22,6 @@ const mergeArrays: MergeWithCustomizer = (objValue, srcValue, key) => { const defaultPlugin: Required = { uiSchema: {}, - recognizers: [], flowWidgets: {}, }; diff --git a/Composer/packages/lib/shared/src/types/sdk.ts b/Composer/packages/lib/shared/src/types/sdk.ts index d31028a3b6..171c0970bb 100644 --- a/Composer/packages/lib/shared/src/types/sdk.ts +++ b/Composer/packages/lib/shared/src/types/sdk.ts @@ -18,8 +18,8 @@ export interface BaseSchema { $copy?: string; /** Extra information for the Bot Framework Composer. */ $designer?: DesignerData; - /** If 'disabled' set to true, runtime will skip this action. */ - disabled: any; + /** If 'disabled' equals to or be evaluated as 'true', runtime will skip this action. */ + disabled?: boolean | string; } /* Union of components which implement the IActivityTemplate interface */ diff --git a/Composer/packages/ui-plugins/composer/src/defaultRecognizerSchema.ts b/Composer/packages/ui-plugins/composer/src/defaultRecognizerSchema.ts new file mode 100644 index 0000000000..d17a2d3fc3 --- /dev/null +++ b/Composer/packages/ui-plugins/composer/src/defaultRecognizerSchema.ts @@ -0,0 +1,25 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { RecognizerUISchema, FallbackRecognizerKey, RecognizerOptions } from '@bfc/extension-client'; +import { SDKKinds } from '@bfc/shared'; +import formatMessage from 'format-message'; +import { RegexIntentField, CustomRecognizerField } from '@bfc/adaptive-form'; + +const FallbackRecognizerJsonEditor: RecognizerOptions = { + displayName: () => formatMessage('Custom recognizer'), + seedNewRecognizer: () => ({}), + recognizerEditor: CustomRecognizerField, +}; + +export const DefaultRecognizerSchema: RecognizerUISchema = { + [SDKKinds.RegexRecognizer]: { + displayName: () => formatMessage('Regular Expression'), + intentEditor: RegexIntentField, + renameIntent: (intentName, newIntentName, shellData, shellApi) => { + const { currentDialog } = shellData; + shellApi.renameRegExIntent(currentDialog.id, intentName, newIntentName); + }, + }, + [FallbackRecognizerKey as SDKKinds]: FallbackRecognizerJsonEditor, +}; diff --git a/Composer/packages/ui-plugins/composer/src/index.ts b/Composer/packages/ui-plugins/composer/src/index.ts index 813fb72d63..9fa439562e 100644 --- a/Composer/packages/ui-plugins/composer/src/index.ts +++ b/Composer/packages/ui-plugins/composer/src/index.ts @@ -1,69 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { PluginConfig, FormUISchema, RecognizerSchema, UISchema, MenuUISchema } from '@bfc/extension-client'; +import { PluginConfig, FormUISchema, UISchema, MenuUISchema, RecognizerUISchema } from '@bfc/extension-client'; import { SDKKinds } from '@bfc/shared'; import formatMessage from 'format-message'; -import mapValues from 'lodash/mapValues'; -import { IntentField, RecognizerField, RegexIntentField, QnAActionsField } from '@bfc/adaptive-form'; +import mergeWith from 'lodash/mergeWith'; +import { IntentField, RecognizerField, QnAActionsField } from '@bfc/adaptive-form'; import { DefaultMenuSchema } from './defaultMenuSchema'; - -const DefaultRecognizers: RecognizerSchema[] = [ - { - id: SDKKinds.RegexRecognizer, - displayName: () => formatMessage('Regular Expression'), - editor: RegexIntentField, - isSelected: (data) => { - return typeof data === 'object' && data.$kind === SDKKinds.RegexRecognizer; - }, - handleRecognizerChange: (props) => { - props.onChange({ $kind: SDKKinds.RegexRecognizer, intents: [] }); - }, - renameIntent: (intentName, newIntentName, shellData, shellApi) => { - const { currentDialog } = shellData; - shellApi.renameRegExIntent(currentDialog.id, intentName, newIntentName); - }, - }, - { - id: SDKKinds.CustomRecognizer, - displayName: () => formatMessage('Custom recognizer'), - isSelected: (data) => typeof data === 'object', - handleRecognizerChange: (props) => - props.onChange({ - $kind: 'Microsoft.MultiLanguageRecognizer', - recognizers: { - 'en-us': { - $kind: 'Microsoft.RegexRecognizer', - intents: [ - { - intent: 'greeting', - pattern: 'hello', - }, - { - intent: 'test', - pattern: 'test', - }, - ], - }, - 'zh-cn': { - $kind: 'Microsoft.RegexRecognizer', - intents: [ - { - intent: 'greeting', - pattern: '你好', - }, - { - intent: 'test', - pattern: '测试', - }, - ], - }, - }, - }), - renameIntent: () => {}, - }, -]; +import { DefaultRecognizerSchema } from './defaultRecognizerSchema'; const DefaultFormSchema: FormUISchema = { [SDKKinds.AdaptiveDialog]: { @@ -216,21 +161,20 @@ const DefaultFormSchema: FormUISchema = { }, }; -const synthesizeUISchema = (formSchema: FormUISchema, menuSchema: MenuUISchema): UISchema => { - const uiSchema: UISchema = mapValues(formSchema, (val) => ({ form: val })); - for (const [$kind, menuConfig] of Object.entries(menuSchema)) { - if (uiSchema[$kind]) { - uiSchema[$kind].menu = menuConfig; - } else { - uiSchema[$kind] = { menu: menuConfig }; - } - } - return uiSchema; +const synthesizeUISchema = ( + formSchema: FormUISchema, + menuSchema: MenuUISchema, + recognizerSchema: RecognizerUISchema +): UISchema => { + let uischema: UISchema = {}; + uischema = mergeWith(uischema, formSchema, (origin, formOption) => ({ ...origin, form: formOption })); + uischema = mergeWith(uischema, menuSchema, (origin, menuOption) => ({ ...origin, menu: menuOption })); + uischema = mergeWith(uischema, recognizerSchema, (origin, opt) => ({ ...origin, recognizer: opt })); + return uischema; }; const config: PluginConfig = { - uiSchema: synthesizeUISchema(DefaultFormSchema, DefaultMenuSchema), - recognizers: DefaultRecognizers, + uiSchema: synthesizeUISchema(DefaultFormSchema, DefaultMenuSchema, DefaultRecognizerSchema), }; export default config; diff --git a/Composer/packages/ui-plugins/cross-trained/src/index.ts b/Composer/packages/ui-plugins/cross-trained/src/index.ts index 220a4054b0..e424e2452a 100644 --- a/Composer/packages/ui-plugins/cross-trained/src/index.ts +++ b/Composer/packages/ui-plugins/cross-trained/src/index.ts @@ -6,29 +6,27 @@ import { SDKKinds } from '@bfc/shared'; import formatMessage from 'format-message'; const config: PluginConfig = { - recognizers: [ - { - id: SDKKinds.CrossTrainedRecognizerSet, - displayName: formatMessage('Default recognizer'), - isSelected: (data) => { - return typeof data === 'string'; - }, - handleRecognizerChange: (props, shellData, _) => { - const { qnaFiles, luFiles, currentDialog, locale } = shellData; - const qnaFile = qnaFiles.find((f) => f.id === `${currentDialog.id}.${locale}`); - const luFile = luFiles.find((f) => f.id === `${currentDialog.id}.${locale}`); + uiSchema: { + [SDKKinds.CrossTrainedRecognizerSet]: { + recognizer: { + displayName: () => formatMessage('Default recognizer'), + isSelected: (data) => { + return typeof data === 'string' && data.endsWith('.lu.qna'); + }, + seedNewRecognizer: (shellData) => { + const { qnaFiles, luFiles, currentDialog, locale } = shellData; + const qnaFile = qnaFiles.find((f) => f.id === `${currentDialog.id}.${locale}`); + const luFile = luFiles.find((f) => f.id === `${currentDialog.id}.${locale}`); + + if (!qnaFile || !luFile) { + alert(formatMessage(`NO LU OR QNA FILE WITH NAME { id }`, { id: currentDialog.id })); + } - if (qnaFile && luFile) { - // strip locale out of id so it doesn't get serialized - // into the .dialog file - props.onChange(`${currentDialog.id}.lu.qna`); - } else { - alert(formatMessage(`NO LU OR QNA FILE WITH NAME { id }`, { id: currentDialog.id })); - } + return `${currentDialog.id}.lu.qna`; + }, }, - renameIntent: () => {}, }, - ], + }, }; export default config; diff --git a/Composer/packages/ui-plugins/luis/src/index.ts b/Composer/packages/ui-plugins/luis/src/index.ts index 36c1dc52fc..732ae29f64 100644 --- a/Composer/packages/ui-plugins/luis/src/index.ts +++ b/Composer/packages/ui-plugins/luis/src/index.ts @@ -8,33 +8,38 @@ import formatMessage from 'format-message'; import { LuisIntentEditor } from './LuisIntentEditor'; const config: PluginConfig = { - recognizers: [ - { - id: SDKKinds.LuisRecognizer, - displayName: formatMessage('LUIS'), - editor: LuisIntentEditor, - isSelected: (data) => { - return typeof data === 'string' && data.endsWith('.lu'); - }, - handleRecognizerChange: (props, shellData) => { - const { luFiles, currentDialog, locale } = shellData; - const luFile = luFiles.find((f) => f.id === `${currentDialog.id}.${locale}`); + uiSchema: { + [SDKKinds.LuisRecognizer]: { + recognizer: { + disabled: true, + displayName: () => formatMessage('LUIS'), + intentEditor: LuisIntentEditor, + isSelected: (data) => { + return typeof data === 'string' && data.endsWith('.lu'); + }, + seedNewRecognizer: (shellData) => { + const { luFiles, currentDialog, locale } = shellData; + const luFile = luFiles.find((f) => f.id === `${currentDialog.id}.${locale}`); - if (luFile) { - // strip locale out of id so it doesn't get serialized - // into the .dialog file - props.onChange(`${luFile.id.split('.')[0]}.lu`); - } else { - alert(formatMessage(`NO LU FILE WITH NAME {id}`, { id: currentDialog.id })); - } - }, - renameIntent: async (intentName, newIntentName, shellData, shellApi) => { - const { currentDialog, locale } = shellData; - shellApi.updateIntentTrigger(currentDialog.id, intentName, newIntentName); - await shellApi.renameLuIntent(`${currentDialog.id}.${locale}`, intentName, newIntentName); + if (!luFile) { + alert(formatMessage(`NO LU FILE WITH NAME {id}`, { id: currentDialog.id })); + return ''; + } + + try { + return `${luFile.id.split('.')[0]}.lu`; + } catch (err) { + return ''; + } + }, + renameIntent: async (intentName, newIntentName, shellData, shellApi) => { + const { currentDialog, locale } = shellData; + shellApi.updateIntentTrigger(currentDialog.id, intentName, newIntentName); + await shellApi.renameLuIntent(`${currentDialog.id}.${locale}`, intentName, newIntentName); + }, }, }, - ], + }, }; export default config; diff --git a/Composer/packages/ui-plugins/prompts/src/PromptField/UserInput.tsx b/Composer/packages/ui-plugins/prompts/src/PromptField/UserInput.tsx index e6b481d525..3f781c2932 100644 --- a/Composer/packages/ui-plugins/prompts/src/PromptField/UserInput.tsx +++ b/Composer/packages/ui-plugins/prompts/src/PromptField/UserInput.tsx @@ -32,21 +32,15 @@ const expectedResponsesPlaceholder = () => const UserInput: React.FC> = (props) => { const { onChange, getSchema, value, id, uiOptions, getError, definitions, depth, schema = {} } = props; - const { currentDialog, designerId } = useShellApi(); - const recognizers = useRecognizerConfig(); + const { designerId } = useShellApi(); + const { currentRecognizer } = useRecognizerConfig(); const { const: $kind } = (schema?.properties?.$kind as { const: string }) || {}; const intentName = new LuMetaData(new LuType($kind).toString(), designerId).toString(); - - const recognizer = recognizers.find((r) => r.isSelected(currentDialog?.content?.recognizer)); - let Editor; - if (recognizer && recognizer.id === SDKKinds.CrossTrainedRecognizerSet) { - Editor = recognizers.find((r) => r.id === SDKKinds.LuisRecognizer)?.editor; - } else { - Editor = recognizer?.editor; - } const intentLabel = formatMessage('Expected responses (intent: #{intentName})', { intentName }); + const Editor = currentRecognizer?.intentEditor; + return ( Date: Thu, 24 Sep 2020 03:48:43 +0800 Subject: [PATCH 7/9] fix: split qna resource to another template (#4212) Co-authored-by: Andy Brown --- .../DeploymentTemplates/qna-template.json | 221 ++++++++++++++++++ .../template-with-preexisting-rg.json | 133 +---------- .../shared/scripts/provisionComposer.js | 175 +++++++++++++- 3 files changed, 385 insertions(+), 144 deletions(-) create mode 100644 Composer/plugins/samples/assets/shared/scripts/DeploymentTemplates/qna-template.json diff --git a/Composer/plugins/samples/assets/shared/scripts/DeploymentTemplates/qna-template.json b/Composer/plugins/samples/assets/shared/scripts/DeploymentTemplates/qna-template.json new file mode 100644 index 0000000000..511420d5d8 --- /dev/null +++ b/Composer/plugins/samples/assets/shared/scripts/DeploymentTemplates/qna-template.json @@ -0,0 +1,221 @@ +{ + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "name": { + "type": "string", + "defaultValue": "[resourceGroup().name]" + }, + "newAppServicePlanName": { + "type": "string", + "defaultValue": "[resourceGroup().name]", + "metadata": { + "description": "The name of the new App Service Plan." + } + }, + "newAppServicePlanSku": { + "type": "object", + "defaultValue": { + "name": "S1", + "tier": "Standard", + "size": "S1", + "family": "S", + "capacity": 1 + }, + "metadata": { + "description": "The SKU of the App Service Plan. Defaults to Standard values." + } + }, + "appServicePlanLocation": { + "type": "string", + "metadata": { + "description": "The location of the App Service Plan." + } + }, + "existingAppServicePlan": { + "type": "string", + "defaultValue": "", + "metadata": { + "description": "Name of the existing App Service Plan used to create the Web App for the bot." + } + }, + "appInsightsName": { + "type": "string", + "defaultValue": "[resourceGroup().name]" + }, + "appInsightsLocation": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "qnaMakerServiceName": { + "type": "string", + "defaultValue": "[concat(parameters('name'), '-qna')]" + }, + "qnaMakerServiceSku": { + "type": "string", + "defaultValue": "S0" + }, + "qnaMakerServiceLocation": { + "type": "string", + "defaultValue": "westus" + }, + "qnaMakerSearchName": { + "type": "string", + "defaultValue": "[concat(parameters('name'), '-search')]" + }, + "qnaMakerSearchSku": { + "type": "string", + "defaultValue": "standard" + }, + "qnaMakerSearchLocation": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + }, + "qnaMakerWebAppName": { + "type": "string", + "defaultValue": "[concat(parameters('name'), '-qnahost')]" + }, + "qnaMakerWebAppLocation": { + "type": "string", + "defaultValue": "[resourceGroup().location]" + } + }, + "variables": { + "defaultAppServicePlanName": "[if(empty(parameters('existingAppServicePlan')), 'createNewAppServicePlan', parameters('existingAppServicePlan'))]", + "useExistingAppServicePlan": "[not(equals(variables('defaultAppServicePlanName'), 'createNewAppServicePlan'))]", + "servicePlanName": "[if(variables('useExistingAppServicePlan'), parameters('existingAppServicePlan'), parameters('newAppServicePlanName'))]", + "resourcesLocation": "[parameters('appServicePlanLocation')]", + "qnaMakerSearchName": "[toLower(replace(parameters('qnaMakerSearchName'), '_', ''))]", + "qnaMakerWebAppName": "[replace(parameters('qnaMakerWebAppName'), '_', '')]" + }, + "resources": [ + { + "apiVersion": "2018-02-01", + "name": "1d41002f-62a1-49f3-bd43-2f3f32a19cbb", + "type": "Microsoft.Resources/deployments", + "properties": { + "mode": "Incremental", + "template": { + "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentTemplate.json#", + "contentVersion": "1.0.0.0", + "resources": [] + } + } + }, + { + "comments": "Create a new App Service Plan if no existing App Service Plan name was passed in.", + "type": "Microsoft.Web/serverfarms", + "condition": "[not(variables('useExistingAppServicePlan'))]", + "name": "[variables('servicePlanName')]", + "apiVersion": "2018-02-01", + "location": "[variables('resourcesLocation')]", + "sku": "[parameters('newAppServicePlanSku')]", + "properties": { + "name": "[variables('servicePlanName')]" + } + }, + { + "comments": "app insights", + "type": "Microsoft.Insights/components", + "kind": "web", + "apiVersion": "2015-05-01", + "name": "[parameters('appInsightsName')]", + "location": "[parameters('appInsightsLocation')]", + "properties": { + "Application_Type": "web" + } + }, + { + "comments": "Cognitive service key for all QnA Maker knowledgebases.", + "type": "Microsoft.CognitiveServices/accounts", + "kind": "QnAMaker", + "apiVersion": "2017-04-18", + "name": "[parameters('qnaMakerServiceName')]", + "location": "[parameters('qnaMakerServiceLocation')]", + "sku": { + "name": "[parameters('qnaMakerServiceSku')]" + }, + "properties": { + "apiProperties": { + "qnaRuntimeEndpoint": "[concat('https://',reference(resourceId('Microsoft.Web/sites', variables('qnaMakerWebAppName'))).hostNames[0])]" + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/Sites', variables('qnaMakerWebAppName'))]", + "[resourceId('Microsoft.Search/searchServices/', variables('qnaMakerSearchName'))]", + "[resourceId('microsoft.insights/components/', parameters('appInsightsName'))]" + ] + }, + { + "comments": "Search service for QnA Maker service.", + "type": "Microsoft.Search/searchServices", + "apiVersion": "2015-08-19", + "name": "[variables('qnaMakerSearchName')]", + "location": "[parameters('qnaMakerSearchLocation')]", + "sku": { + "name": "[parameters('qnaMakerSearchSku')]" + }, + "properties": { + "replicaCount": 1, + "partitionCount": 1, + "hostingMode": "default" + } + }, + { + "comments": "Web app for QnA Maker service.", + "type": "Microsoft.Web/sites", + "apiVersion": "2016-08-01", + "name": "[variables('qnaMakerWebAppName')]", + "location": "[parameters('qnaMakerWebAppLocation')]", + "properties": { + "enabled": true, + "name": "[variables('qnaMakerWebAppName')]", + "hostingEnvironment": "", + "serverFarmId": "[concat('/subscriptions/', Subscription().SubscriptionId,'/resourcegroups/', resourceGroup().name, '/providers/Microsoft.Web/serverfarms/', variables('servicePlanName'))]", + "siteConfig": { + "cors": { + "allowedOrigins": [ + "*" + ] + } + } + }, + "dependsOn": [ + "[resourceId('Microsoft.Web/serverfarms/', variables('servicePlanName'))]" + ], + "resources": [ + { + "apiVersion": "2016-08-01", + "name": "appsettings", + "type": "config", + "dependsOn": [ + "[resourceId('Microsoft.Web/Sites', variables('qnaMakerWebAppName'))]", + "[resourceId('Microsoft.Insights/components', parameters('appInsightsName'))]", + "[resourceId('Microsoft.Search/searchServices/', variables('qnaMakerSearchName'))]" + ], + "properties": { + "AzureSearchName": "[variables('qnaMakerSearchName')]", + "AzureSearchAdminKey": "[listAdminKeys(resourceId('Microsoft.Search/searchServices/', variables('qnaMakerSearchName')), '2015-08-19').primaryKey]", + "UserAppInsightsKey": "[reference(resourceId('Microsoft.Insights/components/', parameters('appInsightsName')), '2015-05-01').InstrumentationKey]", + "UserAppInsightsName": "[parameters('appInsightsName')]", + "UserAppInsightsAppId": "[reference(resourceId('Microsoft.Insights/components/', parameters('appInsightsName')), '2015-05-01').AppId]", + "PrimaryEndpointKey": "[concat(variables('qnaMakerWebAppName'), '-PrimaryEndpointKey')]", + "SecondaryEndpointKey": "[concat(variables('qnaMakerWebAppName'), '-SecondaryEndpointKey')]", + "DefaultAnswer": "No good match found in KB.", + "QNAMAKER_EXTENSION_VERSION": "latest" + } + } + ] + } + ], + "outputs": { + "qna": { + "type": "object", + "value": { + "endpoint": "[concat('https://', reference(resourceId('Microsoft.Web/sites', variables('qnaMakerWebAppName'))).hostNames[0])]", + "subscriptionKey": "[listKeys(resourceId('Microsoft.CognitiveServices/accounts', parameters('qnaMakerServiceName')),'2017-04-18').key1]" + } + } + } + } + \ No newline at end of file diff --git a/Composer/plugins/samples/assets/shared/scripts/DeploymentTemplates/template-with-preexisting-rg.json b/Composer/plugins/samples/assets/shared/scripts/DeploymentTemplates/template-with-preexisting-rg.json index d385e94444..81c8531adc 100644 --- a/Composer/plugins/samples/assets/shared/scripts/DeploymentTemplates/template-with-preexisting-rg.json +++ b/Composer/plugins/samples/assets/shared/scripts/DeploymentTemplates/template-with-preexisting-rg.json @@ -34,10 +34,6 @@ "type": "bool", "defaultValue": true }, - "shouldCreateQnAResource": { - "type": "bool", - "defaultValue": true - }, "cosmosDbName": { "type": "string", "defaultValue": "[resourceGroup().name]" @@ -134,38 +130,6 @@ "luisServiceLocation": { "type": "string", "defaultValue": "[resourceGroup().location]" - }, - "qnaMakerServiceName": { - "type": "string", - "defaultValue": "[concat(parameters('name'), '-qna')]" - }, - "qnaMakerServiceSku": { - "type": "string", - "defaultValue": "S0" - }, - "qnaMakerServiceLocation": { - "type": "string", - "defaultValue": "westus" - }, - "qnaMakerSearchName": { - "type": "string", - "defaultValue": "[concat(parameters('name'), '-search')]" - }, - "qnaMakerSearchSku": { - "type": "string", - "defaultValue": "standard" - }, - "qnaMakerSearchLocation": { - "type": "string", - "defaultValue": "[resourceGroup().location]" - }, - "qnaMakerWebAppName": { - "type": "string", - "defaultValue": "[concat(parameters('name'), '-qnahost')]" - }, - "qnaMakerWebAppLocation": { - "type": "string", - "defaultValue": "[resourceGroup().location]" } }, "variables": { @@ -178,9 +142,7 @@ "siteHost": "[concat(variables('webAppName'), '.azurewebsites.net')]", "botEndpoint": "[concat('https://', variables('siteHost'), '/api/messages')]", "storageAccountName": "[toLower(take(replace(replace(parameters('storageAccountName'), '-', ''), '_', ''), 24))]", - "LuisAuthoringAccountName": "[concat(parameters('luisServiceName'), '-Authoring')]", - "qnaMakerSearchName": "[toLower(replace(parameters('qnaMakerSearchName'), '_', ''))]", - "qnaMakerWebAppName": "[replace(parameters('qnaMakerWebAppName'), '_', '')]" + "LuisAuthoringAccountName": "[concat(parameters('luisServiceName'), '-Authoring')]" }, "resources": [ { @@ -388,92 +350,6 @@ "name": "[parameters('luisServiceRunTimeSku')]" }, "condition": "[parameters('shouldCreateLuisResource')]" - }, - { - "comments": "Cognitive service key for all QnA Maker knowledgebases.", - "type": "Microsoft.CognitiveServices/accounts", - "kind": "QnAMaker", - "apiVersion": "2017-04-18", - "name": "[parameters('qnaMakerServiceName')]", - "location": "[parameters('qnaMakerServiceLocation')]", - "sku": { - "name": "[parameters('qnaMakerServiceSku')]" - }, - "properties": { - "apiProperties": { - "qnaRuntimeEndpoint": "[concat('https://',reference(resourceId('Microsoft.Web/sites', variables('qnaMakerWebAppName'))).hostNames[0])]" - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/Sites', variables('qnaMakerWebAppName'))]", - "[resourceId('Microsoft.Search/searchServices/', variables('qnaMakerSearchName'))]", - "[resourceId('microsoft.insights/components/', parameters('appInsightsName'))]" - ], - "condition": "[parameters('shouldCreateQnAResource')]" - }, - { - "comments": "Search service for QnA Maker service.", - "type": "Microsoft.Search/searchServices", - "apiVersion": "2015-08-19", - "name": "[variables('qnaMakerSearchName')]", - "location": "[parameters('qnaMakerSearchLocation')]", - "sku": { - "name": "[parameters('qnaMakerSearchSku')]" - }, - "properties": { - "replicaCount": 1, - "partitionCount": 1, - "hostingMode": "default" - }, - "condition": "[parameters('shouldCreateQnAResource')]" - }, - { - "comments": "Web app for QnA Maker service.", - "type": "Microsoft.Web/sites", - "apiVersion": "2016-08-01", - "name": "[variables('qnaMakerWebAppName')]", - "location": "[parameters('qnaMakerWebAppLocation')]", - "properties": { - "enabled": true, - "name": "[variables('qnaMakerWebAppName')]", - "hostingEnvironment": "", - "serverFarmId": "[concat('/subscriptions/', Subscription().SubscriptionId,'/resourcegroups/', resourceGroup().name, '/providers/Microsoft.Web/serverfarms/', variables('servicePlanName'))]", - "siteConfig": { - "cors": { - "allowedOrigins": [ - "*" - ] - } - } - }, - "dependsOn": [ - "[resourceId('Microsoft.Web/serverfarms/', variables('servicePlanName'))]" - ], - "condition": "[parameters('shouldCreateQnAResource')]", - "resources": [ - { - "apiVersion": "2016-08-01", - "name": "appsettings", - "type": "config", - "dependsOn": [ - "[resourceId('Microsoft.Web/Sites', variables('qnaMakerWebAppName'))]", - "[resourceId('Microsoft.Insights/components', parameters('appInsightsName'))]", - "[resourceId('Microsoft.Search/searchServices/', variables('qnaMakerSearchName'))]" - ], - "properties": { - "AzureSearchName": "[variables('qnaMakerSearchName')]", - "AzureSearchAdminKey": "[listAdminKeys(resourceId('Microsoft.Search/searchServices/', variables('qnaMakerSearchName')), '2015-08-19').primaryKey]", - "UserAppInsightsKey": "[reference(resourceId('Microsoft.Insights/components/', parameters('appInsightsName')), '2015-05-01').InstrumentationKey]", - "UserAppInsightsName": "[parameters('appInsightsName')]", - "UserAppInsightsAppId": "[reference(resourceId('Microsoft.Insights/components/', parameters('appInsightsName')), '2015-05-01').AppId]", - "PrimaryEndpointKey": "[concat(variables('qnaMakerWebAppName'), '-PrimaryEndpointKey')]", - "SecondaryEndpointKey": "[concat(variables('qnaMakerWebAppName'), '-SecondaryEndpointKey')]", - "DefaultAnswer": "No good match found in KB.", - "QNAMAKER_EXTENSION_VERSION": "latest" - }, - "condition": "[parameters('shouldCreateQnAResource')]" - } - ] } ], "outputs": { @@ -509,13 +385,6 @@ "endpoint": "[if(parameters('shouldCreateLuisResource'), reference(resourceId('Microsoft.CognitiveServices/accounts', parameters('luisServiceName'))).endpoint, '')]", "authoringEndpoint": "[if(parameters('shouldCreateAuthoringResource'), reference(resourceId('Microsoft.CognitiveServices/accounts', variables('LuisAuthoringAccountName'))).endpoint, '')]" } - }, - "qna": { - "type": "object", - "value": { - "endpoint": "[if(parameters('shouldCreateQnAResource'), concat('https://', reference(resourceId('Microsoft.Web/sites', variables('qnaMakerWebAppName'))).hostNames[0]), '')]", - "subscriptionKey": "[if(parameters('shouldCreateQnAResource'), listKeys(resourceId('Microsoft.CognitiveServices/accounts', parameters('qnaMakerServiceName')),'2017-04-18').key1, '')]" - } } } } diff --git a/Composer/plugins/samples/assets/shared/scripts/provisionComposer.js b/Composer/plugins/samples/assets/shared/scripts/provisionComposer.js index 9e85606907..fbf4f2bdd9 100644 --- a/Composer/plugins/samples/assets/shared/scripts/provisionComposer.js +++ b/Composer/plugins/samples/assets/shared/scripts/provisionComposer.js @@ -43,6 +43,10 @@ const usage = () => { 'customArmTemplate', 'Path to runtime ARM template. By default it will use an Azure WebApp template. Pass `DeploymentTemplates/function-template-with-preexisting-rg.json` for Azure Functions or your own template for a custom deployment.', ], + [ + 'qnaTemplate', + 'Path to qna template. By default it will use `DeploymentTemplates/qna-template.json`' + ] ]; const instructions = [ @@ -53,11 +57,11 @@ const usage = () => { ``, chalk.bold(`Basic Usage:`), chalk.greenBright(`node provisionComposer --subscriptionId=`) + - chalk.yellow('') + - chalk.greenBright(' --name=') + - chalk.yellow('') + - chalk.greenBright(' --appPassword=') + - chalk.yellow('<16 character password>'), + chalk.yellow('') + + chalk.greenBright(' --name=') + + chalk.yellow('') + + chalk.greenBright(' --appPassword=') + + chalk.yellow('<16 character password>'), ``, chalk.bold(`All options:`), ...options.map((option) => { @@ -98,6 +102,8 @@ var tenantId = argv.tenantId ? argv.tenantId : ''; const templatePath = argv.customArmTemplate || path.join(__dirname, 'DeploymentTemplates', 'template-with-preexisting-rg.json'); +const qnaTemplatePath = + argv.qnaTemplate || path.join(__dirname, 'DeploymentTemplates', 'qna-template.json'); const BotProjectDeployLoggerType = { // Logger Type for Provision @@ -206,6 +212,18 @@ const getTenantId = async (accessToken) => { } }; +/** + * + * @param {*} appId the appId of application registration + * @param {*} appPwd the app password of application registration + * @param {*} location the locaiton of all resources + * @param {*} name the name of resource group + * @param {*} shouldCreateAuthoringResource + * @param {*} shouldCreateLuisResource + * @param {*} useAppInsights + * @param {*} useCosmosDb + * @param {*} useStorage + */ const getDeploymentTemplateParam = ( appId, appPwd, @@ -213,7 +231,6 @@ const getDeploymentTemplateParam = ( name, shouldCreateAuthoringResource, shouldCreateLuisResource, - shouldCreateQnAResource, useAppInsights, useCosmosDb, useStorage @@ -225,13 +242,65 @@ const getDeploymentTemplateParam = ( botId: pack(name), shouldCreateAuthoringResource: pack(shouldCreateAuthoringResource), shouldCreateLuisResource: pack(shouldCreateLuisResource), - shouldCreateQnAResource: pack(shouldCreateQnAResource), useAppInsights: pack(useAppInsights), useCosmosDb: pack(useCosmosDb), useStorage: pack(useStorage), }; }; +/** + * Get QnA template param + */ +const getQnaTemplateParam = ( + location, + name +) => { + return { + appServicePlanLocation: pack(location), + name: pack(name) + }; +}; + +/** + * Validate the qna template and the qna template param + */ +const validateQnADeployment = async (client, resourceGroupName, deployName, templateParam) => { + logger({ + status: BotProjectDeployLoggerType.PROVISION_INFO, + message: '> Validating QnA deployment ...', + }); + + const templateFile = await readFile(qnaTemplatePath, { encoding: 'utf-8' }); + const deployParam = { + properties: { + template: JSON.parse(templateFile), + parameters: templateParam, + mode: 'Incremental', + }, + }; + return await client.deployments.validate(resourceGroupName, deployName, deployParam); +}; + +/** + * Create a QnA resource deployment + * @param {*} client + * @param {*} resourceGroupName + * @param {*} deployName + * @param {*} templateParam + */ +const createQnADeployment = async (client, resourceGroupName, deployName, templateParam) => { + const templateFile = await readFile(qnaTemplatePath, { encoding: 'utf-8' }); + const deployParam = { + properties: { + template: JSON.parse(templateFile), + parameters: templateParam, + mode: 'Incremental', + }, + }; + + return await client.deployments.createOrUpdate(resourceGroupName, deployName, deployParam); +}; + /** * Validate the deployment using the Azure API */ @@ -347,6 +416,12 @@ const create = async ( createStorage = true, createAppInsights = true ) => { + + // App insights is a dependency of QnA + if (createQnAResource) { + createAppInsights = true; + } + // If tenantId is empty string, get tenanId from API if (!tenantId) { const token = await creds.getToken(); @@ -422,7 +497,6 @@ const create = async ( location, name, createLuisAuthoringResource, - createQnAResource, createLuisResource, createAppInsights, createCosmosDb, @@ -486,6 +560,75 @@ const create = async ( return provisionFailed(); } + var qnaResult = null; + + // Create qna resources, the reason why seperate the qna resources from others: https://github.com/Azure/azure-sdk-for-js/issues/10186 + if (createQnAResource) { + const qnaDeployName = new Date().getTime().toString(); + const qnaDeploymentTemplateParam = getQnaTemplateParam( + location, + name + ); + const qnaValidation = await validateQnADeployment(client, resourceGroupName, qnaDeployName, qnaDeploymentTemplateParam); + if (qnaValidation.error) { + logger({ + status: BotProjectDeployLoggerType.PROVISION_ERROR, + message: `! Error: ${qnaValidation.error.message}`, + }); + if (qnaValidation.error.details) { + logger({ + status: BotProjectDeployLoggerType.PROVISION_ERROR_DETAILS, + message: JSON.stringify(qnaValidation.error.details, null, 2), + }); + } + logger({ + status: BotProjectDeployLoggerType.PROVISION_ERROR, + message: `+ To delete this resource group, run 'az group delete -g ${resourceGroupName} --no-wait'`, + }); + return provisionFailed(); + } + + // Create qna deloyment + logger({ + status: BotProjectDeployLoggerType.PROVISION_INFO, + message: `> Deploying QnA Resources (this could take a while)...`, + }); + const spinner = ora().start(); + try { + const qnaDeployment = await createQnADeployment(client, resourceGroupName, qnaDeployName, qnaDeploymentTemplateParam); + // Handle errors + if (qnaDeployment._response.status != 200) { + spinner.fail(); + logger({ + status: BotProjectDeployLoggerType.PROVISION_ERROR, + message: `! QnA Template is not valid with provided parameters. Review the log for more information.`, + }); + logger({ + status: BotProjectDeployLoggerType.PROVISION_ERROR, + message: `! Error: ${qnaValidation.error}`, + }); + logger({ + status: BotProjectDeployLoggerType.PROVISION_ERROR, + message: `+ To delete this resource group, run 'az group delete -g ${resourceGroupName} --no-wait'`, + }); + return provisionFailed(); + } + } catch (err) { + spinner.fail(); + logger({ + status: BotProjectDeployLoggerType.PROVISION_ERROR, + message: getErrorMesssage(err), + }); + return provisionFailed(); + } + + const qnaDeploymentOutput = await client.deployments.get(resourceGroupName, qnaDeployName); + if (qnaDeploymentOutput && qnaDeploymentOutput.properties && qnaDeploymentOutput.properties.outputs) { + const qnaOutputResult = qnaDeploymentOutput.properties.outputs; + qnaResult = unpackObject(qnaOutputResult); + } + } + // If application insights created, update the application insights settings in azure bot service if (createAppInsights) { logger({ @@ -574,10 +717,10 @@ const create = async ( if (failedOperations) { failedOperations.forEach((operation) => { switch ( - operation && - operation.properties && - operation.properties.statusMessage.error.code && - operation.properties.targetResource + operation && + operation.properties && + operation.properties.statusMessage.error.code && + operation.properties.targetResource ) { case 'MissingRegistrationForLocation': logger({ @@ -609,6 +752,14 @@ const create = async ( }); } } + + // Merge qna outputs with other resources' outputs + if (createQnAResource) { + if (qnaResult) { + Object.assign(updateResult, qnaResult); + } + } + return updateResult; }; From 1d57f5913ade6b2f21ff7635debc7d7b5c586836 Mon Sep 17 00:00:00 2001 From: Andy Brown Date: Wed, 23 Sep 2020 14:14:25 -0700 Subject: [PATCH 8/9] fix: fix loading of extensions by removing sample-ui-plugin (#4251) * fix loading of extensions by removing sample-ui-plugin * remove builtin extension from manifest if not enabled --- 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 29c80a0ade..15fa510409 100644 --- a/Composer/packages/extension/src/manager/manager.ts +++ b/Composer/packages/extension/src/manager/manager.ts @@ -245,12 +245,15 @@ class ExtensionManager { const extensionInstallPath = path.dirname(fullPath); const packageJson = (await readJson(fullPath)) as PackageJSON; const isEnabled = packageJson?.composer && packageJson.composer.enabled !== false; + const metadata = getExtensionMetadata(extensionInstallPath, packageJson); if (packageJson && (isEnabled || packageJson.extendsComposer === true)) { - const metadata = getExtensionMetadata(extensionInstallPath, packageJson); this.manifest.updateExtensionConfig(packageJson.name, { ...metadata, builtIn: true, }); + } else if (this.manifest.getExtensionConfig(packageJson.name)) { + // remove the extension if it exists in the manifest + this.manifest.removeExtension(packageJson.name); } } } From e2510d3744031547827c7b6dd5fd9057918e5960 Mon Sep 17 00:00:00 2001 From: Ben Yackley <61990921+beyackle@users.noreply.github.com> Date: Wed, 23 Sep 2020 14:52:30 -0700 Subject: [PATCH 9/9] fix: update l10n file (#4247) --- .../packages/server/src/locales/en-US.json | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/Composer/packages/server/src/locales/en-US.json b/Composer/packages/server/src/locales/en-US.json index cc71cbef03..3365f9a962 100644 --- a/Composer/packages/server/src/locales/en-US.json +++ b/Composer/packages/server/src/locales/en-US.json @@ -197,9 +197,6 @@ "any_or_expression_acad7d37": { "message": "Any or expression" }, - "app_id_2f53f1f8": { - "message": "App Id" - }, "append_choices_35c45a2d": { "message": "Append choices" }, @@ -743,6 +740,9 @@ "duplicate_dialog_name_824f9fce": { "message": "Duplicate dialog name" }, + "duplicate_fields_9fd0d3c2": { + "message": "duplicate fields" + }, "duplicate_name_d295a09d": { "message": "Duplicate name" }, @@ -1301,6 +1301,9 @@ "manifest_url_30824e88": { "message": "Manifest url" }, + "manifest_url_can_not_be_accessed_a7f147b2": { + "message": "Manifest url can not be accessed" + }, "manifest_version_1edc004a": { "message": "Manifest Version" }, @@ -1346,6 +1349,9 @@ "missing_definition_for_defname_33f2b594": { "message": "Missing definition for { defName }" }, + "missing_fields_1c88ab71": { + "message": "missing fields" + }, "modification_rejected_6a6e8322": { "message": "Modification Rejected" }, @@ -1394,8 +1400,8 @@ "name_and_save_your_skill_manifest_cfd672b7": { "message": "Name and save your skill manifest." }, - "name_contains_invalid_charactors_d1780987": { - "message": "Name contains invalid charactors" + "name_cannot_include_special_characters_or_spaces_59a1950b": { + "message": "Name cannot include special characters or spaces" }, "name_copy_55d27c1a": { "message": "{ name }_Copy" @@ -1541,9 +1547,6 @@ "open_inline_editor_a5aabcfa": { "message": "Open inline editor" }, - "open_skills_page_for_configuration_details_a2a484ea": { - "message": "Open Skills page for configuration details" - }, "or_4f7d4edb": { "message": "Or: " }, @@ -1580,8 +1583,8 @@ "please_enter_an_event_name_a148275a": { "message": "Please enter an event name" }, - "please_input_a_manifest_url_ef6bab5f": { - "message": "Please input a manifest url" + "please_input_a_manifest_url_d726edbf": { + "message": "Please input a manifest Url" }, "please_input_regex_pattern_5cd659a2": { "message": "Please input regEx pattern" @@ -1601,6 +1604,9 @@ "please_select_a_trigger_type_67417abb": { "message": "Please select a trigger type" }, + "please_select_a_valid_endpoint_bf608af1": { + "message": "Please select a valid endpoint" + }, "please_select_a_version_of_the_manifest_schema_4a3efbb1": { "message": "Please select a version of the manifest schema" }, @@ -1742,8 +1748,8 @@ "redo_363c58b7": { "message": "Redo" }, - "redo_is_not_support_1595aaa4": { - "message": "Redo is not support" + "redo_is_not_supported_b743e4dc": { + "message": "Redo is not supported" }, "refer_to_the_syntax_documentation_here_df8dc9b4": { "message": "Refer to the syntax documentation here." @@ -1949,6 +1955,9 @@ "skill_host_endpoint_b1088d0": { "message": "Skill Host Endpoint" }, + "skill_manifest_endpoint_is_configured_improperly_e083731d": { + "message": "Skill manifest endpoint is configured improperly" + }, "skillname_manifest_ef3d9fed": { "message": "{ skillName } Manifest" }, @@ -2201,8 +2210,8 @@ "undo_a7be8fef": { "message": "Undo" }, - "undo_is_not_support_a27c5281": { - "message": "Undo is not support" + "undo_is_not_supported_ecd6f9fc": { + "message": "Undo is not supported" }, "unknown_intent_44b962ba": { "message": "Unknown intent" @@ -2396,4 +2405,4 @@ "your_bot_is_using_luis_and_qna_for_natural_languag_53830684": { "message": "Your bot is using LUIS and QNA for natural language understanding." } -} +} \ No newline at end of file