From 7ca4296c32737f5982c6e9cd3e5a3b91bdc3fd9b Mon Sep 17 00:00:00 2001 From: Srinaath Ravichandran Date: Thu, 8 Oct 2020 15:04:51 -0700 Subject: [PATCH] feat: Bot Project Operations (#4316) --- .../components/CreationFlow/index.test.tsx | 20 +- .../publish-luis-modal.test.tsx | 4 +- .../__tests__/components/skill.test.tsx | 4 +- .../__tests__/pages/publish/Publish.test.tsx | 4 +- .../client/src/Onboarding/Onboarding.tsx | 4 +- .../src/components/CreateSkillModal.tsx | 2 +- .../components/CreationFlow/CreationFlow.tsx | 26 +- .../packages/client/src/components/Header.tsx | 10 +- .../TestController/TestController.tsx | 4 +- .../exportSkillModal/content/Description.tsx | 4 +- .../exportSkillModal/content/SaveManifest.tsx | 4 +- .../packages/client/src/pages/home/Home.tsx | 4 +- .../src/pages/knowledge-base/QnAPage.tsx | 5 +- .../pages/notifications/useNotifications.tsx | 3 + .../client/src/pages/publish/Publish.tsx | 4 +- .../client/src/pages/setting/SettingsPage.tsx | 2 +- .../dialog-settings/DialogSettings.tsx | 10 +- .../runtime-settings/RuntimeSettings.tsx | 4 +- .../client/src/pages/skills/index.tsx | 4 +- .../src/recoilModel/DispatcherWrapper.tsx | 10 +- .../client/src/recoilModel/atoms/appState.ts | 19 +- .../client/src/recoilModel/atoms/botState.ts | 35 +- .../__tests__/botProjectFile.test.tsx | 170 +++++ .../dispatchers/__tests__/export.test.tsx | 6 +- .../__tests__/mocks/mockBotProjectFile.json | 17 + .../__tests__/mocks/mockManifest.json | 25 + .../__tests__/mocks/mockProjectResponse.json | 12 +- .../dispatchers/__tests__/project.test.tsx | 324 ++++++++-- .../dispatchers/__tests__/storage.test.tsx | 93 +++ .../recoilModel/dispatchers/botProjectFile.ts | 80 +++ .../src/recoilModel/dispatchers/export.ts | 4 +- .../src/recoilModel/dispatchers/index.ts | 2 + .../src/recoilModel/dispatchers/multilang.ts | 6 +- .../src/recoilModel/dispatchers/project.ts | 599 +++++++----------- .../src/recoilModel/dispatchers/publisher.ts | 1 + .../src/recoilModel/dispatchers/setting.ts | 2 +- .../src/recoilModel/dispatchers/shared.ts | 18 +- .../src/recoilModel/dispatchers/storage.ts | 72 ++- .../recoilModel/dispatchers/utils/project.ts | 551 ++++++++++++++++ .../persistence/FilePersistence.ts | 23 +- .../src/recoilModel/persistence/types.ts | 1 + .../src/recoilModel/selectors/design.ts | 19 - .../client/src/recoilModel/selectors/index.ts | 2 +- .../src/recoilModel/selectors/project.ts | 59 ++ .../undo/__test__/history.test.tsx | 6 +- .../client/src/recoilModel/undo/history.ts | 11 +- Composer/packages/client/src/router.tsx | 7 +- .../packages/client/src/shell/useShell.ts | 4 +- .../src/utils/__test__/fileUtil.test.ts | 11 + .../packages/client/src/utils/fileUtil.ts | 20 + .../indexers/src/botProjectSpaceIndexer.ts | 19 + Composer/packages/lib/indexers/src/index.ts | 4 + .../lib/indexers/src/utils/fileExtensions.ts | 1 + .../shared/__tests__/fileUtils/index.test.ts | 28 + .../lib/shared/src/fileUtils/index.ts | 22 + Composer/packages/lib/shared/src/index.ts | 1 + .../lib/shared/src/skillsUtils/index.ts | 5 + .../__mocks__/samplebots/bot1/bot1.botproj | 6 + .../a/knowledge-base/en-us/a.en-us.qna | 0 .../b/knowledge-base/en-us/b.en-us.qna | 0 .../root/knowledge-base/en-us/root.en-us.qna | 0 .../bot1/knowledge-base/en-us/bot1.en-us.qna | 0 .../controllers/__tests__/publisher.test.ts | 3 +- .../server/src/controllers/project.ts | 35 +- .../server/src/models/asset/assetManager.ts | 26 + .../models/bot/__tests__/botProject.test.ts | 26 +- .../server/src/models/bot/botProject.ts | 73 ++- .../server/src/models/bot/botStructure.ts | 3 + .../src/models/storage/localDiskStorage.ts | 2 +- Composer/packages/server/src/router/api.ts | 3 +- .../packages/server/src/services/project.ts | 3 - Composer/packages/types/src/indexers.ts | 23 + .../ActionsSample/actionssample.botproj | 6 + .../askingquestionssample.botproj | 6 + .../controllingconversationflowsample.botproj | 6 + .../assets/projects/EchoBot/echobot.botproj | 6 + .../assets/projects/EmptyBot/emptybot.botproj | 6 + .../interruptionssample.botproj | 6 + .../qnamakerluissample.botproj | 6 + .../projects/QnASample/qnasample.botproj | 6 + .../respondingwithcardssample.botproj | 6 + .../respondingwithtextsample.botproj | 6 + .../todobotwithluissample.botproj | 6 + .../projects/TodoSample/todosample.botproj | 6 + 84 files changed, 2123 insertions(+), 563 deletions(-) create mode 100644 Composer/packages/client/src/recoilModel/dispatchers/__tests__/botProjectFile.test.tsx create mode 100644 Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockBotProjectFile.json create mode 100644 Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockManifest.json create mode 100644 Composer/packages/client/src/recoilModel/dispatchers/__tests__/storage.test.tsx create mode 100644 Composer/packages/client/src/recoilModel/dispatchers/botProjectFile.ts create mode 100644 Composer/packages/client/src/recoilModel/dispatchers/utils/project.ts delete mode 100644 Composer/packages/client/src/recoilModel/selectors/design.ts create mode 100644 Composer/packages/client/src/recoilModel/selectors/project.ts create mode 100644 Composer/packages/client/src/utils/__test__/fileUtil.test.ts create mode 100644 Composer/packages/lib/indexers/src/botProjectSpaceIndexer.ts create mode 100644 Composer/packages/lib/shared/__tests__/fileUtils/index.test.ts create mode 100644 Composer/packages/lib/shared/src/fileUtils/index.ts create mode 100644 Composer/packages/server/src/__mocks__/samplebots/bot1/bot1.botproj create mode 100644 Composer/packages/server/src/__mocks__/samplebots/bot1/dialogs/a/knowledge-base/en-us/a.en-us.qna create mode 100644 Composer/packages/server/src/__mocks__/samplebots/bot1/dialogs/b/knowledge-base/en-us/b.en-us.qna create mode 100644 Composer/packages/server/src/__mocks__/samplebots/bot1/dialogs/root/knowledge-base/en-us/root.en-us.qna create mode 100644 Composer/packages/server/src/__mocks__/samplebots/bot1/knowledge-base/en-us/bot1.en-us.qna create mode 100644 Composer/plugins/samples/assets/projects/ActionsSample/actionssample.botproj create mode 100644 Composer/plugins/samples/assets/projects/AskingQuestionsSample/askingquestionssample.botproj create mode 100644 Composer/plugins/samples/assets/projects/ControllingConversationFlowSample/controllingconversationflowsample.botproj create mode 100644 Composer/plugins/samples/assets/projects/EchoBot/echobot.botproj create mode 100644 Composer/plugins/samples/assets/projects/EmptyBot/emptybot.botproj create mode 100644 Composer/plugins/samples/assets/projects/InterruptionSample/interruptionssample.botproj create mode 100644 Composer/plugins/samples/assets/projects/QnAMakerLUISSample/qnamakerluissample.botproj create mode 100644 Composer/plugins/samples/assets/projects/QnASample/qnasample.botproj create mode 100644 Composer/plugins/samples/assets/projects/RespondingWithCardsSample/respondingwithcardssample.botproj create mode 100644 Composer/plugins/samples/assets/projects/RespondingWithTextSample/respondingwithtextsample.botproj create mode 100644 Composer/plugins/samples/assets/projects/ToDoBotWithLuisSample/todobotwithluissample.botproj create mode 100644 Composer/plugins/samples/assets/projects/TodoSample/todosample.botproj diff --git a/Composer/packages/client/__tests__/components/CreationFlow/index.test.tsx b/Composer/packages/client/__tests__/components/CreationFlow/index.test.tsx index 0d4e8641d8..826b96694c 100644 --- a/Composer/packages/client/__tests__/components/CreationFlow/index.test.tsx +++ b/Composer/packages/client/__tests__/components/CreationFlow/index.test.tsx @@ -15,7 +15,7 @@ describe('', () => { const createProjectMock = jest.fn(); const initRecoilState = ({ set }) => { set(dispatcherState, { - createProject: createProjectMock, + createNewBot: createProjectMock, fetchStorages: jest.fn(), fetchTemplateProjects: jest.fn(), onboardingAddCoachMarkRef: jest.fn(), @@ -70,14 +70,14 @@ describe('', () => { act(() => { fireEvent.click(node); }); - expect(createProjectMock).toHaveBeenCalledWith( - 'EchoBot', - 'EchoBot-1', - '', - expect.stringMatching(/(\/|\\)test-folder(\/|\\)Desktop/), - '', - 'en-US', - undefined - ); + expect(createProjectMock).toHaveBeenCalledWith({ + appLocale: 'en-US', + description: '', + location: '/test-folder/Desktop', + name: 'EchoBot-1', + qnaKbUrls: undefined, + schemaUrl: '', + templateId: 'EchoBot', + }); }); }); diff --git a/Composer/packages/client/__tests__/components/TestController/publish-luis-modal.test.tsx b/Composer/packages/client/__tests__/components/TestController/publish-luis-modal.test.tsx index 3bb6f18748..ee850212a2 100644 --- a/Composer/packages/client/__tests__/components/TestController/publish-luis-modal.test.tsx +++ b/Composer/packages/client/__tests__/components/TestController/publish-luis-modal.test.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { fireEvent } from '@bfc/test-utils'; import { PublishDialog } from '../../../src/components/TestController/publishDialog'; -import { botNameState, settingsState, dispatcherState, currentProjectIdState } from '../../../src/recoilModel'; +import { botDisplayNameState, settingsState, dispatcherState, currentProjectIdState } from '../../../src/recoilModel'; import { renderWithRecoil } from '../../testUtils'; jest.useFakeTimers(); @@ -31,7 +31,7 @@ describe('', () => { setSettings: setSettingsMock, }); set(currentProjectIdState, projectId); - set(botNameState(projectId), 'sampleBot0'); + set(botDisplayNameState(projectId), 'sampleBot0'); set(settingsState(projectId), { luis: luisConfig, qna: qnaConfig, diff --git a/Composer/packages/client/__tests__/components/skill.test.tsx b/Composer/packages/client/__tests__/components/skill.test.tsx index 67b0bdf4b1..f25044478a 100644 --- a/Composer/packages/client/__tests__/components/skill.test.tsx +++ b/Composer/packages/client/__tests__/components/skill.test.tsx @@ -223,7 +223,7 @@ describe('', () => { manifestUrl: 'Validating', }) ); - expect(httpClient.get).toBeCalledWith(`/projects/${projectId}/skill/retrieve-skill-manifest`, { + expect(httpClient.get).toBeCalledWith(`/projects/${projectId}/skill/retrieveSkillManifest`, { params: { url: formData.manifestUrl, }, @@ -261,7 +261,7 @@ describe('', () => { manifestUrl: 'Validating', }) ); - expect(httpClient.get).toBeCalledWith(`/projects/${projectId}/skill/retrieve-skill-manifest`, { + expect(httpClient.get).toBeCalledWith(`/projects/${projectId}/skill/retrieveSkillManifest`, { params: { url: formData.manifestUrl, }, diff --git a/Composer/packages/client/__tests__/pages/publish/Publish.test.tsx b/Composer/packages/client/__tests__/pages/publish/Publish.test.tsx index b9aa12ddfe..5d498305a3 100644 --- a/Composer/packages/client/__tests__/pages/publish/Publish.test.tsx +++ b/Composer/packages/client/__tests__/pages/publish/Publish.test.tsx @@ -5,7 +5,7 @@ import React from 'react'; import { renderWithRecoil } from '../../testUtils'; import { settingsState, - botNameState, + botDisplayNameState, publishTypesState, publishHistoryState, currentProjectIdState, @@ -53,7 +53,7 @@ const state = { const initRecoilState = ({ set }) => { set(currentProjectIdState, state.projectId); - set(botNameState(state.projectId), state.botName); + set(botDisplayNameState(state.projectId), state.botName); set(publishTypesState(state.projectId), state.publishTypes); set(publishHistoryState(state.projectId), state.publishHistory); set(settingsState(state.projectId), state.settings); diff --git a/Composer/packages/client/src/Onboarding/Onboarding.tsx b/Composer/packages/client/src/Onboarding/Onboarding.tsx index 81b5c2b20b..50817bf654 100644 --- a/Composer/packages/client/src/Onboarding/Onboarding.tsx +++ b/Composer/packages/client/src/Onboarding/Onboarding.tsx @@ -9,7 +9,7 @@ import { useRecoilValue } from 'recoil'; import onboardingStorage from '../utils/onboardingStorage'; import { OpenConfirmModal } from '../components/Modal/ConfirmDialog'; import { useLocation } from '../utils/hooks'; -import { dispatcherState, onboardingState, botProjectsSpaceState, validateDialogSelectorFamily } from '../recoilModel'; +import { dispatcherState, onboardingState, botProjectIdsState, validateDialogSelectorFamily } from '../recoilModel'; import OnboardingContext from './OnboardingContext'; import TeachingBubbles from './TeachingBubbles/TeachingBubbles'; @@ -20,7 +20,7 @@ const getCurrentSet = (stepSets) => stepSets.findIndex(({ id }) => id === onboar const Onboarding: React.FC = () => { const didMount = useRef(false); - const botProjects = useRecoilValue(botProjectsSpaceState); + const botProjects = useRecoilValue(botProjectIdsState); const rootBotProjectId = botProjects[0]; const dialogs = useRecoilValue(validateDialogSelectorFamily(rootBotProjectId)); const { onboardingSetComplete } = useRecoilValue(dispatcherState); diff --git a/Composer/packages/client/src/components/CreateSkillModal.tsx b/Composer/packages/client/src/components/CreateSkillModal.tsx index f42a21b491..0a900a564e 100644 --- a/Composer/packages/client/src/components/CreateSkillModal.tsx +++ b/Composer/packages/client/src/components/CreateSkillModal.tsx @@ -83,7 +83,7 @@ export const validateManifestUrl = async ({ } else { try { setValidationState({ ...validationState, manifestUrl: ValidationState.Validating }); - const { data } = await httpClient.get(`/projects/${projectId}/skill/retrieve-skill-manifest`, { + const { data } = await httpClient.get(`/projects/${projectId}/skill/retrieveSkillManifest`, { params: { url: manifestUrl, }, diff --git a/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx b/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx index bcfff92fc8..72ceec9211 100644 --- a/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx +++ b/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx @@ -32,9 +32,6 @@ type CreationFlowProps = RouteComponentProps<{}>; const CreationFlow: React.FC = () => { const { fetchTemplates, - openProject, - createProject, - saveProjectAs, fetchStorages, fetchFolderItemsByPath, setCreationFlowStatus, @@ -42,9 +39,13 @@ const CreationFlow: React.FC = () => { updateCurrentPathForStorage, updateFolder, saveTemplateId, - fetchProjectById, fetchRecentProjects, + openProject, + createNewBot, + saveProjectAs, + fetchProjectById, } = useRecoilValue(dispatcherState); + const creationFlowStatus = useRecoilValue(creationFlowStatusState); const projectId = useRecoilValue(currentProjectIdState); const templateProjects = useRecoilValue(templateProjectsState); @@ -102,15 +103,16 @@ const CreationFlow: React.FC = () => { }; const handleCreateNew = async (formData, templateId: string, qnaKbUrls?: string[]) => { - createProject( - templateId || '', - formData.name, - formData.description, - formData.location, - formData.schemaUrl, + const newBotData = { + templateId: templateId || '', + name: formData.name, + description: formData.description, + location: formData.location, + schemaUrl: formData.schemaUrl, appLocale, - qnaKbUrls - ); + qnaKbUrls, + }; + createNewBot(newBotData); }; const handleSaveAs = (formData) => { diff --git a/Composer/packages/client/src/components/Header.tsx b/Composer/packages/client/src/components/Header.tsx index 98519ed5a6..c4bc3796e9 100644 --- a/Composer/packages/client/src/components/Header.tsx +++ b/Composer/packages/client/src/components/Header.tsx @@ -10,7 +10,13 @@ import { useRecoilValue } from 'recoil'; import { SharedColors } from '@uifabric/fluent-theme'; import { FontWeights } from 'office-ui-fabric-react/lib/Styling'; -import { dispatcherState, appUpdateState, botNameState, localeState, currentProjectIdState } from '../recoilModel'; +import { + dispatcherState, + appUpdateState, + botDisplayNameState, + localeState, + currentProjectIdState, +} from '../recoilModel'; import composerIcon from '../images/composerIcon.svg'; import { AppUpdaterStatus } from '../constants'; @@ -75,7 +81,7 @@ const headerTextContainer = css` export const Header = () => { const { setAppUpdateShowing } = useRecoilValue(dispatcherState); const projectId = useRecoilValue(currentProjectIdState); - const projectName = useRecoilValue(botNameState(projectId)); + const projectName = useRecoilValue(botDisplayNameState(projectId)); const locale = useRecoilValue(localeState(projectId)); const appUpdate = useRecoilValue(appUpdateState); const { showing, status } = appUpdate; diff --git a/Composer/packages/client/src/components/TestController/TestController.tsx b/Composer/packages/client/src/components/TestController/TestController.tsx index 1866dd6d0f..27549b7274 100644 --- a/Composer/packages/client/src/components/TestController/TestController.tsx +++ b/Composer/packages/client/src/components/TestController/TestController.tsx @@ -15,7 +15,7 @@ import { dispatcherState, validateDialogSelectorFamily, botStatusState, - botNameState, + botDisplayNameState, luFilesState, qnaFilesState, settingsState, @@ -62,7 +62,7 @@ export const TestController: React.FC<{ projectId: string }> = (props) => { const dialogs = useRecoilValue(validateDialogSelectorFamily(projectId)); const botStatus = useRecoilValue(botStatusState(projectId)); - const botName = useRecoilValue(botNameState(projectId)); + const botName = useRecoilValue(botDisplayNameState(projectId)); const luFiles = useRecoilValue(luFilesState(projectId)); const settings = useRecoilValue(settingsState(projectId)); const qnaFiles = useRecoilValue(qnaFilesState(projectId)); diff --git a/Composer/packages/client/src/pages/design/exportSkillModal/content/Description.tsx b/Composer/packages/client/src/pages/design/exportSkillModal/content/Description.tsx index 269a8fea45..a05a44f953 100644 --- a/Composer/packages/client/src/pages/design/exportSkillModal/content/Description.tsx +++ b/Composer/packages/client/src/pages/design/exportSkillModal/content/Description.tsx @@ -11,7 +11,7 @@ import { useRecoilValue } from 'recoil'; import { v4 as uuid } from 'uuid'; import { ContentProps } from '../constants'; -import { botNameState } from '../../../../recoilModel'; +import { botDisplayNameState } from '../../../../recoilModel'; const styles = { row: css` @@ -51,7 +51,7 @@ const InlineLabelField: React.FC = (props) => { }; export const Description: React.FC = ({ errors, value, schema, onChange, projectId }) => { - const botName = useRecoilValue(botNameState(projectId)); + const botName = useRecoilValue(botDisplayNameState(projectId)); const { $schema, ...rest } = value; const { hidden, properties } = useMemo( diff --git a/Composer/packages/client/src/pages/design/exportSkillModal/content/SaveManifest.tsx b/Composer/packages/client/src/pages/design/exportSkillModal/content/SaveManifest.tsx index 38578069a7..41567316da 100644 --- a/Composer/packages/client/src/pages/design/exportSkillModal/content/SaveManifest.tsx +++ b/Composer/packages/client/src/pages/design/exportSkillModal/content/SaveManifest.tsx @@ -11,7 +11,7 @@ import { useRecoilValue } from 'recoil'; import formatMessage from 'format-message'; import { ContentProps, VERSION_REGEX } from '../constants'; -import { botNameState, skillManifestsState } from '../../../../recoilModel'; +import { botDisplayNameState, skillManifestsState } from '../../../../recoilModel'; const styles = { container: css` @@ -42,7 +42,7 @@ export const getManifestId = ( }; export const SaveManifest: React.FC = ({ errors, manifest, setSkillManifest, projectId }) => { - const botName = useRecoilValue(botNameState(projectId)); + const botName = useRecoilValue(botDisplayNameState(projectId)); const skillManifests = useRecoilValue(skillManifestsState(projectId)); const { id } = manifest; diff --git a/Composer/packages/client/src/pages/home/Home.tsx b/Composer/packages/client/src/pages/home/Home.tsx index 334bf548cc..09c02ed024 100644 --- a/Composer/packages/client/src/pages/home/Home.tsx +++ b/Composer/packages/client/src/pages/home/Home.tsx @@ -12,7 +12,7 @@ import { navigate } from '@reach/router'; import { useRecoilValue } from 'recoil'; import { CreationFlowStatus } from '../../constants'; -import { dispatcherState, botNameState } from '../../recoilModel'; +import { dispatcherState, botDisplayNameState } from '../../recoilModel'; import { recentProjectsState, templateProjectsState, @@ -63,7 +63,7 @@ const tutorials = [ const Home: React.FC = () => { const templateProjects = useRecoilValue(templateProjectsState); const projectId = useRecoilValue(currentProjectIdState); - const botName = useRecoilValue(botNameState(projectId)); + const botName = useRecoilValue(botDisplayNameState(projectId)); const recentProjects = useRecoilValue(recentProjectsState); const templateId = useRecoilValue(templateIdState); const { openProject, setCreationFlowStatus, onboardingAddCoachMarkRef, saveTemplateId } = useRecoilValue( diff --git a/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx b/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx index c4b90d6390..8381f84378 100644 --- a/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx +++ b/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx @@ -15,7 +15,7 @@ import { navigateTo } from '../../utils/navigation'; import { TestController } from '../../components/TestController/TestController'; import { INavTreeItem } from '../../components/NavTree'; import { Page } from '../../components/Page'; -import { botNameState, dialogsState, qnaAllUpViewStatusState } from '../../recoilModel/atoms/botState'; +import { botDisplayNameState, dialogsState, qnaAllUpViewStatusState } from '../../recoilModel/atoms/botState'; import { dispatcherState } from '../../recoilModel'; import { QnAAllUpViewStatus } from '../../recoilModel/types'; @@ -31,9 +31,10 @@ interface QnAPageProps extends RouteComponentProps<{}> { const QnAPage: React.FC = (props) => { const { dialogId = '', projectId = '' } = props; + const actions = useRecoilValue(dispatcherState); const dialogs = useRecoilValue(dialogsState(projectId)); - const botName = useRecoilValue(botNameState(projectId)); + const botName = useRecoilValue(botDisplayNameState(projectId)); //To do: support other languages const locale = 'en-us'; //const locale = useRecoilValue(localeState); diff --git a/Composer/packages/client/src/pages/notifications/useNotifications.tsx b/Composer/packages/client/src/pages/notifications/useNotifications.tsx index bbe66112bd..d81c24781d 100644 --- a/Composer/packages/client/src/pages/notifications/useNotifications.tsx +++ b/Composer/packages/client/src/pages/notifications/useNotifications.tsx @@ -15,6 +15,7 @@ import { skillManifestsState, dialogSchemasState, qnaFilesState, + botProjectFileState, } from '../../recoilModel'; import { @@ -38,6 +39,7 @@ export default function useNotifications(projectId: string, filter?: string) { const skillManifests = useRecoilValue(skillManifestsState(projectId)); const dialogSchemas = useRecoilValue(dialogSchemasState(projectId)); const qnaFiles = useRecoilValue(qnaFilesState(projectId)); + const botProjectFile = useRecoilValue(botProjectFileState(projectId)); const botAssets = { projectId, @@ -48,6 +50,7 @@ export default function useNotifications(projectId: string, filter?: string) { skillManifests, setting, dialogSchemas, + botProjectFile, }; const memoized = useMemo(() => { diff --git a/Composer/packages/client/src/pages/publish/Publish.tsx b/Composer/packages/client/src/pages/publish/Publish.tsx index a378885ed1..ac89b47bbe 100644 --- a/Composer/packages/client/src/pages/publish/Publish.tsx +++ b/Composer/packages/client/src/pages/publish/Publish.tsx @@ -17,7 +17,7 @@ import { projectContainer } from '../design/styles'; import { dispatcherState, settingsState, - botNameState, + botDisplayNameState, publishTypesState, publishHistoryState, } from '../../recoilModel'; @@ -36,7 +36,7 @@ const Publish: React.FC(); const settings = useRecoilValue(settingsState(projectId)); - const botName = useRecoilValue(botNameState(projectId)); + const botName = useRecoilValue(botDisplayNameState(projectId)); const publishTypes = useRecoilValue(publishTypesState(projectId)); const publishHistory = useRecoilValue(publishHistoryState(projectId)); diff --git a/Composer/packages/client/src/pages/setting/SettingsPage.tsx b/Composer/packages/client/src/pages/setting/SettingsPage.tsx index 350fa2ed46..37b12d8b4b 100644 --- a/Composer/packages/client/src/pages/setting/SettingsPage.tsx +++ b/Composer/packages/client/src/pages/setting/SettingsPage.tsx @@ -37,7 +37,7 @@ const getProjectLink = (path: string, id?: string) => { const SettingPage: React.FC = () => { const projectId = useRecoilValue(currentProjectIdState); const { - deleteBotProject, + deleteBot: deleteBotProject, addLanguageDialogBegin, addLanguageDialogCancel, delLanguageDialogBegin, diff --git a/Composer/packages/client/src/pages/setting/dialog-settings/DialogSettings.tsx b/Composer/packages/client/src/pages/setting/dialog-settings/DialogSettings.tsx index d4c781d095..4af89f7a76 100644 --- a/Composer/packages/client/src/pages/setting/dialog-settings/DialogSettings.tsx +++ b/Composer/packages/client/src/pages/setting/dialog-settings/DialogSettings.tsx @@ -14,7 +14,13 @@ import { Dropdown, IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown'; import cloneDeep from 'lodash/cloneDeep'; import { Label } from 'office-ui-fabric-react/lib/Label'; -import { dispatcherState, userSettingsState, botNameState, localeState, settingsState } from '../../../recoilModel'; +import { + dispatcherState, + userSettingsState, + botDisplayNameState, + localeState, + settingsState, +} from '../../../recoilModel'; import { languageListTemplates } from '../../../components/MultiLanguage'; import { settingsEditor, toolbar } from './style'; @@ -22,7 +28,7 @@ import { BotSettings } from './constants'; export const DialogSettings: React.FC> = (props) => { const { projectId = '' } = props; - const botName = useRecoilValue(botNameState(projectId)); + const botName = useRecoilValue(botDisplayNameState(projectId)); const locale = useRecoilValue(localeState(projectId)); const settings = useRecoilValue(settingsState(projectId)); const userSettings = useRecoilValue(userSettingsState); diff --git a/Composer/packages/client/src/pages/setting/runtime-settings/RuntimeSettings.tsx b/Composer/packages/client/src/pages/setting/runtime-settings/RuntimeSettings.tsx index 8280f49d25..59557a652a 100644 --- a/Composer/packages/client/src/pages/setting/runtime-settings/RuntimeSettings.tsx +++ b/Composer/packages/client/src/pages/setting/runtime-settings/RuntimeSettings.tsx @@ -17,7 +17,7 @@ import { dispatcherState, ejectRuntimeSelector, boilerplateVersionState, - botNameState, + botDisplayNameState, settingsState, isEjectRuntimeExistState, } from '../../../recoilModel'; @@ -30,7 +30,7 @@ import { breathingSpace, runtimeSettingsStyle, runtimeControls, runtimeToggle, c export const RuntimeSettings: React.FC> = (props) => { const { projectId = '' } = props; - const botName = useRecoilValue(botNameState(projectId)); + const botName = useRecoilValue(botDisplayNameState(projectId)); const settings = useRecoilValue(settingsState(projectId)); const ejectedRuntimeExists = useRecoilValue(isEjectRuntimeExistState(projectId)); diff --git a/Composer/packages/client/src/pages/skills/index.tsx b/Composer/packages/client/src/pages/skills/index.tsx index bdfa8467b9..12df39ccaf 100644 --- a/Composer/packages/client/src/pages/skills/index.tsx +++ b/Composer/packages/client/src/pages/skills/index.tsx @@ -9,7 +9,7 @@ import formatMessage from 'format-message'; import { useRecoilValue } from 'recoil'; import { SkillSetting } from '@bfc/shared'; -import { dispatcherState, settingsState, botNameState } from '../../recoilModel'; +import { dispatcherState, settingsState, botDisplayNameState } from '../../recoilModel'; import { Toolbar, IToolbarItem } from '../../components/Toolbar'; import { TestController } from '../../components/TestController/TestController'; import { CreateSkillModal } from '../../components/CreateSkillModal'; @@ -22,7 +22,7 @@ const Skills: React.FC> = (props) => const { projectId = '' } = props; const [showAddSkillDialogModal, setShowAddSkillDialogModal] = useState(false); - const botName = useRecoilValue(botNameState(projectId)); + const botName = useRecoilValue(botDisplayNameState(projectId)); const settings = useRecoilValue(settingsState(projectId)); const { addSkill, setSettings } = useRecoilValue(dispatcherState); diff --git a/Composer/packages/client/src/recoilModel/DispatcherWrapper.tsx b/Composer/packages/client/src/recoilModel/DispatcherWrapper.tsx index 892e7b7455..f1ec735e29 100644 --- a/Composer/packages/client/src/recoilModel/DispatcherWrapper.tsx +++ b/Composer/packages/client/src/recoilModel/DispatcherWrapper.tsx @@ -14,7 +14,6 @@ import { UndoRoot } from './undo/history'; import { prepareAxios } from './../utils/auth'; import createDispatchers, { Dispatcher } from './dispatchers'; import { - botProjectsSpaceState, dialogsState, luFilesState, qnaFilesState, @@ -23,7 +22,9 @@ import { dialogSchemasState, settingsState, filePersistenceState, + botProjectFileState, } from './atoms'; +import { botsForFilePersistenceSelector } from './selectors'; const getBotAssets = async (projectId, snapshot: Snapshot): Promise => { const result = await Promise.all([ @@ -34,6 +35,7 @@ const getBotAssets = async (projectId, snapshot: Snapshot): Promise = snapshot.getPromise(skillManifestsState(projectId)), snapshot.getPromise(settingsState(projectId)), snapshot.getPromise(dialogSchemasState(projectId)), + snapshot.getPromise(botProjectFileState(projectId)), ]); return { projectId, @@ -44,6 +46,7 @@ const getBotAssets = async (projectId, snapshot: Snapshot): Promise = skillManifests: result[4], setting: result[5], dialogSchemas: result[6], + botProjectFile: result[7], }; }; @@ -85,10 +88,11 @@ const InitDispatcher = ({ onLoad }) => { export const DispatcherWrapper = ({ children }) => { const [loaded, setLoaded] = useState(false); - const botProjects = useRecoilValue(botProjectsSpaceState); + const botProjects = useRecoilValue(botsForFilePersistenceSelector); useRecoilTransactionObserver_UNSTABLE(async ({ snapshot, previousSnapshot }) => { - for (const projectId of botProjects) { + const botsForFilePersistence = await snapshot.getPromise(botsForFilePersistenceSelector); + for (const projectId of botsForFilePersistence) { const assets = await getBotAssets(projectId, snapshot); const previousAssets = await getBotAssets(projectId, previousSnapshot); const filePersistence = await snapshot.getPromise(filePersistenceState(projectId)); diff --git a/Composer/packages/client/src/recoilModel/atoms/appState.ts b/Composer/packages/client/src/recoilModel/atoms/appState.ts index cb10514fdb..1926d46b6a 100644 --- a/Composer/packages/client/src/recoilModel/atoms/appState.ts +++ b/Composer/packages/client/src/recoilModel/atoms/appState.ts @@ -170,13 +170,8 @@ export const extensionsState = atom[]>({ default: [], }); -export const botOpeningState = atom({ - key: getFullyQualifiedKey('botOpening'), - default: false, -}); - -export const botProjectsSpaceState = atom({ - key: getFullyQualifiedKey('botProjectsSpace'), +export const botProjectIdsState = atom({ + key: getFullyQualifiedKey('botProjectIdsState'), default: [], }); @@ -184,3 +179,13 @@ export const currentProjectIdState = atom({ key: getFullyQualifiedKey('currentProjectId'), default: '', }); + +export const botProjectSpaceLoadedState = atom({ + key: getFullyQualifiedKey('botProjectSpaceLoadedState'), + default: false, +}); + +export const botOpeningState = atom({ + key: getFullyQualifiedKey('botOpeningState'), + default: false, +}); diff --git a/Composer/packages/client/src/recoilModel/atoms/botState.ts b/Composer/packages/client/src/recoilModel/atoms/botState.ts index 5880e99a3d..4c50533561 100644 --- a/Composer/packages/client/src/recoilModel/atoms/botState.ts +++ b/Composer/packages/client/src/recoilModel/atoms/botState.ts @@ -12,6 +12,8 @@ import { BotSchemas, Skill, DialogSetting, + BotProjectSpace, + BotProjectFile, } from '@bfc/shared'; import { BotLoadError, DesignPageLocation, QnAAllUpViewStatus } from '../../recoilModel/types'; @@ -42,8 +44,8 @@ export const dialogSchemasState = atomFamily({ default: [], }); -export const botNameState = atomFamily({ - key: getFullyQualifiedKey('botName'), +export const botDisplayNameState = atomFamily({ + key: getFullyQualifiedKey('botDisplayName'), default: (id) => { return ''; }, @@ -218,10 +220,13 @@ export const onDelLanguageDialogCompleteState = atomFamily({ default: { func: undefined }, }); -export const projectMetaDataState = atomFamily({ +export const projectMetaDataState = atomFamily<{ isRootBot: boolean; isRemote: boolean }, string>({ key: getFullyQualifiedKey('projectsMetaDataState'), - default: (id) => { - return {}; + default: () => { + return { + isRootBot: false, + isRemote: false, + }; }, }); @@ -254,3 +259,23 @@ export const filePersistenceState = atomFamily({ default: {} as FilePersistence, dangerouslyAllowMutability: true, }); + +export const botProjectFileState = atomFamily({ + key: getFullyQualifiedKey('botProjectFile'), + default: { + content: {} as BotProjectSpace, + id: '', + lastModified: '', + }, +}); + +export const botErrorState = atomFamily({ + key: getFullyQualifiedKey('botError'), + default: undefined, +}); + +// Object key to identify the skill in BotProject file and settings.skill +export const botNameIdentifierState = atomFamily({ + key: getFullyQualifiedKey('botNameIdentifier'), + default: '', +}); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/botProjectFile.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/botProjectFile.test.tsx new file mode 100644 index 0000000000..df4050b152 --- /dev/null +++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/botProjectFile.test.tsx @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { selector, useRecoilValue, selectorFamily, useRecoilState } from 'recoil'; +import { act, RenderHookResult, HookResult } from '@bfc/test-utils/lib/hooks'; +import noop from 'lodash/noop'; + +import { botProjectFileDispatcher } from '../botProjectFile'; +import { renderRecoilHook } from '../../../../__tests__/testUtils'; +import { + botDisplayNameState, + botErrorState, + botNameIdentifierState, + botProjectFileState, + botProjectIdsState, + currentProjectIdState, + locationState, + projectMetaDataState, +} from '../../atoms'; +import { dispatcherState } from '../../DispatcherWrapper'; +import { Dispatcher } from '..'; + +jest.mock('../../../utils/httpUtil'); +const rootBotProjectId = '2345.32324'; +const testSkillId = '123.1sd23'; + +describe('Bot Project File dispatcher', () => { + const skillsDataSelector = selectorFamily({ + key: 'skillsDataSelector-botProjectFile', + get: (skillId: string) => noop, + set: (skillId: string) => ({ set }, stateUpdater: any) => { + const { botNameIdentifier, location } = stateUpdater; + set(botNameIdentifierState(skillId), botNameIdentifier); + set(locationState(skillId), location); + }, + }); + + const botStatesSelector = selector({ + key: 'botStatesSelector', + get: ({ get }) => { + const botProjectIds = get(botProjectIdsState); + const botProjectData: { [projectName: string]: { botDisplayName: string; botError: any; location: string } } = {}; + botProjectIds.map((projectId) => { + const botDisplayName = get(botDisplayNameState(projectId)); + const botNameIdentifier = get(botNameIdentifierState(projectId)); + const botError = get(botErrorState(projectId)); + const location = get(locationState(projectId)); + if (botNameIdentifier) { + botProjectData[botNameIdentifier] = { + botDisplayName, + location, + botError, + }; + } + }); + return botProjectData; + }, + }); + + const useRecoilTestHook = () => { + const botName = useRecoilValue(botDisplayNameState(rootBotProjectId)); + const botProjectFile = useRecoilValue(botProjectFileState(rootBotProjectId)); + const currentDispatcher = useRecoilValue(dispatcherState); + const botStates = useRecoilValue(botStatesSelector); + const [skillsData, setSkillsData] = useRecoilState(skillsDataSelector(testSkillId)); + + return { + botName, + currentDispatcher, + botProjectFile, + botStates, + skillsData, + setSkillsData, + }; + }; + + let renderedComponent: HookResult>, dispatcher: Dispatcher; + beforeEach(() => { + const rendered: RenderHookResult> = renderRecoilHook( + useRecoilTestHook, + { + states: [ + { recoilState: currentProjectIdState, initialValue: rootBotProjectId }, + { + recoilState: botProjectFileState(rootBotProjectId), + initialValue: { + content: { + $schema: '', + name: 'TesterBot', + workspace: 'file:///Users/tester/Desktop/LoadedBotProject/TesterBot', + skills: {}, + }, + }, + }, + { + recoilState: projectMetaDataState(rootBotProjectId), + initialValue: { + isRootBot: true, + }, + }, + { recoilState: botProjectIdsState, initialValue: [rootBotProjectId] }, + ], + dispatcher: { + recoilState: dispatcherState, + initialValue: { + botProjectFileDispatcher, + }, + }, + } + ); + renderedComponent = rendered.result; + dispatcher = renderedComponent.current.currentDispatcher; + }); + + it('should add a local skill to bot project file', async () => { + await act(async () => { + renderedComponent.current.setSkillsData({ + location: 'Users/tester/Desktop/LoadedBotProject/Todo-Skill', + botNameIdentifier: 'todoSkill', + }); + }); + + await act(async () => { + dispatcher.addLocalSkillToBotProjectFile(testSkillId); + }); + + expect(renderedComponent.current.botProjectFile.content.skills.todoSkill.workspace).toBe( + 'file:///Users/tester/Desktop/LoadedBotProject/Todo-Skill' + ); + expect(renderedComponent.current.botProjectFile.content.skills.todoSkill.remote).toBeFalsy(); + }); + + it('should add a remote skill to bot project file', async () => { + const manifestUrl = 'https://test-dev.azurewebsites.net/manifests/test-2-1-preview-1-manifest.json'; + await act(async () => { + renderedComponent.current.setSkillsData({ + location: manifestUrl, + botNameIdentifier: 'oneNoteSkill', + }); + }); + + await act(async () => { + dispatcher.addRemoteSkillToBotProjectFile(testSkillId, manifestUrl, 'remote'); + }); + + expect(renderedComponent.current.botProjectFile.content.skills.oneNoteSkill.manifest).toBe(manifestUrl); + expect(renderedComponent.current.botProjectFile.content.skills.oneNoteSkill.workspace).toBeUndefined(); + expect(renderedComponent.current.botProjectFile.content.skills.oneNoteSkill.endpointName).toBe('remote'); + }); + + it('should remove a skill from the bot project file', async () => { + const manifestUrl = 'https://test-dev.azurewebsites.net/manifests/test-2-1-preview-1-manifest.json'; + await act(async () => { + renderedComponent.current.setSkillsData({ + location: manifestUrl, + botNameIdentifier: 'oneNoteSkill', + }); + }); + + await act(async () => { + dispatcher.addRemoteSkillToBotProjectFile(testSkillId, manifestUrl, 'remote'); + }); + expect(renderedComponent.current.botProjectFile.content.skills.oneNoteSkill.manifest).toBe(manifestUrl); + + await act(async () => { + dispatcher.removeSkillFromBotProjectFile(testSkillId); + }); + expect(renderedComponent.current.botProjectFile.content.skills.oneNoteSkill).toBeUndefined(); + }); +}); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/export.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/export.test.tsx index c02ff75ea7..0811086a2e 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/export.test.tsx +++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/export.test.tsx @@ -7,7 +7,7 @@ import { act } from '@bfc/test-utils/lib/hooks'; import httpClient from '../../../utils/httpUtil'; import { exportDispatcher } from '../export'; import { renderRecoilHook } from '../../../../__tests__/testUtils'; -import { botNameState, currentProjectIdState } from '../../atoms'; +import { botDisplayNameState, currentProjectIdState } from '../../atoms'; import { dispatcherState } from '../../../recoilModel/DispatcherWrapper'; import { Dispatcher } from '../../../recoilModel/dispatchers'; @@ -22,7 +22,7 @@ describe('Export dispatcher', () => { prevAppendChild = document.body.appendChild; const useRecoilTestHook = () => { - const botName = useRecoilValue(botNameState(projectId)); + const botName = useRecoilValue(botDisplayNameState(projectId)); const currentDispatcher = useRecoilValue(dispatcherState); return { botName, @@ -33,7 +33,7 @@ describe('Export dispatcher', () => { const { result } = renderRecoilHook(useRecoilTestHook, { states: [ { recoilState: currentProjectIdState, initialValue: projectId }, - { recoilState: botNameState(projectId), initialValue: 'emptybot-1' }, + { recoilState: botDisplayNameState(projectId), initialValue: 'emptybot-1' }, ], dispatcher: { recoilState: dispatcherState, diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockBotProjectFile.json b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockBotProjectFile.json new file mode 100644 index 0000000000..b1bf6ae908 --- /dev/null +++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockBotProjectFile.json @@ -0,0 +1,17 @@ +{ + "$schema": "", + "name": "TesterBot", + "workspace": "file:///Users/tester/Desktop/LoadedBotProject/TesterBot", + "skills": { + "todoSkill": { + "workspace": "file:///Users/tester/Desktop/LoadedBotProject/Todo-Skill", + "manifest": "Todo-Skill-2-1-preview-1-manifest", + "remote": false, + "endpointName": "default" + }, + "googleKeepSync": { + "workspace": "file:///Users/tester/Desktop/LoadedBotProject/GoogleKeepSync", + "remote": false + } + } +} diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockManifest.json b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockManifest.json new file mode 100644 index 0000000000..839e03f889 --- /dev/null +++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockManifest.json @@ -0,0 +1,25 @@ + +{ + "$schema": "https://schemas.botframework.com/schemas/skills/skill-manifest-2.1.preview-1.json", + "$id": "OneNoteSync", + "name": "OneNoteSync", + "version": "1.0", + "publisherName": "Microsoft", + "description": "Sync notes to OneNote", + "endpoints": [ + { + "name": "default", + "protocol": "BotFrameworkV3", + "description": "Local endpoint for SkillBot.", + "endpointUrl": "http://localhost:3988/api/messages", + "msAppId": "123-b33a9-4b2bb-9d6d-21" + }, + { + "name": "remote", + "protocol": "BotFrameworkV3", + "description": "Production endpoint for SkillBot.", + "endpointUrl": "https://test.net/api/messages", + "msAppId": "123-8138c-43144-8676-21" + } + ] +} diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockProjectResponse.json b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockProjectResponse.json index 1f752cb915..e815347024 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockProjectResponse.json +++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockProjectResponse.json @@ -6,8 +6,8 @@ { "name": "emptybot-1.dialog", "content": "{\n \"$kind\": \"Microsoft.AdaptiveDialog\",\n \"$designer\": {\n \"name\": \"AddItem\",\n \"id\": \"225905\"\n },\n \"autoEndDialog\": true,\n \"defaultResultProperty\": \"dialog.result\",\n \"triggers\": [\n {\n \"$kind\": \"Microsoft.OnBeginDialog\",\n \"$designer\": {\n \"name\": \"BeginDialog\",\n \"id\": \"479346\"\n },\n \"actions\": [\n {\n \"$kind\": \"Microsoft.SetProperties\",\n \"$designer\": {\n \"id\": \"811190\",\n \"name\": \"Set properties\"\n },\n \"assignments\": [\n {\n \"property\": \"dialog.itemTitle\",\n \"value\": \"=coalesce(@itemTitle, $itemTitle)\"\n },\n {\n \"property\": \"dialog.listType\",\n \"value\": \"=coalesce(@listType, $listType)\"\n }\n ]\n },\n {\n \"$kind\": \"Microsoft.TextInput\",\n \"$designer\": {\n \"id\": \"282825\",\n \"name\": \"AskForTitle\"\n },\n \"prompt\": \"${TextInput_Prompt_282825()}\",\n \"maxTurnCount\": \"3\",\n \"property\": \"dialog.itemTitle\",\n \"value\": \"=coalesce(@itemTitle, $itemTitle)\",\n \"allowInterruptions\": \"!@itemTitle && #_Interruption.Score >= 0.9\"\n },\n {\n \"$kind\": \"Microsoft.ChoiceInput\",\n \"$designer\": {\n \"id\": \"878594\",\n \"name\": \"AskForListType\"\n },\n \"prompt\": \"${TextInput_Prompt_878594()}\",\n \"maxTurnCount\": \"3\",\n \"property\": \"dialog.listType\",\n \"value\": \"=@listType\",\n \"allowInterruptions\": \"!@listType\",\n \"outputFormat\": \"value\",\n \"choices\": [\n {\n \"value\": \"todo\",\n \"synonyms\": [\n \"to do\"\n ]\n },\n {\n \"value\": \"grocery\",\n \"synonyms\": [\n \"groceries\"\n ]\n },\n {\n \"value\": \"shopping\",\n \"synonyms\": [\n \"shoppers\"\n ]\n }\n ],\n \"appendChoices\": \"true\",\n \"defaultLocale\": \"en-us\",\n \"style\": \"Auto\",\n \"choiceOptions\": {\n \"inlineSeparator\": \", \",\n \"inlineOr\": \" or \",\n \"inlineOrMore\": \", or \",\n \"includeNumbers\": true\n },\n \"recognizerOptions\": {\n \"noValue\": false\n }\n },\n {\n \"$kind\": \"Microsoft.EditArray\",\n \"$designer\": {\n \"id\": \"733511\",\n \"name\": \"Edit an Array property\"\n },\n \"changeType\": \"push\",\n \"itemsProperty\": \"user.lists[dialog.listType]\",\n \"value\": \"=$itemTitle\"\n },\n {\n \"$kind\": \"Microsoft.SendActivity\",\n \"$designer\": {\n \"id\": \"139532\",\n \"name\": \"Send a response\"\n },\n \"activity\": \"${SendActivity_139532()}\"\n }\n ]\n }\n ],\n \"generator\": \"additem.lg\",\n \"recognizer\": \"additem.lu\"\n}\n", - "path": "/Users/tester/Desktop/EmptyBot-1/dialogs/additem/additem.dialog", - "relativePath": "dialogs/additem/additem.dialog", + "path": "/Users/tester/Desktop/EmptyBot-1/additem.dialog", + "relativePath": "", "lastModified": "Thu Jul 09 2020 10:19:09 GMT-0700 (Pacific Daylight Time)" }, { @@ -23,6 +23,12 @@ "path": "/Users/tester/Desktop/EmptyBot-1/dialogs/additem/language-understanding/en-us/additem.en-us.lu", "relativePath": "dialogs/additem/language-understanding/en-us/additem.en-us.lu", "lastModified": "Thu Jul 09 2020 10:19:09 GMT-0700 (Pacific Daylight Time)" + },{ + "name": "EmptyBot-1.botproj", + "content": "{\"$schema\":\"https:\/\/schemas.botframework.com\/schemas\/botprojects\/v0.1\/botproject-schema.json\",\"name\":\"echobot-0\",\"workspace\":\"\/Users\/tester\/Desktop\/samples\/EchoBot-0\",\"skills\":{}}", + "path": "/Users/tester/Desktop/EmptyBot-1/EmptyBot-1.botproj", + "relativePath": "dialogs/additem/language-understanding/en-us/additem.en-us.lu", + "lastModified": "Thu Jul 09 2020 10:19:09 GMT-0700 (Pacific Daylight Time)" } ], "location": "/Users/tester/Desktop/EmptyBot-1", @@ -8623,7 +8629,7 @@ }, "diagnostics": [] }, - "skills": [], + "skills": {}, "diagnostics": [], "settings": { "feature": { 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 b572b7f35d..da7ac2075f 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/project.test.tsx +++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/project.test.tsx @@ -1,12 +1,17 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import { useRecoilValue } from 'recoil'; +import { selector, useRecoilValue } from 'recoil'; +import { v4 as uuid } from 'uuid'; import { act, RenderHookResult, HookResult } from '@bfc/test-utils/lib/hooks'; import { useRecoilState } from 'recoil'; +import cloneDeep from 'lodash/cloneDeep'; +import endsWith from 'lodash/endsWith'; +import findIndex from 'lodash/findIndex'; import httpClient from '../../../utils/httpUtil'; import { projectDispatcher } from '../project'; +import { botProjectFileDispatcher } from '../botProjectFile'; import { renderRecoilHook } from '../../../../__tests__/testUtils'; import { recentProjectsState, @@ -28,15 +33,22 @@ import { schemasState, locationState, skillsState, - botOpeningState, botStatusState, - botNameState, + botDisplayNameState, + botOpeningState, + botProjectFileState, + botProjectIdsState, + botNameIdentifierState, + botErrorState, + botProjectSpaceLoadedState, } from '../../atoms'; import { dispatcherState } from '../../../recoilModel/DispatcherWrapper'; import { Dispatcher } from '../../dispatchers'; import { BotStatus } from '../../../constants'; -import mockProjectResponse from './mocks/mockProjectResponse.json'; +import mockProjectData from './mocks/mockProjectResponse.json'; +import mockManifestData from './mocks/mockManifest.json'; +import mockBotProjectFileData from './mocks/mockBotProjectFile.json'; // let httpMocks; let navigateTo; @@ -73,11 +85,35 @@ jest.mock('../../persistence/FilePersistence', () => { }); describe('Project dispatcher', () => { + let mockProjectResponse, mockManifestResponse, mockBotProjectResponse; + const botStatesSelector = selector({ + key: 'botStatesSelector', + get: ({ get }) => { + const botProjectIds = get(botProjectIdsState); + const botProjectData: { [projectName: string]: any } = {}; + botProjectIds.map((projectId) => { + const botDisplayName = get(botDisplayNameState(projectId)); + const botNameIdentifier = get(botNameIdentifierState(projectId)); + const botError = get(botErrorState(projectId)); + const location = get(locationState(projectId)); + if (botNameIdentifier) { + botProjectData[botNameIdentifier] = { + botDisplayName, + location, + botError, + projectId, + }; + } + }); + return botProjectData; + }, + }); + const useRecoilTestHook = () => { const schemas = useRecoilValue(schemasState(projectId)); const location = useRecoilValue(locationState(projectId)); const skills = useRecoilValue(skillsState(projectId)); - const botName = useRecoilValue(botNameState(projectId)); + const botName = useRecoilValue(botDisplayNameState(projectId)); const skillManifests = useRecoilValue(skillManifestsState(projectId)); const luFiles = useRecoilValue(luFilesState(projectId)); const lgFiles = useRecoilValue(lgFilesState(projectId)); @@ -87,8 +123,9 @@ describe('Project dispatcher', () => { const diagnostics = useRecoilValue(botDiagnosticsState(projectId)); const locale = useRecoilValue(localeState(projectId)); const botStatus = useRecoilValue(botStatusState(projectId)); + const botStates = useRecoilValue(botStatesSelector); + const botProjectSpaceLoaded = useRecoilValue(botProjectSpaceLoadedState); - const botOpening = useRecoilValue(botOpeningState); const currentDispatcher = useRecoilValue(dispatcherState); const [recentProjects, setRecentProjects] = useRecoilState(recentProjectsState); const appError = useRecoilValue(applicationErrorState); @@ -97,9 +134,10 @@ describe('Project dispatcher', () => { const boilerplateVersion = useRecoilValue(boilerplateVersionState); const templates = useRecoilValue(templateProjectsState); const runtimeTemplates = useRecoilValue(runtimeTemplatesState); + const botOpening = useRecoilValue(botOpeningState); + const [botProjectFile, setBotProjectFile] = useRecoilState(botProjectFileState(projectId)); return { - botOpening, skillManifests, luFiles, lgFiles, @@ -117,19 +155,27 @@ describe('Project dispatcher', () => { currentDispatcher, recentProjects, appError, - setRecentProjects, templateId, announcement, boilerplateVersion, templates, runtimeTemplates, + botOpening, + botProjectFile, + setBotProjectFile, + setRecentProjects, + botStates, + botProjectSpaceLoaded, }; }; let renderedComponent: HookResult>, dispatcher: Dispatcher; - beforeEach(() => { + beforeEach(async () => { navigateTo.mockReset(); + mockProjectResponse = cloneDeep(mockProjectData); + mockManifestResponse = cloneDeep(mockManifestData); + mockBotProjectResponse = cloneDeep(mockBotProjectFileData); const rendered: RenderHookResult> = renderRecoilHook( useRecoilTestHook, { @@ -138,6 +184,7 @@ describe('Project dispatcher', () => { recoilState: dispatcherState, initialValue: { projectDispatcher, + botProjectFileDispatcher, }, }, } @@ -146,13 +193,25 @@ describe('Project dispatcher', () => { dispatcher = renderedComponent.current.currentDispatcher; }); + it('should throw an error if no bot project file is present in the bot', async () => { + const cloned = cloneDeep(mockProjectResponse); + const filtered = cloned.files.filter((file) => !endsWith(file.name, '.botproj')); + cloned.files = filtered; + (httpClient.put as jest.Mock).mockResolvedValueOnce({ + data: cloned, + }); + await act(async () => { + await dispatcher.openProject('../test/empty-bot', 'default'); + }); + expect(navigateTo).toHaveBeenLastCalledWith(`/home`); + }); + it('should open bot project', async () => { - let result; (httpClient.put as jest.Mock).mockResolvedValueOnce({ data: mockProjectResponse, }); await act(async () => { - result = await dispatcher.openProject('../test/empty-bot', 'default'); + await dispatcher.openProject('../test/empty-bot', 'default'); }); expect(renderedComponent.current.projectId).toBe(mockProjectResponse.id); @@ -166,8 +225,7 @@ describe('Project dispatcher', () => { expect(renderedComponent.current.schemas.sdk).toBeDefined(); expect(renderedComponent.current.schemas.default).toBeDefined(); expect(renderedComponent.current.schemas.diagnostics?.length).toBe(0); - expect(navigateTo).toHaveBeenLastCalledWith(`/bot/${projectId}/dialogs/`); - expect(result).toBe(renderedComponent.current.projectId); + expect(navigateTo).toHaveBeenLastCalledWith(`/bot/${projectId}/dialogs/emptybot-1`); }); it('should handle project failure if project does not exist', async () => { @@ -187,7 +245,7 @@ describe('Project dispatcher', () => { expect(renderedComponent.current.botOpening).toBeFalsy(); expect(renderedComponent.current.appError).toEqual(errorObj); expect(renderedComponent.current.recentProjects.length).toBe(0); - expect(navigateTo).not.toHaveBeenCalled(); + expect(navigateTo).toHaveBeenLastCalledWith(`/home`); }); it('should fetch recent projects', async () => { @@ -200,36 +258,6 @@ describe('Project dispatcher', () => { expect(renderedComponent.current.recentProjects).toEqual(recentProjects); }); - it('should get runtime templates', async () => { - const templates = [ - { id: 'EchoBot', index: 1, name: 'Echo Bot' }, - { id: 'EmptyBot', index: 2, name: 'Empty Bot' }, - ]; - (httpClient.get as jest.Mock).mockResolvedValue({ - data: templates, - }); - await act(async () => { - await dispatcher.fetchRuntimeTemplates(); - }); - - expect(renderedComponent.current.runtimeTemplates).toEqual(templates); - }); - - it('should get templates', async () => { - const templates = [ - { id: 'EchoBot', index: 1, name: 'Echo Bot' }, - { id: 'EmptyBot', index: 2, name: 'Empty Bot' }, - ]; - (httpClient.get as jest.Mock).mockResolvedValue({ - data: templates, - }); - await act(async () => { - await dispatcher.fetchTemplates(); - }); - - expect(renderedComponent.current.templates).toEqual(templates); - }); - it('should delete a project', async () => { (httpClient.delete as jest.Mock).mockResolvedValue({ data: {} }); (httpClient.put as jest.Mock).mockResolvedValueOnce({ @@ -237,7 +265,7 @@ describe('Project dispatcher', () => { }); await act(async () => { await dispatcher.openProject('../test/empty-bot', 'default'); - await dispatcher.deleteBotProject(projectId); + await dispatcher.deleteBot(projectId); }); expect(renderedComponent.current.botName).toEqual(''); @@ -277,7 +305,7 @@ describe('Project dispatcher', () => { expect(renderedComponent.current.announcement).toEqual('Scripts successfully updated.'); }); - it('should get bolierplate version', async () => { + it('should get boilerplate version', async () => { const version = { updateRequired: true, latestVersion: '3', currentVersion: '2' }; (httpClient.get as jest.Mock).mockResolvedValue({ data: version, @@ -288,4 +316,208 @@ describe('Project dispatcher', () => { expect(renderedComponent.current.boilerplateVersion).toEqual(version); }); + + it('should be able to add an existing skill to Botproject', async () => { + (httpClient.get as jest.Mock).mockResolvedValueOnce({ + data: {}, + }); + const skills = [ + { botName: 'Echo-Skill-1', id: '40876.502871204648', location: '/Users/tester/Desktop/Echo-Skill-1' }, + { botName: 'Echo-Skill-2', id: '50876.502871204648', location: '/Users/tester/Desktop/Echo-Skill-2' }, + ]; + const mappedSkills = skills.map(({ botName, id, location }) => { + const cloned = cloneDeep(mockProjectResponse); + return { + ...cloned, + botName, + id, + location, + }; + }); + + await act(async () => { + (httpClient.put as jest.Mock).mockResolvedValueOnce({ + data: mockProjectResponse, + }); + await dispatcher.openProject('../test/empty-bot', 'default'); + }); + + await act(async () => { + (httpClient.put as jest.Mock).mockResolvedValueOnce({ + data: mappedSkills[0], + }); + await dispatcher.addExistingSkillToBotProject(mappedSkills[0].location, 'default'); + }); + + expect(renderedComponent.current.botStates.echoSkill1).toBeDefined(); + expect(renderedComponent.current.botStates.echoSkill1.botDisplayName).toBe('Echo-Skill-1'); + + await act(async () => { + (httpClient.put as jest.Mock).mockResolvedValueOnce({ + data: mappedSkills[1], + }); + await dispatcher.addExistingSkillToBotProject(mappedSkills[1].location, 'default'); + }); + + expect(renderedComponent.current.botStates.echoSkill2).toBeDefined(); + expect(renderedComponent.current.botStates.echoSkill2.botDisplayName).toBe('Echo-Skill-2'); + + await act(async () => { + await dispatcher.addRemoteSkillToBotProject('https://test.net/api/manifest/man', 'test-skill', 'remote'); + }); + + expect(navigateTo).toHaveBeenLastCalledWith(`/bot/${projectId}/dialogs/emptybot-1`); + }); + + it('should be able to add a remote skill to Botproject', async () => { + const mockImplementation = (httpClient.get as jest.Mock).mockImplementation((url: string) => { + if (endsWith(url, '/projects/generateProjectId')) { + return { + data: '1234.1123213', + }; + } else { + return { + data: mockManifestResponse, + }; + } + }); + + await act(async () => { + (httpClient.put as jest.Mock).mockResolvedValueOnce({ + data: mockProjectResponse, + }); + await dispatcher.openProject('../test/empty-bot', 'default'); + }); + + await act(async () => { + await dispatcher.addRemoteSkillToBotProject( + 'https://test-dev.azurewebsites.net/manifests/onenote-2-1-preview-1-manifest.json', + 'one-note', + 'remote' + ); + }); + + expect(renderedComponent.current.botStates.oneNote).toBeDefined(); + expect(renderedComponent.current.botStates.oneNote.botDisplayName).toBe('OneNoteSync'); + expect(renderedComponent.current.botStates.oneNote.location).toBe( + 'https://test-dev.azurewebsites.net/manifests/onenote-2-1-preview-1-manifest.json' + ); + expect(navigateTo).toHaveBeenLastCalledWith(`/bot/${projectId}/dialogs/emptybot-1`); + mockImplementation.mockClear(); + }); + + it('should remove a skill from bot project', async () => { + const mockImplementation = (httpClient.get as jest.Mock).mockImplementation((url: string) => { + if (endsWith(url, '/projects/generateProjectId')) { + return { + data: uuid(), + }; + } else { + return { + data: mockManifestResponse, + }; + } + }); + + await act(async () => { + (httpClient.put as jest.Mock).mockResolvedValueOnce({ + data: mockProjectResponse, + }); + await dispatcher.openProject('../test/empty-bot', 'default'); + }); + + await act(async () => { + await dispatcher.addRemoteSkillToBotProject( + 'https://test-dev.azurewebsites.net/manifests/onenote-2-1-preview-1-manifest.json', + 'one-note', + 'remote' + ); + }); + + await act(async () => { + await dispatcher.addRemoteSkillToBotProject( + 'https://test-dev.azurewebsites.net/manifests/onenote-second-manifest.json', + 'one-note-2', + 'remote' + ); + }); + + const oneNoteProjectId = renderedComponent.current.botStates.oneNote.projectId; + mockImplementation.mockClear(); + + await act(async () => { + dispatcher.removeSkillFromBotProject(oneNoteProjectId); + }); + expect(renderedComponent.current.botStates.oneNote).toBeUndefined(); + }); + + it('should be able to add a new skill to Botproject', async () => { + await act(async () => { + (httpClient.put as jest.Mock).mockResolvedValueOnce({ + data: mockProjectResponse, + }); + await dispatcher.openProject('../test/empty-bot', 'default'); + }); + + const newProjectDataClone = cloneDeep(mockProjectResponse); + newProjectDataClone.botName = 'new-bot'; + await act(async () => { + (httpClient.post as jest.Mock).mockResolvedValueOnce({ + data: newProjectDataClone, + }); + await dispatcher.addNewSkillToBotProject({ + name: 'new-bot', + description: '', + schemaUrl: '', + 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`); + }); + + it('should be able to open a project and its skills in Bot project file', async (done) => { + let callIndex = 0; + (httpClient.put as jest.Mock).mockImplementation(() => { + let mockSkillData: any; + callIndex++; + switch (callIndex) { + case 1: + return Promise.resolve({ data: mockProjectResponse }); + case 2: { + mockSkillData = cloneDeep(mockProjectResponse); + mockSkillData.botName = 'todo-skill'; + mockSkillData.id = '20876.502871204648'; + return Promise.resolve({ data: mockSkillData }); + } + case 3: { + mockSkillData = cloneDeep(mockProjectResponse); + mockSkillData.botName = 'google-keep-sync'; + mockSkillData.id = '50876.502871204648'; + return Promise.resolve({ data: mockSkillData }); + } + } + }); + const matchIndex = findIndex(mockProjectResponse.files, (file: any) => endsWith(file.name, '.botproj')); + mockProjectResponse.files[matchIndex] = { + ...mockProjectResponse.files[matchIndex], + content: JSON.stringify(mockBotProjectResponse), + }; + expect(renderedComponent.current.botProjectSpaceLoaded).toBeFalsy(); + + await act(async () => { + await dispatcher.openProject('../test/empty-bot', 'default'); + }); + setImmediate(() => { + expect(renderedComponent.current.botStates.todoSkill.botDisplayName).toBe('todo-skill'); + expect(renderedComponent.current.botStates.googleKeepSync.botDisplayName).toBe('google-keep-sync'); + expect(renderedComponent.current.botProjectSpaceLoaded).toBeTruthy(); + done(); + }); + }); }); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/storage.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/storage.test.tsx new file mode 100644 index 0000000000..696a1b53ab --- /dev/null +++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/storage.test.tsx @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useRecoilValue } from 'recoil'; +import { act, RenderHookResult, HookResult } from '@bfc/test-utils/lib/hooks'; + +import httpClient from '../../../utils/httpUtil'; +import { storageDispatcher } from '../storage'; +import { renderRecoilHook } from '../../../../__tests__/testUtils'; +import { runtimeTemplatesState, currentProjectIdState } from '../../atoms'; +import { dispatcherState } from '../../../recoilModel/DispatcherWrapper'; +import { Dispatcher } from '../../dispatchers'; + +// let httpMocks; +let navigateTo; + +const projectId = '30876.502871204648'; + +jest.mock('../../../utils/navigation', () => { + const navigateMock = jest.fn(); + navigateTo = navigateMock; + return { + navigateTo: navigateMock, + }; +}); + +jest.mock('../../../utils/httpUtil'); + +jest.mock('../../parsers/lgWorker', () => { + return { + flush: () => new Promise((resolve) => resolve()), + addProject: () => new Promise((resolve) => resolve()), + }; +}); + +jest.mock('../../parsers/luWorker', () => { + return { + flush: () => new Promise((resolve) => resolve()), + }; +}); + +jest.mock('../../persistence/FilePersistence', () => { + return jest.fn().mockImplementation(() => { + return { flush: () => new Promise((resolve) => resolve()) }; + }); +}); + +describe('Storage dispatcher', () => { + const useRecoilTestHook = () => { + const runtimeTemplates = useRecoilValue(runtimeTemplatesState); + const currentDispatcher = useRecoilValue(dispatcherState); + + return { + runtimeTemplates, + currentDispatcher, + }; + }; + + let renderedComponent: HookResult>, dispatcher: Dispatcher; + + beforeEach(() => { + navigateTo.mockReset(); + const rendered: RenderHookResult> = renderRecoilHook( + useRecoilTestHook, + { + states: [{ recoilState: currentProjectIdState, initialValue: projectId }], + dispatcher: { + recoilState: dispatcherState, + initialValue: { + storageDispatcher, + }, + }, + } + ); + renderedComponent = rendered.result; + dispatcher = renderedComponent.current.currentDispatcher; + }); + + it('should get runtime templates', async () => { + const templates = [ + { id: 'EchoBot', index: 1, name: 'Echo Bot' }, + { id: 'EmptyBot', index: 2, name: 'Empty Bot' }, + ]; + (httpClient.get as jest.Mock).mockResolvedValue({ + data: templates, + }); + await act(async () => { + await dispatcher.fetchRuntimeTemplates(); + }); + + expect(renderedComponent.current.runtimeTemplates).toEqual(templates); + }); +}); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/botProjectFile.ts b/Composer/packages/client/src/recoilModel/dispatchers/botProjectFile.ts new file mode 100644 index 0000000000..ad523b1498 --- /dev/null +++ b/Composer/packages/client/src/recoilModel/dispatchers/botProjectFile.ts @@ -0,0 +1,80 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { CallbackInterface, useRecoilCallback } from 'recoil'; +import { produce } from 'immer'; +import { BotProjectSpaceSkill, convertAbsolutePathToFileProtocol } from '@bfc/shared'; + +import { botNameIdentifierState, botProjectFileState, locationState } from '../atoms'; +import { rootBotProjectIdSelector } from '../selectors'; + +export const botProjectFileDispatcher = () => { + const addLocalSkillToBotProjectFile = useRecoilCallback( + ({ set, snapshot }: CallbackInterface) => async (skillId: string) => { + const rootBotProjectId = await snapshot.getPromise(rootBotProjectIdSelector); + if (!rootBotProjectId) { + return; + } + const skillLocation = await snapshot.getPromise(locationState(skillId)); + const botName = await snapshot.getPromise(botNameIdentifierState(skillId)); + + set(botProjectFileState(rootBotProjectId), (current) => { + const result = produce(current, (draftState) => { + const skill: BotProjectSpaceSkill = { + workspace: convertAbsolutePathToFileProtocol(skillLocation), + remote: false, + }; + draftState.content.skills[botName] = skill; + }); + return result; + }); + } + ); + + const addRemoteSkillToBotProjectFile = useRecoilCallback( + ({ set, snapshot }: CallbackInterface) => async (skillId: string, manifestUrl: string, endpointName: string) => { + const rootBotProjectId = await snapshot.getPromise(rootBotProjectIdSelector); + if (!rootBotProjectId) { + return; + } + const botName = await snapshot.getPromise(botNameIdentifierState(skillId)); + + set(botProjectFileState(rootBotProjectId), (current) => { + const result = produce(current, (draftState) => { + const skill: BotProjectSpaceSkill = { + manifest: manifestUrl, + remote: true, + endpointName, + }; + + draftState.content.skills[botName] = skill; + }); + return result; + }); + } + ); + + const removeSkillFromBotProjectFile = useRecoilCallback( + ({ set, snapshot }: CallbackInterface) => async (skillId: string) => { + const rootBotProjectId = await snapshot.getPromise(rootBotProjectIdSelector); + if (!rootBotProjectId) { + return; + } + + const botName = await snapshot.getPromise(botNameIdentifierState(skillId)); + set(botProjectFileState(rootBotProjectId), (current) => { + const result = produce(current, (draftState) => { + delete draftState.content.skills[botName]; + }); + return result; + }); + } + ); + + return { + addLocalSkillToBotProjectFile, + removeSkillFromBotProjectFile, + addRemoteSkillToBotProjectFile, + }; +}; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/export.ts b/Composer/packages/client/src/recoilModel/dispatchers/export.ts index 3a00d0f35a..ed5eb0ed33 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/export.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/export.ts @@ -5,13 +5,13 @@ import { CallbackInterface, useRecoilCallback } from 'recoil'; import httpClient from '../../utils/httpUtil'; -import { botNameState } from '../atoms'; +import { botDisplayNameState } from '../atoms'; import { logMessage } from './shared'; export const exportDispatcher = () => { const exportToZip = useRecoilCallback((callbackHelpers: CallbackInterface) => async (projectId: string) => { - const botName = await callbackHelpers.snapshot.getPromise(botNameState(projectId)); + const botName = await callbackHelpers.snapshot.getPromise(botDisplayNameState(projectId)); try { const response = await httpClient.get(`/projects/${projectId}/export/`, { responseType: 'blob' }); const url = window.URL.createObjectURL(new Blob([response.data])); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/index.ts b/Composer/packages/client/src/recoilModel/dispatchers/index.ts index edbae77387..ad35085575 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/index.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/index.ts @@ -20,6 +20,7 @@ import { userDispatcher } from './user'; import { multilangDispatcher } from './multilang'; import { notificationDispatcher } from './notification'; import { extensionsDispatcher } from './extensions'; +import { botProjectFileDispatcher } from './botProjectFile'; const createDispatchers = () => { return { @@ -42,6 +43,7 @@ const createDispatchers = () => { ...multilangDispatcher(), ...notificationDispatcher(), ...extensionsDispatcher(), + ...botProjectFileDispatcher(), }; }; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/multilang.ts b/Composer/packages/client/src/recoilModel/dispatchers/multilang.ts index 395748390a..ad03d0afdf 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/multilang.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/multilang.ts @@ -18,7 +18,7 @@ import { onAddLanguageDialogCompleteState, onDelLanguageDialogCompleteState, showDelLanguageModalState, - botNameState, + botDisplayNameState, } from './../atoms/botState'; const copyLanguageResources = (files: any[], fromLanguage: string, toLanguages: string[]): any[] => { @@ -58,7 +58,7 @@ const deleteLanguageResources = ( export const multilangDispatcher = () => { const setLocale = useRecoilCallback( ({ set, snapshot }: CallbackInterface) => async (locale: string, projectId: string) => { - const botName = await snapshot.getPromise(botNameState(projectId)); + const botName = await snapshot.getPromise(botDisplayNameState(projectId)); set(localeState(projectId), locale); languageStorage.setLocale(botName, locale); @@ -68,7 +68,7 @@ export const multilangDispatcher = () => { const addLanguages = useRecoilCallback( (callbackHelpers: CallbackInterface) => async ({ languages, defaultLang, switchTo = false, projectId }) => { const { set, snapshot } = callbackHelpers; - const botName = await snapshot.getPromise(botNameState(projectId)); + const botName = await snapshot.getPromise(botDisplayNameState(projectId)); const prevlgFiles = await snapshot.getPromise(lgFilesState(projectId)); const prevluFiles = await snapshot.getPromise(luFilesState(projectId)); const prevSettings = await snapshot.getPromise(settingsState(projectId)); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/project.ts b/Composer/packages/client/src/recoilModel/dispatchers/project.ts index 506b2a5ff9..c5673b3ba0 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/project.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/project.ts @@ -1,395 +1,303 @@ /* eslint-disable react-hooks/rules-of-hooks */ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. + import { useRecoilCallback, CallbackInterface } from 'recoil'; -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 formatMessage from 'format-message'; +import findIndex from 'lodash/findIndex'; -import lgWorker from '../parsers/lgWorker'; -import luWorker from '../parsers/luWorker'; -import qnaWorker from '../parsers/qnaWorker'; import httpClient from '../../utils/httpUtil'; import { BotStatus } from '../../constants'; -import { getReferredLuFiles } from '../../utils/luUtil'; import luFileStatusStorage from '../../utils/luFileStatusStorage'; -import { getReferredQnaFiles } from '../../utils/qnaUtil'; import qnaFileStatusStorage from '../../utils/qnaFileStatusStorage'; import settingStorage from '../../utils/dialogSettingStorage'; -import filePersistence from '../persistence/FilePersistence'; import { navigateTo } from '../../utils/navigation'; -import languageStorage from '../../utils/languageStorage'; import { projectIdCache } from '../../utils/projectCache'; import { - designPageLocationState, - botDiagnosticsState, - botProjectsSpaceState, + botProjectIdsState, + botStatusState, + botOpeningState, projectMetaDataState, - filePersistenceState, currentProjectIdState, + botErrorState, + botNameIdentifierState, + botProjectSpaceLoadedState, } from '../atoms'; -import { QnABotTemplateId } from '../../constants'; -import FilePersistence from '../persistence/FilePersistence'; -import UndoHistory from '../undo/undoHistory'; -import { undoHistoryState } from '../undo/history'; +import { dispatcherState } from '../DispatcherWrapper'; +import { getFileNameFromPath } from '../../utils/fileUtil'; -import { - skillManifestsState, - settingsState, - localeState, - luFilesState, - qnaFilesState, - skillsState, - schemasState, - lgFilesState, - locationState, - botStatusState, - botNameState, - botEnvironmentState, - dialogsState, - botOpeningState, - recentProjectsState, - templateProjectsState, - runtimeTemplatesState, - applicationErrorState, - templateIdState, - announcementState, - boilerplateVersionState, - dialogSchemasState, -} from './../atoms'; +import { recentProjectsState, templateIdState, announcementState, boilerplateVersionState } from './../atoms'; import { logMessage, setError } from './../dispatchers/shared'; +import { + flushExistingTasks, + handleProjectFailure, + navigateToBot, + openLocalSkill, + saveProject, + removeRecentProject, + createNewBotFromTemplate, + resetBotStates, + openRemoteSkill, + openRootBotAndSkillsByProjectId, + checkIfBotExistsInBotProjectFile, + getSkillNameIdentifier, + openRootBotAndSkillsByPath, +} from './utils/project'; -const handleProjectFailure = (callbackHelpers: CallbackInterface, ex) => { - callbackHelpers.set(botOpeningState, false); - setError(callbackHelpers, ex); -}; - -const processSchema = (projectId: string, schema: any) => ({ - ...schema, - definitions: dereferenceDefinitions(schema.definitions), -}); - -// if user set value in terminal or appsetting.json, it should update the value in localStorage -const refreshLocalStorage = (projectId: string, settings: DialogSetting) => { - for (const property of SensitiveProperties) { - const value = objectGet(settings, property); - if (value) { - settingStorage.setField(projectId, property, value); - } - } -}; +export const projectDispatcher = () => { + const removeSkillFromBotProject = useRecoilCallback( + (callbackHelpers: CallbackInterface) => async (projectIdToRemove: string) => { + try { + const { set, snapshot } = callbackHelpers; + const dispatcher = await snapshot.getPromise(dispatcherState); + await dispatcher.removeSkillFromBotProjectFile(projectIdToRemove); -// merge sensitive values in localStorage -const mergeLocalStorage = (projectId: string, settings: DialogSetting) => { - const localSetting = settingStorage.get(projectId); - const mergedSettings = { ...settings }; - if (localSetting) { - for (const property of SensitiveProperties) { - const value = objectGet(localSetting, property); - if (value) { - objectSet(mergedSettings, property, value); - } else { - objectSet(mergedSettings, property, ''); // set those key back, because that were omit after persisited + set(botProjectIdsState, (currentProjects) => { + const filtered = currentProjects.filter((id) => id !== projectIdToRemove); + return filtered; + }); + resetBotStates(callbackHelpers, projectIdToRemove); + } catch (ex) { + setError(callbackHelpers, ex); } } - } - return mergedSettings; -}; - -const updateLuFilesStatus = (projectId: string, luFiles: LuFile[]) => { - const status = luFileStatusStorage.get(projectId); - return luFiles.map((luFile) => { - if (typeof status[luFile.id] === 'boolean') { - return { ...luFile, published: status[luFile.id] }; - } else { - return { ...luFile, published: false }; - } - }); -}; - -const initLuFilesStatus = (projectId: string, luFiles: LuFile[], dialogs: DialogInfo[]) => { - luFileStatusStorage.checkFileStatus( - projectId, - getReferredLuFiles(luFiles, dialogs).map((file) => file.id) ); - return updateLuFilesStatus(projectId, luFiles); -}; -const updateQnaFilesStatus = (projectId: string, qnaFiles: QnAFile[]) => { - const status = qnaFileStatusStorage.get(projectId); - return qnaFiles.map((qnaFile) => { - if (typeof status[qnaFile.id] === 'boolean') { - return { ...qnaFile, published: status[qnaFile.id] }; - } else { - return { ...qnaFile, published: false }; + const replaceSkillInBotProject = useRecoilCallback( + (callbackHelpers: CallbackInterface) => async (projectIdToRemove: string, path: string, storageId = 'default') => { + try { + const { snapshot } = callbackHelpers; + const dispatcher = await snapshot.getPromise(dispatcherState); + const projectIds = await snapshot.getPromise(botProjectIdsState); + const indexToReplace = findIndex(projectIds, (id) => id === projectIdToRemove); + if (indexToReplace === -1) { + return; + } + await dispatcher.removeSkillFromBotProject(projectIdToRemove); + await dispatcher.addExistingSkillToBotProject(path, storageId); + } catch (ex) { + setError(callbackHelpers, ex); + } } - }); -}; - -const initQnaFilesStatus = (projectId: string, qnaFiles: QnAFile[], dialogs: DialogInfo[]) => { - qnaFileStatusStorage.checkFileStatus( - projectId, - getReferredQnaFiles(qnaFiles, dialogs).map((file) => file.id) ); - return updateQnaFilesStatus(projectId, qnaFiles); -}; - -export const projectDispatcher = () => { - const initBotState = async ( - callbackHelpers: CallbackInterface, - data: any, - jump: boolean, - templateId: string, - qnaKbUrls?: string[] - ) => { - const { snapshot, gotoSnapshot, set } = callbackHelpers; - const { - files, - botName, - botEnvironment, - location, - schemas, - settings, - id: projectId, - diagnostics, - skills: skillContent, - } = data; - const curLocation = await snapshot.getPromise(locationState(projectId)); - const storedLocale = languageStorage.get(botName)?.locale; - const locale = settings.languages.includes(storedLocale) ? storedLocale : settings.defaultLanguage; - // cache current projectId in session, resolve page refresh caused state lost. - projectIdCache.set(projectId); - - const mergedSettings = mergeLocalStorage(projectId, settings); - if (Array.isArray(mergedSettings.skill)) { - const skillsArr = mergedSettings.skill.map((skillData) => ({ ...skillData })); - mergedSettings.skill = convertSkillsToDictionary(skillsArr); - } - - try { - schemas.sdk.content = processSchema(projectId, schemas.sdk.content); - } catch (err) { - const diagnostics = schemas.diagnostics ?? []; - diagnostics.push(err.message); - schemas.diagnostics = diagnostics; - } - - try { - const { dialogs, dialogSchemas, luFiles, lgFiles, qnaFiles, skillManifestFiles, skills } = indexer.index( - files, - botName, - locale, - skillContent, - mergedSettings - ); - - let mainDialog = ''; - const verifiedDialogs = dialogs.map((dialog) => { - if (dialog.isRoot) { - mainDialog = dialog.id; + const addExistingSkillToBotProject = useRecoilCallback( + (callbackHelpers: CallbackInterface) => async (path: string, storageId = 'default'): Promise => { + const { set, snapshot } = callbackHelpers; + try { + set(botOpeningState, true); + const dispatcher = await snapshot.getPromise(dispatcherState); + const botExists = await checkIfBotExistsInBotProjectFile(callbackHelpers, path); + if (botExists) { + throw new Error( + formatMessage('This operation cannot be completed. The skill is already part of the Bot Project') + ); } - dialog.diagnostics = validateDialog(dialog, schemas.sdk.content, lgFiles, luFiles); - return dialog; - }); - - await lgWorker.addProject(projectId, lgFiles); - set(botProjectsSpaceState, []); + const skillNameIdentifier: string = await getSkillNameIdentifier(callbackHelpers, getFileNameFromPath(path)); - // Important: gotoSnapshot will wipe all states. - const newSnapshot = snapshot.map(({ set }) => { - set(skillManifestsState(projectId), skillManifestFiles); - set(luFilesState(projectId), initLuFilesStatus(botName, luFiles, dialogs)); - set(lgFilesState(projectId), lgFiles); - set(dialogsState(projectId), verifiedDialogs); - set(dialogSchemasState(projectId), dialogSchemas); - set(botEnvironmentState(projectId), botEnvironment); - set(botNameState(projectId), botName); - set(qnaFilesState(projectId), initQnaFilesStatus(botName, qnaFiles, dialogs)); - if (location !== curLocation) { - set(botStatusState(projectId), BotStatus.unConnected); - set(locationState(projectId), location); + const { projectId, mainDialog } = await openLocalSkill(callbackHelpers, path, storageId, skillNameIdentifier); + if (!mainDialog) { + const error = await snapshot.getPromise(botErrorState(projectId)); + throw error; } - set(skillsState(projectId), skills); - set(schemasState(projectId), schemas); - set(localeState(projectId), locale); - set(botDiagnosticsState(projectId), diagnostics); - refreshLocalStorage(projectId, settings); - set(settingsState(projectId), mergedSettings); - set(filePersistenceState(projectId), new FilePersistence(projectId)); - set(undoHistoryState(projectId), new UndoHistory(projectId)); - //TODO: Botprojects space will be populated for now with just the rootbot. Once, BotProjects UI is hookedup this will be refactored to use addToBotProject - set(botProjectsSpaceState, (current) => [...current, projectId]); - set(projectMetaDataState(projectId), { - isRootBot: true, - }); + set(botProjectIdsState, (current) => [...current, projectId]); + await dispatcher.addLocalSkillToBotProjectFile(projectId); + } catch (ex) { + handleProjectFailure(callbackHelpers, ex); + } finally { set(botOpeningState, false); - }); - - gotoSnapshot(newSnapshot); + } + } + ); - if (jump && projectId) { - // TODO: Refactor to set it always on init to the root bot - set(currentProjectIdState, projectId); - let url = `/bot/${projectId}/dialogs/${mainDialog}`; - if (templateId === QnABotTemplateId) { - url = `/bot/${projectId}/knowledge-base/${mainDialog}`; - navigateTo(url, { state: { qnaKbUrls } }); - return; + const addRemoteSkillToBotProject = useRecoilCallback( + (callbackHelpers: CallbackInterface) => async (manifestUrl: string, name: string, endpointName: string) => { + const { set, snapshot } = callbackHelpers; + try { + const dispatcher = await snapshot.getPromise(dispatcherState); + const botExists = await checkIfBotExistsInBotProjectFile(callbackHelpers, manifestUrl, true); + if (botExists) { + throw new Error( + formatMessage('This operation cannot be completed. The skill is already part of the Bot Project') + ); } - navigateTo(url); + const skillNameIdentifier: string = await getSkillNameIdentifier(callbackHelpers, name); + set(botOpeningState, true); + const { projectId } = await openRemoteSkill(callbackHelpers, manifestUrl, skillNameIdentifier); + set(botProjectIdsState, (current) => [...current, projectId]); + await dispatcher.addRemoteSkillToBotProjectFile(projectId, manifestUrl, endpointName); + } catch (ex) { + handleProjectFailure(callbackHelpers, ex); + } finally { + set(botOpeningState, false); } - } catch (err) { - callbackHelpers.set(botOpeningState, false); - setError(callbackHelpers, err); - navigateTo('/home'); } - }; + ); - const removeRecentProject = async (callbackHelpers: CallbackInterface, path: string) => { - try { - const { - set, - snapshot: { getPromise }, - } = callbackHelpers; - const currentRecentProjects = await getPromise(recentProjectsState); - const filtered = currentRecentProjects.filter((p) => p.path !== path); - set(recentProjectsState, filtered); - } catch (ex) { - logMessage(callbackHelpers, `Error removing recent project: ${ex}`); - } - }; + const addNewSkillToBotProject = useRecoilCallback( + (callbackHelpers: CallbackInterface) => async (newProjectData: any) => { + const { set, snapshot } = callbackHelpers; + const dispatcher = await snapshot.getPromise(dispatcherState); + try { + const { templateId, name, description, location, schemaUrl, locale, qnaKbUrls } = newProjectData; + set(botOpeningState, true); - const setBotOpeningStatus = async (callbackHelpers: CallbackInterface) => { - const { set, snapshot } = callbackHelpers; - set(botOpeningState, true); - const botProjectSpace = await snapshot.getPromise(botProjectsSpaceState); - const filePersistenceHandlers: filePersistence[] = []; - for (const projectId of botProjectSpace) { - const fp = await snapshot.getPromise(filePersistenceState(projectId)); - filePersistenceHandlers.push(fp); + const { projectId, mainDialog } = await createNewBotFromTemplate( + callbackHelpers, + templateId, + name, + description, + location, + schemaUrl, + locale + ); + const skillNameIdentifier: string = await getSkillNameIdentifier(callbackHelpers, getFileNameFromPath(name)); + set(botNameIdentifierState(projectId), skillNameIdentifier); + set(projectMetaDataState(projectId), { + isRemote: false, + isRootBot: false, + }); + set(botProjectIdsState, (current) => [...current, projectId]); + await dispatcher.addLocalSkillToBotProjectFile(projectId); + navigateToBot(callbackHelpers, projectId, mainDialog, qnaKbUrls, templateId); + return projectId; + } catch (ex) { + handleProjectFailure(callbackHelpers, ex); + } finally { + set(botOpeningState, false); + } } - const workers = [lgWorker, luWorker, qnaWorker, ...filePersistenceHandlers]; - return Promise.all(workers.map((w) => w.flush())); - }; + ); const openProject = useRecoilCallback( (callbackHelpers: CallbackInterface) => async (path: string, storageId = 'default') => { + const { set } = callbackHelpers; try { - await setBotOpeningStatus(callbackHelpers); - const response = await httpClient.put(`/projects/open`, { path, storageId }); - await initBotState(callbackHelpers, response.data, true, ''); - return response.data.id; + set(botOpeningState, true); + await flushExistingTasks(callbackHelpers); + const { projectId, mainDialog } = await openRootBotAndSkillsByPath(callbackHelpers, path, storageId); + + // Post project creation + set(projectMetaDataState(projectId), { + isRootBot: true, + isRemote: false, + }); + projectIdCache.set(projectId); + navigateToBot(callbackHelpers, projectId, mainDialog); } catch (ex) { + set(botProjectIdsState, []); removeRecentProject(callbackHelpers, path); handleProjectFailure(callbackHelpers, ex); + navigateTo('/home'); + } finally { + set(botOpeningState, false); } } ); const fetchProjectById = useRecoilCallback((callbackHelpers: CallbackInterface) => async (projectId: string) => { + const { set } = callbackHelpers; + try { + await flushExistingTasks(callbackHelpers); + set(botOpeningState, true); + await openRootBotAndSkillsByProjectId(callbackHelpers, projectId); + + // Post project creation + set(projectMetaDataState(projectId), { + isRootBot: true, + isRemote: false, + }); + projectIdCache.set(projectId); + } catch (ex) { + set(botProjectIdsState, []); + handleProjectFailure(callbackHelpers, ex); + navigateTo('/home'); + } finally { + set(botOpeningState, false); + } + }); + + const createNewBot = useRecoilCallback((callbackHelpers: CallbackInterface) => async (newProjectData: any) => { + const { set } = callbackHelpers; try { - const response = await httpClient.get(`/projects/${projectId}`); - await initBotState(callbackHelpers, response.data, false, ''); + await flushExistingTasks(callbackHelpers); + set(botOpeningState, true); + const { templateId, name, description, location, schemaUrl, locale, qnaKbUrls } = newProjectData; + const { projectId, mainDialog } = await createNewBotFromTemplate( + callbackHelpers, + templateId, + name, + description, + location, + schemaUrl, + locale + ); + set(botProjectIdsState, [projectId]); + + // Post project creation + set(projectMetaDataState(projectId), { + isRootBot: true, + isRemote: false, + }); + projectIdCache.set(projectId); + navigateToBot(callbackHelpers, projectId, mainDialog, qnaKbUrls, templateId); } catch (ex) { + set(botProjectIdsState, []); handleProjectFailure(callbackHelpers, ex); navigateTo('/home'); + } finally { + set(botOpeningState, false); } }); - const createProject = useRecoilCallback( - (callbackHelpers: CallbackInterface) => async ( - templateId: string, - name: string, - description: string, - location: string, - schemaUrl?: string, - locale?: string, - qnaKbUrls?: string[] - ) => { + const saveProjectAs = useRecoilCallback( + (callbackHelpers: CallbackInterface) => async (oldProjectId, name, description, location) => { + const { set } = callbackHelpers; try { - await setBotOpeningStatus(callbackHelpers); - const response = await httpClient.post(`/projects`, { - storageId: 'default', - templateId, + await flushExistingTasks(callbackHelpers); + set(botOpeningState, true); + const { projectId, mainDialog } = await saveProject(callbackHelpers, { + oldProjectId, name, description, location, - schemaUrl, - locale, }); - const projectId = response.data.id; - if (settingStorage.get(projectId)) { - settingStorage.remove(projectId); - } - await initBotState(callbackHelpers, response.data, true, templateId, qnaKbUrls); - return projectId; + + // Post project creation + set(projectMetaDataState(projectId), { + isRootBot: true, + isRemote: false, + }); + projectIdCache.set(projectId); + navigateToBot(callbackHelpers, projectId, mainDialog); } catch (ex) { + set(botProjectIdsState, []); handleProjectFailure(callbackHelpers, ex); + navigateTo('/home'); + } finally { + set(botOpeningState, false); } } ); - const deleteBotProject = useRecoilCallback((callbackHelpers: CallbackInterface) => async (projectId: string) => { - const { reset } = callbackHelpers; + const deleteBot = useRecoilCallback((callbackHelpers: CallbackInterface) => async (projectId: string) => { try { + const { reset } = callbackHelpers; await httpClient.delete(`/projects/${projectId}`); luFileStatusStorage.removeAllStatuses(projectId); qnaFileStatusStorage.removeAllStatuses(projectId); settingStorage.remove(projectId); projectIdCache.clear(); - reset(dialogsState(projectId)); - reset(botEnvironmentState(projectId)); - reset(botNameState(projectId)); - reset(botStatusState(projectId)); - reset(locationState(projectId)); - reset(lgFilesState(projectId)); - reset(skillsState(projectId)); - reset(schemasState(projectId)); - reset(luFilesState(projectId)); - reset(settingsState(projectId)); - reset(localeState(projectId)); - reset(skillManifestsState(projectId)); - reset(designPageLocationState(projectId)); - reset(filePersistenceState(projectId)); - reset(undoHistoryState(projectId)); - reset(botProjectsSpaceState); + resetBotStates(callbackHelpers, projectId); + reset(botProjectIdsState); reset(currentProjectIdState); + reset(botProjectSpaceLoadedState); } catch (e) { logMessage(callbackHelpers, e.message); } }); - const saveProjectAs = useRecoilCallback( - (callbackHelpers: CallbackInterface) => async (projectId, name, description, location) => { - try { - await setBotOpeningStatus(callbackHelpers); - const response = await httpClient.post(`/projects/${projectId}/project/saveAs`, { - storageId: 'default', - name, - description, - location, - }); - await initBotState(callbackHelpers, response.data, true, ''); - return response.data.id; - } catch (ex) { - handleProjectFailure(callbackHelpers, ex); - logMessage(callbackHelpers, ex.message); - } - } - ); - const fetchRecentProjects = useRecoilCallback((callbackHelpers: CallbackInterface) => async () => { const { set } = callbackHelpers; try { @@ -401,70 +309,12 @@ export const projectDispatcher = () => { } }); - const fetchRuntimeTemplates = useRecoilCallback<[], Promise>( - (callbackHelpers: CallbackInterface) => async () => { - const { set } = callbackHelpers; - try { - const response = await httpClient.get(`/runtime/templates`); - if (Array.isArray(response.data)) { - set(runtimeTemplatesState, [...response.data]); - } - } catch (ex) { - // TODO: Handle exceptions - logMessage(callbackHelpers, `Error fetching runtime templates: ${ex}`); - } - } - ); - - const fetchTemplates = useRecoilCallback<[], Promise>((callbackHelpers: CallbackInterface) => async () => { - try { - const response = await httpClient.get(`/assets/projectTemplates`); - - const data = response && response.data; - - if (data && Array.isArray(data) && data.length > 0) { - callbackHelpers.set(templateProjectsState, data); - } - } catch (err) { - // TODO: Handle exceptions - logMessage(callbackHelpers, `Error fetching runtime templates: ${err}`); - } - }); - const setBotStatus = useRecoilCallback<[BotStatus, string], void>( ({ set }: CallbackInterface) => (status: BotStatus, projectId: string) => { set(botStatusState(projectId), status); } ); - const createFolder = useRecoilCallback<[string, string], Promise>( - ({ set }: CallbackInterface) => async (path, name) => { - const storageId = 'default'; - try { - await httpClient.post(`/storages/folder`, { path, name, storageId }); - } catch (err) { - set(applicationErrorState, { - message: err.message, - summary: formatMessage('Create Folder Error'), - }); - } - } - ); - - const updateFolder = useRecoilCallback<[string, string, string], Promise>( - ({ set }: CallbackInterface) => async (path, oldName, newName) => { - const storageId = 'default'; - try { - await httpClient.put(`/storages/folder`, { path, oldName, newName, storageId }); - } catch (err) { - set(applicationErrorState, { - message: err.message, - summary: formatMessage('Update Folder Name Error'), - }); - } - } - ); - const saveTemplateId = useRecoilCallback<[string], void>(({ set }: CallbackInterface) => (templateId) => { if (templateId) { set(templateIdState, templateId); @@ -492,18 +342,19 @@ export const projectDispatcher = () => { return { openProject, - createProject, - deleteBotProject, + createNewBot, + deleteBot, saveProjectAs, - fetchTemplates, fetchProjectById, fetchRecentProjects, - fetchRuntimeTemplates, setBotStatus, - updateFolder, - createFolder, saveTemplateId, updateBoilerplate, getBoilerplateVersion, + removeSkillFromBotProject, + addNewSkillToBotProject, + addExistingSkillToBotProject, + addRemoteSkillToBotProject, + replaceSkillInBotProject, }; }; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts b/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts index c9eeac9e33..e4ec8f5a2e 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts @@ -217,6 +217,7 @@ export const publisherDispatcher = () => { } } ); + return { getPublishTargetTypes, publishToTarget, diff --git a/Composer/packages/client/src/recoilModel/dispatchers/setting.ts b/Composer/packages/client/src/recoilModel/dispatchers/setting.ts index d13761e46b..df4b388635 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/setting.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/setting.ts @@ -30,7 +30,7 @@ export const setSettingState = async ( keys(settings.skill).map(async (id) => { if (settings?.skill?.[id]?.manifestUrl !== previousSettings?.skill?.[id]?.manifestUrl) { try { - const { data: content } = await httpClient.get(`/projects/${projectId}/skill/retrieve-skill-manifest`, { + const { data: content } = await httpClient.get(`/projects/${projectId}/skill/retrieveSkillManifest`, { params: { url: settings?.skill?.[id]?.manifestUrl, }, diff --git a/Composer/packages/client/src/recoilModel/dispatchers/shared.ts b/Composer/packages/client/src/recoilModel/dispatchers/shared.ts index 811de1fd41..a930d50b10 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/shared.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/shared.ts @@ -41,12 +41,18 @@ export const setError = (callbackHelpers: CallbackInterface, payload) => { ), summary: formatMessage('Modification Rejected'), }); + } else if (payload?.response?.data?.message) { + callbackHelpers.set(applicationErrorState, payload.response.data); + } else if (payload instanceof Error) { + callbackHelpers.set(applicationErrorState, { + summary: payload.name, + message: payload.message, + }); } else { - if (payload?.response?.data?.message) { - callbackHelpers.set(applicationErrorState, payload.response.data); - } else { - callbackHelpers.set(applicationErrorState, payload); - } + callbackHelpers.set(applicationErrorState, payload); + } + if (payload != null) { + const message = JSON.stringify(payload); + logMessage(callbackHelpers, `Error: ${message}`); } - if (payload != null) logMessage(callbackHelpers, `Error: ${JSON.stringify(payload)}`); }; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/storage.ts b/Composer/packages/client/src/recoilModel/dispatchers/storage.ts index 39a36ec702..2c8352e52c 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/storage.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/storage.ts @@ -3,9 +3,17 @@ // Licensed under the MIT License. import { useRecoilCallback, CallbackInterface } from 'recoil'; import isArray from 'lodash/isArray'; +import formatMessage from 'format-message'; import httpClient from '../../utils/httpUtil'; -import { storagesState, storageFileLoadingStatusState, focusedStorageFolderState } from '../atoms/appState'; +import { + storagesState, + storageFileLoadingStatusState, + focusedStorageFolderState, + applicationErrorState, + templateProjectsState, + runtimeTemplatesState, +} from '../atoms/appState'; import { FileTypes } from '../../constants'; import { getExtension } from '../../utils/fileUtil'; @@ -101,6 +109,64 @@ export const storageDispatcher = () => { } ); + const createFolder = useRecoilCallback<[string, string], Promise>( + ({ set }: CallbackInterface) => async (path, name) => { + const storageId = 'default'; + try { + await httpClient.post(`/storages/folder`, { path, name, storageId }); + } catch (err) { + set(applicationErrorState, { + message: err.message, + summary: formatMessage('Create Folder Error'), + }); + } + } + ); + + const updateFolder = useRecoilCallback<[string, string, string], Promise>( + ({ set }: CallbackInterface) => async (path, oldName, newName) => { + const storageId = 'default'; + try { + await httpClient.put(`/storages/folder`, { path, oldName, newName, storageId }); + } catch (err) { + set(applicationErrorState, { + message: err.message, + summary: formatMessage('Update Folder Name Error'), + }); + } + } + ); + + const fetchTemplates = useRecoilCallback<[], Promise>((callbackHelpers: CallbackInterface) => async () => { + try { + const response = await httpClient.get(`/assets/projectTemplates`); + + const data = response && response.data; + + if (data && Array.isArray(data) && data.length > 0) { + callbackHelpers.set(templateProjectsState, data); + } + } catch (err) { + // TODO: Handle exceptions + logMessage(callbackHelpers, `Error fetching runtime templates: ${err}`); + } + }); + + const fetchRuntimeTemplates = useRecoilCallback<[], Promise>( + (callbackHelpers: CallbackInterface) => async () => { + const { set } = callbackHelpers; + try { + const response = await httpClient.get(`/runtime/templates`); + if (Array.isArray(response.data)) { + set(runtimeTemplatesState, [...response.data]); + } + } catch (ex) { + // TODO: Handle exceptions + logMessage(callbackHelpers, `Error fetching runtime templates: ${ex}`); + } + } + ); + return { fetchStorages, updateCurrentPathForStorage, @@ -108,5 +174,9 @@ export const storageDispatcher = () => { fetchStorageByName, fetchFolderItemsByPath, setStorageFileLoadingStatus, + createFolder, + updateFolder, + fetchTemplates, + fetchRuntimeTemplates, }; }; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/utils/project.ts b/Composer/packages/client/src/recoilModel/dispatchers/utils/project.ts new file mode 100644 index 0000000000..4a1718dbe9 --- /dev/null +++ b/Composer/packages/client/src/recoilModel/dispatchers/utils/project.ts @@ -0,0 +1,551 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { v4 as uuid } from 'uuid'; +import { + SensitiveProperties, + convertSkillsToDictionary, + DialogSetting, + dereferenceDefinitions, + DialogInfo, + LuFile, + QnAFile, + BotProjectSpace, + BotProjectFile, + BotProjectSpaceSkill, + convertFileProtocolToPath, +} from '@bfc/shared'; +import objectGet from 'lodash/get'; +import objectSet from 'lodash/set'; +import { indexer, validateDialog } from '@bfc/indexers'; +import { CallbackInterface } from 'recoil'; +import { stringify } from 'query-string'; +import formatMessage from 'format-message'; +import camelCase from 'lodash/camelCase'; + +import * as botstates from '../../atoms/botState'; +import UndoHistory from '../../undo/undoHistory'; +import languageStorage from '../../../utils/languageStorage'; +import settingStorage from '../../../utils/dialogSettingStorage'; +import { + botDiagnosticsState, + botProjectFileState, + botProjectSpaceLoadedState, + botProjectIdsState, + currentProjectIdState, + filePersistenceState, + projectMetaDataState, + qnaFilesState, + recentProjectsState, + botErrorState, + botNameIdentifierState, +} from '../../atoms'; +import lgWorker from '../../parsers/lgWorker'; +import luWorker from '../../parsers/luWorker'; +import qnaWorker from '../../parsers/qnaWorker'; +import FilePersistence from '../../persistence/FilePersistence'; +import { navigateTo } from '../../../utils/navigation'; +import { BotStatus, QnABotTemplateId } from '../../../constants'; +import httpClient from '../../../utils/httpUtil'; +import { getReferredLuFiles } from '../../../utils/luUtil'; +import luFileStatusStorage from '../../../utils/luFileStatusStorage'; +import { getReferredQnaFiles } from '../../../utils/qnaUtil'; +import qnaFileStatusStorage from '../../../utils/qnaFileStatusStorage'; +import { logMessage, setError } from '../shared'; +import { + skillManifestsState, + settingsState, + localeState, + luFilesState, + skillsState, + schemasState, + lgFilesState, + locationState, + botStatusState, + botDisplayNameState, + botEnvironmentState, + dialogsState, + dialogSchemasState, +} from '../../atoms'; +import { undoHistoryState } from '../../undo/history'; +import { rootBotProjectIdSelector } from '../../selectors'; +import { getUniqueName } from '../../../utils/fileUtil'; + +export const resetBotStates = async ({ reset }: CallbackInterface, projectId: string) => { + const botStates = Object.keys(botstates); + botStates.forEach((state) => { + const currentRecoilAtom: any = botstates[state]; + reset(currentRecoilAtom(projectId)); + }); +}; + +export const setErrorOnBotProject = async ( + callbackHelpers: CallbackInterface, + projectId: string, + botName: string, + payload: any +) => { + const { set } = callbackHelpers; + if (payload?.response?.data?.message) { + set(botErrorState(projectId), payload.response.data); + } else { + set(botErrorState(projectId), payload); + } + if (payload != null) logMessage(callbackHelpers, `Error loading ${botName}: ${JSON.stringify(payload)}`); +}; + +export const flushExistingTasks = async (callbackHelpers) => { + const { snapshot, reset } = callbackHelpers; + reset(botProjectSpaceLoadedState); + const projectIds = await snapshot.getPromise(botProjectIdsState); + reset(botProjectIdsState, []); + for (const projectId of projectIds) { + resetBotStates(callbackHelpers, projectId); + } + const workers = [lgWorker, luWorker, qnaWorker]; + + return Promise.all([workers.map((w) => w.flush())]); +}; + +// merge sensitive values in localStorage +const mergeLocalStorage = (projectId: string, settings: DialogSetting) => { + const localSetting = settingStorage.get(projectId); + const mergedSettings = { ...settings }; + if (localSetting) { + for (const property of SensitiveProperties) { + const value = objectGet(localSetting, property); + if (value) { + objectSet(mergedSettings, property, value); + } else { + objectSet(mergedSettings, property, ''); // set those key back, because that were omit after persisited + } + } + } + return mergedSettings; +}; + +export const getMergedSettings = (projectId, settings): DialogSetting => { + const mergedSettings = mergeLocalStorage(projectId, settings); + if (Array.isArray(mergedSettings.skill)) { + const skillsArr = mergedSettings.skill.map((skillData) => ({ ...skillData })); + mergedSettings.skill = convertSkillsToDictionary(skillsArr); + } + return mergedSettings; +}; + +export const navigateToBot = ( + callbackHelpers: CallbackInterface, + projectId: string, + mainDialog: string, + qnaKbUrls?: string[], + templateId?: string +) => { + if (projectId) { + const { set } = callbackHelpers; + set(currentProjectIdState, projectId); + let url = `/bot/${projectId}/dialogs/${mainDialog}`; + if (templateId === QnABotTemplateId) { + url = `/bot/${projectId}/knowledge-base/${mainDialog}`; + navigateTo(url, { state: { qnaKbUrls } }); + return; + } + navigateTo(url); + } +}; + +const loadProjectData = (response) => { + const { files, botName, settings, skills: skillContent, id: projectId } = response.data; + const mergedSettings = getMergedSettings(projectId, settings); + const storedLocale = languageStorage.get(botName)?.locale; + const locale = settings.languages.includes(storedLocale) ? storedLocale : settings.defaultLanguage; + const indexedFiles = indexer.index(files, botName, locale, skillContent, mergedSettings); + return { + botFiles: { ...indexedFiles, mergedSettings }, + projectData: response.data, + error: undefined, + }; +}; + +export const fetchProjectDataByPath = async ( + path: string, + storageId +): Promise<{ botFiles: any; projectData: any; error: any }> => { + try { + const response = await httpClient.put(`/projects/open`, { path, storageId }); + const projectData = loadProjectData(response); + return projectData; + } catch (ex) { + return { + botFiles: undefined, + projectData: undefined, + error: ex, + }; + } +}; + +export const fetchProjectDataById = async (projectId): Promise<{ botFiles: any; projectData: any; error: any }> => { + try { + const response = await httpClient.get(`/projects/${projectId}`); + const projectData = loadProjectData(response); + return projectData; + } catch (ex) { + return { + botFiles: undefined, + projectData: undefined, + error: ex, + }; + } +}; + +export const handleProjectFailure = (callbackHelpers: CallbackInterface, ex) => { + setError(callbackHelpers, ex); +}; + +export const processSchema = (projectId: string, schema: any) => ({ + ...schema, + definitions: dereferenceDefinitions(schema.definitions), +}); + +// if user set value in terminal or appsetting.json, it should update the value in localStorage +export const refreshLocalStorage = (projectId: string, settings: DialogSetting) => { + for (const property of SensitiveProperties) { + const value = objectGet(settings, property); + if (value) { + settingStorage.setField(projectId, property, value); + } + } +}; + +export const updateLuFilesStatus = (projectId: string, luFiles: LuFile[]) => { + const status = luFileStatusStorage.get(projectId); + return luFiles.map((luFile) => { + if (typeof status[luFile.id] === 'boolean') { + return { ...luFile, published: status[luFile.id] }; + } else { + return { ...luFile, published: false }; + } + }); +}; + +export const initLuFilesStatus = (projectId: string, luFiles: LuFile[], dialogs: DialogInfo[]) => { + luFileStatusStorage.checkFileStatus( + projectId, + getReferredLuFiles(luFiles, dialogs).map((file) => file.id) + ); + return updateLuFilesStatus(projectId, luFiles); +}; + +export const updateQnaFilesStatus = (projectId: string, qnaFiles: QnAFile[]) => { + const status = qnaFileStatusStorage.get(projectId); + return qnaFiles.map((qnaFile) => { + if (typeof status[qnaFile.id] === 'boolean') { + return { ...qnaFile, published: status[qnaFile.id] }; + } else { + return { ...qnaFile, published: false }; + } + }); +}; + +export const initQnaFilesStatus = (projectId: string, qnaFiles: QnAFile[], dialogs: DialogInfo[]) => { + qnaFileStatusStorage.checkFileStatus( + projectId, + getReferredQnaFiles(qnaFiles, dialogs).map((file) => file.id) + ); + return updateQnaFilesStatus(projectId, qnaFiles); +}; + +export const initBotState = async (callbackHelpers: CallbackInterface, data: any, botFiles: any) => { + const { snapshot, set } = callbackHelpers; + const { botName, botEnvironment, location, schemas, settings, id: projectId, diagnostics } = data; + const { dialogs, dialogSchemas, luFiles, lgFiles, qnaFiles, skillManifestFiles, skills, mergedSettings } = botFiles; + const curLocation = await snapshot.getPromise(locationState(projectId)); + const storedLocale = languageStorage.get(botName)?.locale; + const locale = settings.languages.includes(storedLocale) ? storedLocale : settings.defaultLanguage; + + try { + schemas.sdk.content = processSchema(projectId, schemas.sdk.content); + } catch (err) { + const diagnostics = schemas.diagnostics ?? []; + diagnostics.push(err.message); + schemas.diagnostics = diagnostics; + } + + let mainDialog = ''; + const verifiedDialogs = dialogs.map((dialog) => { + if (dialog.isRoot) { + mainDialog = dialog.id; + } + dialog.diagnostics = validateDialog(dialog, schemas.sdk.content, lgFiles, luFiles); + return dialog; + }); + + await lgWorker.addProject(projectId, lgFiles); + + set(skillManifestsState(projectId), skillManifestFiles); + set(luFilesState(projectId), initLuFilesStatus(botName, luFiles, dialogs)); + set(lgFilesState(projectId), lgFiles); + set(dialogsState(projectId), verifiedDialogs); + set(dialogSchemasState(projectId), dialogSchemas); + set(botEnvironmentState(projectId), botEnvironment); + set(botDisplayNameState(projectId), botName); + set(qnaFilesState(projectId), initQnaFilesStatus(botName, qnaFiles, dialogs)); + if (location !== curLocation) { + set(botStatusState(projectId), BotStatus.unConnected); + set(locationState(projectId), location); + } + set(skillsState(projectId), skills); + set(schemasState(projectId), schemas); + set(localeState(projectId), locale); + set(botDiagnosticsState(projectId), diagnostics); + + refreshLocalStorage(projectId, settings); + set(settingsState(projectId), mergedSettings); + set(filePersistenceState(projectId), new FilePersistence(projectId)); + set(undoHistoryState(projectId), new UndoHistory(projectId)); + return mainDialog; +}; + +export const removeRecentProject = async (callbackHelpers: CallbackInterface, path: string) => { + try { + const { + set, + snapshot: { getPromise }, + } = callbackHelpers; + const currentRecentProjects = await getPromise(recentProjectsState); + const filtered = currentRecentProjects.filter((p) => p.path !== path); + set(recentProjectsState, filtered); + } catch (ex) { + logMessage(callbackHelpers, `Error removing recent project: ${ex}`); + } +}; + +export const openRemoteSkill = async ( + callbackHelpers: CallbackInterface, + manifestUrl: string, + botNameIdentifier: string +) => { + const { set } = callbackHelpers; + + const response = await httpClient.get(`/projects/generateProjectId`); + const projectId = response.data; + const stringified = stringify({ + url: manifestUrl, + }); + const manifestResponse = await httpClient.get( + `/projects/${projectId}/skill/retrieveSkillManifest?${stringified}&ignoreProjectValidation=true` + ); + set(projectMetaDataState(projectId), { + isRootBot: false, + isRemote: true, + }); + set(botNameIdentifierState(projectId), botNameIdentifier); + set(botDisplayNameState(projectId), manifestResponse.data.name); + set(locationState(projectId), manifestUrl); + return { projectId, manifestResponse: manifestResponse.data }; +}; + +export const openLocalSkill = async (callbackHelpers, pathToBot: string, storageId, botNameIdentifier: string) => { + const { set } = callbackHelpers; + const { projectData, botFiles, error } = await fetchProjectDataByPath(pathToBot, storageId); + + if (error) { + throw error; + } + const mainDialog = await initBotState(callbackHelpers, projectData, botFiles); + set(projectMetaDataState(projectData.id), { + isRootBot: false, + isRemote: false, + }); + set(botNameIdentifierState(projectData.id), botNameIdentifier); + + return { + projectId: projectData.id, + mainDialog, + }; +}; + +export const createNewBotFromTemplate = async ( + callbackHelpers, + templateId: string, + name: string, + description: string, + location: string, + schemaUrl?: string, + locale?: string +) => { + const { set } = callbackHelpers; + const response = await httpClient.post(`/projects`, { + storageId: 'default', + templateId, + name, + description, + location, + schemaUrl, + locale, + }); + const { botFiles, projectData } = loadProjectData(response); + const projectId = response.data.id; + if (settingStorage.get(projectId)) { + settingStorage.remove(projectId); + } + const currentBotProjectFileIndexed: BotProjectFile = botFiles.botProjectSpaceFiles[0]; + set(botProjectFileState(projectId), currentBotProjectFileIndexed); + const mainDialog = await initBotState(callbackHelpers, projectData, botFiles); + return { projectId, mainDialog }; +}; + +const addProjectToBotProjectSpace = (set, projectId: string, skillCt: number) => { + let isBotProjectLoaded = false; + set(botProjectIdsState, (current: string[]) => { + const botProjectIds = [...current, projectId]; + if (botProjectIds.length === skillCt) { + isBotProjectLoaded = true; + } + return botProjectIds; + }); + if (isBotProjectLoaded) { + set(botProjectSpaceLoadedState, true); + } +}; + +const handleSkillLoadingFailure = (callbackHelpers, { ex, skillNameIdentifier }) => { + const { set } = callbackHelpers; + // Generating a dummy project id which will be replaced by the user from the UI. + const projectId = uuid(); + set(botDisplayNameState(projectId), skillNameIdentifier); + set(botNameIdentifierState(projectId), skillNameIdentifier); + setErrorOnBotProject(callbackHelpers, projectId, skillNameIdentifier, ex); + return projectId; +}; + +const openRootBotAndSkills = async (callbackHelpers: CallbackInterface, data, storageId = 'default') => { + const { projectData, botFiles } = data; + const { set } = callbackHelpers; + + const mainDialog = await initBotState(callbackHelpers, projectData, botFiles); + const rootBotProjectId = projectData.id; + const { name } = projectData; + set(botNameIdentifierState(rootBotProjectId), camelCase(name)); + + if (botFiles.botProjectSpaceFiles && botFiles.botProjectSpaceFiles.length) { + const currentBotProjectFileIndexed: BotProjectFile = botFiles.botProjectSpaceFiles[0]; + set(botProjectFileState(rootBotProjectId), currentBotProjectFileIndexed); + const currentBotProjectFile: BotProjectSpace = currentBotProjectFileIndexed.content; + + const skills: { [skillId: string]: BotProjectSpaceSkill } = { + ...currentBotProjectFile.skills, + }; + + // RootBot loads first + skills load async + const totalProjectsCount = Object.keys(skills).length + 1; + if (totalProjectsCount > 0) { + for (const nameIdentifier in skills) { + const skill = skills[nameIdentifier]; + let skillPromise; + if (!skill.remote && skill.workspace) { + const skillPath = convertFileProtocolToPath(skill.workspace); + skillPromise = openLocalSkill(callbackHelpers, skillPath, storageId, nameIdentifier); + } else if (skill.manifest) { + skillPromise = openRemoteSkill(callbackHelpers, skill.manifest, nameIdentifier); + } + if (skillPromise) { + skillPromise + .then(({ projectId }) => { + addProjectToBotProjectSpace(set, projectId, totalProjectsCount); + }) + .catch((ex) => { + const projectId = handleSkillLoadingFailure(callbackHelpers, { + skillNameIdentifier: nameIdentifier, + ex, + }); + addProjectToBotProjectSpace(set, projectId, totalProjectsCount); + }); + } + } + } + } else { + // Should never hit here as all projects should have a botproject file + throw new Error(formatMessage('Bot project file does not exist.')); + } + set(botProjectIdsState, [rootBotProjectId]); + set(currentProjectIdState, rootBotProjectId); + return { + mainDialog, + projectId: rootBotProjectId, + }; +}; + +export const openRootBotAndSkillsByPath = async (callbackHelpers: CallbackInterface, path: string, storageId) => { + const data = await fetchProjectDataByPath(path, storageId); + if (data.error) { + throw data.error; + } + return await openRootBotAndSkills(callbackHelpers, data, storageId); +}; + +export const openRootBotAndSkillsByProjectId = async (callbackHelpers: CallbackInterface, projectId: string) => { + const data = await fetchProjectDataById(projectId); + if (data.error) { + throw data.error; + } + return await openRootBotAndSkills(callbackHelpers, data); +}; + +export const saveProject = async (callbackHelpers, oldProjectData) => { + const { oldProjectId, name, description, location } = oldProjectData; + const response = await httpClient.post(`/projects/${oldProjectId}/project/saveAs`, { + storageId: 'default', + name, + description, + location, + }); + const data = loadProjectData(response); + if (data.error) { + throw data.error; + } + const result = openRootBotAndSkills(callbackHelpers, data); + return result; +}; + +export const getSkillNameIdentifier = async ( + callbackHelpers: CallbackInterface, + displayName: string +): Promise => { + const { snapshot } = callbackHelpers; + const rootBotProjectId = await snapshot.getPromise(rootBotProjectIdSelector); + if (rootBotProjectId) { + const { content: botProjectFile } = await snapshot.getPromise(botProjectFileState(rootBotProjectId)); + return getUniqueName(Object.keys(botProjectFile.skills), camelCase(displayName)); + } + return ''; +}; + +export const checkIfBotExistsInBotProjectFile = async ( + callbackHelpers: CallbackInterface, + pathOrManifest: string, + remote?: boolean +) => { + const { snapshot } = callbackHelpers; + const rootBotProjectId = await snapshot.getPromise(rootBotProjectIdSelector); + if (!rootBotProjectId) { + throw new Error(formatMessage('The root bot is not a bot project')); + } + const { content: botProjectFile } = await snapshot.getPromise(botProjectFileState(rootBotProjectId)); + + for (const uniqueSkillName in botProjectFile.skills) { + const { manifest, workspace } = botProjectFile.skills[uniqueSkillName]; + if (remote) { + if (manifest === pathOrManifest) { + return true; + } + } else { + if (workspace) { + const resolvedPath = convertFileProtocolToPath(workspace); + if (pathOrManifest === resolvedPath) { + return true; + } + } + } + } + return false; +}; diff --git a/Composer/packages/client/src/recoilModel/persistence/FilePersistence.ts b/Composer/packages/client/src/recoilModel/persistence/FilePersistence.ts index a28b100468..e6159e804a 100644 --- a/Composer/packages/client/src/recoilModel/persistence/FilePersistence.ts +++ b/Composer/packages/client/src/recoilModel/persistence/FilePersistence.ts @@ -3,7 +3,7 @@ import keys from 'lodash/keys'; import differenceWith from 'lodash/differenceWith'; import isEqual from 'lodash/isEqual'; -import { DialogInfo, DialogSchemaFile, DialogSetting, SkillManifest, BotAssets } from '@bfc/shared'; +import { DialogInfo, DialogSchemaFile, DialogSetting, SkillManifest, BotAssets, BotProjectFile } from '@bfc/shared'; import { LuFile, LgFile, QnAFile } from '@bfc/types'; import * as client from './http'; @@ -184,6 +184,20 @@ class FilePersistence { return changes; } + private getBotProjectFileChanges(current: BotProjectFile, previous: BotProjectFile) { + if (!isEqual(current, previous)) { + return [ + { + id: `${current.id}${FileExtensions.BotProject}`, + change: JSON.stringify(current.content, null, 2), + type: ChangeType.UPDATE, + projectId: this._projectId, + }, + ]; + } + return []; + } + private getSettingsChanges(current: DialogSetting, previous: DialogSetting) { if (!isEqual(current, previous)) { return [ @@ -209,6 +223,12 @@ class FilePersistence { previousAssets.skillManifests ); const settingChanges = this.getSettingsChanges(currentAssets.setting, previousAssets.setting); + + const botProjectFileChanges = this.getBotProjectFileChanges( + currentAssets.botProjectFile, + previousAssets.botProjectFile + ); + const fileChanges: IFileChange[] = [ ...dialogChanges, ...dialogSchemaChanges, @@ -217,6 +237,7 @@ class FilePersistence { ...lgChanges, ...skillManifestChanges, ...settingChanges, + ...botProjectFileChanges, ]; return fileChanges; } diff --git a/Composer/packages/client/src/recoilModel/persistence/types.ts b/Composer/packages/client/src/recoilModel/persistence/types.ts index d1e344c4d0..f31373a543 100644 --- a/Composer/packages/client/src/recoilModel/persistence/types.ts +++ b/Composer/packages/client/src/recoilModel/persistence/types.ts @@ -15,6 +15,7 @@ export enum FileExtensions { Lg = '.lg', QnA = '.qna', Setting = 'appsettings.json', + BotProject = '.botproj', } export type FileErrorHandler = (error) => void; diff --git a/Composer/packages/client/src/recoilModel/selectors/design.ts b/Composer/packages/client/src/recoilModel/selectors/design.ts deleted file mode 100644 index 7814bdfd45..0000000000 --- a/Composer/packages/client/src/recoilModel/selectors/design.ts +++ /dev/null @@ -1,19 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -import { selector } from 'recoil'; - -import { botNameState, botProjectsSpaceState } from '../atoms'; - -//TODO: This selector will be used when BotProjects is implemented -export const botProjectSpaceSelector = selector({ - key: 'botProjectSpaceSelector', - get: ({ get }) => { - const botProjects = get(botProjectsSpaceState); - const result = botProjects.map((botProjectId: string) => { - const name = get(botNameState(botProjectId)); - return { projectId: botProjectId, name }; - }); - return result; - }, -}); diff --git a/Composer/packages/client/src/recoilModel/selectors/index.ts b/Composer/packages/client/src/recoilModel/selectors/index.ts index 5eeee0529e..2dbb6d83cb 100644 --- a/Composer/packages/client/src/recoilModel/selectors/index.ts +++ b/Composer/packages/client/src/recoilModel/selectors/index.ts @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -export * from './design'; +export * from './project'; export * from './eject'; export * from './extensions'; export * from './validatedDialogs'; diff --git a/Composer/packages/client/src/recoilModel/selectors/project.ts b/Composer/packages/client/src/recoilModel/selectors/project.ts new file mode 100644 index 0000000000..7600eb326d --- /dev/null +++ b/Composer/packages/client/src/recoilModel/selectors/project.ts @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { selector } from 'recoil'; +import isEmpty from 'lodash/isEmpty'; + +import { + botErrorState, + botDisplayNameState, + botProjectFileState, + botProjectIdsState, + dialogsState, + projectMetaDataState, + botNameIdentifierState, +} from '../atoms'; + +// Actions +export const botsForFilePersistenceSelector = selector({ + key: 'botsForFilePersistenceSelector', + get: ({ get }) => { + const botProjectIds = get(botProjectIdsState); + return botProjectIds.filter((projectId: string) => { + const { isRemote } = get(projectMetaDataState(projectId)); + const botError = get(botErrorState(projectId)); + return !botError && !isRemote; + }); + }, +}); + +// TODO: This selector would be modfied and leveraged by the project tree +export const botProjectSpaceSelector = selector({ + key: 'botProjectSpaceSelector', + get: ({ get }) => { + const botProjects = get(botProjectIdsState); + const result = botProjects.map((projectId: string) => { + const dialogs = get(dialogsState(projectId)); + const metaData = get(projectMetaDataState(projectId)); + const botError = get(botErrorState(projectId)); + const name = get(botDisplayNameState(projectId)); + const botNameId = get(botNameIdentifierState(projectId)); + return { dialogs, projectId, name, ...metaData, error: botError, botNameId }; + }); + return result; + }, +}); + +export const rootBotProjectIdSelector = selector({ + key: 'rootBotProjectIdSelector', + get: ({ get }) => { + const projectIds = get(botProjectIdsState); + const rootBotId = projectIds[0]; + const botProjectFile = get(botProjectFileState(rootBotId)); + + const metaData = get(projectMetaDataState(rootBotId)); + if (metaData.isRootBot && !isEmpty(botProjectFile)) { + return rootBotId; + } + }, +}); diff --git a/Composer/packages/client/src/recoilModel/undo/__test__/history.test.tsx b/Composer/packages/client/src/recoilModel/undo/__test__/history.test.tsx index e64a06987e..b1ce067af0 100644 --- a/Composer/packages/client/src/recoilModel/undo/__test__/history.test.tsx +++ b/Composer/packages/client/src/recoilModel/undo/__test__/history.test.tsx @@ -13,14 +13,14 @@ import { luFilesState, projectMetaDataState, currentProjectIdState, - botProjectsSpaceState, + botProjectIdsState, } from '../../atoms'; import { renderRecoilHook } from '../../../../__tests__/testUtils/react-recoil-hooks-testing-library'; import UndoHistory from '../undoHistory'; const projectId = '123-asd'; export const UndoRedoWrapper = () => { - const botProjects = useRecoilValue(botProjectsSpaceState); + const botProjects = useRecoilValue(botProjectIdsState); return botProjects.length > 0 ? : null; }; @@ -59,7 +59,7 @@ describe('', () => { ); }, states: [ - { recoilState: botProjectsSpaceState, initialValue: [projectId] }, + { recoilState: botProjectIdsState, initialValue: [projectId] }, { recoilState: dialogsState(projectId), initialValue: [{ id: '1' }] }, { recoilState: lgFilesState(projectId), initialValue: [{ id: '1.lg' }, { id: '2' }] }, { recoilState: luFilesState(projectId), initialValue: [{ id: '1.lu' }, { id: '2' }] }, diff --git a/Composer/packages/client/src/recoilModel/undo/history.ts b/Composer/packages/client/src/recoilModel/undo/history.ts index b36e4f16e4..1a431b8212 100644 --- a/Composer/packages/client/src/recoilModel/undo/history.ts +++ b/Composer/packages/client/src/recoilModel/undo/history.ts @@ -9,6 +9,7 @@ import { } from 'recoil'; import { atomFamily, Snapshot, useRecoilCallback, CallbackInterface, useSetRecoilState } from 'recoil'; import uniqueId from 'lodash/uniqueId'; +import isEmpty from 'lodash/isEmpty'; import { navigateTo, getUrlSearch } from '../../utils/navigation'; @@ -140,10 +141,12 @@ export const UndoRoot = React.memo((props: UndoRootProps) => { }); const setInitialProjectState = useRecoilCallback(({ snapshot }: CallbackInterface) => () => { - undoHistory.clear(); - const assetMap = getAtomAssetsMap(snapshot, projectId); - undoHistory.add(assetMap); - setInitialStateLoaded(true); + if (!isEmpty(undoHistory)) { + undoHistory.clear(); + const assetMap = getAtomAssetsMap(snapshot, projectId); + undoHistory.add(assetMap); + setInitialStateLoaded(true); + } }); useEffect(() => { diff --git a/Composer/packages/client/src/router.tsx b/Composer/packages/client/src/router.tsx index b29e392b2d..a2fe5634d2 100644 --- a/Composer/packages/client/src/router.tsx +++ b/Composer/packages/client/src/router.tsx @@ -12,7 +12,7 @@ import { resolveToBasePath } from './utils/fileUtil'; import { data } from './styles'; import { NotFound } from './components/NotFound'; import { BASEPATH } from './constants'; -import { dispatcherState, schemasState, botProjectsSpaceState, botOpeningState } from './recoilModel'; +import { dispatcherState, schemasState, botProjectIdsState, botOpeningState } from './recoilModel'; import { openAlertModal } from './components/Modal/AlertDialog'; import { dialogStyle } from './components/Modal/dialogStyle'; import { LoadingSpinner } from './components/LoadingSpinner'; @@ -89,8 +89,7 @@ const ProjectRouter: React.FC> = (pro const { projectId = '' } = props; const schemas = useRecoilValue(schemasState(projectId)); const { fetchProjectById } = useRecoilValue(dispatcherState); - const botProjects = useRecoilValue(botProjectsSpaceState); - const botOpening = useRecoilValue(botOpeningState); + const botProjects = useRecoilValue(botProjectIdsState); useEffect(() => { if (props.projectId && !botProjects.includes(props.projectId)) { @@ -107,7 +106,7 @@ const ProjectRouter: React.FC> = (pro } }, [schemas, projectId]); - if (props.projectId && !botOpening && botProjects.includes(props.projectId)) { + if (props.projectId && botProjects.includes(props.projectId)) { return
{props.children}
; } return ; diff --git a/Composer/packages/client/src/shell/useShell.ts b/Composer/packages/client/src/shell/useShell.ts index cbb8b68da1..bac740a100 100644 --- a/Composer/packages/client/src/shell/useShell.ts +++ b/Composer/packages/client/src/shell/useShell.ts @@ -22,7 +22,7 @@ import { localeState, qnaFilesState, designPageLocationState, - botNameState, + botDisplayNameState, dialogSchemasState, lgFilesState, luFilesState, @@ -54,7 +54,7 @@ export function useShell(source: EventSource, projectId: string): Shell { const luFiles = useRecoilValue(luFilesState(projectId)); const lgFiles = useRecoilValue(lgFilesState(projectId)); const dialogSchemas = useRecoilValue(dialogSchemasState(projectId)); - const botName = useRecoilValue(botNameState(projectId)); + const botName = useRecoilValue(botDisplayNameState(projectId)); const settings = useRecoilValue(settingsState(projectId)); const userSettings = useRecoilValue(userSettingsState); diff --git a/Composer/packages/client/src/utils/__test__/fileUtil.test.ts b/Composer/packages/client/src/utils/__test__/fileUtil.test.ts new file mode 100644 index 0000000000..fe5e476715 --- /dev/null +++ b/Composer/packages/client/src/utils/__test__/fileUtil.test.ts @@ -0,0 +1,11 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { getUniqueName } from '../fileUtil'; + +describe('File utils', () => { + it('should get a unique name', () => { + const uniqueName = getUniqueName(['test', 'test-1', 'test-2', 'test-3'], 'test'); + expect(uniqueName).toBe('test-4'); + }); +}); diff --git a/Composer/packages/client/src/utils/fileUtil.ts b/Composer/packages/client/src/utils/fileUtil.ts index 24f8256f28..6fadad98d1 100644 --- a/Composer/packages/client/src/utils/fileUtil.ts +++ b/Composer/packages/client/src/utils/fileUtil.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import path from 'path'; + import moment from 'moment'; import formatMessage from 'format-message'; import generate from 'format-message-generate-id'; @@ -103,3 +105,21 @@ export async function loadLocale(locale: string) { }); } } + +export const getUniqueName = (list: string[], currentName: string, seperator = '-') => { + let uniqueName = currentName; + let i = 1; + while (list.includes(uniqueName)) { + uniqueName = `${currentName}${seperator}${i}`; + i++; + } + return uniqueName; +}; + +export const getFileNameFromPath = (param: string, ext: string | undefined = undefined) => { + return path.basename(param, ext).replace(/\\/g, '/'); +}; + +export const getAbsolutePath = (basePath: string, relativePath: string) => { + return path.resolve(basePath, relativePath); +}; diff --git a/Composer/packages/lib/indexers/src/botProjectSpaceIndexer.ts b/Composer/packages/lib/indexers/src/botProjectSpaceIndexer.ts new file mode 100644 index 0000000000..e2661d1c85 --- /dev/null +++ b/Composer/packages/lib/indexers/src/botProjectSpaceIndexer.ts @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { BotProjectSpace, FileInfo } from '@bfc/shared'; + +import { getBaseName } from './utils/help'; + +const index = (botProjectSpaceFiles: FileInfo[]) => { + // Handle botproject files for multiple env when Composer brings in Env + return botProjectSpaceFiles.map((file) => { + const { content, lastModified, name } = file; + const jsonContent: BotProjectSpace = JSON.parse(content); + return { content: jsonContent, id: getBaseName(name, '.botproj'), lastModified }; + }); +}; + +export const botProjectSpaceIndexer = { + index, +}; diff --git a/Composer/packages/lib/indexers/src/index.ts b/Composer/packages/lib/indexers/src/index.ts index 72ae1a51d2..bbf1b42848 100644 --- a/Composer/packages/lib/indexers/src/index.ts +++ b/Composer/packages/lib/indexers/src/index.ts @@ -9,6 +9,7 @@ import { luIndexer } from './luIndexer'; import { qnaIndexer } from './qnaIndexer'; import { skillIndexer } from './skillIndexer'; import { skillManifestIndexer } from './skillManifestIndexer'; +import { botProjectSpaceIndexer } from './botProjectSpaceIndexer'; import { FileExtensions } from './utils/fileExtensions'; import { getExtension, getBaseName } from './utils/help'; @@ -29,6 +30,7 @@ class Indexer { [FileExtensions.Dialog]: [], [FileExtensions.DialogSchema]: [], [FileExtensions.Manifest]: [], + [FileExtensions.BotProjectSpace]: [], } ); } @@ -54,6 +56,7 @@ class Indexer { qnaFiles: qnaIndexer.index(result[FileExtensions.QnA]), skillManifestFiles: skillManifestIndexer.index(result[FileExtensions.Manifest]), skills: skillIndexer.index(skillContent, settings.skill), + botProjectSpaceFiles: botProjectSpaceIndexer.index(result[FileExtensions.BotProjectSpace]), }; } } @@ -69,3 +72,4 @@ export * from './qnaIndexer'; export * from './utils'; export * from './validations'; export * from './skillIndexer'; +export * from './botProjectSpaceIndexer'; diff --git a/Composer/packages/lib/indexers/src/utils/fileExtensions.ts b/Composer/packages/lib/indexers/src/utils/fileExtensions.ts index bb77af52c8..bea3ea3ab3 100644 --- a/Composer/packages/lib/indexers/src/utils/fileExtensions.ts +++ b/Composer/packages/lib/indexers/src/utils/fileExtensions.ts @@ -8,4 +8,5 @@ export enum FileExtensions { QnA = '.qna', lg = '.lg', Manifest = '.json', + BotProjectSpace = '.botproj', } diff --git a/Composer/packages/lib/shared/__tests__/fileUtils/index.test.ts b/Composer/packages/lib/shared/__tests__/fileUtils/index.test.ts new file mode 100644 index 0000000000..124c0e62cf --- /dev/null +++ b/Composer/packages/lib/shared/__tests__/fileUtils/index.test.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. +import { convertAbsolutePathToFileProtocol, convertFileProtocolToPath } from '../../src/fileUtils'; + +it('should convert a posix path to file protocol', () => { + const testPath = '/Users/tester/empty-bot-0'; + expect(convertAbsolutePathToFileProtocol(testPath)).toBe('file:///Users/tester/empty-bot-0'); +}); + +it('should convert a windows path to file protocol', () => { + const testPath = 'C:/Users/Tester/empty-bot-0'; + expect(convertAbsolutePathToFileProtocol(testPath)).toBe('file:///C:/Users/Tester/empty-bot-0'); +}); + +it('should convert a Windows file protocol path to regular path', () => { + const testPath = 'file:///C:/Users/Tester/empty-bot-0'; + expect(convertFileProtocolToPath(testPath)).toBe('C:/Users/Tester/empty-bot-0'); +}); + +it('should convert a Mac file protocol path to regular path', () => { + const testPath = 'file:///users/tester/empty-bot-0'; + expect(convertFileProtocolToPath(testPath)).toBe('/users/tester/empty-bot-0'); +}); + +it('should give empty string if path is not available', () => { + const testPath = ''; + expect(convertFileProtocolToPath(testPath)).toBe(''); +}); diff --git a/Composer/packages/lib/shared/src/fileUtils/index.ts b/Composer/packages/lib/shared/src/fileUtils/index.ts new file mode 100644 index 0000000000..0cb4cf7829 --- /dev/null +++ b/Composer/packages/lib/shared/src/fileUtils/index.ts @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import trimStart from 'lodash/trimStart'; + +export const convertFileProtocolToPath = (pathToBot: string): string => { + const fileProtocolRemoved = pathToBot.replace('file://', ''); + if (fileProtocolRemoved.match(/^\/[a-zA-Z]:\//g)) { + //Windows path with file protocol. Remove leading / + return trimStart(fileProtocolRemoved, '/'); + } + return fileProtocolRemoved; +}; + +export const convertAbsolutePathToFileProtocol = (pathToBot: string): string => { + let pathName = pathToBot.replace(/\\/g, '/'); + // Windows drive letter must be prefixed with a slash + if (pathName[0] !== '/') { + pathName = '/' + pathName; + } + return encodeURI('file://' + pathName); +}; diff --git a/Composer/packages/lib/shared/src/index.ts b/Composer/packages/lib/shared/src/index.ts index a5d72c06cf..f21fcd1e18 100644 --- a/Composer/packages/lib/shared/src/index.ts +++ b/Composer/packages/lib/shared/src/index.ts @@ -25,4 +25,5 @@ export * from './schemaUtils'; export * from './viewUtils'; export * from './walkerUtils'; export * from './skillsUtils'; +export * from './fileUtils'; export const DialogUtils = dialogUtils; diff --git a/Composer/packages/lib/shared/src/skillsUtils/index.ts b/Composer/packages/lib/shared/src/skillsUtils/index.ts index aa89b8ad8a..c905f7a8c0 100644 --- a/Composer/packages/lib/shared/src/skillsUtils/index.ts +++ b/Composer/packages/lib/shared/src/skillsUtils/index.ts @@ -35,3 +35,8 @@ export const getSkillNameFromSetting = (value?: string) => { } return ''; }; + +export const getEndpointNameGivenUrl = (manifestData: any, urlToMatch: string) => { + const matchedEndpoint = manifestData?.endpoints.find(({ endpointUrl }) => endpointUrl === urlToMatch); + return matchedEndpoint ? matchedEndpoint.name : ''; +}; diff --git a/Composer/packages/server/src/__mocks__/samplebots/bot1/bot1.botproj b/Composer/packages/server/src/__mocks__/samplebots/bot1/bot1.botproj new file mode 100644 index 0000000000..2827aeb871 --- /dev/null +++ b/Composer/packages/server/src/__mocks__/samplebots/bot1/bot1.botproj @@ -0,0 +1,6 @@ +{ + "$schema": "https://schemas.botframework.com/schemas/botprojects/v0.1/botproject-schema.json", + "name": "bot1", + "workspace": "file:///Users/tester/Projects/BotFramework-Composer/Composer/packages/server/src/__mocks__/samplebots/bot1", + "skills": {} +} diff --git a/Composer/packages/server/src/__mocks__/samplebots/bot1/dialogs/a/knowledge-base/en-us/a.en-us.qna b/Composer/packages/server/src/__mocks__/samplebots/bot1/dialogs/a/knowledge-base/en-us/a.en-us.qna new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Composer/packages/server/src/__mocks__/samplebots/bot1/dialogs/b/knowledge-base/en-us/b.en-us.qna b/Composer/packages/server/src/__mocks__/samplebots/bot1/dialogs/b/knowledge-base/en-us/b.en-us.qna new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Composer/packages/server/src/__mocks__/samplebots/bot1/dialogs/root/knowledge-base/en-us/root.en-us.qna b/Composer/packages/server/src/__mocks__/samplebots/bot1/dialogs/root/knowledge-base/en-us/root.en-us.qna new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Composer/packages/server/src/__mocks__/samplebots/bot1/knowledge-base/en-us/bot1.en-us.qna b/Composer/packages/server/src/__mocks__/samplebots/bot1/knowledge-base/en-us/bot1.en-us.qna new file mode 100644 index 0000000000..e69de29bb2 diff --git a/Composer/packages/server/src/controllers/__tests__/publisher.test.ts b/Composer/packages/server/src/controllers/__tests__/publisher.test.ts index 9fcfc8ccb4..a59cf595c8 100644 --- a/Composer/packages/server/src/controllers/__tests__/publisher.test.ts +++ b/Composer/packages/server/src/controllers/__tests__/publisher.test.ts @@ -68,7 +68,7 @@ describe('get types', () => { describe('status', () => { const target = 'default'; - it.only('should get status', async () => { + it('should get status', async () => { const projectId = await BotProjectService.openProject(location2); const mockReq = { @@ -115,6 +115,7 @@ describe('rollback', () => { body: {}, } as Request; await PublishController.rollback(mockReq, mockRes); + expect(mockRes.status).toHaveBeenCalledWith(400); }); }); diff --git a/Composer/packages/server/src/controllers/project.ts b/Composer/packages/server/src/controllers/project.ts index c609ec934e..562574c7c3 100644 --- a/Composer/packages/server/src/controllers/project.ts +++ b/Composer/packages/server/src/controllers/project.ts @@ -128,7 +128,6 @@ async function openProject(req: Request, res: Response) { }); return; } - const user = await ExtensionContext.getUserFromRequest(req); const location: LocationRef = { @@ -207,6 +206,18 @@ async function getRecentProjects(req: Request, res: Response) { return res.status(200).json(projects); } +async function generateProjectId(req: Request, res: Response) { + try { + const location = req.query.location; + const projectId = await BotProjectService.generateProjectId(location); + res.status(200).json(projectId); + } catch (ex) { + res.status(404).json({ + message: 'Cannot generate project id', + }); + } +} + async function updateFile(req: Request, res: Response) { const projectId = req.params.projectId; const user = await ExtensionContext.getUserFromRequest(req); @@ -255,20 +266,21 @@ async function removeFile(req: Request, res: Response) { async function getSkill(req: Request, res: Response) { const projectId = req.params.projectId; const user = await ExtensionContext.getUserFromRequest(req); - - const currentProject = await BotProjectService.getProjectById(projectId, user); - if (currentProject !== undefined) { - try { - const content = await getSkillManifest(req.query.url); - res.status(200).json(content); - } catch (err) { + const ignoreProjectValidation: boolean = req.query.ignoreProjectValidation; + if (!ignoreProjectValidation) { + const currentProject = await BotProjectService.getProjectById(projectId, user); + if (currentProject === undefined) { res.status(404).json({ - message: err.message, + message: 'No such bot project opened', }); } - } else { + } + try { + const content = await getSkillManifest(req.query.url); + res.status(200).json(content); + } catch (err) { res.status(404).json({ - message: 'No such bot project opened', + message: err.message, }); } } @@ -411,4 +423,5 @@ export const ProjectController = { getRecentProjects, updateBoilerplate, checkBoilerplateVersion, + generateProjectId, }; diff --git a/Composer/packages/server/src/models/asset/assetManager.ts b/Composer/packages/server/src/models/asset/assetManager.ts index 0df9b9ca04..76a614fb20 100644 --- a/Composer/packages/server/src/models/asset/assetManager.ts +++ b/Composer/packages/server/src/models/asset/assetManager.ts @@ -18,11 +18,19 @@ import { BotProject } from '../bot/botProject'; export class AssetManager { public templateStorage: LocalDiskStorage; + private _botProjectFileTemplate; constructor() { this.templateStorage = new LocalDiskStorage(); } + public get botProjectFileTemplate() { + if (!this._botProjectFileTemplate) { + this._botProjectFileTemplate = this.getDefaultBotProjectTemplate(); + } + return this._botProjectFileTemplate; + } + public async getProjectTemplates() { return ExtensionContext.extensions.botTemplates; } @@ -116,4 +124,22 @@ export class AssetManager { return undefined; } } + + private getDefaultBotProjectTemplate() { + if (!ExtensionContext.extensions.botTemplates.length) { + return undefined; + } + const boilerplate = ExtensionContext.extensions.botTemplates[0]; + + const location = Path.join(boilerplate.path, `${boilerplate.id}.botproj`); + try { + if (fs.existsSync(location)) { + const raw = fs.readFileSync(location, 'utf8'); + const json = JSON.parse(raw); + return json; + } + } catch (err) { + return ''; + } + } } diff --git a/Composer/packages/server/src/models/bot/__tests__/botProject.test.ts b/Composer/packages/server/src/models/bot/__tests__/botProject.test.ts index 6dcf62c385..69ae7de469 100644 --- a/Composer/packages/server/src/models/bot/__tests__/botProject.test.ts +++ b/Composer/packages/server/src/models/bot/__tests__/botProject.test.ts @@ -5,6 +5,7 @@ import fs from 'fs'; import rimraf from 'rimraf'; import { DialogFactory, SDKKinds } from '@bfc/shared'; +import endsWith from 'lodash/endsWith'; import { Path } from '../../../utility/path'; import { BotProject } from '../botProject'; @@ -14,6 +15,19 @@ jest.mock('azure-storage', () => { return {}; }); +jest.mock('../../../services/asset', () => { + return { + manager: { + botProjectFileTemplate: { + $schema: '', + name: '', + workspace: '', + skills: {}, + }, + }, + }; +}); + const botDir = '../../../__mocks__/samplebots/bot1'; const mockLocationRef: LocationRef = { @@ -30,7 +44,13 @@ beforeEach(async () => { describe('init', () => { it('should get project successfully', () => { const project: { [key: string]: any } = proj.getProject(); - expect(project.files.length).toBe(13); + expect(project.files.length).toBe(15); + }); + + it('should always have a default bot project file', () => { + const project: { [key: string]: any } = proj.getProject(); + const botprojectFile = project.files.find((file) => endsWith(file.name, 'botproj')); + expect(botprojectFile).toBeDefined(); }); }); @@ -103,7 +123,7 @@ describe('copyTo', () => { const newBotProject = await proj.copyTo(locationRef); await newBotProject.init(); const project: { [key: string]: any } = newBotProject.getProject(); - expect(project.files.length).toBe(13); + expect(project.files.length).toBe(15); }); }); @@ -380,7 +400,7 @@ describe('deleteAllFiles', () => { const newBotProject = await proj.copyTo(locationRef); await newBotProject.init(); const project: { [key: string]: any } = newBotProject.getProject(); - expect(project.files.length).toBe(14); + expect(project.files.length).toBe(15); await newBotProject.deleteAllFiles(); expect(fs.existsSync(copyDir)).toBe(false); }); diff --git a/Composer/packages/server/src/models/bot/botProject.ts b/Composer/packages/server/src/models/bot/botProject.ts index 2dacd7b9c4..1e79db6aea 100644 --- a/Composer/packages/server/src/models/bot/botProject.ts +++ b/Composer/packages/server/src/models/bot/botProject.ts @@ -6,7 +6,16 @@ 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, + IBotProject, + DialogSetting, + FileExtensions, + convertAbsolutePathToFileProtocol, +} from '@bfc/shared'; import merge from 'lodash/merge'; import { UserIdentity, ExtensionContext } from '@bfc/extension'; import { FeedbackType, generate } from '@microsoft/bf-generate-library'; @@ -19,6 +28,7 @@ import { ISettingManager, OBFUSCATED_VALUE } from '../settings'; import { DefaultSettingManager } from '../settings/defaultSettingManager'; import log from '../../logger'; import { BotProjectService } from '../../services/project'; +import AssetService from '../../services/asset'; import { Builder } from './builder'; import { IFileStorage } from './../storage/interface'; @@ -83,6 +93,17 @@ export class BotProject implements IBotProject { return files; } + public get botProjectFiles() { + const files: FileInfo[] = []; + this.files.forEach((file) => { + if (file.name.endsWith(FileExtensions.BotProject)) { + files.push(file); + } + }); + + return files; + } + public get dialogSchemaFiles() { const files: FileInfo[] = []; this.files.forEach((file) => { @@ -336,6 +357,14 @@ export class BotProject implements IBotProject { content.id = name; const updatedContent = autofixReferInDialog(botName, JSON.stringify(content, null, 2)); await this._updateFile(relativePath, updatedContent); + + for (const botProjectFile of this.botProjectFiles) { + const { relativePath } = botProjectFile; + const content = JSON.parse(botProjectFile.content); + content.workspace = convertAbsolutePathToFileProtocol(this.dataDir); + content.name = botName; + await this._updateFile(relativePath, JSON.stringify(content, null, 2)); + } await serializeFiles(this.fileStorage, this.dataDir, botName); }; @@ -554,6 +583,7 @@ export class BotProject implements IBotProject { await pluginMethod.call(null, projectId); } } + if (ExtensionContext.extensions.publish[method]?.methods?.removeRuntimeData) { const pluginMethod = ExtensionContext.extensions.publish[method].methods.removeRuntimeData; if (typeof pluginMethod === 'function') { @@ -681,6 +711,7 @@ export class BotProject implements IBotProject { 'sdk.override.uischema', 'sdk.schema', 'sdk.uischema', + '*.botproj', ]; for (const pattern of patterns) { // load only from the data dir, otherwise may get "build" versions from @@ -708,9 +739,16 @@ export class BotProject implements IBotProject { fileList.set(file.name, file); }); - const migrationFiles = await this._createQnAFilesForOldBot(fileList); + const migrationFilesList = await Promise.all([ + this._createQnAFilesForOldBot(fileList), + this._createBotProjectFileForOldBots(fileList), + ]); - return new Map([...fileList, ...migrationFiles]); + const files = [...fileList]; + migrationFilesList.forEach((migrationFiles) => { + files.push(...migrationFiles); + }); + return new Map(files); }; // migration: create qna files for old bots @@ -751,6 +789,35 @@ export class BotProject implements IBotProject { return fileList; }; + private _createBotProjectFileForOldBots = async (files: Map) => { + const fileList = new Map(); + try { + const defaultBotProjectFile: any = await AssetService.manager.botProjectFileTemplate; + + for (const [_, file] of files) { + if (file.name.endsWith(FileExtensions.BotProject)) { + return fileList; + } + } + const fileName = `${this.name}${FileExtensions.BotProject}`; + const root = this.dataDir; + + defaultBotProjectFile.workspace = convertAbsolutePathToFileProtocol(root); + defaultBotProjectFile.name = this.name; + + await this._createFile(fileName, JSON.stringify(defaultBotProjectFile, null, 2)); + const pathToBotProject: string = Path.join(root, fileName); + const fileInfo = await this._getFileInfo(pathToBotProject); + + if (fileInfo) { + fileList.set(fileInfo.name, fileInfo); + } + return fileList; + } catch (ex) { + return fileList; + } + }; + private _getSchemas = async (): Promise => { if (!(await this.exists())) { throw new Error(`${this.dir} is not a valid path`); diff --git a/Composer/packages/server/src/models/bot/botStructure.ts b/Composer/packages/server/src/models/bot/botStructure.ts index 02d0e0eb82..5731702c57 100644 --- a/Composer/packages/server/src/models/bot/botStructure.ts +++ b/Composer/packages/server/src/models/bot/botStructure.ts @@ -26,6 +26,7 @@ const BotStructureTemplate = { }, formDialogs: 'form-dialogs/${FORMDIALOGNAME}', skillManifests: 'manifests/${MANIFESTFILENAME}', + botProject: '${BOTNAME}.botproj', }; const templateInterpolate = (str: string, obj: { [key: string]: string }) => @@ -91,6 +92,7 @@ export const defaultFilePath = (botName: string, defaultLocale: string, filename if (fileType === FileExtensions.DialogSchema) { TemplatePath = isRootFile ? BotStructureTemplate.dialogSchema : BotStructureTemplate.dialogs.dialogSchema; } + return templateInterpolate(TemplatePath, { BOTNAME, DIALOGNAME, @@ -106,6 +108,7 @@ export const serializeFiles = async (fileStorage, rootPath, botName) => { templateInterpolate(BotStructureTemplate.lu, { LOCALE: '*', BOTNAME: '*' }), templateInterpolate(BotStructureTemplate.qna, { LOCALE: '*', BOTNAME: '*' }), templateInterpolate(BotStructureTemplate.dialogSchema, { BOTNAME: '*' }), + templateInterpolate(BotStructureTemplate.botProject, { BOTNAME: '*' }), ]; for (const pattern of entryPatterns) { const paths = await fileStorage.glob(pattern, rootPath); diff --git a/Composer/packages/server/src/models/storage/localDiskStorage.ts b/Composer/packages/server/src/models/storage/localDiskStorage.ts index bd49659a05..441ba5603c 100644 --- a/Composer/packages/server/src/models/storage/localDiskStorage.ts +++ b/Composer/packages/server/src/models/storage/localDiskStorage.ts @@ -109,7 +109,7 @@ export class LocalDiskStorage implements IFileStorage { archive.directory(directory, directory.split(source)[1]); }); - const files = await glob('*.dialog', { cwd: source, dot: true }); + const files = await glob(['*.dialog', '*.botproj'], { cwd: source, dot: true }); files.forEach((file) => { archive.file(path.format({ dir: `${source}/`, base: `${file}` }), { name: path.basename(file) }); }); diff --git a/Composer/packages/server/src/router/api.ts b/Composer/packages/server/src/router/api.ts index 224253d6a6..75833b9649 100644 --- a/Composer/packages/server/src/router/api.ts +++ b/Composer/packages/server/src/router/api.ts @@ -20,6 +20,7 @@ const router: Router = express.Router({}); router.post('/projects', ProjectController.createProject); router.get('/projects', ProjectController.getAllProjects); router.get('/projects/recent', ProjectController.getRecentProjects); +router.get('/projects/generateProjectId', ProjectController.generateProjectId); router.get('/projects/:projectId', ProjectController.getProjectById); router.put('/projects/open', ProjectController.openProject); @@ -27,7 +28,7 @@ router.delete('/projects/:projectId', ProjectController.removeProject); router.put('/projects/:projectId/files/:name', ProjectController.updateFile); router.delete('/projects/:projectId/files/:name', ProjectController.removeFile); router.post('/projects/:projectId/files', ProjectController.createFile); -router.get('/projects/:projectId/skill/retrieve-skill-manifest', ProjectController.getSkill); +router.get('/projects/:projectId/skill/retrieveSkillManifest', ProjectController.getSkill); router.post('/projects/:projectId/build', ProjectController.build); router.post('/projects/:projectId/qnaSettings/set', ProjectController.setQnASettings); router.post('/projects/:projectId/project/saveAs', ProjectController.saveProjectAs); diff --git a/Composer/packages/server/src/services/project.ts b/Composer/packages/server/src/services/project.ts index 9aebf5571e..31d0eaac8e 100644 --- a/Composer/packages/server/src/services/project.ts +++ b/Composer/packages/server/src/services/project.ts @@ -239,9 +239,6 @@ export class BotProjectService { }; private static addRecentProject = (path: string): void => { - // if (!BotProjectService.currentBotProject) { - // return; - // } const currDir = Path.resolve(path); const idx = BotProjectService.recentBotProjects.findIndex((ref) => currDir === Path.resolve(ref.path)); if (idx > -1) { diff --git a/Composer/packages/types/src/indexers.ts b/Composer/packages/types/src/indexers.ts index 8df6145067..f2cf6f1356 100644 --- a/Composer/packages/types/src/indexers.ts +++ b/Composer/packages/types/src/indexers.ts @@ -15,6 +15,7 @@ export enum FileExtensions { Qna = '.qna', Setting = 'appsettings.json', FormDialogSchema = '.form-dialog', + BotProject = '.botproj', } export type FileInfo = { @@ -182,6 +183,7 @@ export type BotAssets = { skillManifests: SkillManifest[]; setting: DialogSetting; dialogSchemas: DialogSchemaFile[]; + botProjectFile: BotProjectFile; }; export type BotInfo = { @@ -189,3 +191,24 @@ export type BotInfo = { diagnostics: IDiagnostic[]; name: string; }; + +export interface BotProjectSpaceSkill { + workspace?: string; + manifest?: string; + remote: boolean; + endpointName?: string; +} + +export interface BotProjectSpace { + workspace: string; + name: string; + skills: { + [skillId: string]: BotProjectSpaceSkill; + }; +} + +export interface BotProjectFile { + id: string; + content: BotProjectSpace; + lastModified: string; +} diff --git a/Composer/plugins/samples/assets/projects/ActionsSample/actionssample.botproj b/Composer/plugins/samples/assets/projects/ActionsSample/actionssample.botproj new file mode 100644 index 0000000000..543cc3b976 --- /dev/null +++ b/Composer/plugins/samples/assets/projects/ActionsSample/actionssample.botproj @@ -0,0 +1,6 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/BotFramework-Composer/main/Composer/packages/server/schemas/botproject.schema", + "name": "", + "workspace": "", + "skills": {} +} diff --git a/Composer/plugins/samples/assets/projects/AskingQuestionsSample/askingquestionssample.botproj b/Composer/plugins/samples/assets/projects/AskingQuestionsSample/askingquestionssample.botproj new file mode 100644 index 0000000000..543cc3b976 --- /dev/null +++ b/Composer/plugins/samples/assets/projects/AskingQuestionsSample/askingquestionssample.botproj @@ -0,0 +1,6 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/BotFramework-Composer/main/Composer/packages/server/schemas/botproject.schema", + "name": "", + "workspace": "", + "skills": {} +} diff --git a/Composer/plugins/samples/assets/projects/ControllingConversationFlowSample/controllingconversationflowsample.botproj b/Composer/plugins/samples/assets/projects/ControllingConversationFlowSample/controllingconversationflowsample.botproj new file mode 100644 index 0000000000..543cc3b976 --- /dev/null +++ b/Composer/plugins/samples/assets/projects/ControllingConversationFlowSample/controllingconversationflowsample.botproj @@ -0,0 +1,6 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/BotFramework-Composer/main/Composer/packages/server/schemas/botproject.schema", + "name": "", + "workspace": "", + "skills": {} +} diff --git a/Composer/plugins/samples/assets/projects/EchoBot/echobot.botproj b/Composer/plugins/samples/assets/projects/EchoBot/echobot.botproj new file mode 100644 index 0000000000..543cc3b976 --- /dev/null +++ b/Composer/plugins/samples/assets/projects/EchoBot/echobot.botproj @@ -0,0 +1,6 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/BotFramework-Composer/main/Composer/packages/server/schemas/botproject.schema", + "name": "", + "workspace": "", + "skills": {} +} diff --git a/Composer/plugins/samples/assets/projects/EmptyBot/emptybot.botproj b/Composer/plugins/samples/assets/projects/EmptyBot/emptybot.botproj new file mode 100644 index 0000000000..543cc3b976 --- /dev/null +++ b/Composer/plugins/samples/assets/projects/EmptyBot/emptybot.botproj @@ -0,0 +1,6 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/BotFramework-Composer/main/Composer/packages/server/schemas/botproject.schema", + "name": "", + "workspace": "", + "skills": {} +} diff --git a/Composer/plugins/samples/assets/projects/InterruptionSample/interruptionssample.botproj b/Composer/plugins/samples/assets/projects/InterruptionSample/interruptionssample.botproj new file mode 100644 index 0000000000..543cc3b976 --- /dev/null +++ b/Composer/plugins/samples/assets/projects/InterruptionSample/interruptionssample.botproj @@ -0,0 +1,6 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/BotFramework-Composer/main/Composer/packages/server/schemas/botproject.schema", + "name": "", + "workspace": "", + "skills": {} +} diff --git a/Composer/plugins/samples/assets/projects/QnAMakerLUISSample/qnamakerluissample.botproj b/Composer/plugins/samples/assets/projects/QnAMakerLUISSample/qnamakerluissample.botproj new file mode 100644 index 0000000000..543cc3b976 --- /dev/null +++ b/Composer/plugins/samples/assets/projects/QnAMakerLUISSample/qnamakerluissample.botproj @@ -0,0 +1,6 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/BotFramework-Composer/main/Composer/packages/server/schemas/botproject.schema", + "name": "", + "workspace": "", + "skills": {} +} diff --git a/Composer/plugins/samples/assets/projects/QnASample/qnasample.botproj b/Composer/plugins/samples/assets/projects/QnASample/qnasample.botproj new file mode 100644 index 0000000000..543cc3b976 --- /dev/null +++ b/Composer/plugins/samples/assets/projects/QnASample/qnasample.botproj @@ -0,0 +1,6 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/BotFramework-Composer/main/Composer/packages/server/schemas/botproject.schema", + "name": "", + "workspace": "", + "skills": {} +} diff --git a/Composer/plugins/samples/assets/projects/RespondingWithCardsSample/respondingwithcardssample.botproj b/Composer/plugins/samples/assets/projects/RespondingWithCardsSample/respondingwithcardssample.botproj new file mode 100644 index 0000000000..543cc3b976 --- /dev/null +++ b/Composer/plugins/samples/assets/projects/RespondingWithCardsSample/respondingwithcardssample.botproj @@ -0,0 +1,6 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/BotFramework-Composer/main/Composer/packages/server/schemas/botproject.schema", + "name": "", + "workspace": "", + "skills": {} +} diff --git a/Composer/plugins/samples/assets/projects/RespondingWithTextSample/respondingwithtextsample.botproj b/Composer/plugins/samples/assets/projects/RespondingWithTextSample/respondingwithtextsample.botproj new file mode 100644 index 0000000000..543cc3b976 --- /dev/null +++ b/Composer/plugins/samples/assets/projects/RespondingWithTextSample/respondingwithtextsample.botproj @@ -0,0 +1,6 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/BotFramework-Composer/main/Composer/packages/server/schemas/botproject.schema", + "name": "", + "workspace": "", + "skills": {} +} diff --git a/Composer/plugins/samples/assets/projects/ToDoBotWithLuisSample/todobotwithluissample.botproj b/Composer/plugins/samples/assets/projects/ToDoBotWithLuisSample/todobotwithluissample.botproj new file mode 100644 index 0000000000..543cc3b976 --- /dev/null +++ b/Composer/plugins/samples/assets/projects/ToDoBotWithLuisSample/todobotwithluissample.botproj @@ -0,0 +1,6 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/BotFramework-Composer/main/Composer/packages/server/schemas/botproject.schema", + "name": "", + "workspace": "", + "skills": {} +} diff --git a/Composer/plugins/samples/assets/projects/TodoSample/todosample.botproj b/Composer/plugins/samples/assets/projects/TodoSample/todosample.botproj new file mode 100644 index 0000000000..543cc3b976 --- /dev/null +++ b/Composer/plugins/samples/assets/projects/TodoSample/todosample.botproj @@ -0,0 +1,6 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/BotFramework-Composer/main/Composer/packages/server/schemas/botproject.schema", + "name": "", + "workspace": "", + "skills": {} +}