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": {}
+}