@@ -693,7 +787,7 @@ const DesignPage: React.FC
- {breadcrumbItems} + {!isRemoteSkill ? breadcrumbItems : null} {dialogJsonVisible ? ( { + if (!dialogId) return; + openNewTriggerModal(projectId, dialogId); + }} onBlur={() => onBlurFlowEditor()} onFocus={() => onFocusFlowEditor()} /> @@ -726,7 +824,11 @@ const DesignPage: React.FC - + {isRemoteSkill && skillManifestFile ? ( + + ) : ( + + )}
@@ -736,12 +838,17 @@ const DesignPage: React.FC }> {showCreateDialogModal && ( - + createDialogCancel(projectId)} - onSubmit={handleCreateDialogSubmit} + projectId={dialogCreateSource} + onDismiss={() => { + createDialogCancel(dialogCreateSource); + setDialogModalInfo(undefined); + }} + onSubmit={(dialogName, dialogData) => { + handleCreateDialogSubmit(dialogModalInfo ?? skillId ?? projectId, dialogName, dialogData); + }} /> )} @@ -757,24 +864,28 @@ const DesignPage: React.FC )} - {exportSkillModalVisible && ( + {exportSkillModalInfo && ( setExportSkillModalVisible(false)} - onSubmit={() => setExportSkillModalVisible(false)} + isOpen + projectId={exportSkillModalInfo} + onDismiss={() => setExportSkillModalInfo(undefined)} + onSubmit={() => setExportSkillModalInfo(undefined)} /> )} - {triggerModalVisible && ( + {triggerModalInfo && ( { + createTrigger(triggerModalInfo.projectId, dialogId, formData); + }} /> )} - + {dialogId && ( + + )} {displaySkillManifest && ( dismissManifestModal(projectId)} /> )} + {brokenSkillInfo && ( + { + setBrokenSkillInfo(undefined); + }} + onNext={(option) => { + const skillIdToRemove = brokenSkillInfo.skillId; + if (!skillIdToRemove) return; + + if (option === RepairSkillModalOptionKeys.repairSkill) { + setCreationFlowType('Skill'); + setCreationFlowStatus(CreationFlowStatus.OPEN); + setBrokenSkillRepairCallback(() => { + removeSkillFromBotProject(skillIdToRemove); + }); + } else if (option === RepairSkillModalOptionKeys.removeSkill) { + removeSkillFromBotProject(skillIdToRemove); + } + setBrokenSkillInfo(undefined); + }} + > + )} + { + if (brokenSkillRepairCallback) { + brokenSkillRepairCallback(); + } + }} + > ); diff --git a/Composer/packages/client/src/pages/design/ManifestEditor.tsx b/Composer/packages/client/src/pages/design/ManifestEditor.tsx new file mode 100644 index 0000000000..94354ac6bc --- /dev/null +++ b/Composer/packages/client/src/pages/design/ManifestEditor.tsx @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx, css } from '@emotion/core'; +import formatMessage from 'format-message'; +import ErrorBoundary from 'react-error-boundary'; +import React from 'react'; +import { LoadingTimeout } from '@bfc/adaptive-form/lib/components/LoadingTimeout'; +import { FieldLabel } from '@bfc/adaptive-form/lib/components/FieldLabel'; +import ErrorInfo from '@bfc/adaptive-form/lib/components/ErrorInfo'; +import { FontSizes, NeutralColors } from '@uifabric/fluent-theme'; +import { FontWeights } from '@uifabric/styling'; +import { DetailsList, DetailsListLayoutMode, SelectionMode } from 'office-ui-fabric-react/lib/DetailsList'; +import { Link } from 'office-ui-fabric-react/lib/Link'; +import get from 'lodash/get'; + +import { SkillInfo } from '../../recoilModel'; + +import { formEditor } from './styles'; +import { PropertyEditorHeader } from './PropertyEditorHeader'; + +const styles = { + errorLoading: css` + padding: 18px; + `, + + banner: css` + border-bottom: 1px solid #c8c6c4; + padding: 0 18px; + margin-bottom: 0px; + `, + + title: css` + font-size: ${FontSizes.size20}; + font-weight: ${FontWeights.semibold}; + margin: 5px 0px; + `, + + subtitle: css` + height: 15px; + line-height: 15px; + font-size: ${FontSizes.size12}; + color: ${NeutralColors.gray130}; + font-weight: ${FontWeights.semibold}; + margin: 5px 0; + `, + + description: css` + margin-top: 0; + margin-bottom: 10px; + white-space: pre-line; + font-size: ${FontSizes.size12}; + `, + + helplink: css` + margin-top: 15px; + font-size: ${FontSizes.size12}; + `, + + body: css` + padding: 0 18px; + font-size: ${FontSizes.size12}; + .ms-DetailsHeader { + padding-top: 0; + } + `, + + section: css` + padding-top: 20px; + `, +}; + +const helpLink = + 'https://docs.microsoft.com/en-us/azure/bot-service/skills-write-manifest-2-1?view=azure-bot-service-4.0'; + +export interface ManifestEditorProps { + formData: SkillInfo; +} + +export const ManifestEditor: React.FC = (props) => { + const { formData } = props; + const { manifest } = formData; + + if (!manifest) { + return ( + +
{formatMessage('Manifest could not be loaded')}
+
+ ); + } + + const activities = get(manifest, 'activities', {}); + const activitiesToDisplay: { name: string; description: string }[] = []; + + for (const key in activities) { + activitiesToDisplay.push({ + name: get(activities, [key, 'name'], key), + description: get(activities, [key, 'description'], ''), + }); + } + + return ( +
+ + +
+
+ +

+ + {formData.location} + +

+
+
+ + { + return ( + + {item.endpointUrl} + + ); + }, + }, + ]} + items={get(manifest, 'endpoints', [])} + layoutMode={DetailsListLayoutMode.justified} + selectionMode={SelectionMode.none} + /> +
+
+ + +
+
+
+
+ ); +}; diff --git a/Composer/packages/client/src/pages/design/PropertyEditor.tsx b/Composer/packages/client/src/pages/design/PropertyEditor.tsx index d523df8ca7..cac6cfbeac 100644 --- a/Composer/packages/client/src/pages/design/PropertyEditor.tsx +++ b/Composer/packages/client/src/pages/design/PropertyEditor.tsx @@ -11,9 +11,20 @@ import isEqual from 'lodash/isEqual'; import debounce from 'lodash/debounce'; import get from 'lodash/get'; import { MicrosoftAdaptiveDialog } from '@bfc/shared'; +import { useRecoilValue } from 'recoil'; +import { css } from '@emotion/core'; +import { botDisplayNameState, projectMetaDataState } from '../../recoilModel'; + +import { PropertyEditorHeader } from './PropertyEditorHeader'; import { formEditor } from './styles'; +const propertyEditorWrapperStyle = css` + display: flex; + flex-direction: column; + height: 100%; +`; + function resolveBaseSchema(schema: JSONSchema7, $kind: string): JSONSchema7 | undefined { const defSchema = schema.definitions?.[$kind]; if (defSchema && typeof defSchema === 'object') { @@ -26,8 +37,10 @@ function resolveBaseSchema(schema: JSONSchema7, $kind: string): JSONSchema7 | un const PropertyEditor: React.FC = () => { const { shellApi, ...shellData } = useShellApi(); - const { currentDialog, focusPath, focusedSteps, focusedTab, schemas } = shellData; + const { currentDialog, focusPath, focusedSteps, focusedTab, schemas, projectId } = shellData; const { onFocusSteps } = shellApi; + const botName = useRecoilValue(botDisplayNameState(projectId)); + const projectData = useRecoilValue(projectMetaDataState(projectId)); const dialogData = useMemo(() => { if (currentDialog?.content) { @@ -120,16 +133,19 @@ const PropertyEditor: React.FC = () => { }; return ( -
- +
+ {!localData || !$schema ? : null} +
+ +
); }; diff --git a/Composer/packages/client/src/pages/design/PropertyEditorHeader.tsx b/Composer/packages/client/src/pages/design/PropertyEditorHeader.tsx new file mode 100644 index 0000000000..a4dc8eb40a --- /dev/null +++ b/Composer/packages/client/src/pages/design/PropertyEditorHeader.tsx @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { useMemo } from 'react'; +import { css } from '@emotion/core'; +import { FontSizes, NeutralColors } from '@uifabric/fluent-theme'; +import formatMessage from 'format-message'; +import { Link } from 'office-ui-fabric-react/lib/Link'; +import { FontWeights } from '@uifabric/styling'; + +const styles = { + errorLoading: css` + padding: 18px; + `, + + propertyEditorHeaderStyle: css` + border-top: 1px solid ${NeutralColors.gray10}; + padding: 0 18px; + margin-bottom: 0px; + border-bottom: 1px solid ${NeutralColors.gray60}; + `, + + title: css` + font-weight: ${FontWeights.semibold}; + margin: 5px 0px; + `, + + subtitle: css` + display: block; + height: 15px; + line-height: 15px; + font-size: ${FontSizes.size12}; + color: ${NeutralColors.gray130}; + font-weight: ${FontWeights.semibold}; + `, + + description: css` + margin-top: 0; + margin-bottom: 10px; + white-space: pre-line; + font-size: ${FontSizes.size12}; + margin-bottom: 10px; + `, + + helplink: css` + font-size: ${FontSizes.size12}; + `, +}; + +export type PropertyEditorHeaderProps = { + projectData: { isRootBot: boolean; isRemote: boolean }; + botName: string; + helpLink?: string; +}; + +const PropertyEditorHeader: React.FC = (props) => { + const { + projectData: { isRootBot, isRemote }, + botName, + helpLink, + } = props; + + const botTypeText = useMemo(() => { + if (isRootBot) { + return formatMessage('Root bot.'); + } else { + if (isRemote) { + return formatMessage('Remote Skill.'); + } + return formatMessage('Local Skill.'); + } + }, [isRemote, isRootBot]); + + const botDescriptionText = useMemo(() => { + if (isRootBot) { + return formatMessage('Root bot of your project that greets users, and can call skills.'); + } else { + if (isRemote) { + return formatMessage('This configures a data driven dialog via a collection of events and actions.'); + } + return formatMessage('A skill bot that can be called from a host bot.'); + } + }, [isRemote, isRootBot]); + + return ( +
+

+ {botName} {isRemote ? '(Remote)' : ''} + {botTypeText} +

+

{botDescriptionText}

+

+ {isRemote ? ( + + {formatMessage('Learn more')} + + ) : null} +

+
+ ); +}; + +export { PropertyEditorHeader }; diff --git a/Composer/packages/client/src/pages/design/VisualEditor.tsx b/Composer/packages/client/src/pages/design/VisualEditor.tsx index 859d1c2a96..6ab43a061e 100644 --- a/Composer/packages/client/src/pages/design/VisualEditor.tsx +++ b/Composer/packages/client/src/pages/design/VisualEditor.tsx @@ -27,11 +27,16 @@ const addIconProps = { styles: { root: { fontSize: '12px' } }, }; -function onRenderBlankVisual(isTriggerEmpty, onClickAddTrigger) { +function onRenderBlankVisual(isTriggerEmpty, onClickAddTrigger, isRemoteSkill) { return (
- {isTriggerEmpty ? ( + {isRemoteSkill ? ( + + {formatMessage('bot + {formatMessage('Remote skill')} + + ) : isTriggerEmpty ? ( {formatMessage(`This dialog has no trigger yet.`)} void; onFocus?: (event: React.FocusEvent) => void; onBlur?: (event: React.FocusEvent) => void; + isRemoteSkill?: boolean; } const VisualEditor: React.FC = (props) => { const { ...shellData } = useShellApi(); const { projectId, currentDialog } = shellData; - const { openNewTriggerModal, onFocus, onBlur } = props; + const { openNewTriggerModal, onFocus, onBlur, isRemoteSkill } = props; const [triggerButtonVisible, setTriggerButtonVisibility] = useState(false); const { onboardingAddCoachMarkRef } = useRecoilValue(dispatcherState); const dialogs = useRecoilValue(validateDialogsSelectorFamily(projectId)); @@ -75,6 +81,8 @@ const VisualEditor: React.FC = (props) => { const formConfig = useFormConfig(); const overridedSDKSchema = useMemo(() => { + if (!dialogId) return {}; + const sdkSchema = cloneDeep(schemas.sdk?.content ?? {}); const sdkDefinitions = sdkSchema.definitions; @@ -90,7 +98,7 @@ const VisualEditor: React.FC = (props) => { useEffect(() => { const dialog = dialogs.find((d) => d.id === dialogId); - const visible = get(dialog, 'triggers', []).length === 0; + const visible = dialog ? get(dialog, 'triggers', []).length === 0 : false; setTriggerButtonVisibility(visible); }, [dialogs, dialogId]); @@ -102,14 +110,16 @@ const VisualEditor: React.FC = (props) => { css={visualEditor(triggerButtonVisible || !selected)} data-testid="VisualEditor" > - + {!isRemoteSkill ? ( + + ) : null}
- {!selected && onRenderBlankVisual(triggerButtonVisible, openNewTriggerModal)} + {!selected && onRenderBlankVisual(triggerButtonVisible, openNewTriggerModal, isRemoteSkill)} ); }; diff --git a/Composer/packages/client/src/pages/design/__tests__/PropertyEditorHeader.test.tsx b/Composer/packages/client/src/pages/design/__tests__/PropertyEditorHeader.test.tsx new file mode 100644 index 0000000000..11133b8265 --- /dev/null +++ b/Composer/packages/client/src/pages/design/__tests__/PropertyEditorHeader.test.tsx @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import React from 'react'; +import { render } from '@botframework-composer/test-utils'; + +import { PropertyEditorHeader } from '../PropertyEditorHeader'; + +describe('', () => { + it('renders property editor header without help link if not a remote bot', () => { + const metadata = { + isRootBot: true, + isRemote: false, + }; + + const { queryAllByText } = render(); + expect(queryAllByText('Learn more')).toEqual([]); + }); + + it('renders property editor header for a root bot', () => { + const metadata = { + isRootBot: true, + isRemote: false, + }; + const { findByText } = render(); + expect(findByText('Root bot')); + expect(findByText('Root bot of your project that greets users, and can call skills.')); + }); + + it('renders property editor header for a local skill', () => { + const metadata = { + isRootBot: false, + isRemote: false, + }; + const { findByText } = render(); + expect(findByText('Local Skill')); + }); + + it('renders property editor header for a remote skill', () => { + const metadata = { + isRootBot: false, + isRemote: true, + }; + const helpLink = 'https://botframework-skill/manifest'; + const { findByText } = render( + + ); + expect(findByText('Remote Skill')); + expect(findByText('Learn more')); + }); +}); diff --git a/Composer/packages/client/src/pages/design/creationModal.tsx b/Composer/packages/client/src/pages/design/creationModal.tsx new file mode 100644 index 0000000000..7113ca5a44 --- /dev/null +++ b/Composer/packages/client/src/pages/design/creationModal.tsx @@ -0,0 +1,162 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import Path from 'path'; + +import React, { Fragment, useEffect, useRef, useState } from 'react'; +import { useRecoilValue } from 'recoil'; + +import { CreateOptions } from '../../components/CreationFlow/CreateOptions'; +import { OpenProject } from '../../components/CreationFlow/OpenProject'; +import DefineConversation from '../../components/CreationFlow/DefineConversation'; +import { + dispatcherState, + creationFlowStatusState, + storagesState, + focusedStorageFolderState, + creationFlowTypeState, + userSettingsState, + filteredTemplatesSelector, +} from '../../recoilModel'; +import { CreationFlowStatus } from '../../constants'; + +interface CreationModalProps { + onSubmit: () => void; + onDismiss?: () => void; +} + +export const CreationModal: React.FC = (props) => { + const { + fetchStorages, + fetchTemplates, + fetchFolderItemsByPath, + setCreationFlowStatus, + createFolder, + updateCurrentPathForStorage, + updateFolder, + saveTemplateId, + createNewBot, + openProject, + addNewSkillToBotProject, + addExistingSkillToBotProject, + } = useRecoilValue(dispatcherState); + + const templateProjects = useRecoilValue(filteredTemplatesSelector); + const creationFlowStatus = useRecoilValue(creationFlowStatusState); + const creationFlowType = useRecoilValue(creationFlowTypeState); + const focusedStorageFolder = useRecoilValue(focusedStorageFolderState); + const { appLocale } = useRecoilValue(userSettingsState); + const storages = useRecoilValue(storagesState); + const currentStorageIndex = useRef(0); + const storage = storages[currentStorageIndex.current]; + const currentStorageId = storage ? storage.id : 'default'; + const [templateId, setTemplateId] = useState(''); + + useEffect(() => { + if (storages && storages.length) { + const storageId = storage.id; + const path = storage.path; + const formattedPath = Path.normalize(path); + fetchFolderItemsByPath(storageId, formattedPath); + } + }, [storages]); + + const fetchResources = async () => { + await fetchStorages(); + fetchTemplates(); + }; + + useEffect(() => { + fetchResources(); + }, []); + + const updateCurrentPath = async (newPath, storageId) => { + if (!storageId) { + storageId = currentStorageId; + } + if (newPath) { + const formattedPath = Path.normalize(newPath); + updateCurrentPathForStorage(formattedPath, storageId); + } + }; + + const handleCreateNew = async (formData, templateId: string) => { + const newBotData = { + templateId: templateId || '', + name: formData.name, + description: formData.description, + location: formData.location, + schemaUrl: formData.schemaUrl, + appLocale, + }; + if (creationFlowType === 'Skill') { + addNewSkillToBotProject(newBotData); + } else { + createNewBot(newBotData); + } + }; + + const handleDismiss = () => { + setCreationFlowStatus(CreationFlowStatus.CLOSE); + props.onDismiss && props.onDismiss(); + }; + + const handleDefineConversationSubmit = async (formData, templateId: string) => { + // If selected template is vaCore then route to VA Customization modal + if (templateId === 'va-core') { + return; + } + + handleSubmit(formData, templateId); + }; + + const handleSubmit = async (formData, templateId: string) => { + handleDismiss(); + saveTemplateId(templateId); + await handleCreateNew(formData, templateId); + }; + + const handleCreateNext = async (templateId: string) => { + setCreationFlowStatus(CreationFlowStatus.NEW_FROM_TEMPLATE); + setTemplateId(templateId); + }; + + const openBot = async (botFolder) => { + handleDismiss(); + if (creationFlowType === 'Skill') { + addExistingSkillToBotProject(botFolder); + } else { + openProject(botFolder); + } + }; + + return ( + + {creationFlowStatus === CreationFlowStatus.NEW_FROM_TEMPLATE ? ( + + ) : null} + + {creationFlowStatus === CreationFlowStatus.NEW ? ( + + ) : null} + + {creationFlowStatus === CreationFlowStatus.OPEN ? ( + + ) : null} + + ); +}; + +export default CreationModal; diff --git a/Composer/packages/client/src/pages/home/Home.tsx b/Composer/packages/client/src/pages/home/Home.tsx index d524cddfa7..dd71d682d0 100644 --- a/Composer/packages/client/src/pages/home/Home.tsx +++ b/Composer/packages/client/src/pages/home/Home.tsx @@ -60,9 +60,13 @@ const Home: React.FC = () => { const botName = useRecoilValue(botDisplayNameState(projectId)); const recentProjects = useRecoilValue(recentProjectsState); const templateId = useRecoilValue(templateIdState); - const { openProject, setCreationFlowStatus, onboardingAddCoachMarkRef, saveTemplateId } = useRecoilValue( - dispatcherState - ); + const { + openProject, + setCreationFlowStatus, + onboardingAddCoachMarkRef, + saveTemplateId, + setCreationFlowType, + } = useRecoilValue(dispatcherState); const filteredTemplates = useRecoilValue(filteredTemplatesSelector); const onItemChosen = async (item) => { @@ -90,6 +94,7 @@ const Home: React.FC = () => { iconName: 'CirclePlus', }, onClick: () => { + setCreationFlowType('Bot'); setCreationFlowStatus(CreationFlowStatus.NEW); navigate(`projects/create`); }, diff --git a/Composer/packages/client/src/recoilModel/atoms/appState.ts b/Composer/packages/client/src/recoilModel/atoms/appState.ts index 6760acd45a..39162f7019 100644 --- a/Composer/packages/client/src/recoilModel/atoms/appState.ts +++ b/Composer/packages/client/src/recoilModel/atoms/appState.ts @@ -15,7 +15,7 @@ import { } from '../../recoilModel/types'; import { getUserSettings } from '../utils'; import onboardingStorage from '../../utils/onboardingStorage'; -import { CreationFlowStatus, AppUpdaterStatus } from '../../constants'; +import { CreationFlowStatus, AppUpdaterStatus, CreationFlowType } from '../../constants'; export type BotProject = { readonly id: string; @@ -137,6 +137,11 @@ export const creationFlowStatusState = atom({ default: CreationFlowStatus.CLOSE, }); +export const creationFlowTypeState = atom({ + key: getFullyQualifiedKey('creationFlowTpye'), + default: 'Bot', +}); + export const logEntryListState = atom({ key: getFullyQualifiedKey('logEntryList'), default: [], @@ -231,3 +236,8 @@ export const pageElementState = atom<{ [page in PageMode]?: { [key: string]: any qna: {}, }, }); + +export const showCreateDialogModalState = atom({ + key: getFullyQualifiedKey('showCreateDialogModal'), + default: false, +}); diff --git a/Composer/packages/client/src/recoilModel/atoms/botState.ts b/Composer/packages/client/src/recoilModel/atoms/botState.ts index 3036904769..355ccc7c37 100644 --- a/Composer/packages/client/src/recoilModel/atoms/botState.ts +++ b/Composer/packages/client/src/recoilModel/atoms/botState.ts @@ -174,13 +174,6 @@ export const skillManifestsState = atomFamily({ }, }); -export const showCreateDialogModalState = atomFamily({ - key: getFullyQualifiedKey('showCreateDialogModal'), - default: (id) => { - return false; - }, -}); - export const showAddSkillDialogModalState = atomFamily({ key: getFullyQualifiedKey('showAddSkillDialogModal'), default: false, diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/dialog.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/dialog.test.tsx index 2cff90f10f..ebb5b3a544 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/dialog.test.tsx +++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/dialog.test.tsx @@ -111,7 +111,7 @@ describe('dialog dispatcher', () => { const lgFiles = useRecoilValue(lgFilesState(projectId)); const actionsSeed = useRecoilValue(actionsSeedState(projectId)); const onCreateDialogComplete = useRecoilValue(onCreateDialogCompleteState(projectId)); - const showCreateDialogModal = useRecoilValue(showCreateDialogModalState(projectId)); + const showCreateDialogModal = useRecoilValue(showCreateDialogModalState); const qnaFiles = useRecoilValue(qnaFilesState(projectId)); const currentDispatcher = useRecoilValue(dispatcherState); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/project.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/project.test.tsx index 7c9b48a243..8e1e6df417 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/project.test.tsx +++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/project.test.tsx @@ -358,19 +358,33 @@ describe('Project dispatcher', () => { expect(renderedComponent.current.botStates.echoSkill2).toBeDefined(); expect(renderedComponent.current.botStates.echoSkill2.botDisplayName).toBe('Echo-Skill-2'); + const skillId = '1234.1123213'; + const mockImplementation = (httpClient.get as jest.Mock).mockImplementation((url: string) => { + if (endsWith(url, '/projects/generateProjectId')) { + return { + data: skillId, + }; + } else { + return { + data: {}, + }; + } + }); await act(async () => { await dispatcher.addRemoteSkillToBotProject('https://test.net/api/manifest/test', 'remote'); }); - expect(navigateTo).toHaveBeenLastCalledWith(`/bot/${projectId}/dialogs/emptybot-1`); + expect(navigateTo).toHaveBeenLastCalledWith(`/bot/${projectId}/skill/${skillId}`); + mockImplementation.mockClear(); }); it('should be able to add a remote skill to Botproject', async () => { + const skillId = '1234.1123213'; const mockImplementation = (httpClient.get as jest.Mock).mockImplementation((url: string) => { if (endsWith(url, '/projects/generateProjectId')) { return { - data: '1234.1123213', + data: skillId, }; } else { return { @@ -397,7 +411,7 @@ describe('Project dispatcher', () => { expect(renderedComponent.current.botStates.oneNoteSync.location).toBe( 'https://test-dev.azurewebsites.net/manifests/onenote-2-1-preview-1-manifest.json' ); - expect(navigateTo).toHaveBeenLastCalledWith(`/bot/${projectId}/dialogs/emptybot-1`); + expect(navigateTo).toHaveBeenLastCalledWith(`/bot/${projectId}/skill/${skillId}`); mockImplementation.mockClear(); }); @@ -438,6 +452,7 @@ describe('Project dispatcher', () => { }); it('should be able to add a new skill to Botproject', async () => { + const skillId = projectId; await act(async () => { (httpClient.put as jest.Mock).mockResolvedValueOnce({ data: mockProjectResponse, @@ -458,13 +473,12 @@ describe('Project dispatcher', () => { location: '/Users/tester/Desktop/samples', templateId: 'InterruptionSample', locale: 'us-en', - qnaKbUrls: [], }); }); expect(renderedComponent.current.botStates.newBot).toBeDefined(); expect(renderedComponent.current.botStates.newBot.botDisplayName).toBe('new-bot'); - expect(navigateTo).toHaveBeenLastCalledWith(`/bot/${projectId}/dialogs/emptybot-1`); + expect(navigateTo).toHaveBeenLastCalledWith(`/bot/${projectId}/skill/${skillId}/dialogs/emptybot-1`); }); it('should be able to open a project and its skills in Bot project file', async (done) => { diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/trigger.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/trigger.test.tsx new file mode 100644 index 0000000000..a6257c6a37 --- /dev/null +++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/trigger.test.tsx @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useRecoilValue } from 'recoil'; +import { act } from '@botframework-composer/test-utils/lib/hooks'; + +import { dialogsDispatcher } from '../dialogs'; +import { triggerDispatcher } from '../trigger'; +import { lgDispatcher } from '../lg'; +import { luDispatcher } from '../lu'; +import { navigationDispatcher } from '../navigation'; +import { renderRecoilHook } from '../../../../__tests__/testUtils'; +import { + lgFilesState, + luFilesState, + schemasState, + dialogSchemasState, + actionsSeedState, + qnaFilesState, +} from '../../atoms'; +import { dialogsSelectorFamily } from '../../selectors'; +import { dispatcherState } from '../../../recoilModel/DispatcherWrapper'; +import { Dispatcher } from '..'; + +const projectId = '42345.23432'; + +const QnATriggerData1 = { + $kind: 'Microsoft.OnQnAMatch', + errors: { $kind: '', intent: '', event: '', triggerPhrases: '', regEx: '', activity: '' }, + event: '', + intent: '', + regEx: '', + triggerPhrases: '', +}; + +const intentTriggerData1 = { + $kind: 'Microsoft.OnIntent', + errors: { $kind: '', intent: '', event: '', triggerPhrases: '', regEx: '', activity: '' }, + event: '', + intent: '', + regEx: '', + triggerPhrases: '', +}; + +const chooseIntentTriggerData1 = { + $kind: 'Microsoft.OnChooseIntent', + errors: { $kind: '', intent: '', event: '', triggerPhrases: '', regEx: '', activity: '' }, + event: '', + intent: '', + regEx: '', + triggerPhrases: '', +}; + +jest.mock('@bfc/indexers', () => { + return { + dialogIndexer: { + parse: (id, content) => ({ + id, + content, + }), + }, + validateDialog: () => [], + autofixReferInDialog: (_, content) => content, + lgIndexer: { + parse: (content, id) => ({ + id, + content, + }), + }, + luIndexer: { + parse: (content, id) => ({ + id, + content, + }), + }, + qnaIndexer: { + parse: (id, content) => ({ + id, + content, + }), + }, + lgUtil: { + parse: (id, content) => ({ + id, + content, + }), + }, + luUtil: { + parse: (id, content) => ({ + id, + content, + }), + }, + qnaUtil: { + parse: (id, content) => ({ + id, + content, + }), + }, + }; +}); + +describe('trigger dispatcher', () => { + let renderedComponent, dispatcher: Dispatcher; + beforeEach(() => { + const useRecoilTestHook = () => { + const dialogs = useRecoilValue(dialogsSelectorFamily(projectId)); + const dialogSchemas = useRecoilValue(dialogSchemasState(projectId)); + const luFiles = useRecoilValue(luFilesState(projectId)); + const lgFiles = useRecoilValue(lgFilesState(projectId)); + const actionsSeed = useRecoilValue(actionsSeedState(projectId)); + const qnaFiles = useRecoilValue(qnaFilesState(projectId)); + const currentDispatcher = useRecoilValue(dispatcherState); + + return { + dialogs, + dialogSchemas, + luFiles, + lgFiles, + currentDispatcher, + actionsSeed, + qnaFiles, + }; + }; + + const { result } = renderRecoilHook(useRecoilTestHook, { + states: [ + { + recoilState: dialogsSelectorFamily(projectId), + initialValue: [ + { id: '1', content: {} }, + { id: '2', content: {} }, + ], + }, + { recoilState: dialogSchemasState(projectId), initialValue: [{ id: '1' }, { id: '2' }] }, + { + recoilState: lgFilesState(projectId), + initialValue: [ + { id: '1.en-us', content: '' }, + { id: '2.en-us', content: '' }, + ], + }, + { + recoilState: luFilesState(projectId), + initialValue: [ + { id: '1.en-us', content: '' }, + { id: '2.en-us', content: '' }, + ], + }, + { + recoilState: qnaFilesState(projectId), + initialValue: [ + { id: '1.en-us', content: '' }, + { id: '2.en-us', content: '' }, + ], + }, + { recoilState: schemasState(projectId), initialValue: { sdk: { content: {} } } }, + ], + dispatcher: { + recoilState: dispatcherState, + initialValue: { + dialogsDispatcher, + triggerDispatcher, + lgDispatcher, + luDispatcher, + navigationDispatcher, + }, + }, + }); + renderedComponent = result; + dispatcher = renderedComponent.current.currentDispatcher; + }); + + it('create a qna intent trigger', async () => { + const dialogId = '1'; + await act(async () => { + await dispatcher.createTrigger(projectId, dialogId, QnATriggerData1); + }); + const updatedDialog = renderedComponent.current.dialogs.find(({ id }) => id === dialogId); + expect(updatedDialog.content.triggers.length).toEqual(1); + }); + + it('create a choose intent trigger', async () => { + const dialogId = '1'; + await act(async () => { + await dispatcher.createTrigger(projectId, dialogId, chooseIntentTriggerData1); + }); + const updatedDialog = renderedComponent.current.dialogs.find(({ id }) => id === dialogId); + expect(updatedDialog.content.triggers.length).toEqual(1); + }); + + it('create a intent trigger', async () => { + const dialogId = '1'; + await act(async () => { + await dispatcher.createTrigger(projectId, dialogId, intentTriggerData1); + }); + const updatedDialog = renderedComponent.current.dialogs.find(({ id }) => id === dialogId); + expect(updatedDialog.content.triggers.length).toEqual(1); + }); + + it('delete a trigger', async () => { + const dialogId = '1'; + await act(async () => { + await dispatcher.createTrigger(projectId, dialogId, QnATriggerData1); + }); + const updatedDialog = renderedComponent.current.dialogs.find(({ id }) => id === dialogId); + expect(updatedDialog.content.triggers.length).toEqual(1); + + const targetTrigger = updatedDialog.content.triggers[0]; + await act(async () => { + await dispatcher.deleteTrigger(projectId, dialogId, targetTrigger); + }); + const updatedDialog2 = renderedComponent.current.dialogs.find(({ id }) => id === dialogId); + expect(updatedDialog2.content.triggers.length).toEqual(1); + }); +}); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/application.ts b/Composer/packages/client/src/recoilModel/dispatchers/application.ts index aab3bf32be..5078421354 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/application.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/application.ts @@ -12,9 +12,10 @@ import { creationFlowStatusState, currentModeState, PageMode, + creationFlowTypeState, pageElementState, } from '../atoms/appState'; -import { AppUpdaterStatus, CreationFlowStatus } from '../../constants'; +import { AppUpdaterStatus, CreationFlowStatus, CreationFlowType } from '../../constants'; import OnboardingState from '../../utils/onboardingStorage'; import { StateError, AppUpdateState } from '../../recoilModel/types'; @@ -111,6 +112,10 @@ export const applicationDispatcher = () => { set(creationFlowStatusState, status); }); + const setCreationFlowType = useRecoilCallback(({ set }: CallbackInterface) => (type: CreationFlowType) => { + set(creationFlowTypeState, type); + }); + const setApplicationLevelError = useRecoilCallback( (callbackHelpers: CallbackInterface) => (errorObj: StateError | undefined) => { setError(callbackHelpers, errorObj); @@ -127,6 +132,7 @@ export const applicationDispatcher = () => { onboardingAddCoachMarkRef, setCreationFlowStatus, setApplicationLevelError, + setCreationFlowType, setCurrentPageMode, setPageElementState, }; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/dialogs.ts b/Composer/packages/client/src/recoilModel/dispatchers/dialogs.ts index d169b96857..60fcd130a0 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/dialogs.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/dialogs.ts @@ -14,7 +14,7 @@ import { actionsSeedState, showCreateDialogModalState, dialogState, -} from '../atoms/botState'; +} from '../atoms'; import { dispatcherState } from '../DispatcherWrapper'; import { createLgFileState, removeLgFileState } from './lg'; @@ -67,7 +67,7 @@ export const dialogsDispatcher = () => { const { set } = callbackHelpers; set(actionsSeedState(projectId), actions); set(onCreateDialogCompleteState(projectId), { func: onComplete }); - set(showCreateDialogModalState(projectId), true); + set(showCreateDialogModalState, true); } ); @@ -75,7 +75,7 @@ export const dialogsDispatcher = () => { const { set } = callbackHelpers; set(actionsSeedState(projectId), []); set(onCreateDialogCompleteState(projectId), { func: undefined }); - set(showCreateDialogModalState(projectId), false); + set(showCreateDialogModalState, false); }); const createDialog = useRecoilCallback((callbackHelpers: CallbackInterface) => async ({ id, content, projectId }) => { @@ -99,7 +99,7 @@ export const dialogsDispatcher = () => { set(dialogState({ projectId, dialogId: dialog.id }), dialog); set(dialogIdsState(projectId), (dialogsIds) => [...dialogsIds, dialog.id]); set(actionsSeedState(projectId), []); - set(showCreateDialogModalState(projectId), false); + set(showCreateDialogModalState, false); const onComplete = (await snapshot.getPromise(onCreateDialogCompleteState(projectId))).func; if (typeof onComplete === 'function') { setTimeout(() => onComplete(id)); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/index.ts b/Composer/packages/client/src/recoilModel/dispatchers/index.ts index 4830bdfd3d..bc9df1d989 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/index.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/index.ts @@ -11,6 +11,7 @@ import { exportDispatcher } from './export'; import { lgDispatcher } from './lg'; import { luDispatcher } from './lu'; import { qnaDispatcher } from './qna'; +import { triggerDispatcher } from './trigger'; import { builderDispatcher } from './builder'; import { navigationDispatcher } from './navigation'; import { publisherDispatcher } from './publisher'; @@ -37,6 +38,7 @@ const createDispatchers = () => { ...lgDispatcher(), ...luDispatcher(), ...qnaDispatcher(), + ...triggerDispatcher(), ...builderDispatcher(), ...navigationDispatcher(), ...publisherDispatcher(), diff --git a/Composer/packages/client/src/recoilModel/dispatchers/project.ts b/Composer/packages/client/src/recoilModel/dispatchers/project.ts index 04cb8e24e6..98c1011c36 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/project.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/project.ts @@ -26,6 +26,7 @@ import { projectMetaDataState, } from '../atoms'; import { dispatcherState } from '../DispatcherWrapper'; +import { rootBotProjectIdSelector } from '../selectors'; import { announcementState, boilerplateVersionState, recentProjectsState, templateIdState } from './../atoms'; import { logMessage, setError } from './../dispatchers/shared'; @@ -39,6 +40,7 @@ import { initBotState, loadProjectData, navigateToBot, + navigateToSkillBot, openLocalSkill, openRemoteSkill, openRootBotAndSkillsByPath, @@ -55,12 +57,16 @@ export const projectDispatcher = () => { const { set, snapshot } = callbackHelpers; const dispatcher = await snapshot.getPromise(dispatcherState); await dispatcher.removeSkillFromBotProjectFile(projectIdToRemove); + const rootBotProjectId = await snapshot.getPromise(rootBotProjectIdSelector); set(botProjectIdsState, (currentProjects) => { const filtered = currentProjects.filter((id) => id !== projectIdToRemove); return filtered; }); resetBotStates(callbackHelpers, projectIdToRemove); + if (rootBotProjectId) { + navigateToBot(callbackHelpers, rootBotProjectId, ''); + } } catch (ex) { setError(callbackHelpers, ex); } @@ -91,6 +97,9 @@ export const projectDispatcher = () => { try { set(botOpeningState, true); const dispatcher = await snapshot.getPromise(dispatcherState); + const rootBotProjectId = await snapshot.getPromise(rootBotProjectIdSelector); + if (!rootBotProjectId) return; + const botExists = await checkIfBotExistsInBotProjectFile(callbackHelpers, path); if (botExists) { throw new Error( @@ -107,6 +116,7 @@ export const projectDispatcher = () => { set(botProjectIdsState, (current) => [...current, projectId]); await dispatcher.addLocalSkillToBotProjectFile(projectId); + navigateToSkillBot(rootBotProjectId, projectId, mainDialog); } catch (ex) { handleProjectFailure(callbackHelpers, ex); } finally { @@ -120,6 +130,9 @@ export const projectDispatcher = () => { const { set, snapshot } = callbackHelpers; try { const dispatcher = await snapshot.getPromise(dispatcherState); + const rootBotProjectId = await snapshot.getPromise(rootBotProjectIdSelector); + if (!rootBotProjectId) return; + const botExists = await checkIfBotExistsInBotProjectFile(callbackHelpers, manifestUrl, true); if (botExists) { throw new Error( @@ -131,6 +144,7 @@ export const projectDispatcher = () => { const { projectId } = await openRemoteSkill(callbackHelpers, manifestUrl); set(botProjectIdsState, (current) => [...current, projectId]); await dispatcher.addRemoteSkillToBotProjectFile(projectId, manifestUrl, endpointName); + navigateToSkillBot(rootBotProjectId, projectId); } catch (ex) { handleProjectFailure(callbackHelpers, ex); } finally { @@ -144,9 +158,10 @@ export const projectDispatcher = () => { const { set, snapshot } = callbackHelpers; const dispatcher = await snapshot.getPromise(dispatcherState); try { - const { templateId, name, description, location, schemaUrl, locale, qnaKbUrls } = newProjectData; + const { templateId, name, description, location, schemaUrl, locale } = newProjectData; set(botOpeningState, true); - + const rootBotProjectId = await snapshot.getPromise(rootBotProjectIdSelector); + if (!rootBotProjectId) return; const { projectId, mainDialog } = await createNewBotFromTemplate( callbackHelpers, templateId, @@ -164,7 +179,7 @@ export const projectDispatcher = () => { }); set(botProjectIdsState, (current) => [...current, projectId]); await dispatcher.addLocalSkillToBotProjectFile(projectId); - navigateToBot(callbackHelpers, projectId, mainDialog, qnaKbUrls, templateId); + navigateToSkillBot(rootBotProjectId, projectId, mainDialog); return projectId; } catch (ex) { handleProjectFailure(callbackHelpers, ex); @@ -236,7 +251,6 @@ export const projectDispatcher = () => { location, schemaUrl, locale, - qnaKbUrls, templateDir, eTag, urlSuffix, @@ -264,7 +278,7 @@ export const projectDispatcher = () => { isRemote: false, }); projectIdCache.set(projectId); - navigateToBot(callbackHelpers, projectId, mainDialog, qnaKbUrls, templateId, urlSuffix); + navigateToBot(callbackHelpers, projectId, mainDialog, urlSuffix); } catch (ex) { set(botProjectIdsState, []); handleProjectFailure(callbackHelpers, ex); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/trigger.ts b/Composer/packages/client/src/recoilModel/dispatchers/trigger.ts new file mode 100644 index 0000000000..0bf760a647 --- /dev/null +++ b/Composer/packages/client/src/recoilModel/dispatchers/trigger.ts @@ -0,0 +1,181 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +/* eslint-disable react-hooks/rules-of-hooks */ +import { useRecoilCallback, CallbackInterface } from 'recoil'; +import { BaseSchema, deleteActions, ITriggerCondition, LgTemplate, LgTemplateSamples, SDKKinds } from '@bfc/shared'; +import get from 'lodash/get'; + +import { lgFilesState, luFilesState, schemasState, dialogState, localeState } from '../atoms/botState'; +import { dispatcherState } from '../DispatcherWrapper'; +import { dialogsSelectorFamily } from '../selectors'; +import { + onChooseIntentKey, + generateNewDialog, + intentTypeKey, + qnaMatcherKey, + TriggerFormData, +} from '../../utils/dialogUtil'; + +import { setError } from './shared'; + +const defaultQnATriggerData = { + $kind: qnaMatcherKey, + errors: { $kind: '', intent: '', event: '', triggerPhrases: '', regEx: '', activity: '' }, + event: '', + intent: '', + regEx: '', + triggerPhrases: '', +}; + +const getDesignerIdFromDialogPath = (dialog, path) => { + const value = get(dialog, path, ''); + const startIndex = value.lastIndexOf('_'); + const endIndex = value.indexOf('()'); + const designerId = value.substring(startIndex + 1, endIndex); + if (!designerId) throw new Error(`missing designerId in path: ${path}`); + return designerId; +}; + +export const triggerDispatcher = () => { + const createTrigger = useRecoilCallback( + (callbackHelpers: CallbackInterface) => async ( + projectId: string, + dialogId: string, + formData: TriggerFormData, + autoSelected = true + ) => { + try { + const { snapshot } = callbackHelpers; + const dispatcher = await snapshot.getPromise(dispatcherState); + const lgFiles = await snapshot.getPromise(lgFilesState(projectId)); + const luFiles = await snapshot.getPromise(luFilesState(projectId)); + const dialogs = await snapshot.getPromise(dialogsSelectorFamily(projectId)); + const dialog = await snapshot.getPromise(dialogState({ projectId, dialogId })); + const schemas = await snapshot.getPromise(schemasState(projectId)); + const locale = await snapshot.getPromise(localeState(projectId)); + + const { createLuIntent, createLgTemplates, updateDialog, selectTo } = dispatcher; + + const lgFile = lgFiles.find((file) => file.id === `${dialogId}.${locale}`); + const luFile = luFiles.find((file) => file.id === `${dialogId}.${locale}`); + + if (!luFile) throw new Error(`lu file ${dialogId} not found`); + if (!lgFile) throw new Error(`lg file ${dialogId} not found`); + if (!dialog) throw new Error(`dialog ${dialogId} not found`); + const newDialog = generateNewDialog(dialogs, dialog.id, formData, schemas.sdk?.content); + const index = get(newDialog, 'content.triggers', []).length - 1; + if (formData.$kind === intentTypeKey && formData.triggerPhrases) { + const intent = { Name: formData.intent, Body: formData.triggerPhrases }; + luFile && (await createLuIntent({ id: luFile.id, intent, projectId })); + } else if (formData.$kind === qnaMatcherKey) { + const designerId1 = getDesignerIdFromDialogPath( + newDialog, + `content.triggers[${index}].actions[0].actions[1].prompt` + ); + const designerId2 = getDesignerIdFromDialogPath( + newDialog, + `content.triggers[${index}].actions[0].elseActions[0].activity` + ); + const lgTemplates: LgTemplate[] = [ + LgTemplateSamples.TextInputPromptForQnAMatcher(designerId1) as LgTemplate, + LgTemplateSamples.SendActivityForQnAMatcher(designerId2) as LgTemplate, + ]; + await createLgTemplates({ id: lgFile.id, templates: lgTemplates, projectId }); + } else if (formData.$kind === onChooseIntentKey) { + const designerId1 = getDesignerIdFromDialogPath(newDialog, `content.triggers[${index}].actions[4].prompt`); + const designerId2 = getDesignerIdFromDialogPath( + newDialog, + `content.triggers[${index}].actions[5].elseActions[0].activity` + ); + const lgTemplates1: LgTemplate[] = [ + LgTemplateSamples.TextInputPromptForOnChooseIntent(designerId1) as LgTemplate, + LgTemplateSamples.SendActivityForOnChooseIntent(designerId2) as LgTemplate, + ]; + + let lgTemplates2: LgTemplate[] = [ + LgTemplateSamples.adaptiveCardJson as LgTemplate, + LgTemplateSamples.whichOneDidYouMean as LgTemplate, + LgTemplateSamples.pickOne as LgTemplate, + LgTemplateSamples.getAnswerReadBack as LgTemplate, + LgTemplateSamples.getIntentReadBack as LgTemplate, + ]; + const commonlgFile = lgFiles.find(({ id }) => id === `common.${locale}`); + + lgTemplates2 = lgTemplates2.filter( + (t) => commonlgFile?.templates.findIndex((clft) => clft.name === t.name) === -1 + ); + + await createLgTemplates({ id: `common.${locale}`, templates: lgTemplates2, projectId }); + await createLgTemplates({ id: lgFile.id, templates: lgTemplates1, projectId }); + } + const dialogPayload = { + id: newDialog.id, + projectId, + content: newDialog.content, + }; + await updateDialog(dialogPayload); + if (autoSelected) { + selectTo(projectId, newDialog.id, `triggers[${index}]`); + } + } catch (ex) { + setError(callbackHelpers, ex); + } + } + ); + + const deleteTrigger = useRecoilCallback( + (callbackHelpers: CallbackInterface) => async (projectId: string, dialogId: string, trigger: ITriggerCondition) => { + try { + const { snapshot } = callbackHelpers; + const dispatcher = await snapshot.getPromise(dispatcherState); + + const { removeLuIntent, removeLgTemplates } = dispatcher; + + if (trigger.$kind === SDKKinds.OnIntent) { + const intentName = trigger.intent as string; + removeLuIntent({ id: dialogId, intentName, projectId }); + } + + // Clean action resources + const actions = trigger.actions as BaseSchema[]; + if (!actions || !Array.isArray(actions)) return; + + deleteActions( + actions, + (templateNames: string[]) => removeLgTemplates({ id: dialogId, templateNames, projectId }), + (intentNames: string[]) => + Promise.all(intentNames.map((intentName) => removeLuIntent({ id: dialogId, intentName, projectId }))) + ); + } catch (ex) { + setError(callbackHelpers, ex); + } + } + ); + + const createQnATrigger = useRecoilCallback( + (callbackHelpers: CallbackInterface) => async (projectId: string, dialogId: string) => { + try { + const { snapshot } = callbackHelpers; + const dispatcher = await snapshot.getPromise(dispatcherState); + const dialogs = await snapshot.getPromise(dialogsSelectorFamily(projectId)); + + const targetDialog = dialogs.find((item) => item.id === dialogId); + if (!targetDialog) throw new Error(`dialog ${dialogId} not found`); + const existedQnATrigger = get(targetDialog, 'content.triggers', []).find( + (item) => item.$kind === SDKKinds.OnQnAMatch + ); + if (!existedQnATrigger) { + await dispatcher.createTrigger(projectId, dialogId, defaultQnATriggerData); + } + } catch (ex) { + setError(callbackHelpers, ex); + } + } + ); + + return { + createTrigger, + deleteTrigger, + createQnATrigger, + }; +}; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/utils/project.ts b/Composer/packages/client/src/recoilModel/dispatchers/utils/project.ts index 3512e5970f..061c2ce3fa 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/utils/project.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/utils/project.ts @@ -153,8 +153,6 @@ export const navigateToBot = ( callbackHelpers: CallbackInterface, projectId: string, mainDialog: string, - qnaKbUrls?: string[], - templateId?: string, urlSuffix?: string ) => { if (projectId) { @@ -170,6 +168,14 @@ export const navigateToBot = ( } }; +export const navigateToSkillBot = (rootProjectId: string, skillId: string, mainDialog?: string) => { + if (rootProjectId && skillId) { + let url = `/bot/${rootProjectId}/skill/${skillId}`; + if (mainDialog) url += `/dialogs/${mainDialog}`; + navigateTo(url); + } +}; + export const loadProjectData = (response) => { const { files, botName, settings, id: projectId } = response.data; const mergedSettings = getMergedSettings(projectId, settings); @@ -287,7 +293,7 @@ export const initBotState = async (callbackHelpers: CallbackInterface, data: any qnaFiles, jsonSchemaFiles, formDialogSchemas, - skillManifestFiles, + skillManifests, mergedSettings, recognizers, crossTrainConfig, @@ -330,7 +336,7 @@ export const initBotState = async (callbackHelpers: CallbackInterface, data: any set(formDialogSchemaState({ projectId, schemaId: id }), { id, content }); }); - set(skillManifestsState(projectId), skillManifestFiles); + set(skillManifestsState(projectId), skillManifests); set(luFilesState(projectId), initLuFilesStatus(botName, luFiles, dialogs)); set(lgFilesState(projectId), lgFiles); set(jsonSchemaFilesState(projectId), jsonSchemaFiles); @@ -345,8 +351,7 @@ export const initBotState = async (callbackHelpers: CallbackInterface, data: any } set(schemasState(projectId), schemas); set(localeState(projectId), locale); - set(botDiagnosticsState(projectId), diagnostics); - + set(botDiagnosticsState(projectId), [...diagnostics, ...botFiles.diagnostics]); refreshLocalStorage(projectId, settings); set(settingsState(projectId), mergedSettings); @@ -381,14 +386,17 @@ export const openRemoteSkill = async ( const stringified = stringify({ url: manifestUrl, }); - const manifestResponse = await httpClient.get( - `/projects/${projectId}/skill/retrieveSkillManifest?${stringified}&ignoreProjectValidation=true` - ); + set(projectMetaDataState(projectId), { isRootBot: false, isRemote: true, }); + //TODO: open remote url 404. isRemote set to false? + const manifestResponse = await httpClient.get( + `/projects/${projectId}/skill/retrieveSkillManifest?${stringified}&ignoreProjectValidation=true` + ); + let uniqueSkillNameIdentifier = botNameIdentifier; if (!uniqueSkillNameIdentifier) { uniqueSkillNameIdentifier = await getSkillNameIdentifier(callbackHelpers, manifestResponse.data.name); diff --git a/Composer/packages/client/src/recoilModel/selectors/project.ts b/Composer/packages/client/src/recoilModel/selectors/project.ts index 6fdfd057b6..cbb56698f7 100644 --- a/Composer/packages/client/src/recoilModel/selectors/project.ts +++ b/Composer/packages/client/src/recoilModel/selectors/project.ts @@ -1,7 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { FormDialogSchema, JsonSchemaFile } from '@bfc/shared'; +import { BotIndexer } from '@bfc/indexers'; +import { BotAssets, DialogInfo, FormDialogSchema, JsonSchemaFile } from '@bfc/shared'; import isEmpty from 'lodash/isEmpty'; import { selector, selectorFamily } from 'recoil'; @@ -13,10 +14,18 @@ import { botProjectIdsState, formDialogSchemaIdsState, formDialogSchemaState, + settingsState, + luFilesState, + lgFilesState, + qnaFilesState, + skillManifestsState, + dialogSchemasState, jsonSchemaFilesState, projectMetaDataState, + dialogIdsState, + dialogState, } from '../atoms'; -import { dialogsSelectorFamily } from '../selectors'; +import { dialogsSelectorFamily, buildEssentialsSelector } from '../selectors'; // Actions export const localBotsWithoutErrorsSelector = selector({ @@ -68,12 +77,31 @@ export const botProjectSpaceSelector = selector({ const botProjects = get(botProjectIdsState); const result = botProjects.map((projectId: string) => { const dialogs = get(dialogsSelectorFamily(projectId)); + const luFiles = get(luFilesState(projectId)); + const lgFiles = get(lgFilesState(projectId)); + const qnaFiles = get(qnaFilesState(projectId)); const formDialogSchemas = get(formDialogSchemasSelectorFamily(projectId)); const metaData = get(projectMetaDataState(projectId)); const botError = get(botErrorState(projectId)); + const buildEssentials = get(buildEssentialsSelector(projectId)); const name = get(botDisplayNameState(projectId)); const botNameId = get(botNameIdentifierState(projectId)); - return { dialogs, formDialogSchemas, projectId, name, ...metaData, error: botError, botNameId }; + const setting = get(settingsState(projectId)); + const skillManifests = get(skillManifestsState(projectId)); + + const diagnostics = BotIndexer.validate({ dialogs, setting, luFiles, lgFiles, qnaFiles, skillManifests }); + + return { + dialogs, + formDialogSchemas, + projectId, + name, + ...metaData, + error: botError, + diagnostics, + botNameId, + buildEssentials, + }; }); return result; }, @@ -105,13 +133,53 @@ export const jsonSchemaFilesByProjectIdSelector = selector({ }, }); -export const skillsProjectIdSelector = selector({ - key: 'skillsProjectIdSelector', +export const botProjectDiagnosticsSelector = selector({ + key: 'botProjectDiagnosticsSelector', get: ({ get }) => { - const botIds = get(botProjectIdsState); - return botIds.filter((projectId: string) => { - const { isRootBot } = get(projectMetaDataState(projectId)); - return !isRootBot; + const botProjects = get(botProjectIdsState); + const result = botProjects.map((projectId: string) => { + const dialogs = get(dialogsSelectorFamily(projectId)); + const formDialogSchemas = get(formDialogSchemasSelectorFamily(projectId)); + const luFiles = get(luFilesState(projectId)); + const lgFiles = get(lgFilesState(projectId)); + const setting = get(settingsState(projectId)); + const skillManifests = get(skillManifestsState(projectId)); + const dialogSchemas = get(dialogSchemasState(projectId)); + const qnaFiles = get(qnaFilesState(projectId)); + const botProjectFile = get(botProjectFileState(projectId)); + const jsonSchemaFiles = get(jsonSchemaFilesState(projectId)); + const botAssets: BotAssets = { + projectId, + dialogs, + luFiles, + qnaFiles, + lgFiles, + skillManifests, + setting, + dialogSchemas, + formDialogSchemas, + botProjectFile, + jsonSchemaFiles, + recognizers: [], + crossTrainConfig: {}, + }; + return BotIndexer.validate(botAssets); }); + return result; + }, +}); + +export const projectDialogsMapSelector = selector<{ [key: string]: DialogInfo[] }>({ + key: 'projectDialogsMap', + get: ({ get }) => { + const projectIds = get(botProjectIdsState); + + return projectIds.reduce((result, projectId) => { + const dialogIds = get(dialogIdsState(projectId)); + result[projectId] = dialogIds.map((dialogId) => { + return get(dialogState({ projectId, dialogId })); + }); + return result; + }, {}); }, }); diff --git a/Composer/packages/client/src/recoilModel/selectors/skills.ts b/Composer/packages/client/src/recoilModel/selectors/skills.ts index 60a64cdef5..c5dd228a7c 100644 --- a/Composer/packages/client/src/recoilModel/selectors/skills.ts +++ b/Composer/packages/client/src/recoilModel/selectors/skills.ts @@ -10,23 +10,42 @@ import { botNameIdentifierState, botDisplayNameState, projectMetaDataState, + locationState, + botProjectIdsState, } from '../atoms'; -import { skillsProjectIdSelector } from './project'; +export const skillsProjectIdSelector = selector({ + key: 'skillsProjectIdSelector', + get: ({ get }) => { + const botIds = get(botProjectIdsState); + return botIds.filter((projectId: string) => { + const { isRootBot } = get(projectMetaDataState(projectId)); + return !isRootBot; + }); + }, +}); +export interface SkillInfo extends Skill { + manifestId: string; + location: string; // path to skill bot or manifest url + remote: boolean; +} // Actions export const skillsStateSelector = selector({ key: 'skillsStateSelector', get: ({ get }) => { const skillsProjectIds = get(skillsProjectIdSelector); - const skills: Record = skillsProjectIds.reduce((result, skillId: string) => { + const skills: Record = skillsProjectIds.reduce((result, skillId: string) => { const manifests = get(skillManifestsState(skillId)); + const location = get(locationState(skillId)); const currentSkillManifestIndex = get(currentSkillManifestIndexState(skillId)); const skillNameIdentifier = get(botNameIdentifierState(skillId)); const botName = get(botDisplayNameState(skillId)); - let manifest; + let manifest = undefined; + let manifestId; if (manifests[currentSkillManifestIndex]) { manifest = manifests[currentSkillManifestIndex].content; + manifestId = manifests[currentSkillManifestIndex].id; } const { isRemote } = get(projectMetaDataState(skillId)); @@ -36,6 +55,8 @@ export const skillsStateSelector = selector({ manifest, name: botName, remote: isRemote, + manifestId, + location, }; } return result; @@ -43,3 +64,18 @@ export const skillsStateSelector = selector({ return skills; }, }); + +export const skillNameIdentifierByProjectIdSelector = selector({ + key: 'skillNameIdentifierByProjectIdSelector', + get: ({ get }) => { + const skillsProjectIds = get(skillsProjectIdSelector); + const skills: Record = skillsProjectIds.reduce((result, skillId: string) => { + const skillNameIdentifier = get(botNameIdentifierState(skillId)); + if (skillNameIdentifier) { + result[skillId] = skillNameIdentifier; + } + return result; + }, {}); + return skills; + }, +}); diff --git a/Composer/packages/client/src/shell/triggerApi.ts b/Composer/packages/client/src/shell/triggerApi.ts index c1d6ceb6c5..c975a4d0d8 100644 --- a/Composer/packages/client/src/shell/triggerApi.ts +++ b/Composer/packages/client/src/shell/triggerApi.ts @@ -2,186 +2,30 @@ // Licensed under the MIT License. import { useEffect, useState } from 'react'; -import { - LgTemplate, - LuFile, - LgFile, - DialogInfo, - ITriggerCondition, - SDKKinds, - BaseSchema, - MicrosoftIDialog, -} from '@botframework-composer/types'; +import { ITriggerCondition } from '@botframework-composer/types'; import { useRecoilValue } from 'recoil'; -import { LgTemplateSamples } from '@bfc/shared'; -import get from 'lodash/get'; -import { useResolvers } from '../hooks/useResolver'; -import { onChooseIntentKey, generateNewDialog, intentTypeKey, qnaMatcherKey } from '../utils/dialogUtil'; -import { schemasState, lgFilesState, dialogsSelectorFamily, localeState } from '../recoilModel'; +import { TriggerFormData } from '../utils/dialogUtil'; import { Dispatcher } from '../recoilModel/dispatchers'; import { dispatcherState } from './../recoilModel/DispatcherWrapper'; -import { useActionApi } from './actionApi'; -import { useLuApi } from './luApi'; - -const defaultQnATriggerData = { - $kind: qnaMatcherKey, - errors: { $kind: '', intent: '', event: '', triggerPhrases: '', regEx: '', activity: '' }, - event: '', - intent: '', - regEx: '', - triggerPhrases: '', -}; - -function createTriggerApi( - state: { projectId; schemas; dialogs; locale; lgFiles }, - dispatchers: Dispatcher, //TODO - luFileResolver: (id: string) => LuFile | undefined, - lgFileResolver: (id: string) => LgFile | undefined, - dialogResolver: (id: string) => DialogInfo | undefined, - deleteActions: (dialogId: string, actions: MicrosoftIDialog[]) => Promise, - removeLuIntent: (id: string, intentName: string) => void -) { - const getDesignerIdFromDialogPath = (dialog, path) => { - const value = get(dialog, path, ''); - const startIndex = value.lastIndexOf('_'); - const endIndex = value.indexOf('()'); - return value.substring(startIndex + 1, endIndex); - }; - - const createTriggerHandler = async (id, formData, autoSelected = true) => { - const luFile = luFileResolver(id); - const lgFile = lgFileResolver(id); - const dialog = dialogResolver(id); - const { createLuIntent, createLgTemplates, updateDialog, selectTo } = dispatchers; - const { projectId, schemas, dialogs, locale, lgFiles } = state; - if (!luFile) throw new Error(`lu file ${id} not found`); - if (!lgFile) throw new Error(`lg file ${id} not found`); - if (!dialog) throw new Error(`dialog ${id} not found`); - const newDialog = generateNewDialog(dialogs, dialog.id, formData, schemas.sdk?.content); - const index = get(newDialog, 'content.triggers', []).length - 1; - if (formData.$kind === intentTypeKey && formData.triggerPhrases) { - const intent = { Name: formData.intent, Body: formData.triggerPhrases }; - luFile && (await createLuIntent({ id: luFile.id, intent, projectId })); - } else if (formData.$kind === qnaMatcherKey) { - const designerId1 = getDesignerIdFromDialogPath( - newDialog, - `content.triggers[${index}].actions[0].actions[1].prompt` - ); - const designerId2 = getDesignerIdFromDialogPath( - newDialog, - `content.triggers[${index}].actions[0].elseActions[0].activity` - ); - const lgTemplates: LgTemplate[] = [ - LgTemplateSamples.TextInputPromptForQnAMatcher(designerId1) as LgTemplate, - LgTemplateSamples.SendActivityForQnAMatcher(designerId2) as LgTemplate, - ]; - await createLgTemplates({ id: lgFile.id, templates: lgTemplates, projectId }); - } else if (formData.$kind === onChooseIntentKey) { - const designerId1 = getDesignerIdFromDialogPath(newDialog, `content.triggers[${index}].actions[4].prompt`); - const designerId2 = getDesignerIdFromDialogPath( - newDialog, - `content.triggers[${index}].actions[5].elseActions[0].activity` - ); - const lgTemplates1: LgTemplate[] = [ - LgTemplateSamples.TextInputPromptForOnChooseIntent(designerId1) as LgTemplate, - LgTemplateSamples.SendActivityForOnChooseIntent(designerId2) as LgTemplate, - ]; - - let lgTemplates2: LgTemplate[] = [ - LgTemplateSamples.adaptiveCardJson as LgTemplate, - LgTemplateSamples.whichOneDidYouMean as LgTemplate, - LgTemplateSamples.pickOne as LgTemplate, - LgTemplateSamples.getAnswerReadBack as LgTemplate, - LgTemplateSamples.getIntentReadBack as LgTemplate, - ]; - const commonlgFile = lgFiles.find(({ id }) => id === `common.${locale}`); - - lgTemplates2 = lgTemplates2.filter( - (t) => commonlgFile?.templates.findIndex((clft) => clft.name === t.name) === -1 - ); - - await createLgTemplates({ id: `common.${locale}`, templates: lgTemplates2, projectId }); - await createLgTemplates({ id: lgFile.id, templates: lgTemplates1, projectId }); - } - const dialogPayload = { - id: newDialog.id, - projectId, - content: newDialog.content, - }; - await updateDialog(dialogPayload); - if (autoSelected) { - selectTo(projectId, newDialog.id, `triggers[${index}]`); - } - }; - - const deleteTrigger = (dialogId: string, trigger: ITriggerCondition) => { - if (!trigger) return; - - // Clean the lu resource on intent trigger - if (get(trigger, '$kind') === SDKKinds.OnIntent) { - const triggerIntent = get(trigger, 'intent', '') as string; - removeLuIntent(dialogId, triggerIntent); - } - - // Clean action resources - const actions = get(trigger, 'actions') as BaseSchema[]; - if (!actions || !Array.isArray(actions)) return; - - deleteActions(dialogId, actions); - }; - - const createQnATrigger = async (id) => { - const targetDialog = state.dialogs.find((item) => item.id === id); - if (!targetDialog) throw new Error(`dialog ${id} not found`); - const existedQnATrigger = get(targetDialog, 'content.triggers', []).find( - (item) => item.$kind === SDKKinds.OnQnAMatch - ); - if (!existedQnATrigger) { - await createTriggerHandler(id, defaultQnATriggerData); - } - }; +function createTriggerApi(projectId: string, dispatchers: Dispatcher) { return { - createTrigger: createTriggerHandler, - deleteTrigger, - createQnATrigger, + createTrigger: (dialogId: string, formData: TriggerFormData, autoSelected = true) => + dispatchers.createTrigger(projectId, dialogId, formData, autoSelected), + deleteTrigger: (dialogId: string, trigger: ITriggerCondition) => + dispatchers.deleteTrigger(projectId, dialogId, trigger), + createQnATrigger: (dialogId: string) => dispatchers.createQnATrigger(projectId, dialogId), }; } export function useTriggerApi(projectId: string) { - const schemas = useRecoilValue(schemasState(projectId)); - const lgFiles = useRecoilValue(lgFilesState(projectId)); - const dialogs = useRecoilValue(dialogsSelectorFamily(projectId)); - const locale = useRecoilValue(localeState(projectId)); - const { deleteActions } = useActionApi(projectId); - const { removeLuIntent } = useLuApi(projectId); - const dispatchers = useRecoilValue(dispatcherState); - const { luFileResolver, lgFileResolver, dialogResolver } = useResolvers(projectId); - const [api, setApi] = useState( - createTriggerApi( - { projectId, schemas, dialogs, locale, lgFiles }, - dispatchers, - luFileResolver, - lgFileResolver, - dialogResolver, - deleteActions, - removeLuIntent - ) - ); + const [api, setApi] = useState(createTriggerApi(projectId, dispatchers)); useEffect(() => { - const newApi = createTriggerApi( - { projectId, schemas, dialogs, locale, lgFiles }, - dispatchers, - luFileResolver, - lgFileResolver, - dialogResolver, - deleteActions, - removeLuIntent - ); + const newApi = createTriggerApi(projectId, dispatchers); setApi(newApi); return () => { Object.keys(newApi).forEach((apiName) => { @@ -190,7 +34,7 @@ export function useTriggerApi(projectId: string) { } }); }; - }, [projectId, schemas, dialogs, locale, lgFiles]); + }, [projectId]); return api; } diff --git a/Composer/packages/client/src/utils/navigation.ts b/Composer/packages/client/src/utils/navigation.ts index 7a8432d25e..5ec81d2625 100644 --- a/Composer/packages/client/src/utils/navigation.ts +++ b/Composer/packages/client/src/utils/navigation.ts @@ -54,7 +54,6 @@ export function checkUrl( export interface NavigationState { breadcrumb?: string[]; - qnaKbUrls?: string[]; } export function convertPathToUrl( diff --git a/Composer/packages/lib/indexers/__tests__/botIndexer.test.ts b/Composer/packages/lib/indexers/__tests__/botIndexer.test.ts index 9f56f91ec2..d066ab43aa 100644 --- a/Composer/packages/lib/indexers/__tests__/botIndexer.test.ts +++ b/Composer/packages/lib/indexers/__tests__/botIndexer.test.ts @@ -11,10 +11,11 @@ import { ILUFeaturesConfig, IQnAConfig, SkillSetting, + QnAFile, } from '@bfc/shared'; import { BotIndexer } from '../src/botIndexer'; -const { checkSkillSetting, checkLUISLocales, filterLUISFilesToPublish } = BotIndexer; +const { checkManifest, checkSetting, checkSkillSetting, checkLUISLocales, filterLUISFilesToPublish } = BotIndexer; const botAssets: BotAssets = { projectId: 'test', @@ -30,17 +31,18 @@ const botAssets: BotAssets = { dialogSchemas: [], qnaFiles: [], lgFiles: [], - qnaFiles: [], - dialogSchemas: [], luFiles: [ { id: 'a.en-us', + empty: false, } as LuFile, { id: 'a.zh-cn', + empty: true, } as LuFile, { id: 'a.ar', + empty: true, } as LuFile, ], skillManifests: [], @@ -70,6 +72,63 @@ const botAssets: BotAssets = { } as DialogSetting, }; +describe('check manifest', () => { + it('manifest file should exist', () => { + const diagnostics = checkManifest(botAssets); + expect(diagnostics.length).toEqual(1); + }); +}); + +describe('check LUIS & QnA key', () => { + it('LUIS authoringKey should exist in setting', () => { + const diagnostics = checkSetting(botAssets); + expect(diagnostics.length).toEqual(1); + }); + + it('LUIS authoringKey should exist in setting', () => { + const mergedSettings = { + ...botAssets.setting, + luis: { authoringKey: '4d210acc6d794d71a2a3450*****2fb7', endpointKey: '' } as ILuisConfig, + }; + const diagnostics = checkSetting({ ...botAssets, setting: mergedSettings }); + expect(diagnostics.length).toEqual(0); + }); + + it('QnA subscriptionKey should exist in setting, when qna file is not empty', () => { + const botAssets2 = { + ...botAssets, + dialogs: [ + { + luFile: 'a.lu', + qnaFile: 'a.lu.qna', + } as DialogInfo, + ], + qnaFiles: [ + { + id: 'a.en-us', + empty: false, + } as QnAFile, + ], + }; + const diagnostics = checkSetting(botAssets2); + expect(diagnostics.length).toEqual(2); + }); + + it('QnA subscriptionKey should exist in setting, when qna file is empty', () => { + const botAssets2 = { + ...botAssets, + dialogs: [ + { + luFile: 'a.lu', + qnaFile: 'a.lu.qna', + } as DialogInfo, + ], + }; + const diagnostics = checkSetting(botAssets2); + expect(diagnostics.length).toEqual(1); + }); +}); + describe('checkLUISLocales', () => { it('should check luis not supported locales', () => { const diagnostics = checkLUISLocales(botAssets); diff --git a/Composer/packages/lib/indexers/src/botIndexer.ts b/Composer/packages/lib/indexers/src/botIndexer.ts index 61bf195ca2..53d4877691 100644 --- a/Composer/packages/lib/indexers/src/botIndexer.ts +++ b/Composer/packages/lib/indexers/src/botIndexer.ts @@ -3,24 +3,102 @@ /** * Verify bot settings, files meet LUIS/QnA requirments. */ - +import get from 'lodash/get'; import { - BotAssets, - BotInfo, LUISLocales, Diagnostic, DiagnosticSeverity, LuFile, getSkillNameFromSetting, fetchFromSettings, + SkillManifestFile, + DialogInfo, + DialogSetting, + LgFile, + QnAFile, } from '@bfc/shared'; import difference from 'lodash/difference'; import map from 'lodash/map'; -import { getLocale } from './utils/help'; +import { getBaseName, getLocale } from './utils/help'; + +/** + * Check skill manifest.json. + * 1. Manifest should exist + */ +const checkManifest = (assets: { skillManifests: SkillManifestFile[] }): Diagnostic[] => { + const { skillManifests } = assets; -// Verify bot settings, files meet LUIS/QnA requirments. -const checkLUISLocales = (assets: BotAssets): Diagnostic[] => { + const diagnostics: Diagnostic[] = []; + if (skillManifests.length === 0) { + diagnostics.push(new Diagnostic('Missing skill manifest', 'manifest.json', DiagnosticSeverity.Warning)); + } + return diagnostics; +}; + +/** + * Check skill appsettings.json. + * 1. Missing LUIS key + * 2. Missing QnA Maker subscription key. + */ +const checkSetting = (assets: { + dialogs: DialogInfo[]; + lgFiles: LgFile[]; + luFiles: LuFile[]; + qnaFiles: QnAFile[]; + setting: DialogSetting; +}): Diagnostic[] => { + const { dialogs, setting, luFiles, qnaFiles } = assets; + const diagnostics: Diagnostic[] = []; + + let useLUIS = false; + let useQnA = false; + dialogs.forEach((item) => { + const luFileName = item.luFile; + if (luFileName) { + const luFileId = luFileName.replace(/\.lu$/, ''); + luFiles + .filter(({ id }) => getBaseName(id) === luFileId) + .forEach((item) => { + if (!item.empty) useLUIS = true; + }); + } + + const qnaFileName = item.qnaFile; + if (qnaFileName) { + const qnaFileId = qnaFileName.replace(/\.qna$/, '').replace(/\.lu$/, ''); + qnaFiles + .filter(({ id }) => getBaseName(id) === qnaFileId) + .forEach((item) => { + if (!item.empty) useQnA = true; + }); + } + }); + + // if use LUIS, check LUIS authoringKey key + if (useLUIS) { + if (!get(setting, 'luis.authoringKey')) { + diagnostics.push(new Diagnostic('Missing LUIS key', 'appsettings.json', DiagnosticSeverity.Error)); + } + } + + // if use QnA, check QnA subscriptionKey + if (useQnA) { + if (!get(setting, 'qna.subscriptionKey')) { + diagnostics.push( + new Diagnostic('Missing QnA Maker subscription key', 'appsettings.json', DiagnosticSeverity.Error) + ); + } + } + + return diagnostics; +}; + +/** + * Check bot settings & dialog + * files meet LUIS/QnA requirments. + */ +const checkLUISLocales = (assets: { dialogs: DialogInfo[]; setting: DialogSetting }): Diagnostic[] => { const { dialogs, setting: { languages }, @@ -36,8 +114,12 @@ const checkLUISLocales = (assets: BotAssets): Diagnostic[] => { }); }; -// Verify bot skill setting. -const checkSkillSetting = (assets: BotAssets): Diagnostic[] => { +/** + * Check bot skill & setting + * 1. used skill not existed in setting + * 2. appsettings.json Microsoft App Id or Skill Host Endpoint are empty + */ +const checkSkillSetting = (assets: { dialogs: DialogInfo[]; setting: DialogSetting }): Diagnostic[] => { const { setting: { skill = {}, botId, skillHostEndpoint }, dialogs, @@ -77,15 +159,15 @@ const checkSkillSetting = (assets: BotAssets): Diagnostic[] => { return diagnostics; }; -const index = (name: string, assets: BotAssets): BotInfo => { - const diagnostics: Diagnostic[] = []; - diagnostics.push(...checkLUISLocales(assets), ...checkSkillSetting(assets)); - - return { - name, - assets, - diagnostics, - }; +const validate = (assets: { + dialogs: DialogInfo[]; + lgFiles: LgFile[]; + luFiles: LuFile[]; + qnaFiles: QnAFile[]; + setting: DialogSetting; + skillManifests: SkillManifestFile[]; +}): Diagnostic[] => { + return [...checkManifest(assets), ...checkSetting(assets), ...checkLUISLocales(assets), ...checkSkillSetting(assets)]; }; const filterLUISFilesToPublish = (luFiles: LuFile[]): LuFile[] => { @@ -96,7 +178,9 @@ const filterLUISFilesToPublish = (luFiles: LuFile[]): LuFile[] => { }; export const BotIndexer = { - index, + validate, + checkManifest, + checkSetting, checkLUISLocales, checkSkillSetting, filterLUISFilesToPublish, diff --git a/Composer/packages/lib/indexers/src/index.ts b/Composer/packages/lib/indexers/src/index.ts index 35b5bf9550..5a4bdaf558 100644 --- a/Composer/packages/lib/indexers/src/index.ts +++ b/Composer/packages/lib/indexers/src/index.ts @@ -15,6 +15,7 @@ import { FileExtensions } from './utils/fileExtensions'; import { getExtension, getBaseName } from './utils/help'; import { formDialogSchemaIndexer } from './formDialogSchemaIndexer'; import { crossTrainConfigIndexer } from './crossTrainConfigIndexer'; +import { BotIndexer } from './botIndexer'; class Indexer { private classifyFile(files: FileInfo[]) { @@ -84,19 +85,21 @@ class Indexer { const luFeatures = settings.luFeatures; const { dialogs, recognizers } = this.separateDialogsAndRecognizers(result[FileExtensions.Dialog]); const { skillManifestFiles, crossTrainConfigs } = this.separateConfigAndManifests(result[FileExtensions.Manifest]); - return { + const assets = { dialogs: dialogIndexer.index(dialogs, botName), dialogSchemas: dialogSchemaIndexer.index(result[FileExtensions.DialogSchema]), lgFiles: lgIndexer.index(result[FileExtensions.lg], this.getLgImportResolver(result[FileExtensions.lg], locale)), luFiles: luIndexer.index(result[FileExtensions.Lu], luFeatures), qnaFiles: qnaIndexer.index(result[FileExtensions.QnA]), - skillManifestFiles: skillManifestIndexer.index(skillManifestFiles), + skillManifests: skillManifestIndexer.index(skillManifestFiles), botProjectSpaceFiles: botProjectSpaceIndexer.index(result[FileExtensions.BotProjectSpace]), jsonSchemaFiles: jsonSchemaFileIndexer.index(result[FileExtensions.Json]), formDialogSchemas: formDialogSchemaIndexer.index(result[FileExtensions.FormDialog]), recognizers: recognizerIndexer.index(recognizers), crossTrainConfig: crossTrainConfigIndexer.index(crossTrainConfigs), }; + const diagnostics = BotIndexer.validate({ ...assets, setting: settings }); + return { ...assets, diagnostics }; } }