diff --git a/Composer/packages/client/__tests__/pages/notifications/useNotifications.test.tsx b/Composer/packages/client/__tests__/pages/notifications/useNotifications.test.tsx index 45228c028f..c0914b37a9 100644 --- a/Composer/packages/client/__tests__/pages/notifications/useNotifications.test.tsx +++ b/Composer/packages/client/__tests__/pages/notifications/useNotifications.test.tsx @@ -26,7 +26,7 @@ const state = { content: 'test', luFile: 'test', referredLuIntents: [], - skills: ['https://yuesuemailskill0207-gjvga67.azurewebsites.net/manifest/manifest-1.0.json'], + skills: [`=settings.skill['Email-Skill'].endpointUrl`], }, ], luFiles: [ @@ -86,12 +86,13 @@ const state = { }, ], settings: { - skill: [ - { + skill: { + 'Email-Skill': { manifestUrl: 'https://yuesuemailskill0207-gjvga67.azurewebsites.net/manifest/manifest-1.0.json', - name: 'Email Skill', + endpointUrl: 'https://yuesuemailskill0207-gjvga67.azurewebsites.net/api/messages', + name: 'Email-Skill', }, - ], + }, }, }; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/project.ts b/Composer/packages/client/src/recoilModel/dispatchers/project.ts index 094aff011e..5c9c7c2a0d 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/project.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/project.ts @@ -2,11 +2,18 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. import { useRecoilCallback, CallbackInterface } from 'recoil'; -import { dereferenceDefinitions, LuFile, QnAFile, DialogInfo, SensitiveProperties, DialogSetting } from '@bfc/shared'; +import { + dereferenceDefinitions, + LuFile, + QnAFile, + DialogInfo, + SensitiveProperties, + DialogSetting, + convertSkillsToDictionary, +} from '@bfc/shared'; import { indexer, validateDialog } from '@bfc/indexers'; import objectGet from 'lodash/get'; import objectSet from 'lodash/set'; -import isArray from 'lodash/isArray'; import formatMessage from 'format-message'; import lgWorker from '../parsers/lgWorker'; @@ -193,6 +200,14 @@ export const projectDispatcher = () => { set(projectIdState, projectId); refreshLocalStorage(projectId, settings); const mergedSettings = mergeLocalStorage(projectId, settings); + if (Array.isArray(mergedSettings.skill)) { + const skillsArr = mergedSettings.skill.map((skillData) => { + return { + ...skillData, + }; + }); + mergedSettings.skill = convertSkillsToDictionary(skillsArr); + } set(settingsState, mergedSettings); }); gotoSnapshot(newSnapshot); @@ -347,7 +362,7 @@ export const projectDispatcher = () => { const { set } = callbackHelpers; try { const response = await httpClient.get(`/runtime/templates`); - if (isArray(response.data)) { + if (Array.isArray(response.data)) { set(runtimeTemplatesState, [...response.data]); } } catch (ex) { diff --git a/Composer/packages/client/src/recoilModel/dispatchers/setting.ts b/Composer/packages/client/src/recoilModel/dispatchers/setting.ts index 2ec099b01f..aa2e75964d 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/setting.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/setting.ts @@ -3,7 +3,7 @@ /* eslint-disable react-hooks/rules-of-hooks */ import { CallbackInterface, useRecoilCallback } from 'recoil'; -import { SensitiveProperties, DialogSetting, PublishTarget } from '@bfc/shared'; +import { SensitiveProperties, DialogSetting, PublishTarget, Skill } from '@bfc/shared'; import get from 'lodash/get'; import has from 'lodash/has'; @@ -89,6 +89,25 @@ export const settingsDispatcher = () => { } ); + const updateSkillsInSetting = useRecoilCallback( + ({ set, snapshot }: CallbackInterface) => async (skillName: string, skillInfo: Partial) => { + const currentSettings: DialogSetting = await snapshot.getPromise(settingsState); + const matchedSkill = get(currentSettings, `skill[${skillName}]`, undefined); + if (matchedSkill) { + set(settingsState, { + ...currentSettings, + skill: { + ...currentSettings.skill, + [skillName]: { + ...matchedSkill, + ...skillInfo, + }, + }, + }); + } + } + ); + return { setSettings, setRuntimeSettings, @@ -96,5 +115,6 @@ export const settingsDispatcher = () => { setRuntimeField, setCustomRuntime, setQnASettings, + updateSkillsInSetting, }; }; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/skill.ts b/Composer/packages/client/src/recoilModel/dispatchers/skill.ts index 9d5e190c47..2301a213e3 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/skill.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/skill.ts @@ -3,7 +3,7 @@ /* eslint-disable react-hooks/rules-of-hooks */ import { CallbackInterface, useRecoilCallback } from 'recoil'; -import { SkillManifest } from '@bfc/shared'; +import { SkillManifest, convertSkillsToDictionary } from '@bfc/shared'; import httpClient from '../../utils/httpUtil'; @@ -76,9 +76,7 @@ export const skillDispatcher = () => { set(onAddSkillDialogCompleteState, { func: undefined }); set(settingsState, (settings) => ({ ...settings, - skill: skills.map(({ manifestUrl, name }) => { - return { manifestUrl, name }; - }), + skill: convertSkillsToDictionary(skills), })); set(skillsState, skills); } catch (err) { diff --git a/Composer/packages/client/src/shell/useShell.ts b/Composer/packages/client/src/shell/useShell.ts index c27156b5dc..d65310c2d0 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 } from '@bfc/shared'; +import { ShellApi, ShellData, Shell, fetchFromSettings } from '@bfc/shared'; import { useRecoilValue } from 'recoil'; import formatMessage from 'format-message'; @@ -26,6 +26,7 @@ import { focusPathState, userSettingsState, clipboardActionsState, + settingsState, } from '../recoilModel'; import { validatedDialogsSelector } from '../recoilModel/selectors/validatedDialogs'; @@ -56,6 +57,7 @@ export function useShell(source: EventSource): Shell { const userSettings = useRecoilValue(userSettingsState); const clipboardActions = useRecoilValue(clipboardActionsState); const { undo, redo, commitChanges } = useRecoilValue(undoFunctionState); + const settings = useRecoilValue(settingsState); const { updateDialog, updateDialogSchema, @@ -70,6 +72,7 @@ export function useShell(source: EventSource): Shell { updateUserSettings, setMessage, displayManifestModal, + updateSkillsInSetting, } = useRecoilValue(dispatcherState); const lgApi = useLgApi(); const luApi = useLuApi(); @@ -177,7 +180,7 @@ export function useShell(source: EventSource): Shell { }, addSkillDialog: () => { return new Promise((resolve) => { - addSkillDialogBegin((newSkill: { manifestUrl: string } | null) => { + addSkillDialogBegin((newSkill: { manifestUrl: string; name: string } | null) => { resolve(newSkill); }); }); @@ -190,6 +193,10 @@ export function useShell(source: EventSource): Shell { announce: setMessage, displayManifestModal: displayManifestModal, updateDialogSchema, + skillsInSettings: { + get: (path: string) => fetchFromSettings(path, settings), + set: updateSkillsInSetting, + }, }; const currentDialog = useMemo(() => dialogs.find((d) => d.id === dialogId), [dialogs, dialogId]); diff --git a/Composer/packages/lib/indexers/__tests__/botIndexer.test.ts b/Composer/packages/lib/indexers/__tests__/botIndexer.test.ts index 82d9587cb0..7eee9509c4 100644 --- a/Composer/packages/lib/indexers/__tests__/botIndexer.test.ts +++ b/Composer/packages/lib/indexers/__tests__/botIndexer.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { BotAssets, DialogSetting, DialogInfo, DiagnosticSeverity, LuFile } from '@bfc/shared'; +import { BotAssets, DialogSetting, DialogInfo, DiagnosticSeverity, LuFile, ILuisConfig, IQnAConfig } from '@bfc/shared'; import { BotIndexer } from '../src/botIndexer'; const { checkSkillSetting, checkLUISLocales, filterLUISFilesToPublish } = BotIndexer; @@ -32,12 +32,15 @@ const botAssets: BotAssets = { defaultLanguage: 'en-us', botId: '', skillHostEndpoint: '', - skill: [ - { - name: 'Email Skill', + skill: { + 'Email-Skill': { + name: 'Email-Skill', manifestUrl: 'skill1', }, - ], + }, + luis: {} as ILuisConfig, + qna: {} as IQnAConfig, + runtime: {} as any, } as DialogSetting, }; diff --git a/Composer/packages/lib/indexers/src/botIndexer.ts b/Composer/packages/lib/indexers/src/botIndexer.ts index a85d945c6e..0e58a8834a 100644 --- a/Composer/packages/lib/indexers/src/botIndexer.ts +++ b/Composer/packages/lib/indexers/src/botIndexer.ts @@ -4,8 +4,18 @@ * Verify bot settings, files meet LUIS/QnA requirments. */ -import { BotAssets, BotInfo, LUISLocales, Diagnostic, DiagnosticSeverity, LuFile } from '@bfc/shared'; +import { + BotAssets, + BotInfo, + LUISLocales, + Diagnostic, + DiagnosticSeverity, + LuFile, + fetchFromSettings, + getSkillNameFromSetting, +} from '@bfc/shared'; import difference from 'lodash/difference'; +import map from 'lodash/map'; import { getLocale } from './utils/help'; @@ -29,7 +39,7 @@ const checkLUISLocales = (assets: BotAssets): Diagnostic[] => { // Verify bot skill setting. const checkSkillSetting = (assets: BotAssets): Diagnostic[] => { const { - setting: { skill = [], botId, skillHostEndpoint }, + setting: { skill = {}, botId, skillHostEndpoint }, dialogs, } = assets; const diagnostics: Diagnostic[] = []; @@ -38,9 +48,15 @@ const checkSkillSetting = (assets: BotAssets): Diagnostic[] => { dialogs.forEach((dialog) => { // used skill not existed in setting dialog.skills.forEach((skillId) => { - if (skill.findIndex(({ manifestUrl }) => manifestUrl === skillId) === -1) { + const endpointUrlCollection = map(skill, ({ endpointUrl }) => endpointUrl); + if (!endpointUrlCollection.includes(fetchFromSettings(skillId, assets.setting))) { + const skillName = getSkillNameFromSetting(skillId) || skillId; diagnostics.push( - new Diagnostic(`skill '${skillId}' is not existed in appsettings.json`, dialog.id, DiagnosticSeverity.Error) + new Diagnostic( + `The skill '${skillName}' does not exist in in appsettings.json`, + dialog.id, + DiagnosticSeverity.Error + ) ); } }); @@ -61,13 +77,6 @@ const checkSkillSetting = (assets: BotAssets): Diagnostic[] => { return diagnostics; }; -const filterLUISFilesToPublish = (luFiles: LuFile[]): LuFile[] => { - return luFiles.filter((file) => { - const locale = getLocale(file.id); - return locale && LUISLocales.includes(locale); - }); -}; - const index = (name: string, assets: BotAssets): BotInfo => { const diagnostics: Diagnostic[] = []; diagnostics.push(...checkLUISLocales(assets), ...checkSkillSetting(assets)); @@ -79,6 +88,13 @@ const index = (name: string, assets: BotAssets): BotInfo => { }; }; +const filterLUISFilesToPublish = (luFiles: LuFile[]): LuFile[] => { + return luFiles.filter((file) => { + const locale = getLocale(file.id); + return locale && LUISLocales.includes(locale); + }); +}; + export const BotIndexer = { index, checkLUISLocales, diff --git a/Composer/packages/lib/indexers/src/dialogIndexer.ts b/Composer/packages/lib/indexers/src/dialogIndexer.ts index 3f7e96095c..d1663a9b60 100644 --- a/Composer/packages/lib/indexers/src/dialogIndexer.ts +++ b/Composer/packages/lib/indexers/src/dialogIndexer.ts @@ -166,8 +166,7 @@ function extractReferredSkills(dialog): string[] { const visitor: VisitorFunc = (path: string, value: any): boolean => { // it's a valid schema dialog node. if (has(value, '$kind') && value.$kind === SDKKinds.BeginSkill) { - const skillId = value.id; - skills.push(skillId); + skills.push(value.skillEndpoint); } return false; }; diff --git a/Composer/packages/lib/shared/src/index.ts b/Composer/packages/lib/shared/src/index.ts index 002cbe6d7e..0b726ece36 100644 --- a/Composer/packages/lib/shared/src/index.ts +++ b/Composer/packages/lib/shared/src/index.ts @@ -21,4 +21,5 @@ export * from './schemaUtils'; export * from './types'; export * from './viewUtils'; export * from './walkerUtils'; +export * from './skillsUtils'; export const DialogUtils = dialogUtils; diff --git a/Composer/packages/lib/shared/src/skillsUtils/index.ts b/Composer/packages/lib/shared/src/skillsUtils/index.ts new file mode 100644 index 0000000000..ab79cba7c9 --- /dev/null +++ b/Composer/packages/lib/shared/src/skillsUtils/index.ts @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import get from 'lodash/get'; +import keyBy from 'lodash/keyBy'; + +import { DialogSetting, Skill } from '../types'; + +export function fetchFromSettings(path: string, settings: DialogSetting): string { + if (path) { + const trimmed = path.replace(/=settings.(.*?)/gi, ''); + return get(settings, trimmed, ''); + } + return ''; +} + +export const convertSkillsToDictionary = (skills: Skill[]) => { + const mappedSkills = skills.map(({ msAppId, endpointUrl, manifestUrl, name }: Skill) => { + return { + name, + msAppId, + endpointUrl, + manifestUrl, + }; + }); + + return keyBy(mappedSkills, 'name'); +}; + +export const getSkillNameFromSetting = (value: string) => { + const matched = value.match(/\['(.*?)'\]/); + if (matched && matched.length > 1) { + return matched[1]; + } + return ''; +}; diff --git a/Composer/packages/lib/shared/src/types/settings.ts b/Composer/packages/lib/shared/src/types/settings.ts index 845f70c418..7280eb2844 100644 --- a/Composer/packages/lib/shared/src/types/settings.ts +++ b/Composer/packages/lib/shared/src/types/settings.ts @@ -33,9 +33,13 @@ export interface DialogSetting { defaultLanguage: string; languages: string[]; skill?: { - name: string; - manifestUrl: string; - }[]; + [skillName: string]: { + name: string; + manifestUrl: string; + msAppId: string; + endpointUrl: string; + }; + }; botId?: string; skillHostEndpoint?: string; [key: string]: any; diff --git a/Composer/packages/lib/shared/src/types/shell.ts b/Composer/packages/lib/shared/src/types/shell.ts index 9613ef7bbf..a077fc39c4 100644 --- a/Composer/packages/lib/shared/src/types/shell.ts +++ b/Composer/packages/lib/shared/src/types/shell.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. /* eslint-disable @typescript-eslint/no-explicit-any */ -import { DialogInfo, LuFile, LgFile, QnAFile, LuIntentSection, LgTemplate, DialogSchemaFile } from './indexers'; +import { DialogInfo, LuFile, LgFile, QnAFile, LuIntentSection, LgTemplate, DialogSchemaFile, Skill } from './indexers'; import { UserSettings } from './settings'; import { OBISchema } from './schema'; @@ -57,7 +57,7 @@ export interface ShellData { luFiles: LuFile[]; qnaFiles: QnAFile[]; userSettings: UserSettings; - skills: any[]; + skills: Skill[]; // TODO: remove schemas: BotSchemas; } @@ -95,11 +95,15 @@ export interface ShellApi { redo: () => void; commitChanges: () => void; updateUserSettings: (settings: AllPartial) => void; - addSkillDialog: () => Promise<{ manifestUrl: string } | null>; + addSkillDialog: () => Promise<{ manifestUrl: string; name: string } | null>; announce: (message: string) => void; displayManifestModal: (manifestId: string) => void; updateDialogSchema: (_: DialogSchemaFile) => Promise; createTrigger: (id: string, formData, url?: string) => void; + skillsInSettings: { + get: (path: string) => any; + set: (skillName: string, skillsData: Partial) => Promise; + }; } export interface Shell { diff --git a/Composer/packages/server/__tests__/mocks/samplebots/bot1/settings/appsettings.json b/Composer/packages/server/__tests__/mocks/samplebots/bot1/settings/appsettings.json index 4f742339ad..e468dd1746 100644 --- a/Composer/packages/server/__tests__/mocks/samplebots/bot1/settings/appsettings.json +++ b/Composer/packages/server/__tests__/mocks/samplebots/bot1/settings/appsettings.json @@ -4,11 +4,25 @@ "name": "", "authoringRegion": "westus", "defaultLanguage": "en-us", - "environment": "composer" + "environment": "composer", + "endpoint": "", + "authoringEndpoint": "", + "authoringKey": "", + "endpointKey": "" }, "qna": { "knowledgebaseid": "", - "endpointkey": "", - "hostname": "" - } + "hostname": "", + "endpointKey": "", + "subscriptionKey": "" + }, + "downsampling": { + "maxImbalanceRatio": 10, + "maxUtteranceAllowed": 15000 + }, + "defaultLanguage": "en-us", + "languages": [ + "en-us" + ], + "MicrosoftAppPassword": "" } \ No newline at end of file diff --git a/Composer/packages/server/src/models/bot/botProject.ts b/Composer/packages/server/src/models/bot/botProject.ts index dd31f6a43d..b9c5e2c79d 100644 --- a/Composer/packages/server/src/models/bot/botProject.ts +++ b/Composer/packages/server/src/models/bot/botProject.ts @@ -6,9 +6,19 @@ import fs from 'fs'; import axios from 'axios'; import { autofixReferInDialog } from '@bfc/indexers'; -import { getNewDesigner, FileInfo, Skill, Diagnostic, IBotProject, DialogSetting, FileExtensions } from '@bfc/shared'; +import { + getNewDesigner, + FileInfo, + Skill, + Diagnostic, + convertSkillsToDictionary, + IBotProject, + DialogSetting, + FileExtensions, +} from '@bfc/shared'; import { UserIdentity, pluginLoader } from '@bfc/extension'; import { FeedbackType, generate } from '@microsoft/bf-generate-library'; +import values from 'lodash/values'; import { Path } from '../../utility/path'; import { copyDir } from '../../utility/storage'; @@ -133,7 +143,8 @@ export class BotProject implements IBotProject { public init = async () => { this.diagnostics = []; this.settings = await this.getEnvSettings(false); - const { skillsParsed, diagnostics } = await extractSkillManifestUrl(this.settings?.skill || ([] as any)); + const skillsCollection = values(this.settings?.skill); + const { skillsParsed, diagnostics } = await extractSkillManifestUrl(skillsCollection || ([] as any)); this.skills = skillsParsed; this.diagnostics.push(...diagnostics); this.files = await this._getFiles(); @@ -200,15 +211,11 @@ export class BotProject implements IBotProject { }; // update skill in settings - public updateSkill = async (config: Skill[]) => { + public updateSkill = async (config: any[]) => { const settings = await this.getEnvSettings(false); const { skillsParsed } = await extractSkillManifestUrl(config); - - settings.skill = skillsParsed.map(({ manifestUrl, name }) => { - return { manifestUrl, name }; - }); - await this.settingManager.set(settings); - + const mapped = convertSkillsToDictionary(skillsParsed); + settings.skill = await this.settingManager.set(mapped); this.skills = skillsParsed; return skillsParsed; }; diff --git a/Composer/packages/ui-plugins/select-dialog/src/ComboBoxField.tsx b/Composer/packages/ui-plugins/select-dialog/src/ComboBoxField.tsx index 845cd622d1..8654a83836 100644 --- a/Composer/packages/ui-plugins/select-dialog/src/ComboBoxField.tsx +++ b/Composer/packages/ui-plugins/select-dialog/src/ComboBoxField.tsx @@ -17,19 +17,20 @@ interface ComboBoxFieldProps extends FieldProps { onChange: any; } -export const ComboBoxField: React.FC = ({ - comboboxTitle, - description, - id, - label, - options, - value = '', - required, - uiOptions, - onBlur, - onChange, - onFocus, -}) => { +export const ComboBoxField: React.FC = (props) => { + const { + comboboxTitle, + description, + id, + label, + options, + value = '', + required, + uiOptions, + onBlur, + onChange, + onFocus, + } = props; const onRenderOption: IRenderFunction = (option) => option ? (
diff --git a/Composer/packages/ui-plugins/select-skill-dialog/jest.config.js b/Composer/packages/ui-plugins/select-skill-dialog/jest.config.js index 05fd7d1da6..e1eea01308 100644 --- a/Composer/packages/ui-plugins/select-skill-dialog/jest.config.js +++ b/Composer/packages/ui-plugins/select-skill-dialog/jest.config.js @@ -5,5 +5,5 @@ const { createConfig } = require('@bfc/test-utils'); module.exports = createConfig('ui-plugin/select-skill-dialog', 'react', { - testPathIgnorePatterns: ['__tests__/constants.ts'], + testPathIgnorePatterns: ['__tests__/constants.ts', 'lib'], }); 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 b1952569fa..4cb5857c09 100644 --- a/Composer/packages/ui-plugins/select-skill-dialog/src/BeginSkillDialogField.tsx +++ b/Composer/packages/ui-plugins/select-skill-dialog/src/BeginSkillDialogField.tsx @@ -1,66 +1,122 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import React, { useMemo } from 'react'; +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 { ObjectField, SchemaField } from '@bfc/adaptive-form'; +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 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 } = shellApi; + const { displayManifestModal, skillsInSettings } = shellApi; + const [selectedSkill, setSelectedSkill] = useState(''); + const [oldEndpoint, loadEndpointForOldBots] = useState(''); - const manifest = useMemo(() => skills.find(({ manifestUrl }) => manifestUrl === value.id), [skills, value.id]); - const endpointOptions = useMemo(() => (manifest?.endpoints || []).map(({ name }) => name), [manifest]); + useEffect(() => { + const { skillEndpoint } = value; + const skill = skills.find(({ name }) => name === getSkillNameFromSetting(skillEndpoint)); - const handleIdChange = ({ key }) => { - if (!manifest || key !== manifest.manifestUrl) { - const { skillEndpoint, skillAppId, ...rest } = value; - onChange({ ...rest, id: key }); + if (skill) { + setSelectedSkill(skill.name); + } else { + const result = handleBackwardCompatibility(skills, value); + if (result) { + setSelectedSkill(result.name); + if (result.endpointName) { + loadEndpointForOldBots(result.endpointName); + } + } } - }; + }, []); - const handleEndpointChange = (skillEndpoint) => { - const { msAppId } = - (manifest?.endpoints || []).find(({ endpointUrl }) => endpointUrl === skillEndpoint) || ({} as any); - onChange({ ...value, skillEndpoint, ...(msAppId ? { skillAppId: msAppId } : {}) }); + const matchedSkill: Skill | undefined = useMemo(() => { + return skills.find(({ name }) => name === selectedSkill); + }, [skills, selectedSkill]); + + const endpointOptions = useMemo(() => { + return (matchedSkill?.endpoints || []).map(({ name }) => name); + }, [matchedSkill]); + + const handleEndpointChange = async (skillEndpoint) => { + if (matchedSkill) { + const { msAppId, endpointUrl } = + (matchedSkill.endpoints || []).find(({ name }) => name === skillEndpoint) || ({} as any); + const schemaUpdate: any = {}; + const settingsUpdate: any = {}; + if (endpointUrl) { + skillsInSettings.set(matchedSkill.name, { endpointUrl }); + schemaUpdate.skillEndpoint = referBySettings(matchedSkill?.name, 'endpointUrl'); + settingsUpdate.endpointUrl = endpointUrl; + } + if (msAppId) { + schemaUpdate.skillAppId = referBySettings(matchedSkill?.name, 'msAppId'); + settingsUpdate.msAppId = msAppId; + } + skillsInSettings.set(matchedSkill.name, { ...settingsUpdate }); + onChange({ + ...value, + ...schemaUpdate, + }); + } }; + useEffect(() => { + if (oldEndpoint) { + handleEndpointChange(oldEndpoint); + } + }, [oldEndpoint]); + const handleShowManifestClick = () => { - value.id && displayManifestModal(value.id); + matchedSkill && displayManifestModal(matchedSkill.manifestUrl); }; const skillEndpointUiSchema = uiOptions.properties?.skillEndpoint || {}; skillEndpointUiSchema.serializer = { get: (value) => { - const endpoint = (manifest?.endpoints || []).find(({ endpointUrl }) => endpointUrl === value); + const url: any = skillsInSettings.get(value); + const endpoint = (matchedSkill?.endpoints || []).find(({ endpointUrl }) => endpointUrl === url); return endpoint?.name; }, set: (value) => { - const endpoint = (manifest?.endpoints || []).find(({ name }) => name === value); + const endpoint = (matchedSkill?.endpoints || []).find(({ name }) => name === value); return endpoint?.endpointUrl; }, }; + const onSkillSelectionChange = (option: IComboBoxOption | null) => { + if (option) { + setSelectedSkill(option?.text); + } + }; + return ( - + diff --git a/Composer/packages/ui-plugins/select-skill-dialog/src/ComboBoxField.tsx b/Composer/packages/ui-plugins/select-skill-dialog/src/ComboBoxField.tsx index 54c480bf81..ee526c4dab 100644 --- a/Composer/packages/ui-plugins/select-skill-dialog/src/ComboBoxField.tsx +++ b/Composer/packages/ui-plugins/select-skill-dialog/src/ComboBoxField.tsx @@ -4,18 +4,21 @@ import React from 'react'; import { ComboBox, IComboBoxOption } from 'office-ui-fabric-react/lib/ComboBox'; import { FieldLabel } from '@bfc/adaptive-form'; -import { FieldProps } from '@bfc/extension-client'; import { Icon } from 'office-ui-fabric-react/lib/Icon'; import { ISelectableOption } from 'office-ui-fabric-react/lib/utilities/selectableOption'; import { IRenderFunction } from 'office-ui-fabric-react/lib/Utilities'; export const ADD_DIALOG = 'ADD_DIALOG'; -interface ComboBoxFieldProps extends FieldProps { +interface ComboBoxFieldProps { comboboxTitle: string | null; options: IComboBoxOption[]; onChange: any; required?: boolean; + description: string; + id: string; + label: string; + value: string; } export const ComboBoxField: React.FC = ({ @@ -26,10 +29,7 @@ export const ComboBoxField: React.FC = ({ options, value = '', required, - uiOptions, - onBlur, onChange, - onFocus, }) => { const onRenderOption: IRenderFunction = (option) => option ? ( @@ -45,15 +45,13 @@ export const ComboBoxField: React.FC = ({ return ( - + onBlur && onBlur(id, value)} - onFocus={() => onFocus && onFocus(id, value)} onItemClick={onChange} onRenderOption={onRenderOption} /> 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 035f1020a5..c7b2159041 100644 --- a/Composer/packages/ui-plugins/select-skill-dialog/src/SelectSkillDialogField.tsx +++ b/Composer/packages/ui-plugins/select-skill-dialog/src/SelectSkillDialogField.tsx @@ -1,25 +1,31 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +/** @jsx jsx */ +import { jsx } from '@emotion/core'; import React, { useState } from 'react'; import { IComboBoxOption, SelectableOptionMenuItemType } from 'office-ui-fabric-react/lib/ComboBox'; -import { FieldProps, useShellApi } from '@bfc/extension-client'; +import { useShellApi } from '@bfc/extension-client'; import formatMessage from 'format-message'; +import { schemaField } from '@bfc/adaptive-form'; import { ComboBoxField } from './ComboBoxField'; const ADD_DIALOG = 'ADD_DIALOG'; -export const SelectSkillDialog: React.FC = (props) => { +export const SelectSkillDialog: React.FC<{ + value: string; + onChange: (option: IComboBoxOption | null) => void; +}> = (props) => { const { value, onChange } = props; const { shellApi, skills = [] } = useShellApi(); const { addSkillDialog } = shellApi; const [comboboxTitle, setComboboxTitle] = useState(null); - const options: IComboBoxOption[] = skills.map(({ name, manifestUrl }) => ({ - key: manifestUrl, + const options: IComboBoxOption[] = skills.map(({ name }) => ({ + key: name, text: name, - isSelected: value === manifestUrl, + isSelected: value === name, })); options.push( @@ -39,9 +45,9 @@ export const SelectSkillDialog: React.FC = (props) => { if (option) { if (option.key === ADD_DIALOG) { setComboboxTitle(formatMessage('Add a new Skill Dialog')); - addSkillDialog().then((newSkill) => { - if (newSkill && newSkill?.manifestUrl) { - onChange({ key: newSkill.manifestUrl }); + addSkillDialog().then((skill) => { + if (skill?.manifestUrl && skill?.name) { + onChange({ key: skill?.manifestUrl, text: skill?.name }); } setComboboxTitle(null); }); @@ -53,5 +59,17 @@ export const SelectSkillDialog: React.FC = (props) => { } }; - return ; + return ( +
+ +
+ ); }; 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 3bb6ba0cf6..0787695d5e 100644 --- a/Composer/packages/ui-plugins/select-skill-dialog/src/SkillEndpointField.tsx +++ b/Composer/packages/ui-plugins/select-skill-dialog/src/SkillEndpointField.tsx @@ -26,8 +26,10 @@ export const SkillEndpointField: React.FC = (props) => { const deserializedValue = typeof uiOptions?.serializer?.get === 'function' ? uiOptions.serializer.get(value) : value; const handleChange = (newValue: any) => { - const serializedValue = - typeof uiOptions?.serializer?.set === 'function' ? uiOptions.serializer.set(newValue) : newValue; + const serializedValue = newValue; + if (typeof uiOptions?.serializer?.set === 'function') { + uiOptions.serializer.set(newValue); + } onChange(serializedValue); }; 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 index e5876528da..4208e87867 100644 --- 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 @@ -5,12 +5,29 @@ import React from 'react'; import { fireEvent, getAllByRole, render } from '@bfc/test-utils'; import { EditorExtension, JSONSchema7 } from '@bfc/extension-client'; -import { SDKKinds } from '@bfc/shared'; +import { SDKKinds, convertSkillsToDictionary, fetchFromSettings } from '@bfc/shared'; +import { act } from '@bfc/test-utils/lib/hooks'; import { BeginSkillDialogField } from '../BeginSkillDialogField'; import pluginConfig from '..'; -import { schema, skills } from './constants'; +import { schema } from './constants'; + +const skills: any = [ + { + 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://' }); @@ -26,13 +43,20 @@ const renderBeginSkillDialog = ({ value = {}, onChange = jest.fn() } = {}) => { name: 'select.skillDialog', }; - const shell: any = { - addSkillDialog, - }; - const shellData: any = { skills, }; + const setting: any = { + skill: convertSkillsToDictionary(skills), + }; + + const shell: any = { + addSkillDialog, + skillsInSettings: { + get: (path: string) => fetchFromSettings(path, setting), + set: () => {}, + }, + }; return render( @@ -48,19 +72,35 @@ describe('Begin Skill Dialog', () => { it('should add a new skill', async () => { const onChange = jest.fn(); - const value = { id: 'https://yuesuemailskill0207-gjvga67.azurewebsites.net/manifest/manifest-1.0.json' }; + const value = { skillEndpoint: `=settings.skill['yuesuemailskill0207'].endpointUrl` }; const { baseElement, findByRole } = renderBeginSkillDialog({ value, onChange }); const listbox = await findByRole('listbox'); fireEvent.click(listbox); - const endpoints = await getAllByRole(baseElement, 'option'); - fireEvent.click(endpoints[endpoints.length - 1]); + const endpoints = getAllByRole(baseElement, 'option'); + act(() => { + fireEvent.click(endpoints[endpoints.length - 1]); + }); expect(onChange).toHaveBeenCalledWith({ - id: 'https://yuesuemailskill0207-gjvga67.azurewebsites.net/manifest/manifest-1.0.json', - skillAppId: '79432da8-0f7e-4a16-8c23-ddbba30ae85d', + 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 87fa0b6714..7b28cc1adc 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 @@ -4,8 +4,9 @@ // @ts-nocheck import React from 'react'; -import { fireEvent, getAllByRole, render, act } from '@bfc/test-utils'; +import { fireEvent, getAllByRole, render } from '@bfc/test-utils'; import { EditorExtension } from '@bfc/extension-client'; +import { fetchFromSettings, convertSkillsToDictionary } from '@bfc/shared'; import { SelectSkillDialog } from '../SelectSkillDialogField'; @@ -48,6 +49,13 @@ const renderSelectSkillDialog = ({ addSkillDialog, onChange } = {}) => { const shell = { addSkillDialog, + skillsInSettings: { + get: (path: string) => + fetchFromSettings(path, { + skill: convertSkillsToDictionary(skills), + }), + set: () => {}, + }, }; const shellData = { @@ -66,7 +74,7 @@ describe('Select Skill Dialog', () => { const addSkillDialog = jest.fn().mockImplementation(() => { return { then: (cb) => { - cb({ manifestUrl: 'https://' }); + cb({ manifestUrl: 'https://', name: 'test-skill' }); }, }; }); @@ -80,7 +88,7 @@ describe('Select Skill Dialog', () => { fireEvent.click(dialogs[dialogs.length - 1]); expect(addSkillDialog).toHaveBeenCalled(); - expect(onChange).toHaveBeenCalledWith({ key: 'https://' }); + expect(onChange).toHaveBeenCalledWith({ key: 'https://', text: 'test-skill' }); }); it('should select skill', async () => { @@ -96,7 +104,7 @@ describe('Select Skill Dialog', () => { expect(onChange).toHaveBeenCalledWith({ index: 0, isSelected: false, - key: 'https://yuesuemailskill0207-gjvga67.azurewebsites.net/manifest/manifest-1.0.json', + key: 'Email Skill', text: 'Email Skill', }); }); 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 5f9c6293e4..6dcd1ca222 100644 --- a/Composer/packages/ui-plugins/select-skill-dialog/src/index.ts +++ b/Composer/packages/ui-plugins/select-skill-dialog/src/index.ts @@ -2,10 +2,8 @@ // Licensed under the MIT License. import { PluginConfig } from '@bfc/extension-client'; -import formatMessage from 'format-message'; import { SDKKinds } from '@bfc/shared'; -import { SelectSkillDialog } from './SelectSkillDialogField'; import { BeginSkillDialogField } from './BeginSkillDialogField'; const config: PluginConfig = { @@ -15,13 +13,6 @@ const config: PluginConfig = { order: ['skillAppId', '*', 'resultProperty', 'disabled', 'activityProcessed'], hidden: ['botId', 'skillEndpoint', 'skillAppId', 'skillHostEndpoint'], field: BeginSkillDialogField, - properties: { - id: { - description: () => formatMessage('Name of skill dialog to call'), - label: () => formatMessage('Skill Dialog Name'), - field: SelectSkillDialog, - }, - }, }, }, }, diff --git a/Composer/yarn.lock b/Composer/yarn.lock index 73fca233f7..38ffa3e36d 100644 --- a/Composer/yarn.lock +++ b/Composer/yarn.lock @@ -18283,10 +18283,10 @@ tr46@^2.0.2: dependencies: punycode "^2.1.1" -tree-kill@^1.2.1: - version "1.2.1" - resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.1.tgz#5398f374e2f292b9dcc7b2e71e30a5c3bb6c743a" - integrity sha512-4hjqbObwlh2dLyW4tcz0Ymw0ggoaVDMveUB9w8kFSQScdRLo0gxO9J7WFcUBo+W3C1TLdFIEwNOWebgZZ0RH9Q== +tree-kill@^1.1.0: + version "1.2.2" + resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" + integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== treeify@^1.1.0: version "1.1.0"