+
+
+ {showStartBotsWidget && }
{showUpdateAvailableIcon && (
void;
projectId: string;
}
@@ -61,16 +61,19 @@ interface DisplayManifestModalProps {
export const DisplayManifestModal: React.FC = ({
isDraggable = true,
isModeless = true,
- manifestId,
+ skillNameIdentifier,
onDismiss,
- projectId,
}) => {
- const skills = useRecoilValue(skillsState(projectId));
+ const skills = useRecoilValue(skillsStateSelector);
const userSettings = useRecoilValue(userSettingsState);
useEffect(() => onDismiss, []);
- const selectedSkill = useMemo(() => skills.find(({ manifestUrl }) => manifestUrl === manifestId), [manifestId]);
+ const selectedSkill = useMemo(() => {
+ if (skillNameIdentifier) {
+ return skills[skillNameIdentifier];
+ }
+ }, [skillNameIdentifier]);
if (!selectedSkill) {
return null;
@@ -98,7 +101,7 @@ export const DisplayManifestModal: React.FC = ({
height={'100%'}
id={'modaljsonview'}
options={{ readOnly: true }}
- value={selectedSkill.content}
+ value={selectedSkill.manifest}
onChange={() => {}}
/>
diff --git a/Composer/packages/client/src/components/Notifications/NotificationButton.tsx b/Composer/packages/client/src/components/Notifications/NotificationButton.tsx
index 89c7f97cac..e211a727fd 100644
--- a/Composer/packages/client/src/components/Notifications/NotificationButton.tsx
+++ b/Composer/packages/client/src/components/Notifications/NotificationButton.tsx
@@ -10,7 +10,7 @@ import { NeutralColors, SharedColors } from '@uifabric/fluent-theme';
import { useRecoilValue } from 'recoil';
import formatMessage from 'format-message';
-import { notificationsSelector } from '../../recoilModel/selectors/notificationsSelector';
+import { notificationsSelector } from '../../recoilModel/selectors/notifications';
import { dispatcherState } from '../../recoilModel';
import { NotificationPanel } from './NotificationPanel';
diff --git a/Composer/packages/client/src/components/Notifications/NotificationContainer.tsx b/Composer/packages/client/src/components/Notifications/NotificationContainer.tsx
index e157f8638c..2620b6c800 100644
--- a/Composer/packages/client/src/components/Notifications/NotificationContainer.tsx
+++ b/Composer/packages/client/src/components/Notifications/NotificationContainer.tsx
@@ -7,7 +7,7 @@ import isEmpty from 'lodash/isEmpty';
import { useRecoilValue } from 'recoil';
import { dispatcherState } from '../../recoilModel';
-import { notificationsSelector } from '../../recoilModel/selectors/notificationsSelector';
+import { notificationsSelector } from '../../recoilModel/selectors/notifications';
import { NotificationCard } from './NotificationCard';
diff --git a/Composer/packages/client/src/components/TestController/TestController.tsx b/Composer/packages/client/src/components/TestController/TestController.tsx
deleted file mode 100644
index d9a91d529c..0000000000
--- a/Composer/packages/client/src/components/TestController/TestController.tsx
+++ /dev/null
@@ -1,261 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-/** @jsx jsx */
-
-import React, { useState, useRef, Fragment, useEffect, useCallback } from 'react';
-import { jsx, css } from '@emotion/core';
-import { PrimaryButton } from 'office-ui-fabric-react/lib/Button';
-import formatMessage from 'format-message';
-import { useRecoilValue } from 'recoil';
-import { IConfig, IPublishConfig, defaultPublishConfig } from '@bfc/shared';
-
-import {
- botEndpointsState,
- dispatcherState,
- validateDialogsSelectorFamily,
- botStatusState,
- botDisplayNameState,
- luFilesState,
- qnaFilesState,
- settingsState,
- botLoadErrorState,
-} from '../../recoilModel';
-import settingsStorage from '../../utils/dialogSettingStorage';
-import { BotStatus } from '../../constants';
-import { isAbsHosted } from '../../utils/envUtil';
-import useNotifications from '../../pages/notifications/useNotifications';
-import { navigateTo, openInEmulator } from '../../utils/navigation';
-
-import { isBuildConfigComplete, needsBuild } from './../../utils/buildUtil';
-import { PublishDialog } from './publishDialog';
-import { ErrorCallout } from './errorCallout';
-import { EmulatorOpenButton } from './emulatorOpenButton';
-import { Loading } from './loading';
-import { ErrorInfo } from './errorInfo';
-import { WarningInfo } from './warningInfo';
-
-// -------------------- Styles -------------------- //
-
-export const bot = css`
- display: flex;
- align-items: center;
- position: relative;
- height: 100%;
-`;
-
-export const botButton = css`
- margin-left: 5px;
-`;
-
-let botStatusInterval: NodeJS.Timeout | undefined = undefined;
-
-// -------------------- TestController -------------------- //
-const POLLING_INTERVAL = 2500;
-export const TestController: React.FC<{ projectId: string }> = (props) => {
- const { projectId = '' } = props;
- const [modalOpen, setModalOpen] = useState(false);
- const [calloutVisible, setCalloutVisible] = useState(false);
-
- const botActionRef = useRef(null);
- const notifications = useNotifications(projectId);
-
- const dialogs = useRecoilValue(validateDialogsSelectorFamily(projectId));
- const botStatus = useRecoilValue(botStatusState(projectId));
- const botName = useRecoilValue(botDisplayNameState(projectId));
- const luFiles = useRecoilValue(luFilesState(projectId));
- const settings = useRecoilValue(settingsState(projectId));
- const qnaFiles = useRecoilValue(qnaFilesState(projectId));
- const botLoadErrorMsg = useRecoilValue(botLoadErrorState(projectId));
-
- const botEndpoints = useRecoilValue(botEndpointsState);
- const {
- publishToTarget,
- onboardingAddCoachMarkRef,
- build,
- getPublishStatus,
- setBotStatus,
- setSettings,
- setQnASettings,
- } = useRecoilValue(dispatcherState);
- const connected = botStatus === BotStatus.connected;
- const publishing = botStatus === BotStatus.publishing;
- const reloading = botStatus === BotStatus.reloading;
- const addRef = useCallback((startBot) => onboardingAddCoachMarkRef({ startBot }), []);
- const errorLength = notifications.filter((n) => n.severity === 'Error').length;
- const showError = errorLength > 0;
- const publishDialogConfig = { subscriptionKey: settings.qna?.subscriptionKey, ...settings.luis } as IConfig;
- const warningLength = notifications.filter((n) => n.severity === 'Warning').length;
- const showWarning = !showError && warningLength > 0;
-
- useEffect(() => {
- if (projectId) {
- getPublishStatus(projectId, defaultPublishConfig);
- }
- }, [projectId]);
-
- useEffect(() => {
- switch (botStatus) {
- case BotStatus.failed:
- openCallout();
- stopPollingRuntime();
- setBotStatus(BotStatus.pending, projectId);
- break;
- case BotStatus.published:
- stopPollingRuntime();
- handleLoadBot();
- break;
- case BotStatus.reloading:
- startPollingRuntime();
- break;
- default:
- case BotStatus.connected:
- stopPollingRuntime();
- break;
- }
- return () => {
- stopPollingRuntime();
- return;
- };
- }, [botStatus]);
-
- function dismissDialog() {
- setModalOpen(false);
- }
-
- function openDialog() {
- setModalOpen(true);
- }
-
- function dismissCallout() {
- if (calloutVisible) setCalloutVisible(false);
- }
-
- function openCallout() {
- setCalloutVisible(true);
- }
-
- function startPollingRuntime() {
- if (!botStatusInterval) {
- const cancelInterval = setInterval(() => {
- // get publish status
- getPublishStatus(projectId, defaultPublishConfig);
- }, POLLING_INTERVAL);
- botStatusInterval = cancelInterval;
- }
- }
-
- function stopPollingRuntime() {
- if (botStatusInterval) {
- clearInterval(botStatusInterval);
- botStatusInterval = undefined;
- }
- }
-
- async function handleBuild(config: IPublishConfig) {
- setBotStatus(BotStatus.publishing, projectId);
- dismissDialog();
- const { luis, qna } = config;
-
- await setSettings(projectId, {
- ...settings,
- luis: luis,
- qna: Object.assign({}, settings.qna, qna),
- });
- await build(luis, qna, projectId);
- }
-
- async function handleLoadBot() {
- setBotStatus(BotStatus.reloading, projectId);
- if (settings.qna && settings.qna.subscriptionKey) {
- await setQnASettings(projectId, settings.qna.subscriptionKey);
- }
- const sensitiveSettings = settingsStorage.get(projectId);
- await publishToTarget(projectId, defaultPublishConfig, { comment: '' }, sensitiveSettings);
- }
-
- async function handleStart() {
- dismissCallout();
- const config = Object.assign(
- {},
- {
- luis: settings.luis,
- qna: settings.qna,
- }
- );
- if (!isAbsHosted() && needsBuild(dialogs)) {
- if (
- botStatus === BotStatus.failed ||
- botStatus === BotStatus.pending ||
- !isBuildConfigComplete(config, dialogs, luFiles, qnaFiles)
- ) {
- openDialog();
- } else {
- await handleBuild(config);
- }
- } else {
- await handleLoadBot();
- }
- }
-
- function handleErrorButtonClick() {
- navigateTo(`/bot/${projectId}/notifications`);
- }
-
- async function handleOpenEmulator() {
- return Promise.resolve(
- openInEmulator(
- botEndpoints[projectId] || 'http://localhost:3979/api/messages',
- settings.MicrosoftAppId && settings.MicrosoftAppPassword
- ? { MicrosoftAppId: settings.MicrosoftAppId, MicrosoftAppPassword: settings.MicrosoftAppPassword }
- : { MicrosoftAppPassword: '', MicrosoftAppId: '' }
- )
- );
- }
-
- return (
-
-
-
- {settings.luis && modalOpen && (
-
- )}
-
- );
-};
diff --git a/Composer/packages/client/src/components/TestController/emulatorOpenButton.tsx b/Composer/packages/client/src/components/TestController/emulatorOpenButton.tsx
deleted file mode 100644
index 2f7bed4474..0000000000
--- a/Composer/packages/client/src/components/TestController/emulatorOpenButton.tsx
+++ /dev/null
@@ -1,45 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-/** @jsx jsx */
-import { jsx } from '@emotion/core';
-import formatMessage from 'format-message';
-import { ActionButton, IconButton } from 'office-ui-fabric-react/lib/Button';
-import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip';
-import { Fragment } from 'react';
-
-import { BotStatus } from '../../constants';
-
-interface IEmulatorOpenButtonProps {
- botEndpoint: string;
- botStatus: BotStatus;
- hidden: boolean;
- onClick: () => void;
-}
-
-export const EmulatorOpenButton: React.FC
= (props) => {
- const { onClick, botStatus, hidden, botEndpoint } = props;
- const connected = botStatus === BotStatus.connected;
-
- if (hidden || !connected) return null;
-
- return (
-
- {botEndpoint}
- navigator.clipboard.writeText(botEndpoint)} />
-
- }
- >
-
- {formatMessage('Test in Emulator')}
-
-
- );
-};
diff --git a/Composer/packages/client/src/components/__tests__/BotRuntimeController/BotController.test.tsx b/Composer/packages/client/src/components/__tests__/BotRuntimeController/BotController.test.tsx
new file mode 100644
index 0000000000..3b3bfeda8c
--- /dev/null
+++ b/Composer/packages/client/src/components/__tests__/BotRuntimeController/BotController.test.tsx
@@ -0,0 +1,114 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import React from 'react';
+import { act, fireEvent } from '@botframework-composer/test-utils';
+
+import { renderWithRecoil } from '../../../../__tests__/testUtils';
+import { BotStatus } from '../../../constants';
+import { botProjectIdsState, botStatusState } from '../../../recoilModel';
+import { BotController } from '../../BotRuntimeController/BotController';
+
+const mockStart = jest.fn();
+const mockStop = jest.fn();
+const mockSingleStop = jest.fn();
+const mockSingleStart = jest.fn();
+
+jest.mock('office-ui-fabric-react/lib/Button', () => ({
+ DefaultButton: ({ children, onClick }) => (
+
+ ),
+ IconButton: ({ onClick }) => (
+
+ ),
+}));
+
+jest.mock('../../BotRuntimeController/useBotOperations', () => {
+ return {
+ useBotOperations: () => ({
+ startAllBots: mockStart,
+ stopAllBots: mockStop,
+ startSingleBot: mockSingleStart,
+ stopSingleBot: mockSingleStop,
+ }),
+ };
+});
+
+// BotController Menu is tested in its own test file
+jest.mock('../../BotRuntimeController/BotControllerMenu', () => {
+ return {
+ BotControllerMenu: () => {
+ return <>>;
+ },
+ };
+});
+
+describe('', () => {
+ beforeEach(() => {
+ mockStop.mockReset();
+ mockStart.mockReset();
+ });
+
+ it('should show that 2/3 bots have been started correctly', async () => {
+ const initRecoilState = ({ set }) => {
+ const projectIds = ['123a.234', '456a.234', '789a.234'];
+ set(botProjectIdsState, projectIds);
+ set(botStatusState(projectIds[0]), BotStatus.connected);
+ set(botStatusState(projectIds[1]), BotStatus.connected);
+ set(botStatusState(projectIds[2]), BotStatus.failed);
+ };
+ const { findByText } = renderWithRecoil(, initRecoilState);
+ await findByText('Stop all bots (2/3 running)');
+ });
+
+ it('should show that no bots have been started', async () => {
+ const initRecoilState = ({ set }) => {
+ const projectIds = ['123a.234', '456a.234', '789a.234'];
+ set(botProjectIdsState, projectIds);
+ set(botStatusState(projectIds[0]), BotStatus.unConnected);
+ set(botStatusState(projectIds[1]), BotStatus.unConnected);
+ set(botStatusState(projectIds[2]), BotStatus.unConnected);
+ };
+ const { findByText } = renderWithRecoil(, initRecoilState);
+ await findByText('Start all bots');
+ });
+
+ it('should stop all bots if Stop all bots is clicked', async () => {
+ const initRecoilState = ({ set }) => {
+ const projectIds = ['123a.234', '456a.234', '789a.234'];
+ set(botProjectIdsState, projectIds);
+ set(botStatusState(projectIds[0]), BotStatus.published);
+ set(botStatusState(projectIds[1]), BotStatus.publishing);
+ set(botStatusState(projectIds[2]), BotStatus.connected);
+ };
+ const { findByTestId } = renderWithRecoil(, initRecoilState);
+ const button = await findByTestId('button');
+
+ act(() => {
+ fireEvent.click(button);
+ });
+ expect(mockStop).toHaveBeenCalled();
+ });
+
+ it('should start all bots if Start All bots is clicked', async () => {
+ const initRecoilState = ({ set }) => {
+ const projectIds = ['123a.234', '456a.234', '789a.234'];
+ set(botProjectIdsState, projectIds);
+ set(botStatusState(projectIds[0]), BotStatus.unConnected);
+ set(botStatusState(projectIds[1]), BotStatus.unConnected);
+ set(botStatusState(projectIds[2]), BotStatus.unConnected);
+ };
+ const { findByTestId } = renderWithRecoil(, initRecoilState);
+ const button = await findByTestId('button');
+
+ act(() => {
+ fireEvent.click(button);
+ });
+
+ expect(mockStart).toHaveBeenCalled();
+ });
+});
diff --git a/Composer/packages/client/src/components/__tests__/BotRuntimeController/BotRuntimeOperations.test.tsx b/Composer/packages/client/src/components/__tests__/BotRuntimeController/BotRuntimeOperations.test.tsx
new file mode 100644
index 0000000000..28b45b25a2
--- /dev/null
+++ b/Composer/packages/client/src/components/__tests__/BotRuntimeController/BotRuntimeOperations.test.tsx
@@ -0,0 +1,40 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import * as React from 'react';
+
+import { renderWithRecoil } from '../../../../__tests__/testUtils/renderWithRecoil';
+import { botStatusState } from '../../../recoilModel';
+import { BotRuntimeOperations } from '../../BotRuntimeController/BotRuntimeOperations';
+import { BotStatus } from '../../../constants';
+
+describe('', () => {
+ const projectId = '123a.324';
+ it('should render the BotRuntimeOperations with failed status', () => {
+ const { container } = renderWithRecoil(, ({ set }) => {
+ set(botStatusState(projectId), BotStatus.failed);
+ });
+ expect(container.innerHTML.includes('Play')).toBeTruthy();
+ });
+
+ it('should render the BotRuntimeOperations with connected status', () => {
+ const { container } = renderWithRecoil(, ({ set }) => {
+ set(botStatusState(projectId), BotStatus.connected);
+ });
+ expect(container.innerHTML.includes('CircleStopSolid')).toBeTruthy();
+ });
+
+ it('should render the BotRuntimeOperations with unconnected status', () => {
+ const { container } = renderWithRecoil(, ({ set }) => {
+ set(botStatusState(projectId), BotStatus.unConnected);
+ });
+ expect(container.innerHTML.includes('Play')).toBeTruthy();
+ });
+
+ it('should render the spinner for any other bot status', () => {
+ const { container } = renderWithRecoil(, ({ set }) => {
+ set(botStatusState(projectId), BotStatus.publishing);
+ });
+ expect(container.innerHTML.includes('Spinner')).toBeTruthy();
+ });
+});
diff --git a/Composer/packages/client/src/components/__tests__/BotRuntimeController/BotRuntimeStatus.test.tsx b/Composer/packages/client/src/components/__tests__/BotRuntimeController/BotRuntimeStatus.test.tsx
new file mode 100644
index 0000000000..c28c9d9685
--- /dev/null
+++ b/Composer/packages/client/src/components/__tests__/BotRuntimeController/BotRuntimeStatus.test.tsx
@@ -0,0 +1,107 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import * as React from 'react';
+
+import httpClient from '../../../utils/httpUtil';
+import { renderWithRecoil } from '../../../../__tests__/testUtils/renderWithRecoil';
+import { botRuntimeErrorState, botStatusState } from '../../../recoilModel';
+import { BotStatus } from '../../../constants';
+import { BotRuntimeStatus } from '../../BotRuntimeController/BotRuntimeStatus';
+
+jest.mock('../../../utils/httpUtil');
+
+const mockStart = jest.fn();
+const mockStop = jest.fn();
+const pollingInterval = 3000;
+
+jest.mock('../../BotRuntimeController/useBotOperations', () => {
+ return {
+ useBotOperations: () => ({
+ startSingleBot: mockStart,
+ stopSingleBot: mockStop,
+ }),
+ };
+});
+
+describe('', () => {
+ const projectId = '123a.324';
+
+ beforeEach(() => {
+ mockStop.mockClear();
+ mockStart.mockClear();
+ });
+
+ it('should start the bot once its published', async () => {
+ renderWithRecoil(, ({ set }) => {
+ set(botStatusState(projectId), BotStatus.published);
+ });
+
+ expect(mockStart).toHaveBeenCalledWith(projectId, true);
+ });
+
+ describe('', () => {
+ const updatePublishStatusMock = jest.fn();
+ (httpClient.get as jest.Mock).mockImplementation(() => {
+ updatePublishStatusMock();
+ return new Promise((resolve) => {
+ resolve({
+ status: 200,
+ data: {
+ status: 200,
+ },
+ });
+ });
+ });
+ beforeEach(() => {
+ jest.useFakeTimers();
+ updatePublishStatusMock.mockClear();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ });
+
+ it('should not poll if bot is started', async () => {
+ renderWithRecoil(, ({ set }) => {
+ set(botStatusState(projectId), BotStatus.connected);
+ });
+
+ jest.advanceTimersByTime(pollingInterval);
+ expect(updatePublishStatusMock).toHaveBeenCalledTimes(0);
+ });
+
+ it('should not poll if bot is stopped', async () => {
+ renderWithRecoil(, ({ set }) => {
+ set(botStatusState(projectId), BotStatus.failed);
+ });
+
+ jest.advanceTimersByTime(pollingInterval);
+ expect(updatePublishStatusMock).toHaveBeenCalledTimes(0);
+ });
+
+ it('should poll if bot is loading', async () => {
+ renderWithRecoil(, ({ set }) => {
+ set(botStatusState(projectId), BotStatus.reloading);
+ });
+
+ jest.advanceTimersByTime(pollingInterval);
+ expect(updatePublishStatusMock).toHaveBeenCalledTimes(1);
+
+ jest.advanceTimersByTime(pollingInterval);
+
+ expect(updatePublishStatusMock).toHaveBeenCalledTimes(2);
+ });
+
+ it('should show error if bot start failed', async () => {
+ const { findByText } = renderWithRecoil(, ({ set }) => {
+ set(botStatusState(projectId), BotStatus.failed);
+ set(botRuntimeErrorState(projectId), {
+ title: 'Error',
+ message: 'Failed to bind to port 3979',
+ });
+ });
+ expect(findByText('See Details')).toBeDefined();
+ });
+ });
+});
diff --git a/Composer/packages/client/src/components/__tests__/BotRuntimeController/BotStatusIndicator.test.tsx b/Composer/packages/client/src/components/__tests__/BotRuntimeController/BotStatusIndicator.test.tsx
new file mode 100644
index 0000000000..c9359a002d
--- /dev/null
+++ b/Composer/packages/client/src/components/__tests__/BotRuntimeController/BotStatusIndicator.test.tsx
@@ -0,0 +1,51 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import * as React from 'react';
+
+import { renderWithRecoil } from '../../../../__tests__/testUtils/renderWithRecoil';
+import { botRuntimeErrorState, botStatusState } from '../../../recoilModel';
+import { BotStatus, BotStatusesCopy } from '../../../constants';
+import { BotStatusIndicator } from '../../BotRuntimeController/BotStatusIndicator';
+
+jest.mock('../../../utils/httpUtil');
+
+const mockStart = jest.fn();
+const mockStop = jest.fn();
+
+jest.mock('../../BotRuntimeController/useBotOperations', () => {
+ return {
+ useBotOperations: () => ({
+ startSingleBot: mockStart,
+ stopSingleBot: mockStop,
+ }),
+ };
+});
+
+describe('', () => {
+ const projectId = '123a.324';
+
+ beforeEach(() => {
+ mockStop.mockClear();
+ mockStart.mockClear();
+ });
+
+ it('should render the Local Bot Runtime with publishing status', async () => {
+ const { findAllByText } = renderWithRecoil(, ({ set }) => {
+ set(botStatusState(projectId), BotStatus.publishing);
+ });
+ const element = await findAllByText(BotStatusesCopy.publishing);
+ expect(element).toBeDefined();
+ });
+
+ it('should show error if bot start failed', async () => {
+ const { findByText } = renderWithRecoil(, ({ set }) => {
+ set(botStatusState(projectId), BotStatus.failed);
+ set(botRuntimeErrorState(projectId), {
+ title: 'Error',
+ message: 'Failed to bind to port 3979',
+ });
+ });
+ expect(findByText('See Details')).toBeDefined();
+ });
+});
diff --git a/Composer/packages/client/src/components/__tests__/BotRuntimeController/useBotOperations.test.tsx b/Composer/packages/client/src/components/__tests__/BotRuntimeController/useBotOperations.test.tsx
new file mode 100644
index 0000000000..a2b67226b2
--- /dev/null
+++ b/Composer/packages/client/src/components/__tests__/BotRuntimeController/useBotOperations.test.tsx
@@ -0,0 +1,83 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import * as React from 'react';
+import { RecoilRoot } from 'recoil';
+import { renderHook } from '@botframework-composer/test-utils/lib/hooks';
+import { act } from '@botframework-composer/test-utils';
+import { defaultPublishConfig } from '@bfc/shared';
+
+import { useBotOperations } from '../../BotRuntimeController/useBotOperations';
+import { botProjectIdsState, dispatcherState, projectMetaDataState } from '../../../recoilModel';
+
+const state = {
+ projectId: '123a.23fs',
+ skillId: '456a.23fs',
+};
+
+const mocks = {
+ resetBotRuntimeError: jest.fn(),
+ publishToTarget: jest.fn(),
+ setBotStatus: jest.fn(),
+ stopBot: jest.fn(),
+};
+
+const initRecoilState = ({ set }) => {
+ set(botProjectIdsState, [state.projectId, state.skillId]);
+ set(projectMetaDataState(state.projectId), {
+ isRootBot: true,
+ });
+ set(projectMetaDataState(state.skillId), {
+ isRootBot: false,
+ });
+
+ set(dispatcherState, {
+ resetBotRuntimeError: mocks.resetBotRuntimeError,
+ publishToTarget: mocks.publishToTarget,
+ setBotStatus: mocks.setBotStatus,
+ stopPublishBot: mocks.stopBot,
+ });
+};
+
+// TODO: An integration test needs to be added to test this component better.
+describe('useBotOperations', () => {
+ afterEach(() => {
+ mocks.resetBotRuntimeError.mockReset();
+ mocks.publishToTarget.mockReset();
+ mocks.setBotStatus.mockReset();
+ mocks.stopBot.mockReset();
+ });
+
+ it('should start a single bot', async () => {
+ const wrapper = (props: { children?: React.ReactNode }) => {
+ const { children } = props;
+ return {children};
+ };
+
+ const { result } = renderHook(() => useBotOperations(), {
+ wrapper,
+ });
+
+ await act(async () => {
+ result.current.startSingleBot(state.skillId);
+ });
+ expect(mocks.resetBotRuntimeError).toHaveBeenLastCalledWith(state.skillId);
+ expect(mocks.publishToTarget).toHaveBeenLastCalledWith(state.skillId, defaultPublishConfig, { comment: '' }, {});
+ });
+
+ it('should stop a single bot', async () => {
+ const wrapper = (props: { children?: React.ReactNode }) => {
+ const { children } = props;
+ return {children};
+ };
+
+ const { result } = renderHook(() => useBotOperations(), {
+ wrapper,
+ });
+
+ await act(async () => {
+ result.current.stopSingleBot(state.skillId);
+ });
+ expect(mocks.stopBot).toHaveBeenLastCalledWith(state.skillId);
+ });
+});
diff --git a/Composer/packages/client/src/components/__tests__/BotRuntimeController/useBotStatusTracker.test.tsx b/Composer/packages/client/src/components/__tests__/BotRuntimeController/useBotStatusTracker.test.tsx
new file mode 100644
index 0000000000..b72826ad0b
--- /dev/null
+++ b/Composer/packages/client/src/components/__tests__/BotRuntimeController/useBotStatusTracker.test.tsx
@@ -0,0 +1,105 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import * as React from 'react';
+import { RecoilRoot } from 'recoil';
+import { renderHook } from '@botframework-composer/test-utils/lib/hooks';
+
+import { botProjectIdsState, botStatusState, dispatcherState, projectMetaDataState } from '../../../recoilModel';
+import { useStartedRuntimesTracker } from '../../BotRuntimeController/useStartedRuntimesTracker';
+import { BotStatus } from '../../../constants';
+
+const state = {
+ projectId: '123a.23fs',
+ skillId: '456a.23fs',
+};
+
+const mocks = {
+ resetBotRuntimeError: jest.fn(),
+ publishToTarget: jest.fn(),
+ setBotStatus: jest.fn(),
+};
+
+const initRecoilState = (set) => {
+ set(botProjectIdsState, [state.projectId, state.skillId]);
+ set(projectMetaDataState(state.projectId), {
+ isRootBot: true,
+ });
+ set(projectMetaDataState(state.skillId), {
+ isRootBot: false,
+ });
+
+ set(dispatcherState, {
+ resetBotRuntimeError: mocks.resetBotRuntimeError,
+ publishToTarget: mocks.publishToTarget,
+ setBotStatus: mocks.setBotStatus,
+ });
+};
+
+// TODO: An integration test needs to be added to test this component better.
+describe('useBotStatusTracker', () => {
+ const onBotStartedAction = jest.fn();
+ afterEach(() => {
+ onBotStartedAction.mockClear();
+ });
+
+ it('should call action once tracked bots started or failed', async () => {
+ const rootBotId = '234.2234a';
+ const trackedProjectIds = ['124a.asd', '356b.asd'];
+ const wrapper = (props: { children?: React.ReactNode }) => {
+ const { children } = props;
+ const updateRecoilState = (set) => {
+ set(botProjectIdsState, [rootBotId, ...trackedProjectIds]);
+ set(botStatusState(trackedProjectIds[0]), BotStatus.connected);
+ set(botStatusState(trackedProjectIds[1]), BotStatus.failed);
+ };
+
+ return (
+ {
+ initRecoilState(set);
+ updateRecoilState(set);
+ }}
+ >
+ {children}
+
+ );
+ };
+
+ renderHook(() => useStartedRuntimesTracker(onBotStartedAction, trackedProjectIds), {
+ wrapper,
+ });
+ expect(onBotStartedAction).toHaveBeenCalledTimes(1);
+ });
+
+ it('should not call action if a tracked bot is still running', async () => {
+ const rootBotId = '234.2234a';
+ const trackedProjectIds = ['124a.asd', '356b.asd'];
+ const wrapper = (props: { children?: React.ReactNode }) => {
+ const { children } = props;
+ const updateRecoilState = (set) => {
+ set(botProjectIdsState, [rootBotId, ...trackedProjectIds]);
+ set(botStatusState(trackedProjectIds[0]), BotStatus.connected);
+ set(botStatusState(trackedProjectIds[1]), BotStatus.publishing);
+ };
+
+ return (
+ {
+ initRecoilState(set);
+ updateRecoilState(set);
+ }}
+ >
+ {children}
+
+ );
+ };
+
+ renderHook(() => useStartedRuntimesTracker(onBotStartedAction, trackedProjectIds), {
+ wrapper,
+ });
+ expect(onBotStartedAction).toHaveBeenCalledTimes(0);
+ });
+});
diff --git a/Composer/packages/client/src/constants.ts b/Composer/packages/client/src/constants.ts
index 7a0f8b3855..0b5bf88bec 100644
--- a/Composer/packages/client/src/constants.ts
+++ b/Composer/packages/client/src/constants.ts
@@ -97,6 +97,15 @@ export const Steps = {
NONE: 'NONE',
};
+export const BotStatusesCopy = {
+ connected: formatMessage('Running'),
+ publishing: formatMessage('Building'),
+ published: formatMessage('Starting'),
+ unConnected: formatMessage('Inactive'),
+ failed: formatMessage('Failed to start'),
+ loading: formatMessage('Loading'),
+};
+
export const DialogCreationCopy = {
CREATE_NEW_BOT: {
title: formatMessage('Create bot from template or scratch?'),
diff --git a/Composer/packages/client/src/pages/design/DesignPage.tsx b/Composer/packages/client/src/pages/design/DesignPage.tsx
index fd19dc4697..2037a12582 100644
--- a/Composer/packages/client/src/pages/design/DesignPage.tsx
+++ b/Composer/packages/client/src/pages/design/DesignPage.tsx
@@ -16,7 +16,6 @@ import { useRecoilValue } from 'recoil';
import { LeftRightSplit } from '../../components/Split/LeftRightSplit';
import { LoadingSpinner } from '../../components/LoadingSpinner';
-import { TestController } from '../../components/TestController/TestController';
import { DialogDeleting } from '../../constants';
import { createSelectedPath, deleteTrigger, TriggerFormData, getDialogData } from '../../utils/dialogUtil';
import { Conversation } from '../../components/Conversation';
@@ -39,10 +38,9 @@ import {
validateDialogsSelectorFamily,
focusPathState,
showCreateDialogModalState,
- showAddSkillDialogModalState,
localeState,
- rootBotProjectIdSelector,
qnaFilesState,
+ rootBotProjectIdSelector,
} from '../../recoilModel';
import { CreateQnAModal } from '../../components/QnA';
import { triggerNotSupported } from '../../utils/dialogValidator';
@@ -126,15 +124,15 @@ const DesignPage: React.FC {
+ setCurrentPageMode('design');
+ }, []);
+
useEffect(() => {
if (location && props.dialogId && props.projectId) {
const { dialogId, projectId } = props;
@@ -544,11 +546,6 @@ const DesignPage: React.FC,
- align: 'right',
- },
];
const createBreadcrumbItem: (breadcrumb: BreadcrumbItem) => IBreadcrumbItem = (breadcrumb: BreadcrumbItem) => {
@@ -743,8 +740,13 @@ const DesignPage: React.FC addSkillDialogCancel(projectId)}
- onSubmit={(skill) => addSkill(projectId, skill)}
+ onDismiss={() => {
+ setAddSkillDialogModalVisibility(false);
+ }}
+ onSubmit={(manifestUrl, endpointName) => {
+ setAddSkillDialogModalVisibility(false);
+ addRemoteSkillToBotProject(manifestUrl, endpointName);
+ }}
/>
)}
{exportSkillModalVisible && (
@@ -767,8 +769,8 @@ const DesignPage: React.FC
{displaySkillManifest && (
dismissManifestModal(projectId)}
/>
)}
diff --git a/Composer/packages/client/src/pages/design/exportSkillModal/constants.tsx b/Composer/packages/client/src/pages/design/exportSkillModal/constants.tsx
index c99ddd7e7e..1fa550ebb9 100644
--- a/Composer/packages/client/src/pages/design/exportSkillModal/constants.tsx
+++ b/Composer/packages/client/src/pages/design/exportSkillModal/constants.tsx
@@ -4,7 +4,7 @@
import formatMessage from 'format-message';
import { JSONSchema7 } from '@bfc/extension-client';
import { resolveRef } from '@bfc/adaptive-form';
-import { SkillManifest } from '@bfc/shared';
+import { SkillManifestFile } from '@bfc/shared';
import startCase from 'lodash/startCase';
import { SDKKinds } from '@bfc/shared';
@@ -88,14 +88,14 @@ export interface ContentProps {
completeStep: () => void;
errors: { [key: string]: any };
editJson: () => void;
- manifest: Partial;
+ manifest: Partial;
setErrors: (errors: { [key: string]: any }) => void;
setSchema: (_: JSONSchema7) => void;
setSelectedDialogs: (dialogs: any[]) => void;
setSelectedTriggers: (selectedTriggers: any[]) => void;
- setSkillManifest: (_: Partial) => void;
+ setSkillManifest: (_: Partial) => void;
schema: JSONSchema7;
- skillManifests: SkillManifest[];
+ skillManifests: SkillManifestFile[];
value: { [key: string]: any };
onChange: (_: any) => void;
projectId: string;
@@ -113,7 +113,7 @@ interface ValidationDetails {
editingId?: string;
id?: string;
schema: any;
- skillManifests: SkillManifest[];
+ skillManifests: SkillManifestFile[];
}
interface EditorStep {
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 41567316da..c014758ad6 100644
--- a/Composer/packages/client/src/pages/design/exportSkillModal/content/SaveManifest.tsx
+++ b/Composer/packages/client/src/pages/design/exportSkillModal/content/SaveManifest.tsx
@@ -5,7 +5,7 @@
import { css, jsx } from '@emotion/core';
import React, { useEffect } from 'react';
import { Label } from 'office-ui-fabric-react/lib/Label';
-import { SkillManifest } from '@bfc/shared';
+import { SkillManifestFile } from '@bfc/shared';
import { TextField } from 'office-ui-fabric-react/lib/TextField';
import { useRecoilValue } from 'recoil';
import formatMessage from 'format-message';
@@ -22,8 +22,8 @@ const styles = {
export const getManifestId = (
botName: string,
- skillManifests: SkillManifest[],
- { content: { $schema } = {} }: Partial
+ skillManifests: SkillManifestFile[],
+ { content: { $schema } = {} }: Partial
): string => {
const [version] = VERSION_REGEX.exec($schema) || [''];
diff --git a/Composer/packages/client/src/pages/design/exportSkillModal/generateSkillManifest.ts b/Composer/packages/client/src/pages/design/exportSkillModal/generateSkillManifest.ts
index cd893c8e27..0990531c16 100644
--- a/Composer/packages/client/src/pages/design/exportSkillModal/generateSkillManifest.ts
+++ b/Composer/packages/client/src/pages/design/exportSkillModal/generateSkillManifest.ts
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
import get from 'lodash/get';
-import { DialogInfo, DialogSchemaFile, ITrigger, SDKKinds, SkillManifest, LuFile, QnAFile } from '@bfc/shared';
+import { DialogInfo, DialogSchemaFile, ITrigger, SDKKinds, SkillManifestFile, LuFile, QnAFile } from '@bfc/shared';
import { JSONSchema7 } from '@bfc/extension-client';
import { Activities, Activity, activityHandlerMap, ActivityTypes, DispatchModels } from './constants';
@@ -13,7 +13,7 @@ export const isSupportedTrigger = ({ type }: ITrigger) => Object.keys(activityHa
export const generateSkillManifest = (
schema: JSONSchema7,
- skillManifest: Partial,
+ skillManifest: Partial,
dialogs: DialogInfo[],
dialogSchemas: DialogSchemaFile[],
luFiles: LuFile[],
diff --git a/Composer/packages/client/src/pages/design/exportSkillModal/index.tsx b/Composer/packages/client/src/pages/design/exportSkillModal/index.tsx
index 644e97d970..41be8bcf9d 100644
--- a/Composer/packages/client/src/pages/design/exportSkillModal/index.tsx
+++ b/Composer/packages/client/src/pages/design/exportSkillModal/index.tsx
@@ -10,7 +10,7 @@ import { DefaultButton, PrimaryButton } from 'office-ui-fabric-react/lib/Button'
import { JSONSchema7 } from '@bfc/extension-client';
import { Link } from 'office-ui-fabric-react/lib/components/Link';
import { useRecoilValue } from 'recoil';
-import { SkillManifest } from '@bfc/shared';
+import { SkillManifestFile } from '@bfc/shared';
import {
dispatcherState,
@@ -45,7 +45,7 @@ const ExportSkillModal: React.FC = ({ onSubmit, onDismiss
const [errors, setErrors] = useState({});
const [schema, setSchema] = useState({});
- const [skillManifest, setSkillManifest] = useState>({});
+ const [skillManifest, setSkillManifest] = useState>({});
const { content = {}, id } = skillManifest;
@@ -79,7 +79,7 @@ const ExportSkillModal: React.FC = ({ onSubmit, onDismiss
const handleSave = () => {
if (skillManifest.content && skillManifest.id) {
- updateSkillManifest(skillManifest as SkillManifest, projectId);
+ updateSkillManifest(skillManifest as SkillManifestFile, projectId);
}
};
diff --git a/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx b/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx
index c53392b190..d54b371cd3 100644
--- a/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx
+++ b/Composer/packages/client/src/pages/knowledge-base/QnAPage.tsx
@@ -11,7 +11,6 @@ import { RouteComponentProps, Router } from '@reach/router';
import { LoadingSpinner } from '../../components/LoadingSpinner';
import { navigateTo } from '../../utils/navigation';
-import { TestController } from '../../components/TestController/TestController';
import { INavTreeItem } from '../../components/NavTree';
import { Page } from '../../components/Page';
import { dialogsSelectorFamily, qnaFilesState } from '../../recoilModel';
@@ -108,14 +107,6 @@ const QnAPage: React.FC = (props) => {
actions.setCurrentPageMode('qna');
}, []);
- const toolbarItems = [
- {
- type: 'element',
- element: ,
- align: 'right',
- },
- ];
-
const onRenderHeaderContent = () => {
if (!isRoot) {
return (
@@ -134,7 +125,7 @@ const QnAPage: React.FC = (props) => {
navLinks={navLinks}
navRegionName={formatMessage('Qna Navigation Pane')}
title={formatMessage('QnA')}
- toolbarItems={toolbarItems}
+ toolbarItems={[]}
onRenderHeaderContent={onRenderHeaderContent}
>
}>
diff --git a/Composer/packages/client/src/pages/language-generation/LGPage.tsx b/Composer/packages/client/src/pages/language-generation/LGPage.tsx
index b3f914871d..fa55cb201c 100644
--- a/Composer/packages/client/src/pages/language-generation/LGPage.tsx
+++ b/Composer/packages/client/src/pages/language-generation/LGPage.tsx
@@ -11,7 +11,6 @@ import { useRecoilValue } from 'recoil';
import { LoadingSpinner } from '../../components/LoadingSpinner';
import { navigateTo } from '../../utils/navigation';
-import { TestController } from '../../components/TestController/TestController';
import { INavTreeItem } from '../../components/NavTree';
import { Page } from '../../components/Page';
import { validateDialogsSelectorFamily } from '../../recoilModel';
@@ -81,14 +80,6 @@ const LGPage: React.FC> = (props: RouteComponen
[dialogId, projectId, edit]
);
- const toolbarItems = [
- {
- type: 'element',
- element: ,
- align: 'right',
- },
- ];
-
const onRenderHeaderContent = () => {
return (
@@ -104,7 +95,7 @@ const LGPage: React.FC> = (props: RouteComponen
navLinks={navLinks}
navRegionName={formatMessage('LG Navigation Pane')}
title={formatMessage('Bot Responses')}
- toolbarItems={toolbarItems}
+ toolbarItems={[]}
onRenderHeaderContent={onRenderHeaderContent}
>
}>
diff --git a/Composer/packages/client/src/pages/language-understanding/LUPage.tsx b/Composer/packages/client/src/pages/language-understanding/LUPage.tsx
index 8460bac7c1..70e4fc433b 100644
--- a/Composer/packages/client/src/pages/language-understanding/LUPage.tsx
+++ b/Composer/packages/client/src/pages/language-understanding/LUPage.tsx
@@ -10,7 +10,6 @@ import { useRecoilValue } from 'recoil';
import { navigateTo } from '../../utils/navigation';
import { LoadingSpinner } from '../../components/LoadingSpinner';
-import { TestController } from '../../components/TestController/TestController';
import { INavTreeItem } from '../../components/NavTree';
import { Page } from '../../components/Page';
import { validateDialogsSelectorFamily } from '../../recoilModel';
@@ -73,14 +72,6 @@ const LUPage: React.FC,
- align: 'right',
- },
- ];
-
const onRenderHeaderContent = () => {
if (!isRoot) {
return (
@@ -99,7 +90,7 @@ const LUPage: React.FC
}>
diff --git a/Composer/packages/client/src/pages/setting/SettingsPage.tsx b/Composer/packages/client/src/pages/setting/SettingsPage.tsx
index 23cbde35e8..37b61f2e77 100644
--- a/Composer/packages/client/src/pages/setting/SettingsPage.tsx
+++ b/Composer/packages/client/src/pages/setting/SettingsPage.tsx
@@ -18,7 +18,6 @@ import {
settingsState,
currentProjectIdState,
} from '../../recoilModel';
-import { TestController } from '../../components/TestController/TestController';
import { OpenConfirmModal } from '../../components/Modal/ConfirmDialog';
import { navigateTo } from '../../utils/navigation';
import { Page } from '../../components/Page';
@@ -217,12 +216,6 @@ const SettingPage: React.FC = () => {
dataTestid: 'AddLanguageFlyout',
disabled: false,
},
-
- {
- type: 'element',
- element: ,
- align: 'right',
- },
];
const title = useMemo(() => {
diff --git a/Composer/packages/client/src/pages/skills/index.tsx b/Composer/packages/client/src/pages/skills/index.tsx
deleted file mode 100644
index 12df39ccaf..0000000000
--- a/Composer/packages/client/src/pages/skills/index.tsx
+++ /dev/null
@@ -1,87 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-/** @jsx jsx */
-import { jsx } from '@emotion/core';
-import { RouteComponentProps } from '@reach/router';
-import React, { useCallback, useState } from 'react';
-import formatMessage from 'format-message';
-import { useRecoilValue } from 'recoil';
-import { SkillSetting } from '@bfc/shared';
-
-import { dispatcherState, settingsState, botDisplayNameState } from '../../recoilModel';
-import { Toolbar, IToolbarItem } from '../../components/Toolbar';
-import { TestController } from '../../components/TestController/TestController';
-import { CreateSkillModal } from '../../components/CreateSkillModal';
-
-import { ContainerStyle, ContentHeaderStyle, HeaderText } from './styles';
-import SkillSettings from './skill-settings';
-import SkillList from './skill-list';
-
-const Skills: React.FC> = (props) => {
- const { projectId = '' } = props;
- const [showAddSkillDialogModal, setShowAddSkillDialogModal] = useState(false);
-
- const botName = useRecoilValue(botDisplayNameState(projectId));
- const settings = useRecoilValue(settingsState(projectId));
- const { addSkill, setSettings } = useRecoilValue(dispatcherState);
-
- const toolbarItems: IToolbarItem[] = [
- {
- type: 'action',
- text: formatMessage('Connect to a new skill'),
- buttonProps: {
- iconProps: {
- iconName: 'Add',
- },
- onClick: () => {
- setShowAddSkillDialogModal(true);
- },
- },
- align: 'left',
- },
- {
- type: 'element',
- element: ,
- align: 'right',
- },
- ];
-
- const onSubmitForm = useCallback(
- (skill: SkillSetting) => {
- addSkill(projectId, skill);
- setShowAddSkillDialogModal(false);
- },
- [projectId]
- );
-
- const onDismissForm = useCallback(() => {
- setShowAddSkillDialogModal(false);
- }, []);
-
- return (
-
-
-
-
{formatMessage('Skills')}
-
-
-
-
-
- {showAddSkillDialogModal && (
-
- )}
-
- );
-};
-
-export default Skills;
diff --git a/Composer/packages/client/src/pages/skills/skill-list.tsx b/Composer/packages/client/src/pages/skills/skill-list.tsx
deleted file mode 100644
index 63435f0e8d..0000000000
--- a/Composer/packages/client/src/pages/skills/skill-list.tsx
+++ /dev/null
@@ -1,193 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-/** @jsx jsx */
-import { jsx } from '@emotion/core';
-import {
- DetailsList,
- DetailsListLayoutMode,
- SelectionMode,
- CheckboxVisibility,
- IColumn,
-} from 'office-ui-fabric-react/lib/DetailsList';
-import React, { useState, useCallback, useMemo } from 'react';
-import { Dropdown, IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown';
-import { IconButton } from 'office-ui-fabric-react/lib/Button';
-import { TooltipHost } from 'office-ui-fabric-react/lib/Tooltip';
-import { ScrollablePane, ScrollbarVisibility } from 'office-ui-fabric-react/lib/ScrollablePane';
-import { Sticky, StickyPositionType } from 'office-ui-fabric-react/lib/Sticky';
-import { Stack } from 'office-ui-fabric-react/lib/Stack';
-import { FontSizes } from '@uifabric/fluent-theme';
-import { useRecoilValue } from 'recoil';
-import formatMessage from 'format-message';
-
-import { DisplayManifestModal } from '../../components/Modal/DisplayManifestModal';
-import { dispatcherState, skillsState } from '../../recoilModel';
-
-import { TableView, TableCell } from './styles';
-
-const columns: IColumn[] = [
- {
- key: 'name',
- name: formatMessage('Available Skills'),
- fieldName: 'name',
- minWidth: 100,
- maxWidth: 150,
- isResizable: true,
- data: 'string',
- onRender: ({ skill: { name } }) => {
- return {name}
;
- },
- },
- {
- key: 'endpointUrl',
- name: formatMessage('Skill Endpoint'),
- fieldName: 'endpointUrl',
- minWidth: 250,
- maxWidth: 400,
- isResizable: true,
- data: 'string',
- onRender: ({ skill, onEditSkill }) => {
- const { endpoints, endpointUrl: selectedEndpointUrl } = skill;
-
- const options = (endpoints || []).map(({ name, endpointUrl, msAppId }, key) => ({
- key,
- text: name,
- data: {
- endpointUrl,
- msAppId,
- },
- selected: endpointUrl === selectedEndpointUrl,
- }));
-
- const handleChange = (_, option?: IDropdownOption) => {
- if (option) {
- onEditSkill({ ...skill, ...option.data });
- }
- };
-
- return ;
- },
- },
- {
- key: 'description',
- name: formatMessage('Description'),
- fieldName: 'description',
- minWidth: 200,
- maxWidth: 400,
- isResizable: true,
- data: 'string',
- onRender: ({ skill: { description } }) => {
- return {description}
;
- },
- },
- {
- key: 'buttons',
- name: '',
- minWidth: 120,
- maxWidth: 120,
- fieldName: 'buttons',
- data: 'string',
- onRender: ({ onDelete, onViewManifest }) => {
- return (
-
-
- onDelete()}
- />
- onViewManifest()}
- />
-
-
- );
- },
- },
-];
-
-interface SkillListProps {
- projectId: string;
-}
-
-const SkillList: React.FC = ({ projectId }) => {
- const { removeSkill, updateSkill } = useRecoilValue(dispatcherState);
- const skills = useRecoilValue(skillsState(projectId));
-
- const [selectedSkillUrl, setSelectedSkillUrl] = useState(null);
-
- const handleViewManifest = (item) => {
- if (item && item.name && item.content) {
- setSelectedSkillUrl(item.manifestUrl);
- }
- };
-
- const handleEditSkill = (projectId: string, skillId: string) => (skillData) => {
- updateSkill(projectId, skillId, skillData);
- };
-
- const items = useMemo(
- () =>
- skills.map((skill) => ({
- skill,
- onDelete: () => removeSkill(projectId, skill.id),
- onViewManifest: () => handleViewManifest(skill),
- onEditSkill: handleEditSkill(projectId, skill.id),
- })),
- [skills, projectId]
- );
-
- const onDismissManifest = () => {
- setSelectedSkillUrl(null);
- };
-
- const onRenderDetailsHeader = useCallback((props, defaultRender) => {
- return (
-
-
- {defaultRender({
- ...props,
- onRenderColumnHeaderTooltip: (tooltipHostProps) => ,
- })}
-
-
- );
- }, []);
-
- return (
-
-
-
-
-
-
-
-
- );
-};
-
-export default SkillList;
diff --git a/Composer/packages/client/src/pages/skills/skill-settings.tsx b/Composer/packages/client/src/pages/skills/skill-settings.tsx
deleted file mode 100644
index cb14016c1a..0000000000
--- a/Composer/packages/client/src/pages/skills/skill-settings.tsx
+++ /dev/null
@@ -1,97 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-/** @jsx jsx */
-import { jsx } from '@emotion/core';
-import React, { useState, useEffect } from 'react';
-import formatMessage from 'format-message';
-import { TextField } from 'office-ui-fabric-react/lib/TextField';
-import { DialogSetting } from '@bfc/shared';
-
-import { FormFieldAlignHorizontalBotSettings } from './styles';
-
-export interface ISkillFormProps {
- botId?: string;
- skillHostEndpoint?: string;
- botPassword?: string;
- setSettings: (projectId: string, settings: DialogSetting) => Promise;
- projectId: string;
- botName: string;
- settings: DialogSetting;
-}
-
-const SkillSettings: React.FC = (props) => {
- const [skillSettings, setSkillSettings] = useState({
- botId: props.botId,
- skillHostEndpoint: props.skillHostEndpoint,
- botPassword: props.botPassword,
- });
- useEffect(() => {
- setSkillSettings({
- botId: props.botId,
- skillHostEndpoint: props.skillHostEndpoint,
- botPassword: props.botPassword,
- });
- }, [props.botId, props.botPassword, props.skillHostEndpoint]);
-
- const handleFieldChange = (event) => {
- const localSettings = {
- ...skillSettings,
- [event.target.id]: event.target.value,
- };
-
- // auto fill `botId` with `MicrosoftAppId`
- if (event.target.id === 'MicrosoftAppId') {
- localSettings.botId = event.target.value;
- }
-
- setSkillSettings({ ...localSettings });
- props.setSettings(props.projectId, { ...props.settings, ...localSettings });
- };
-
- return (
-
- );
-};
-
-export default SkillSettings;
diff --git a/Composer/packages/client/src/pages/skills/styles.ts b/Composer/packages/client/src/pages/skills/styles.ts
deleted file mode 100644
index 9221fa0a08..0000000000
--- a/Composer/packages/client/src/pages/skills/styles.ts
+++ /dev/null
@@ -1,72 +0,0 @@
-// Copyright (c) Microsoft Corporation.
-// Licensed under the MIT License.
-
-import { FontWeights, FontSizes } from 'office-ui-fabric-react/lib/Styling';
-import { css } from '@emotion/core';
-
-export const ContainerStyle = css`
- display: flex;
- flex-direction: column;
- height: 100%;
-`;
-
-export const ContentHeaderStyle = css`
- padding: 5px 20px;
- height: 60px;
- display: flex;
- flex-shrink: 0;
- justify-content: space-between;
- align-items: center;
-`;
-
-export const HeaderText = css`
- font-size: ${FontSizes.xLarge};
- font-weight: ${FontWeights.semibold};
-`;
-
-export const ContentStyle = css`
- margin-left: 2px;
- display: flex;
- border-top: 1px solid #dddddd;
- flex: 1;
- position: relative;
- nav {
- ul {
- margin-top: 0px;
- }
- }
-`;
-
-export const TableView = css`
- flex: 4;
- margin: 20px;
- position: relative;
- overflow: visible;
- fontsize: 16px;
-`;
-
-export const TableCell = css`
- white-space: pre-wrap;
- font-size: 14px;
- textarea,
- input {
- border: 1px solid #dddddd;
- }
-`;
-
-export const FormFieldAlignHorizontalBotSettings = css`
- max-width: 1500px;
- margin-top: 20px;
- align-items: flex-start;
- display: flex;
- flex-wrap: wrap;
- flex-direction: row;
- padding-bottom: 15px;
-`;
-
-export const ManifestModalHeaderStyle = css`
- margin: 14px 0 0 16px;
- font-size: 20px;
- font-weight: bolder;
- align-items: left;
-`;
diff --git a/Composer/packages/client/src/recoilModel/DispatcherWrapper.tsx b/Composer/packages/client/src/recoilModel/DispatcherWrapper.tsx
index 2dc625e09b..2352c7feb9 100644
--- a/Composer/packages/client/src/recoilModel/DispatcherWrapper.tsx
+++ b/Composer/packages/client/src/recoilModel/DispatcherWrapper.tsx
@@ -26,39 +26,38 @@ import {
jsonSchemaFilesState,
crossTrainConfigState,
} from './atoms';
-import { botsForFilePersistenceSelector, formDialogSchemasSelectorFamily } from './selectors';
+import { localBotsWithoutErrorsSelector, formDialogSchemasSelectorFamily } from './selectors';
import { Recognizer } from './Recognizers';
import { recognizersSelectorFamily } from './selectors/recognizers';
const getBotAssets = async (projectId, snapshot: Snapshot): Promise => {
- const result = await Promise.all([
- snapshot.getPromise(dialogsSelectorFamily(projectId)),
- snapshot.getPromise(luFilesState(projectId)),
- snapshot.getPromise(qnaFilesState(projectId)),
- snapshot.getPromise(lgFilesState(projectId)),
- snapshot.getPromise(skillManifestsState(projectId)),
- snapshot.getPromise(settingsState(projectId)),
- snapshot.getPromise(dialogSchemasState(projectId)),
- snapshot.getPromise(botProjectFileState(projectId)),
- snapshot.getPromise(formDialogSchemasSelectorFamily(projectId)),
- snapshot.getPromise(jsonSchemaFilesState(projectId)),
- snapshot.getPromise(recognizersSelectorFamily(projectId)),
- snapshot.getPromise(crossTrainConfigState(projectId)),
- ]);
+ const dialogs = await snapshot.getPromise(dialogsSelectorFamily(projectId));
+ const luFiles = await snapshot.getPromise(luFilesState(projectId));
+ const lgFiles = await snapshot.getPromise(lgFilesState(projectId));
+ const skillManifests = await snapshot.getPromise(skillManifestsState(projectId));
+ const setting = await snapshot.getPromise(settingsState(projectId));
+ const botProjectFile = await snapshot.getPromise(botProjectFileState(projectId));
+ const dialogSchemas = await snapshot.getPromise(dialogSchemasState(projectId));
+ const formDialogSchemas = await snapshot.getPromise(formDialogSchemasSelectorFamily(projectId));
+ const jsonSchemaFiles = await snapshot.getPromise(jsonSchemaFilesState(projectId));
+ const recognizers = await snapshot.getPromise(recognizersSelectorFamily(projectId));
+ const crossTrainConfig = await snapshot.getPromise(crossTrainConfigState(projectId));
+ const qnaFiles = await snapshot.getPromise(qnaFilesState(projectId));
+
return {
projectId,
- dialogs: result[0],
- luFiles: result[1],
- qnaFiles: result[2],
- lgFiles: result[3],
- skillManifests: result[4],
- setting: result[5],
- dialogSchemas: result[6],
- botProjectFile: result[7],
- formDialogSchemas: result[8],
- jsonSchemaFiles: result[9],
- recognizers: result[10],
- crossTrainConfig: result[11],
+ dialogs,
+ luFiles,
+ qnaFiles,
+ lgFiles,
+ skillManifests,
+ setting,
+ dialogSchemas,
+ botProjectFile,
+ formDialogSchemas,
+ jsonSchemaFiles,
+ recognizers,
+ crossTrainConfig,
};
};
@@ -100,10 +99,10 @@ const InitDispatcher = ({ onLoad }) => {
export const DispatcherWrapper = ({ children }) => {
const [loaded, setLoaded] = useState(false);
- const botProjects = useRecoilValue(botsForFilePersistenceSelector);
+ const botProjects = useRecoilValue(localBotsWithoutErrorsSelector);
useRecoilTransactionObserver_UNSTABLE(async ({ snapshot, previousSnapshot }) => {
- const botsForFilePersistence = await snapshot.getPromise(botsForFilePersistenceSelector);
+ const botsForFilePersistence = await snapshot.getPromise(localBotsWithoutErrorsSelector);
for (const projectId of botsForFilePersistence) {
const assets = await getBotAssets(projectId, snapshot);
const previousAssets = await getBotAssets(projectId, previousSnapshot);
diff --git a/Composer/packages/client/src/recoilModel/atoms/botState.ts b/Composer/packages/client/src/recoilModel/atoms/botState.ts
index 0df22daf4d..3036904769 100644
--- a/Composer/packages/client/src/recoilModel/atoms/botState.ts
+++ b/Composer/packages/client/src/recoilModel/atoms/botState.ts
@@ -15,12 +15,13 @@ import {
LgFile,
LuFile,
QnAFile,
+ SkillManifestFile,
RecognizerFile,
Skill,
} from '@bfc/shared';
import { atomFamily } from 'recoil';
-import { BotLoadError, DesignPageLocation } from '../../recoilModel/types';
+import { BotRuntimeError, DesignPageLocation } from '../../recoilModel/types';
import FilePersistence from '../persistence/FilePersistence';
import { BotStatus } from './../../constants';
@@ -117,7 +118,7 @@ export const botDiagnosticsState = atomFamily({
},
});
-export const botLoadErrorState = atomFamily({
+export const botRuntimeErrorState = atomFamily({
key: getFullyQualifiedKey('botLoadErrorMsg'),
default: (id) => {
return { title: '', message: '' };
@@ -138,13 +139,6 @@ export const luFilesState = atomFamily({
},
});
-export const skillsState = atomFamily({
- key: getFullyQualifiedKey('skills'),
- default: (id) => {
- return [];
- },
-});
-
export const recognizerIdsState = atomFamily({
key: getFullyQualifiedKey('recognizerIds'),
default: (id) => {
@@ -173,7 +167,7 @@ export const actionsSeedState = atomFamily({
},
});
-export const skillManifestsState = atomFamily({
+export const skillManifestsState = atomFamily({
key: getFullyQualifiedKey('skillManifests'),
default: (id) => {
return [];
@@ -355,6 +349,19 @@ export const botNameIdentifierState = atomFamily({
default: '',
});
+// TODO: Currently always setting to 0 as we dont support more than 1 manifest. This index would need to change based on the default manifest chosen in the future.
+export const currentSkillManifestIndexState = atomFamily({
+ key: getFullyQualifiedKey('currentSkillManifestIndex'),
+ default: 0,
+});
+
+export const skillsState = atomFamily({
+ key: getFullyQualifiedKey('skills'),
+ default: (id) => {
+ return [];
+ },
+});
+
export const canUndoState = atomFamily({
key: getFullyQualifiedKey('canUndoState'),
default: false,
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/botProjectFile.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/botProjectFile.test.tsx
index f6e49a076a..0afea4b0c3 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/botProjectFile.test.tsx
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/botProjectFile.test.tsx
@@ -4,8 +4,10 @@
import { selector, useRecoilValue, selectorFamily, useRecoilState } from 'recoil';
import { act, RenderHookResult, HookResult } from '@botframework-composer/test-utils/lib/hooks';
import noop from 'lodash/noop';
+import { BotProjectFile, Skill } from '@bfc/shared';
import { botProjectFileDispatcher } from '../botProjectFile';
+import { settingsDispatcher } from '../setting';
import { renderRecoilHook } from '../../../../__tests__/testUtils';
import {
botDisplayNameState,
@@ -16,6 +18,7 @@ import {
currentProjectIdState,
locationState,
projectMetaDataState,
+ settingsState,
} from '../../atoms';
import { dispatcherState } from '../../DispatcherWrapper';
import { Dispatcher } from '..';
@@ -59,10 +62,11 @@ describe('Bot Project File dispatcher', () => {
const useRecoilTestHook = () => {
const botName = useRecoilValue(botDisplayNameState(rootBotProjectId));
- const botProjectFile = useRecoilValue(botProjectFileState(rootBotProjectId));
+ const [botProjectFile, setBotProjectFile] = useRecoilState(botProjectFileState(rootBotProjectId));
const currentDispatcher = useRecoilValue(dispatcherState);
const botStates = useRecoilValue(botStatesSelector);
const [skillsData, setSkillsData] = useRecoilState(skillsDataSelector(testSkillId));
+ const [settings, setSettings] = useRecoilState(settingsState(rootBotProjectId));
return {
botName,
@@ -71,6 +75,11 @@ describe('Bot Project File dispatcher', () => {
botStates,
skillsData,
setSkillsData,
+ settings,
+ setters: {
+ setBotProjectFile,
+ setSettings,
+ },
};
};
@@ -107,6 +116,7 @@ describe('Bot Project File dispatcher', () => {
recoilState: dispatcherState,
initialValue: {
botProjectFileDispatcher,
+ settingsDispatcher,
},
},
}
@@ -165,9 +175,203 @@ describe('Bot Project File dispatcher', () => {
});
expect(renderedComponent.current.botProjectFile.content.skills.oneNoteSkill.manifest).toBe(manifestUrl);
+ await act(async () => {
+ renderedComponent.current.setters.setSettings({
+ ...renderedComponent.current.settings,
+ skill: {
+ oneNoteSkill: {
+ endpointUrl: 'https://test/api/messages',
+ msAppId: '1234-2312-23432-32432',
+ },
+ },
+ });
+ });
+ expect(renderedComponent.current.settings.skill?.oneNoteSkill).toBeDefined();
+
await act(async () => {
dispatcher.removeSkillFromBotProjectFile(testSkillId);
});
expect(renderedComponent.current.botProjectFile.content.skills.oneNoteSkill).toBeUndefined();
+ expect(renderedComponent.current.settings.skill?.oneNoteSkill).toBeUndefined();
+ });
+
+ it('should update endpoint name of skill in Botproject file', async () => {
+ const mockFile: BotProjectFile = {
+ id: '',
+ content: {
+ name: 'TesterBot',
+ skills: {
+ googleSkill: {
+ workspace: '../googleSkill',
+ remote: false,
+ endpointName: 'default',
+ },
+ },
+ },
+ lastModified: '',
+ };
+ await act(async () => {
+ renderedComponent.current.setters.setBotProjectFile(mockFile);
+ });
+
+ await act(async () => {
+ dispatcher.updateEndpointNameInBotProjectFile('googleSkill', 'remote');
+ });
+
+ expect(renderedComponent.current.botProjectFile.content.skills.googleSkill.endpointName).toBe('remote');
+ });
+
+ it('should delete endpoint in BotProject file if Local Composer endpoint is chosen', async () => {
+ const googleKeepSkill: Skill = {
+ id: '12a-asd',
+ manifest: undefined,
+ name: 'googleSkill',
+ remote: false,
+ };
+
+ const mockFile: BotProjectFile = {
+ id: '',
+ content: {
+ name: 'TesterBot',
+ skills: {
+ googleSkill: {
+ workspace: '../googleSkill',
+ remote: false,
+ endpointName: 'default',
+ },
+ },
+ },
+ lastModified: '',
+ };
+
+ await act(async () => {
+ renderedComponent.current.setters.setSettings({
+ ...renderedComponent.current.settings,
+ skill: {
+ googleSkill: {
+ endpointUrl: 'https://test/api/messages',
+ msAppId: '1234-2312-23432-32432',
+ },
+ },
+ });
+ });
+
+ await act(async () => {
+ renderedComponent.current.setters.setBotProjectFile(mockFile);
+ });
+
+ await act(async () => {
+ await dispatcher.updateSkillsDataInBotProjectFile('googleSkill', googleKeepSkill, -1);
+ });
+
+ expect(renderedComponent.current.botProjectFile.content.skills.googleSkill.endpointName).toBeUndefined();
+
+ expect(renderedComponent.current.settings.skill).toEqual({
+ googleSkill: {
+ endpointUrl: '',
+ msAppId: '',
+ },
+ });
+ });
+
+ it('should update manifest in BotProject file', async () => {
+ await act(async () => {
+ renderedComponent.current.setSkillsData({
+ location: '/Users/tester/Desktop/LoadedBotProject/Google-Skill',
+ botNameIdentifier: 'googleSkill',
+ });
+ });
+
+ const mockFile: BotProjectFile = {
+ id: '',
+ content: {
+ name: 'TesterBot',
+ skills: {
+ googleSkill: {
+ workspace: '../googleSkill',
+ remote: false,
+ endpointName: 'default',
+ },
+ },
+ },
+ lastModified: '',
+ };
+ await act(async () => {
+ renderedComponent.current.setters.setBotProjectFile(mockFile);
+ });
+
+ await act(async () => {
+ await dispatcher.updateManifestInBotProjectFile(testSkillId, 'googleKeepManifest');
+ });
+ expect(renderedComponent.current.botProjectFile.content.skills.googleSkill.manifest).toBe('googleKeepManifest');
+ });
+
+ fit('should update endpoint in BotProject file', async () => {
+ await act(async () => {
+ renderedComponent.current.setters.setSettings({
+ ...renderedComponent.current.settings,
+ skill: {},
+ });
+ });
+ const googleKeepSkill: Skill = {
+ id: '12a.asd',
+ manifest: {
+ name: 'google-keep-manifest',
+ version: '1.0',
+ description: 'Manifest',
+ endpoints: [
+ {
+ name: 'local',
+ endpointUrl: 'http://localhost:3978/api/messages',
+ msAppId: '1232-1233-1234-1231',
+ description: 'Local endpoint skill',
+ },
+ {
+ name: 'remote',
+ endpointUrl: 'http://azure.websites/api/messages',
+ msAppId: '6734-1233-1234-1231',
+ description: 'Remote endpoint skill',
+ },
+ ],
+ },
+ name: 'googleSkill',
+ remote: false,
+ };
+
+ await act(async () => {
+ renderedComponent.current.setSkillsData({
+ location: '/Users/tester/Desktop/LoadedBotProject/Todo-Skill',
+ botNameIdentifier: 'todoSkill',
+ });
+ });
+
+ const mockFile: BotProjectFile = {
+ id: '',
+ content: {
+ name: 'TesterBot',
+ skills: {
+ googleSkill: {
+ workspace: '../googleSkill',
+ remote: false,
+ endpointName: 'default',
+ },
+ },
+ },
+ lastModified: '',
+ };
+ await act(async () => {
+ renderedComponent.current.setters.setBotProjectFile(mockFile);
+ });
+
+ await act(async () => {
+ await dispatcher.updateSkillsDataInBotProjectFile('googleSkill', googleKeepSkill, 1);
+ });
+ expect(renderedComponent.current.botProjectFile.content.skills.googleSkill.endpointName).toBe('remote');
+ expect(renderedComponent.current.settings.skill).toEqual({
+ googleSkill: {
+ endpointUrl: 'http://azure.websites/api/messages',
+ msAppId: '6734-1233-1234-1231',
+ },
+ });
});
});
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockBotProjectFile.json b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockBotProjectFile.json
index b1bf6ae908..7fe333a568 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockBotProjectFile.json
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/mocks/mockBotProjectFile.json
@@ -1,16 +1,15 @@
{
"$schema": "",
"name": "TesterBot",
- "workspace": "file:///Users/tester/Desktop/LoadedBotProject/TesterBot",
"skills": {
"todoSkill": {
- "workspace": "file:///Users/tester/Desktop/LoadedBotProject/Todo-Skill",
+ "workspace": "../Todo-Skill",
"manifest": "Todo-Skill-2-1-preview-1-manifest",
"remote": false,
"endpointName": "default"
},
"googleKeepSync": {
- "workspace": "file:///Users/tester/Desktop/LoadedBotProject/GoogleKeepSync",
+ "workspace": "../GoogleKeepSync",
"remote": false
}
}
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 f55ad6b515..7c9b48a243 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/project.test.tsx
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/project.test.tsx
@@ -12,6 +12,7 @@ import findIndex from 'lodash/findIndex';
import httpClient from '../../../utils/httpUtil';
import { projectDispatcher } from '../project';
import { botProjectFileDispatcher } from '../botProjectFile';
+import { publisherDispatcher } from '../publisher';
import { renderRecoilHook } from '../../../../__tests__/testUtils';
import {
recentProjectsState,
@@ -31,7 +32,6 @@ import {
localeState,
schemasState,
locationState,
- skillsState,
botStatusState,
botDisplayNameState,
botOpeningState,
@@ -112,7 +112,6 @@ describe('Project dispatcher', () => {
const useRecoilTestHook = () => {
const schemas = useRecoilValue(schemasState(projectId));
const location = useRecoilValue(locationState(projectId));
- const skills = useRecoilValue(skillsState(projectId));
const botName = useRecoilValue(botDisplayNameState(projectId));
const skillManifests = useRecoilValue(skillManifestsState(projectId));
const luFiles = useRecoilValue(luFilesState(projectId));
@@ -146,7 +145,6 @@ describe('Project dispatcher', () => {
botEnvironment,
botName,
botStatus,
- skills,
location,
schemas,
diagnostics,
@@ -185,6 +183,7 @@ describe('Project dispatcher', () => {
initialValue: {
projectDispatcher,
botProjectFileDispatcher,
+ publisherDispatcher,
},
},
}
@@ -220,7 +219,6 @@ describe('Project dispatcher', () => {
expect(renderedComponent.current.lgFiles.length).toBe(1);
expect(renderedComponent.current.luFiles.length).toBe(1);
expect(renderedComponent.current.botEnvironment).toBe(mockProjectResponse.botEnvironment);
- expect(renderedComponent.current.skills.length).toBe(0);
expect(renderedComponent.current.botOpening).toBeFalsy();
expect(renderedComponent.current.schemas.sdk).toBeDefined();
expect(renderedComponent.current.schemas.default).toBeDefined();
@@ -273,7 +271,6 @@ describe('Project dispatcher', () => {
expect(renderedComponent.current.lgFiles.length).toBe(0);
expect(renderedComponent.current.luFiles.length).toBe(0);
expect(renderedComponent.current.botEnvironment).toBe('production');
- expect(renderedComponent.current.skills.length).toBe(0);
expect(renderedComponent.current.botOpening).toBeFalsy();
expect(renderedComponent.current.schemas.sdk).toBeUndefined();
expect(renderedComponent.current.schemas.default).toBeUndefined();
@@ -282,7 +279,7 @@ describe('Project dispatcher', () => {
it('should set bot status', async () => {
await act(async () => {
- await dispatcher.setBotStatus(BotStatus.pending, projectId);
+ await dispatcher.setBotStatus(projectId, BotStatus.pending);
});
expect(renderedComponent.current.botStatus).toEqual(BotStatus.pending);
@@ -509,4 +506,40 @@ describe('Project dispatcher', () => {
done();
});
});
+
+ it('should migrate skills from existing bots and add them to botproject file', async () => {
+ const newProjectDataClone = cloneDeep(mockProjectResponse);
+ newProjectDataClone.botName = 'new-bot';
+ newProjectDataClone.settings = {
+ ...newProjectDataClone.settings,
+ skill: {
+ 'one-note-sync': {
+ endpointUrl: 'https://azure-webservice.net/oneNoteSync/api/messages',
+ manifestUrl: 'https://azure-webservice.net/oneNoteSnyc-manifest.json',
+ msAppId: '123-234-234',
+ },
+ },
+ };
+
+ await act(async () => {
+ (httpClient.put as jest.Mock).mockResolvedValueOnce({
+ data: newProjectDataClone,
+ });
+ await dispatcher.openProject('../test/empty-bot', 'default');
+ });
+
+ expect(renderedComponent.current.settings.skill).toEqual({
+ oneNoteSync: {
+ endpointUrl: 'https://azure-webservice.net/oneNoteSync/api/messages',
+ msAppId: '123-234-234',
+ },
+ });
+
+ expect(renderedComponent.current.botProjectFile.content.skills).toEqual({
+ oneNoteSync: {
+ manifest: 'https://azure-webservice.net/oneNoteSnyc-manifest.json',
+ remote: true,
+ },
+ });
+ });
});
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/setting.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/setting.test.tsx
index 8c6f4b3a4d..a551fa6902 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/setting.test.tsx
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/setting.test.tsx
@@ -5,11 +5,10 @@ import { useRecoilValue } from 'recoil';
import { act } from '@botframework-composer/test-utils/lib/hooks';
import { renderRecoilHook } from '../../../../__tests__/testUtils';
-import { settingsState, currentProjectIdState, skillsState } from '../../atoms';
+import { settingsState, currentProjectIdState } from '../../atoms';
import { dispatcherState } from '../../../recoilModel/DispatcherWrapper';
import { Dispatcher } from '..';
import { settingsDispatcher } from '../setting';
-import httpClient from '../../../utils/httpUtil';
jest.mock('../../../utils/httpUtil');
@@ -76,11 +75,9 @@ describe('setting dispatcher', () => {
beforeEach(() => {
const useRecoilTestHook = () => {
const settings = useRecoilValue(settingsState(projectId));
- const skills = useRecoilValue(skillsState(projectId));
const currentDispatcher = useRecoilValue(dispatcherState);
return {
settings,
- skills,
currentDispatcher,
};
};
@@ -89,7 +86,6 @@ describe('setting dispatcher', () => {
states: [
{ recoilState: settingsState(projectId), initialValue: settings },
{ recoilState: currentProjectIdState, initialValue: projectId },
- { recoilState: skillsState(projectId), initialValue: [] },
],
dispatcher: {
recoilState: dispatcherState,
@@ -158,38 +154,4 @@ describe('setting dispatcher', () => {
});
expect(renderedComponent.current.settings.runtime.customRuntime).toBeTruthy();
});
-
- it('should update skills state', async () => {
- (httpClient.get as jest.Mock).mockResolvedValue({
- data: { description: 'description', endpoints: [{ endpointUrl: 'https://test' }] },
- });
-
- await act(async () => {
- await dispatcher.setSettings(projectId, {
- skill: {
- foo: {
- msAppId: '00000000-0000',
- endpointUrl: 'https://skill-manifest/api/messages',
- name: 'foo',
- manifestUrl: 'https://skill-manifest',
- },
- },
- } as any);
- });
-
- expect(renderedComponent.current.skills).toEqual(
- expect.arrayContaining([
- {
- id: 'foo',
- name: 'foo',
- manifestUrl: 'https://skill-manifest',
- msAppId: '00000000-0000',
- endpointUrl: 'https://skill-manifest/api/messages',
- description: 'description',
- endpoints: expect.any(Array),
- content: expect.any(Object),
- },
- ])
- );
- });
});
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/skill.test.ts b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/skill.test.ts
index ffeced8fc7..ecd78eaf49 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/skill.test.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/skill.test.ts
@@ -1,23 +1,30 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import { useRecoilValue } from 'recoil';
+import { selectorFamily, useRecoilState, useRecoilValue } from 'recoil';
import { act } from '@botframework-composer/test-utils/lib/hooks';
-import { Skill } from '@bfc/shared';
import { skillDispatcher } from '../skill';
+import { botProjectFileDispatcher } from '../botProjectFile';
import { renderRecoilHook } from '../../../../__tests__/testUtils';
import {
skillManifestsState,
onAddSkillDialogCompleteState,
- skillsState,
settingsState,
showAddSkillDialogModalState,
displaySkillManifestState,
+ botProjectFileState,
+ botNameIdentifierState,
+ locationState,
+ projectMetaDataState,
+ botDisplayNameState,
} from '../../atoms/botState';
import { dispatcherState } from '../../DispatcherWrapper';
-import { currentProjectIdState } from '../../atoms';
+import { botEndpointsState, botProjectIdsState, currentProjectIdState } from '../../atoms';
import { Dispatcher } from '..';
+import { skillsStateSelector } from '../../selectors';
+
+import mockBotProjectFileData from './mocks/mockBotProjectFile.json';
jest.mock('../../../utils/httpUtil', () => {
return {
@@ -33,23 +40,28 @@ jest.mock('../../../utils/httpUtil', () => {
const mockDialogComplete = jest.fn();
const projectId = '42345.23432';
-
-const makeTestSkill: (number) => Skill = (n) => ({
- id: 'id' + n,
- manifestUrl: 'url' + n,
- name: 'skill' + n,
- description: 'test skill' + n,
- endpointUrl: 'url',
- endpoints: [{ test: 'foo' }],
- msAppId: 'ID',
- content: {
- description: 'test skill' + n,
- endpoints: [{ test: 'foo' }],
- },
-});
+const skillIds = ['1234.123', '234.234'];
describe('skill dispatcher', () => {
let renderedComponent, dispatcher: Dispatcher;
+
+ const skillsDataSelector = selectorFamily({
+ key: 'skillSelector-skill',
+ get: (skillId: string) => ({ get }) => {
+ return {
+ skillNameIdentifier: get(botNameIdentifierState(skillId)),
+ location: get(locationState(skillId)),
+ };
+ },
+ set: (skillId: string) => ({ set }, stateUpdater: any) => {
+ const { botNameIdentifier, location, displayName, settings } = stateUpdater;
+ set(botNameIdentifierState(skillId), botNameIdentifier);
+ set(locationState(skillId), location);
+ set(settingsState(skillId), settings);
+ set(botDisplayNameState(skillId), displayName);
+ },
+ });
+
beforeEach(() => {
mockDialogComplete.mockClear();
@@ -57,22 +69,33 @@ describe('skill dispatcher', () => {
const projectId = useRecoilValue(currentProjectIdState);
const skillManifests = useRecoilValue(skillManifestsState(projectId));
const onAddSkillDialogComplete = useRecoilValue(onAddSkillDialogCompleteState(projectId));
- const skills: Skill[] = useRecoilValue(skillsState(projectId));
const settings = useRecoilValue(settingsState(projectId));
const showAddSkillDialogModal = useRecoilValue(showAddSkillDialogModalState(projectId));
const displaySkillManifest = useRecoilValue(displaySkillManifestState(projectId));
-
+ const skills = useRecoilValue(skillsStateSelector);
+ const [botEndpoints, setBotEndpoints] = useRecoilState(botEndpointsState);
const currentDispatcher = useRecoilValue(dispatcherState);
+ const [todoSkillData, setTodoSkillData] = useRecoilState(skillsDataSelector(skillIds[0]));
+ const [googleKeepData, setGoogleKeepData] = useRecoilState(skillsDataSelector(skillIds[1]));
+
return {
projectId,
skillManifests,
onAddSkillDialogComplete,
- skills,
settings,
showAddSkillDialogModal,
displaySkillManifest,
currentDispatcher,
+ skills,
+ botEndpoints,
+ todoSkillData,
+ googleKeepData,
+ setters: {
+ setBotEndpoints,
+ setTodoSkillData,
+ setGoogleKeepData,
+ },
};
};
@@ -86,19 +109,30 @@ describe('skill dispatcher', () => {
],
},
{ recoilState: onAddSkillDialogCompleteState(projectId), initialValue: { func: undefined } },
- {
- recoilState: skillsState(projectId),
- initialValue: [makeTestSkill(1), makeTestSkill(2)],
- },
{ recoilState: settingsState(projectId), initialValue: {} },
{ recoilState: showAddSkillDialogModalState(projectId), initialValue: false },
{ recoilState: displaySkillManifestState(projectId), initialValue: undefined },
{ recoilState: currentProjectIdState, initialValue: projectId },
+ { recoilState: botProjectIdsState, initialValue: [projectId, ...skillIds] },
+ { recoilState: settingsState(projectId), initialValue: {} },
+ {
+ recoilState: botProjectFileState(projectId),
+ initialValue: {
+ content: mockBotProjectFileData,
+ },
+ },
+ {
+ recoilState: projectMetaDataState(projectId),
+ initialValue: {
+ isRootBot: true,
+ },
+ },
],
dispatcher: {
recoilState: dispatcherState,
initialValue: {
skillDispatcher,
+ botProjectFileDispatcher,
},
},
});
@@ -135,72 +169,56 @@ describe('skill dispatcher', () => {
]);
});
- it('addsSkill', async () => {
- await act(async () => {
- dispatcher.addSkill(projectId, makeTestSkill(3));
- });
- expect(renderedComponent.current.showAddSkillDialogModal).toBe(false);
- expect(renderedComponent.current.onAddSkillDialogComplete.func).toBeUndefined();
- expect(renderedComponent.current.skills).toContainEqual(makeTestSkill(3));
- });
-
- it('updateSkill', async () => {
+ it('displayManifestModal', async () => {
await act(async () => {
- dispatcher.updateSkill(projectId, 'id1', {
- msAppId: 'test',
- manifestUrl: 'test',
- endpointUrl: 'test',
- name: 'test',
- });
+ dispatcher.displayManifestModal('foo', projectId);
});
-
- expect(renderedComponent.current.skills[0]).toEqual(
- expect.objectContaining({
- id: 'id1',
- content: {},
- name: 'test',
- msAppId: 'test',
- manifestUrl: 'test',
- endpointUrl: 'test',
- endpoints: [],
- })
- );
+ expect(renderedComponent.current.displaySkillManifest).toEqual('foo');
});
- it('removeSkill', async () => {
+ it('dismissManifestModal', async () => {
await act(async () => {
- dispatcher.removeSkill(projectId, makeTestSkill(1).id);
+ dispatcher.dismissManifestModal(projectId);
});
- expect(renderedComponent.current.skills).not.toContain(makeTestSkill(1));
+ expect(renderedComponent.current.displaySkillManifest).toBeUndefined();
});
- it('addSkillDialogBegin', async () => {
+ fit('should update setting.skill on local skills with "Composer Local" chosen as endpoint', async () => {
await act(async () => {
- dispatcher.addSkillDialogBegin(mockDialogComplete, projectId);
- });
- expect(renderedComponent.current.showAddSkillDialogModal).toBe(true);
- expect(renderedComponent.current.onAddSkillDialogComplete.func).toBe(mockDialogComplete);
- });
+ const botEndpoints = {};
+ botEndpoints[`${skillIds[0]}`] = 'http://localhost:3978/api/messages';
+ botEndpoints[`${skillIds[1]}`] = 'http://localhost:3979/api/messages';
+ renderedComponent.current.setters.setBotEndpoints(botEndpoints);
+ renderedComponent.current.setters.setTodoSkillData({
+ location: '/Users/tester/Desktop/LoadedBotProject/Todo-Skill',
+ botNameIdentifier: 'todoSkill',
+ settings: {
+ MicrosoftAppId: 'abc-defg-3431-sdfd',
+ },
+ displayName: 'todo-skill',
+ });
- it('addSkillDialogCancel', async () => {
- await act(async () => {
- dispatcher.addSkillDialogCancel(projectId);
+ renderedComponent.current.setters.setGoogleKeepData({
+ location: '/Users/tester/Desktop/LoadedBotProject/GoogleKeep-Skill',
+ botNameIdentifier: 'googleKeepSync',
+ settings: {
+ MicrosoftAppId: '1231-1231-1231-1231',
+ },
+ displayName: 'google-keep',
+ });
});
- expect(renderedComponent.current.showAddSkillDialogModal).toBe(false);
- expect(renderedComponent.current.onAddSkillDialogComplete.func).toBe(undefined);
- });
- it('displayManifestModal', async () => {
await act(async () => {
- dispatcher.displayManifestModal('foo', projectId);
+ dispatcher.updateSettingsForSkillsWithoutManifest();
});
- expect(renderedComponent.current.displaySkillManifest).toEqual('foo');
- });
-
- it('dismissManifestModal', async () => {
- await act(async () => {
- dispatcher.dismissManifestModal(projectId);
+ // Only skills with no endpoint name in BotProject file use the Local Composer endpoint
+ expect(renderedComponent.current.settings).toEqual({
+ skill: {
+ googleKeepSync: {
+ endpointUrl: 'http://localhost:3979/api/messages',
+ msAppId: '1231-1231-1231-1231',
+ },
+ },
});
- expect(renderedComponent.current.displaySkillManifest).toBeUndefined();
});
});
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/botProjectFile.ts b/Composer/packages/client/src/recoilModel/dispatchers/botProjectFile.ts
index cb0e937d8e..f28eb4f356 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/botProjectFile.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/botProjectFile.ts
@@ -6,10 +6,13 @@ import path from 'path';
import { CallbackInterface, useRecoilCallback } from 'recoil';
import { produce } from 'immer';
-import { BotProjectSpaceSkill } from '@bfc/shared';
+import { BotProjectFile, BotProjectSpaceSkill, Skill } from '@bfc/shared';
-import { botNameIdentifierState, botProjectFileState, locationState } from '../atoms';
+import { botNameIdentifierState, botProjectFileState, locationState, settingsState } from '../atoms';
import { rootBotProjectIdSelector } from '../selectors';
+import { dispatcherState } from '../DispatcherWrapper';
+
+import { setSettingState } from './setting';
export const botProjectFileDispatcher = () => {
const addLocalSkill = useRecoilCallback(({ set, snapshot }: CallbackInterface) => async (skillId: string) => {
@@ -56,24 +59,134 @@ export const botProjectFileDispatcher = () => {
}
);
- const removeSkill = useRecoilCallback(({ set, snapshot }: CallbackInterface) => async (skillId: string) => {
+ const removeSkill = useRecoilCallback((callbackHelpers: CallbackInterface) => async (skillId: string) => {
+ const { set, snapshot } = callbackHelpers;
const rootBotProjectId = await snapshot.getPromise(rootBotProjectIdSelector);
if (!rootBotProjectId) {
return;
}
- const botName = await snapshot.getPromise(botNameIdentifierState(skillId));
+ const botNameIdentifier = await snapshot.getPromise(botNameIdentifierState(skillId));
set(botProjectFileState(rootBotProjectId), (current) => {
const result = produce(current, (draftState) => {
- delete draftState.content.skills[botName];
+ delete draftState.content.skills[botNameIdentifier];
});
return result;
});
+
+ const rootBotSettings = await snapshot.getPromise(settingsState(rootBotProjectId));
+ if (rootBotSettings.skill) {
+ const updatedSettings = produce(rootBotSettings, (draftState) => {
+ if (draftState.skill && draftState.skill[botNameIdentifier]) {
+ delete draftState.skill[botNameIdentifier];
+ }
+ });
+ setSettingState(callbackHelpers, rootBotProjectId, updatedSettings);
+ }
});
+ const updateManifest = useRecoilCallback(
+ ({ set, snapshot }: CallbackInterface) => async (skillProjectId: string, manifestId?: string) => {
+ const rootBotProjectId = await snapshot.getPromise(rootBotProjectIdSelector);
+ if (!rootBotProjectId) {
+ return;
+ }
+
+ const skillNameIdentifier = await snapshot.getPromise(botNameIdentifierState(skillProjectId));
+ set(botProjectFileState(rootBotProjectId), (current: BotProjectFile) => {
+ const result = produce(current, (draftState) => {
+ if (!manifestId) {
+ delete draftState.content.skills[skillNameIdentifier].manifest;
+ } else {
+ draftState.content.skills[skillNameIdentifier] = {
+ ...draftState.content.skills[skillNameIdentifier],
+ manifest: manifestId,
+ };
+ }
+ });
+ return result;
+ });
+ }
+ );
+
+ const updateSkillsData = useRecoilCallback(
+ (callbackHelpers: CallbackInterface) => async (
+ skillNameIdentifier: string,
+ skillsData: Skill,
+ selectedEndpointIndex: number
+ ) => {
+ const { set, snapshot } = callbackHelpers;
+ const rootBotProjectId = await snapshot.getPromise(rootBotProjectIdSelector);
+ if (!rootBotProjectId) {
+ return;
+ }
+
+ const settings = await snapshot.getPromise(settingsState(rootBotProjectId));
+ const dispatcher = await snapshot.getPromise(dispatcherState);
+
+ let msAppId = '',
+ endpointUrl = '',
+ endpointName = '';
+
+ if (selectedEndpointIndex !== -1 && skillsData.manifest) {
+ const data = skillsData.manifest?.endpoints[selectedEndpointIndex];
+ msAppId = data.msAppId;
+ endpointUrl = data.endpointUrl;
+ endpointName = data.name;
+
+ set(botProjectFileState(rootBotProjectId), (current) => {
+ const result = produce(current, (draftState) => {
+ draftState.content.skills[skillNameIdentifier].endpointName = endpointName;
+ });
+ return result;
+ });
+ } else {
+ set(botProjectFileState(rootBotProjectId), (current) => {
+ const result = produce(current, (draftState) => {
+ delete draftState.content.skills[skillNameIdentifier].endpointName;
+ });
+ return result;
+ });
+ }
+ if (settings.skill) {
+ dispatcher.setSettings(
+ rootBotProjectId,
+ produce(settings, (draftSettings) => {
+ draftSettings.skill = {
+ ...settings.skill,
+ [skillNameIdentifier]: {
+ endpointUrl,
+ msAppId,
+ },
+ };
+ })
+ );
+ }
+ }
+ );
+
+ const updateEndpointName = useRecoilCallback(
+ ({ set, snapshot }: CallbackInterface) => async (skillNameIdentifier: string, endpointName: string) => {
+ const rootBotProjectId = await snapshot.getPromise(rootBotProjectIdSelector);
+ if (!rootBotProjectId) {
+ return;
+ }
+
+ set(botProjectFileState(rootBotProjectId), (current) => {
+ const result = produce(current, (draftState) => {
+ draftState.content.skills[skillNameIdentifier].endpointName = endpointName;
+ });
+ return result;
+ });
+ }
+ );
+
return {
addLocalSkillToBotProjectFile: addLocalSkill,
removeSkillFromBotProjectFile: removeSkill,
addRemoteSkillToBotProjectFile: addRemoteSkill,
+ updateSkillsDataInBotProjectFile: updateSkillsData,
+ updateManifestInBotProjectFile: updateManifest,
+ updateEndpointNameInBotProjectFile: updateEndpointName,
};
};
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/builder.ts b/Composer/packages/client/src/recoilModel/dispatchers/builder.ts
index 3c9f7888bf..4f084baf35 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/builder.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/builder.ts
@@ -10,7 +10,7 @@ import { Text, BotStatus } from '../../constants';
import httpClient from '../../utils/httpUtil';
import luFileStatusStorage from '../../utils/luFileStatusStorage';
import qnaFileStatusStorage from '../../utils/qnaFileStatusStorage';
-import { luFilesState, qnaFilesState, botStatusState, botLoadErrorState } from '../atoms';
+import { luFilesState, qnaFilesState, botStatusState, botRuntimeErrorState } from '../atoms';
import { dialogsSelectorFamily } from '../selectors';
const checkEmptyQuestionOrAnswerInQnAFile = (sections) => {
@@ -20,9 +20,9 @@ const checkEmptyQuestionOrAnswerInQnAFile = (sections) => {
export const builderDispatcher = () => {
const build = useRecoilCallback(
({ set, snapshot }: CallbackInterface) => async (
+ projectId: string,
luisConfig: ILuisConfig,
- qnaConfig: IQnAConfig,
- projectId: string
+ qnaConfig: IQnAConfig
) => {
const dialogs = await snapshot.getPromise(dialogsSelectorFamily(projectId));
const luFiles = await snapshot.getPromise(luFilesState(projectId));
@@ -42,7 +42,7 @@ export const builderDispatcher = () => {
{ title: Text.LUISDEPLOYFAILURE, message: '' }
);
if (errorMsg.message) {
- set(botLoadErrorState(projectId), errorMsg);
+ set(botRuntimeErrorState(projectId), errorMsg);
set(botStatusState(projectId), BotStatus.failed);
return;
}
@@ -59,7 +59,7 @@ export const builderDispatcher = () => {
set(botStatusState(projectId), BotStatus.published);
} catch (err) {
set(botStatusState(projectId), BotStatus.failed);
- set(botLoadErrorState(projectId), {
+ set(botRuntimeErrorState(projectId), {
title: Text.LUISDEPLOYFAILURE,
message: err.response?.data?.message || err.message,
});
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/project.ts b/Composer/packages/client/src/recoilModel/dispatchers/project.ts
index 784e5aea50..42c0217cd3 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/project.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/project.ts
@@ -312,8 +312,8 @@ export const projectDispatcher = () => {
}
});
- const setBotStatus = useRecoilCallback<[BotStatus, string], void>(
- ({ set }: CallbackInterface) => (status: BotStatus, projectId: string) => {
+ const setBotStatus = useRecoilCallback<[string, BotStatus], void>(
+ ({ set }: CallbackInterface) => (projectId: string, status: BotStatus) => {
set(botStatusState(projectId), status);
}
);
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts b/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts
index 96f342d7c7..a22c0019ad 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/publisher.ts
@@ -4,21 +4,25 @@
import formatMessage from 'format-message';
import { CallbackInterface, useRecoilCallback } from 'recoil';
-import { defaultPublishConfig } from '@bfc/shared';
+import { defaultPublishConfig, isSkillHostUpdateRequired } from '@bfc/shared';
import {
publishTypesState,
botStatusState,
publishHistoryState,
- botLoadErrorState,
+ botRuntimeErrorState,
isEjectRuntimeExistState,
filePersistenceState,
+ settingsState,
} from '../atoms/botState';
import { botEndpointsState } from '../atoms';
+import { openInEmulator } from '../../utils/navigation';
+import { rootBotProjectIdSelector } from '../selectors';
import { BotStatus, Text } from './../../constants';
import httpClient from './../../utils/httpUtil';
import { logMessage, setError } from './shared';
+import { setSettingState } from './setting';
const PUBLISH_SUCCESS = 200;
const PUBLISH_PENDING = 202;
@@ -28,7 +32,7 @@ export const publisherDispatcher = () => {
const publishFailure = async ({ set }: CallbackInterface, title: string, error, target, projectId: string) => {
if (target.name === defaultPublishConfig.name) {
set(botStatusState(projectId), BotStatus.failed);
- set(botLoadErrorState(projectId), { ...error, title });
+ set(botRuntimeErrorState(projectId), { ...error, title });
}
// prepend the latest publish results to the history
@@ -67,13 +71,27 @@ export const publisherDispatcher = () => {
});
};
- const updatePublishStatus = ({ set }: CallbackInterface, projectId: string, target: any, data: any) => {
+ const updatePublishStatus = async (callbackHelpers: CallbackInterface, projectId: string, target: any, data: any) => {
if (data == null) return;
+ const { set, snapshot } = callbackHelpers;
const { endpointURL, status, id } = data;
// the action below only applies to when a bot is being started using the "start bot" button
// a check should be added to this that ensures this ONLY applies to the "default" profile.
if (target.name === defaultPublishConfig.name) {
if (status === PUBLISH_SUCCESS && endpointURL) {
+ const rootBotId = await snapshot.getPromise(rootBotProjectIdSelector);
+ if (rootBotId === projectId) {
+ // Update the skill host endpoint
+ const settings = await snapshot.getPromise(settingsState(projectId));
+ if (isSkillHostUpdateRequired(settings?.skillHostEndpoint)) {
+ // Update skillhost endpoint only if ngrok url not set meaning empty or localhost url
+ const updatedSettings = {
+ ...settings,
+ skillHostEndpoint: endpointURL + '/api/skills',
+ };
+ setSettingState(callbackHelpers, projectId, updatedSettings);
+ }
+ }
set(botStatusState(projectId), BotStatus.connected);
set(botEndpointsState, (botEndpoints) => ({
...botEndpoints,
@@ -83,7 +101,7 @@ export const publisherDispatcher = () => {
set(botStatusState(projectId), BotStatus.reloading);
} else if (status === PUBLISH_FAILED) {
set(botStatusState(projectId), BotStatus.failed);
- set(botLoadErrorState(projectId), { ...data, title: formatMessage('Start bot failed') });
+ set(botRuntimeErrorState(projectId), { ...data, title: formatMessage('Start bot failed') });
}
}
@@ -208,10 +226,13 @@ export const publisherDispatcher = () => {
// only support local publish
const stopPublishBot = useRecoilCallback(
(callbackHelpers: CallbackInterface) => async (projectId: string, target: any = defaultPublishConfig) => {
- const { set } = callbackHelpers;
+ const { set, snapshot } = callbackHelpers;
try {
await httpClient.post(`/publish/${projectId}/stopPublish/${target.name}`);
- set(botStatusState(projectId), BotStatus.unConnected);
+ const currentBotStatus = await snapshot.getPromise(botStatusState(projectId));
+ if (currentBotStatus !== BotStatus.failed) {
+ set(botStatusState(projectId), BotStatus.unConnected);
+ }
} catch (err) {
setError(callbackHelpers, err);
logMessage(callbackHelpers, err.message);
@@ -219,6 +240,28 @@ export const publisherDispatcher = () => {
}
);
+ const resetBotRuntimeError = useRecoilCallback((callbackHelpers: CallbackInterface) => async (projectId: string) => {
+ const { reset } = callbackHelpers;
+ reset(botRuntimeErrorState(projectId));
+ });
+
+ const openBotInEmulator = useRecoilCallback((callbackHelpers: CallbackInterface) => async (projectId: string) => {
+ const { snapshot } = callbackHelpers;
+ const botEndpoints = await snapshot.getPromise(botEndpointsState);
+ const settings = await snapshot.getPromise(settingsState(projectId));
+ try {
+ openInEmulator(
+ botEndpoints[projectId] || 'http://localhost:3979/api/messages',
+ settings.MicrosoftAppId && settings.MicrosoftAppPassword
+ ? { MicrosoftAppId: settings.MicrosoftAppId, MicrosoftAppPassword: settings.MicrosoftAppPassword }
+ : { MicrosoftAppPassword: '', MicrosoftAppId: '' }
+ );
+ } catch (err) {
+ setError(callbackHelpers, err);
+ logMessage(callbackHelpers, err.message);
+ }
+ });
+
return {
getPublishTargetTypes,
publishToTarget,
@@ -227,5 +270,7 @@ export const publisherDispatcher = () => {
getPublishStatus,
getPublishHistory,
setEjectRuntimeExist,
+ openBotInEmulator,
+ resetBotRuntimeError,
};
};
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/setting.ts b/Composer/packages/client/src/recoilModel/dispatchers/setting.ts
index df4b388635..adebbffb71 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/setting.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/setting.ts
@@ -3,15 +3,12 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { CallbackInterface, useRecoilCallback } from 'recoil';
-import { SensitiveProperties, DialogSetting, PublishTarget, Skill } from '@bfc/shared';
-import { skillIndexer } from '@bfc/indexers';
+import { SensitiveProperties, DialogSetting, PublishTarget } from '@bfc/shared';
import get from 'lodash/get';
import has from 'lodash/has';
-import isEqual from 'lodash/isEqual';
-import keys from 'lodash/keys';
import settingStorage from '../../utils/dialogSettingStorage';
-import { settingsState, skillsState } from '../atoms/botState';
+import { settingsState } from '../atoms/botState';
import httpClient from './../../utils/httpUtil';
import { setError } from './shared';
@@ -21,34 +18,7 @@ export const setSettingState = async (
projectId: string,
settings: DialogSetting
) => {
- const { set, snapshot } = callbackHelpers;
- const previousSettings = await snapshot.getPromise(settingsState(projectId));
-
- if (!isEqual(settings.skill, previousSettings.skill)) {
- const skills = await snapshot.getPromise(skillsState(projectId));
- const skillContent = await Promise.all(
- 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/retrieveSkillManifest`, {
- params: {
- url: settings?.skill?.[id]?.manifestUrl,
- },
- });
- return { id, content };
- } catch (error) {
- return { id };
- }
- }
-
- const { content = {} } = skills.find(({ id: key }) => id === key) || ({} as Skill);
-
- return { id, content };
- })
- );
-
- set(skillsState(projectId), skillIndexer.index(skillContent, settings.skill));
- }
+ const { set } = callbackHelpers;
// set value in local storage
for (const property of SensitiveProperties) {
@@ -128,7 +98,6 @@ export const settingsDispatcher = () => {
}
}
);
-
return {
setSettings,
setRuntimeSettings,
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/skill.ts b/Composer/packages/client/src/recoilModel/dispatchers/skill.ts
index c7938aacbd..f494d24a84 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/skill.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/skill.ts
@@ -3,34 +3,98 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { CallbackInterface, useRecoilCallback } from 'recoil';
-import { SkillManifest, SkillSetting } from '@bfc/shared';
+import { SkillManifestFile } from '@bfc/shared';
import produce from 'immer';
+import { dispatcherState } from '../DispatcherWrapper';
+import { rootBotProjectIdSelector, skillsStateSelector } from '../selectors';
import {
skillManifestsState,
- onAddSkillDialogCompleteState,
- showAddSkillDialogModalState,
displaySkillManifestState,
+ botProjectFileState,
settingsState,
-} from './../atoms/botState';
+ botEndpointsState,
+} from '../atoms';
+
import { setSettingState } from './setting';
export const skillDispatcher = () => {
- const createSkillManifest = ({ set }, { id, content, projectId }) => {
- set(skillManifestsState(projectId), (skillManifests) => [...skillManifests, { content, id }]);
+ // For endpoints in manifests the settings are updated immediately in SelectSkill. If "Composer Local" is chosen needs updating when rootbot is started.
+ const updateSettingsForSkillsWithoutManifest = useRecoilCallback((callbackHelpers: CallbackInterface) => async () => {
+ const { snapshot } = callbackHelpers;
+ const botEndpoints = await snapshot.getPromise(botEndpointsState);
+ const skills = await snapshot.getPromise(skillsStateSelector);
+ const rootBotId = await snapshot.getPromise(rootBotProjectIdSelector);
+ if (!rootBotId) {
+ return;
+ }
+ const settings = await snapshot.getPromise(settingsState(rootBotId));
+ let updatedSettings = { ...settings };
+ const botProjectFile = await snapshot.getPromise(botProjectFileState(rootBotId));
+ if (!botProjectFile) {
+ return;
+ }
+
+ for (const skillNameIdentifier in botProjectFile.content.skills) {
+ const botProjectSkill = botProjectFile.content.skills[skillNameIdentifier];
+ const projectId = skills[skillNameIdentifier]?.id;
+ const currentSetting = await snapshot.getPromise(settingsState(projectId));
+
+ // Update settings only for skills that have chosen the "Composer local" endpoint and not manifest endpoints
+ if (projectId && botEndpoints[projectId] && !botProjectSkill.endpointName) {
+ updatedSettings = produce(updatedSettings, (draftState) => {
+ if (!draftState.skill) {
+ draftState.skill = {};
+ }
+ draftState.skill[skillNameIdentifier] = {
+ endpointUrl: botEndpoints[projectId],
+ msAppId: currentSetting.MicrosoftAppId ?? '',
+ };
+ });
+ }
+ }
+ setSettingState(callbackHelpers, rootBotId, updatedSettings);
+ });
+
+ const createSkillManifest = async (callbackHelpers: CallbackInterface, { id, content, projectId }) => {
+ const { set, snapshot } = callbackHelpers;
+ let manifestForBotProjectFile;
+ const dispatcher = await snapshot.getPromise(dispatcherState);
+ set(skillManifestsState(projectId), (skillManifests) => {
+ if (!skillManifests.length) {
+ manifestForBotProjectFile = id;
+ }
+ return [...skillManifests, { content, id }];
+ });
+ if (manifestForBotProjectFile) {
+ dispatcher.updateManifestInBotProjectFile(projectId, id);
+ }
};
const removeSkillManifest = useRecoilCallback(
- ({ set }: CallbackInterface) => async (id: string, projectId: string) => {
- set(skillManifestsState(projectId), (skillManifests) => skillManifests.filter((manifest) => manifest.id !== id));
+ ({ set, snapshot }: CallbackInterface) => async (id: string, projectId: string) => {
+ let newCurrentManifestId: string | undefined;
+ const dispatcher = await snapshot.getPromise(dispatcherState);
+ set(skillManifestsState(projectId), (skillManifests) => {
+ const filtered = skillManifests.filter((manifest) => manifest.id !== id);
+ if (filtered.length > 0) {
+ newCurrentManifestId = filtered[0].id;
+ }
+ return filtered;
+ });
+ dispatcher.updateManifestInBotProjectFile(projectId, newCurrentManifestId);
}
);
const updateSkillManifest = useRecoilCallback(
- ({ set, snapshot }: CallbackInterface) => async ({ id, content }: SkillManifest, projectId: string) => {
+ (callbackHelpers: CallbackInterface) => async ({ id, content }: SkillManifestFile, projectId: string) => {
+ const { set, snapshot } = callbackHelpers;
const manifests = await snapshot.getPromise(skillManifestsState(projectId));
+ const dispatcher = await snapshot.getPromise(dispatcherState);
+
if (!manifests.some((manifest) => manifest.id === id)) {
- createSkillManifest({ set }, { id, content, projectId });
+ createSkillManifest(callbackHelpers, { id, content, projectId });
+ dispatcher.updateManifestInBotProjectFile(projectId, id);
}
set(skillManifestsState(projectId), (skillManifests) =>
@@ -39,81 +103,6 @@ export const skillDispatcher = () => {
}
);
- const addSkill = useRecoilCallback(
- (callbackHelpers: CallbackInterface) => async (projectId: string, skill: SkillSetting) => {
- const { set, snapshot } = callbackHelpers;
- const { func: onAddSkillDialogComplete } = await snapshot.getPromise(onAddSkillDialogCompleteState(projectId));
- const settings = await snapshot.getPromise(settingsState(projectId));
-
- setSettingState(
- callbackHelpers,
- projectId,
- produce(settings, (updateSettings) => {
- updateSettings.skill = { ...(updateSettings.skill || {}), [skill.name]: skill };
- })
- );
-
- if (typeof onAddSkillDialogComplete === 'function') {
- onAddSkillDialogComplete(skill || null);
- }
-
- set(showAddSkillDialogModalState(projectId), false);
- set(onAddSkillDialogCompleteState(projectId), {});
- }
- );
-
- const removeSkill = useRecoilCallback(
- (callbackHelpers: CallbackInterface) => async (projectId: string, key: string) => {
- const { snapshot } = callbackHelpers;
- const settings = await snapshot.getPromise(settingsState(projectId));
-
- setSettingState(
- callbackHelpers,
- projectId,
- produce(settings, (updateSettings) => {
- delete updateSettings.skill?.[key];
- })
- );
- }
- );
-
- const updateSkill = useRecoilCallback(
- (callbackHelpers: CallbackInterface) => async (
- projectId: string,
- key: string,
- { endpointUrl, manifestUrl, msAppId, name }: SkillSetting
- ) => {
- const { snapshot } = callbackHelpers;
- const settings = await snapshot.getPromise(settingsState(projectId));
-
- setSettingState(
- callbackHelpers,
- projectId,
- produce(settings, (updateSettings) => {
- updateSettings.skill = {
- ...(updateSettings.skill || {}),
- [key]: {
- endpointUrl,
- manifestUrl,
- msAppId,
- name,
- },
- };
- })
- );
- }
- );
-
- const addSkillDialogBegin = useRecoilCallback(({ set }: CallbackInterface) => (onComplete, projectId: string) => {
- set(showAddSkillDialogModalState(projectId), true);
- set(onAddSkillDialogCompleteState(projectId), { func: onComplete });
- });
-
- const addSkillDialogCancel = useRecoilCallback(({ set }: CallbackInterface) => (projectId: string) => {
- set(showAddSkillDialogModalState(projectId), false);
- set(onAddSkillDialogCompleteState(projectId), {});
- });
-
const displayManifestModal = useRecoilCallback(({ set }: CallbackInterface) => (id: string, projectId: string) => {
set(displaySkillManifestState(projectId), id);
});
@@ -123,15 +112,11 @@ export const skillDispatcher = () => {
});
return {
- addSkill,
- removeSkill,
- updateSkill,
createSkillManifest,
removeSkillManifest,
updateSkillManifest,
- addSkillDialogBegin,
- addSkillDialogCancel,
displayManifestModal,
dismissManifestModal,
+ updateSettingsForSkillsWithoutManifest,
};
};
diff --git a/Composer/packages/client/src/recoilModel/dispatchers/utils/project.ts b/Composer/packages/client/src/recoilModel/dispatchers/utils/project.ts
index eba4cb23da..f2c9ce014b 100644
--- a/Composer/packages/client/src/recoilModel/dispatchers/utils/project.ts
+++ b/Composer/packages/client/src/recoilModel/dispatchers/utils/project.ts
@@ -9,12 +9,16 @@ import {
BotProjectSpace,
BotProjectSpaceSkill,
convertSkillsToDictionary,
+ migrateSkillsForExistingBots,
dereferenceDefinitions,
+ fetchEndpointNameForSkill,
DialogInfo,
DialogSetting,
+ getManifestNameFromUrl,
LuFile,
QnAFile,
SensitiveProperties,
+ defaultPublishConfig,
} from '@bfc/shared';
import formatMessage from 'format-message';
import camelCase from 'lodash/camelCase';
@@ -23,6 +27,7 @@ import objectSet from 'lodash/set';
import { stringify } from 'query-string';
import { CallbackInterface } from 'recoil';
import { v4 as uuid } from 'uuid';
+import isEmpty from 'lodash/isEmpty';
import { BotStatus, QnABotTemplateId } from '../../../constants';
import settingStorage from '../../../utils/dialogSettingStorage';
@@ -61,19 +66,20 @@ import {
schemasState,
settingsState,
skillManifestsState,
- skillsState,
dialogIdsState,
showCreateQnAFromUrlDialogState,
} from '../../atoms';
import * as botstates from '../../atoms/botState';
+import { dispatcherState } from '../../DispatcherWrapper';
import lgWorker from '../../parsers/lgWorker';
import luWorker from '../../parsers/luWorker';
import qnaWorker from '../../parsers/qnaWorker';
import FilePersistence from '../../persistence/FilePersistence';
-import { rootBotProjectIdSelector } from '../../selectors';
+import { botRuntimeOperationsSelector, rootBotProjectIdSelector } from '../../selectors';
import { undoHistoryState } from '../../undo/history';
import UndoHistory from '../../undo/undoHistory';
import { logMessage, setError } from '../shared';
+import { setSettingState } from '../setting';
import { crossTrainConfigState } from './../../atoms/botState';
import { recognizersSelectorFamily } from './../../selectors/recognizers';
@@ -101,12 +107,15 @@ export const setErrorOnBotProject = async (
if (payload != null) logMessage(callbackHelpers, `Error loading ${botName}: ${JSON.stringify(payload)}`);
};
-export const flushExistingTasks = async (callbackHelpers) => {
+export const flushExistingTasks = async (callbackHelpers: CallbackInterface) => {
const { snapshot, reset } = callbackHelpers;
reset(botProjectSpaceLoadedState);
const projectIds = await snapshot.getPromise(botProjectIdsState);
- reset(botProjectIdsState, []);
+ const runtimeOperations = await snapshot.getPromise(botRuntimeOperationsSelector);
+
+ reset(botProjectIdsState);
for (const projectId of projectIds) {
+ await runtimeOperations?.stopBot(projectId);
resetBotStates(callbackHelpers, projectId);
}
const workers = [lgWorker, luWorker, qnaWorker];
@@ -156,11 +165,11 @@ export const navigateToBot = (
};
export const loadProjectData = (response) => {
- const { files, botName, settings, skills: skillContent, id: projectId } = response.data;
+ const { files, botName, settings, 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);
+ const indexedFiles = indexer.index(files, botName, locale, mergedSettings);
// migrate script move qna pairs in *.qna to *-manual.source.qna.
// TODO: remove after a period of time.
@@ -273,7 +282,6 @@ export const initBotState = async (callbackHelpers: CallbackInterface, data: any
jsonSchemaFiles,
formDialogSchemas,
skillManifestFiles,
- skills,
mergedSettings,
recognizers,
crossTrainConfig,
@@ -329,13 +337,13 @@ export const initBotState = async (callbackHelpers: CallbackInterface, data: any
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;
@@ -358,7 +366,7 @@ export const removeRecentProject = async (callbackHelpers: CallbackInterface, pa
export const openRemoteSkill = async (
callbackHelpers: CallbackInterface,
manifestUrl: string,
- botNameIdentifier?: string
+ botNameIdentifier = ''
) => {
const { set } = callbackHelpers;
@@ -375,9 +383,21 @@ export const openRemoteSkill = async (
isRemote: true,
});
- set(botNameIdentifierState(projectId), botNameIdentifier || camelCase(manifestResponse.data.name));
+ let uniqueSkillNameIdentifier = botNameIdentifier;
+ if (!uniqueSkillNameIdentifier) {
+ uniqueSkillNameIdentifier = await getSkillNameIdentifier(callbackHelpers, manifestResponse.data.name);
+ }
+
+ set(botNameIdentifierState(projectId), uniqueSkillNameIdentifier);
set(botDisplayNameState(projectId), manifestResponse.data.name);
set(locationState(projectId), manifestUrl);
+ set(skillManifestsState(projectId), [
+ {
+ content: manifestResponse.data,
+ id: getManifestNameFromUrl(manifestUrl),
+ lastModified: new Date().toDateString(),
+ },
+ ]);
return { projectId, manifestResponse: manifestResponse.data };
};
@@ -463,24 +483,41 @@ const handleSkillLoadingFailure = (callbackHelpers, { ex, skillNameIdentifier })
const openRootBotAndSkills = async (callbackHelpers: CallbackInterface, data, storageId = 'default') => {
const { projectData, botFiles } = data;
- const { set } = callbackHelpers;
+ const { set, snapshot } = callbackHelpers;
+ const dispatcher = await snapshot.getPromise(dispatcherState);
const mainDialog = await initBotState(callbackHelpers, projectData, botFiles);
const rootBotProjectId = projectData.id;
const { name, location } = projectData;
+ const { mergedSettings } = botFiles;
set(botNameIdentifierState(rootBotProjectId), camelCase(name));
-
+ set(botProjectIdsState, [rootBotProjectId]);
+ // Get the status of the bot on opening if it was opened and run in another window.
+ dispatcher.getPublishStatus(rootBotProjectId, defaultPublishConfig);
if (botFiles.botProjectSpaceFiles && botFiles.botProjectSpaceFiles.length) {
const currentBotProjectFileIndexed: BotProjectFile = botFiles.botProjectSpaceFiles[0];
- set(botProjectFileState(rootBotProjectId), currentBotProjectFileIndexed);
+
+ if (mergedSettings.skill) {
+ const { botProjectFile, skillSettings } = migrateSkillsForExistingBots(
+ currentBotProjectFileIndexed.content,
+ mergedSettings.skill
+ );
+ if (!isEmpty(skillSettings)) {
+ setSettingState(callbackHelpers, rootBotProjectId, {
+ ...mergedSettings,
+ skill: skillSettings,
+ });
+ }
+ currentBotProjectFileIndexed.content = botProjectFile;
+ }
+
const currentBotProjectFile: BotProjectSpace = currentBotProjectFileIndexed.content;
- const skills: { [skillId: string]: BotProjectSpaceSkill } = {
- ...currentBotProjectFile.skills,
- };
+ set(botProjectFileState(rootBotProjectId), currentBotProjectFileIndexed);
+
+ 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) {
@@ -496,8 +533,13 @@ const openRootBotAndSkills = async (callbackHelpers: CallbackInterface, data, st
}
if (skillPromise) {
skillPromise
- .then(({ projectId }) => {
+ .then(({ projectId, manifestResponse }) => {
addProjectToBotProjectSpace(set, projectId, totalProjectsCount);
+ const matchedEndpoint = fetchEndpointNameForSkill(mergedSettings, nameIdentifier, manifestResponse);
+ if (matchedEndpoint) {
+ dispatcher.updateEndpointNameInBotProjectFile(nameIdentifier, matchedEndpoint);
+ }
+ dispatcher.getPublishStatus(projectId, defaultPublishConfig);
})
.catch((ex) => {
const projectId = handleSkillLoadingFailure(callbackHelpers, {
@@ -513,7 +555,7 @@ const openRootBotAndSkills = async (callbackHelpers: CallbackInterface, data, st
// 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,
diff --git a/Composer/packages/client/src/recoilModel/persistence/FilePersistence.ts b/Composer/packages/client/src/recoilModel/persistence/FilePersistence.ts
index edc826e0e7..b02c476df6 100644
--- a/Composer/packages/client/src/recoilModel/persistence/FilePersistence.ts
+++ b/Composer/packages/client/src/recoilModel/persistence/FilePersistence.ts
@@ -7,7 +7,6 @@ import {
DialogInfo,
DialogSchemaFile,
DialogSetting,
- SkillManifest,
BotAssets,
BotProjectFile,
LuFile,
@@ -16,6 +15,7 @@ import {
FormDialogSchema,
RecognizerFile,
CrosstrainConfig,
+ SkillManifestFile,
} from '@bfc/shared';
import keys from 'lodash/keys';
@@ -193,7 +193,7 @@ class FilePersistence {
return changes;
}
- private getSkillManifestsChanges(current: SkillManifest[], previous: SkillManifest[]) {
+ private getSkillManifestsChanges(current: SkillManifestFile[], previous: SkillManifestFile[]) {
const changeItems = this.getDifferenceItems(current, previous);
const changes = this.getFileChanges(FileExtensions.Manifest, changeItems);
return changes;
diff --git a/Composer/packages/client/src/recoilModel/selectors/index.ts b/Composer/packages/client/src/recoilModel/selectors/index.ts
index 37b4503a74..09ed24958d 100644
--- a/Composer/packages/client/src/recoilModel/selectors/index.ts
+++ b/Composer/packages/client/src/recoilModel/selectors/index.ts
@@ -8,4 +8,6 @@ export * from './validatedDialogs';
export * from './dialogs';
export * from './dialogImports';
export * from './projectTemplates';
+export * from './skills';
+export * from './localRuntimeBuilder';
export * from './messagers';
diff --git a/Composer/packages/client/src/recoilModel/selectors/localRuntimeBuilder.ts b/Composer/packages/client/src/recoilModel/selectors/localRuntimeBuilder.ts
new file mode 100644
index 0000000000..c1a9735539
--- /dev/null
+++ b/Composer/packages/client/src/recoilModel/selectors/localRuntimeBuilder.ts
@@ -0,0 +1,128 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { defaultPublishConfig, IPublishConfig } from '@bfc/shared';
+import { selector, selectorFamily } from 'recoil';
+
+import settingsStorage from '../../utils/dialogSettingStorage';
+import { BotStatus } from '../../constants';
+import { isAbsHosted } from '../../utils/envUtil';
+import { botDisplayNameState, botStatusState, luFilesState, qnaFilesState, settingsState } from '../atoms';
+import { Dispatcher } from '../dispatchers';
+import { dispatcherState } from '../DispatcherWrapper';
+import { isBuildConfigComplete as isBuildConfigurationComplete, needsBuild } from '../../utils/buildUtil';
+
+import { validateDialogsSelectorFamily } from './validatedDialogs';
+import { localBotsWithoutErrorsSelector } from './project';
+
+export const trackBotStatusesSelector = selectorFamily({
+ key: 'trackBotStatusesSelector',
+ get: (trackedProjectIds: string[]) => ({ get }) => {
+ if (trackedProjectIds.length === 0) {
+ return false;
+ }
+ const areBotsRunning = trackedProjectIds.find((projectId: string) => {
+ const currentStatus = get(botStatusState(projectId));
+ return currentStatus !== BotStatus.connected && currentStatus !== BotStatus.failed;
+ });
+ return areBotsRunning;
+ },
+});
+
+export const botBuildRequiredSelector = selectorFamily({
+ key: 'botBuildRequiredSelector',
+ get: (projectId: string) => ({ get }) => {
+ const dialogs = get(validateDialogsSelectorFamily(projectId));
+ return !isAbsHosted() && needsBuild(dialogs);
+ },
+});
+
+export const buildEssentialsSelector = selectorFamily({
+ key: 'buildEssentialsSelector',
+ get: (projectId: string) => ({ get }) => {
+ const settings = get(settingsState(projectId));
+ const configuration = {
+ luis: settings.luis,
+ qna: settings.qna,
+ };
+ const dialogs = get(validateDialogsSelectorFamily(projectId));
+ const luFiles = get(luFilesState(projectId));
+ const qnaFiles = get(qnaFilesState(projectId));
+ const buildRequired = get(botBuildRequiredSelector(projectId));
+ const status = get(botStatusState(projectId));
+
+ return {
+ isConfigurationComplete: isBuildConfigurationComplete(configuration, dialogs, luFiles, qnaFiles),
+ configuration,
+ buildRequired,
+ projectId,
+ status,
+ };
+ },
+});
+
+export const buildConfigurationSelector = selector({
+ key: 'buildConfigurationSelector',
+ get: ({ get }) => {
+ const localProjects = get(localBotsWithoutErrorsSelector);
+ return localProjects.map((projectId: string) => {
+ const result = get(buildEssentialsSelector(projectId));
+ const name = get(botDisplayNameState(projectId));
+ const dialogs = get(validateDialogsSelectorFamily(projectId));
+ return { ...result, name, dialogs };
+ });
+ },
+});
+
+export const runningBotsSelector = selector({
+ key: 'runningBotsSelector',
+ get: ({ get }) => {
+ const localProjects = get(localBotsWithoutErrorsSelector);
+ const botsRunning = localProjects.filter((projectId: string) => {
+ const result = get(botStatusState(projectId));
+ return result === BotStatus.connected;
+ });
+ return {
+ totalBots: localProjects.length,
+ projectIds: botsRunning,
+ };
+ },
+});
+
+const botRuntimeAction = (dispatcher: Dispatcher) => {
+ return {
+ buildWithDefaultRecognizer: async (projectId: string, buildDependencies) => {
+ const { config } = buildDependencies;
+ if (config) {
+ dispatcher.setBotStatus(projectId, BotStatus.publishing);
+ await dispatcher.build(projectId, config.luis, config.qna);
+ }
+ },
+ startBot: async (projectId: string, config?: IPublishConfig) => {
+ dispatcher.setBotStatus(projectId, BotStatus.reloading);
+
+ // TODO: This code will be removed when endpoint keys are obtained for new qna configs
+ if (typeof config?.qna?.subscriptionKey === 'string' && config.qna.subscriptionKey && !config?.qna?.endpointKey) {
+ await dispatcher.setQnASettings(projectId, config?.qna?.subscriptionKey);
+ }
+
+ const sensitiveSettings = settingsStorage.get(projectId);
+ await dispatcher.publishToTarget(projectId, defaultPublishConfig, { comment: '' }, sensitiveSettings);
+ },
+ stopBot: async (projectId: string) => {
+ dispatcher.stopPublishBot(projectId);
+ dispatcher.setBotStatus(projectId, BotStatus.unConnected);
+ },
+ };
+};
+
+export const botRuntimeOperationsSelector = selector({
+ key: 'botRuntimeOperationsSelector',
+ get: ({ get }) => {
+ const dispatcher = get(dispatcherState);
+ if (!dispatcher) {
+ return undefined;
+ }
+ return botRuntimeAction(dispatcher);
+ },
+});
diff --git a/Composer/packages/client/src/recoilModel/selectors/notificationsSelector.ts b/Composer/packages/client/src/recoilModel/selectors/notifications.ts
similarity index 100%
rename from Composer/packages/client/src/recoilModel/selectors/notificationsSelector.ts
rename to Composer/packages/client/src/recoilModel/selectors/notifications.ts
diff --git a/Composer/packages/client/src/recoilModel/selectors/project.ts b/Composer/packages/client/src/recoilModel/selectors/project.ts
index a0faea636f..6fdfd057b6 100644
--- a/Composer/packages/client/src/recoilModel/selectors/project.ts
+++ b/Composer/packages/client/src/recoilModel/selectors/project.ts
@@ -19,14 +19,27 @@ import {
import { dialogsSelectorFamily } from '../selectors';
// Actions
-export const botsForFilePersistenceSelector = selector({
- key: 'botsForFilePersistenceSelector',
+export const localBotsWithoutErrorsSelector = selector({
+ key: 'localBotsWithoutErrorsSelector',
get: ({ get }) => {
const botProjectIds = get(botProjectIdsState);
return botProjectIds.filter((projectId: string) => {
const { isRemote } = get(projectMetaDataState(projectId));
const botError = get(botErrorState(projectId));
- return !botError && !isRemote;
+ return !isRemote && !botError;
+ });
+ },
+});
+
+export const localBotsDataSelector = selector({
+ key: 'localBotsDataSelector',
+ get: ({ get }) => {
+ const botProjectIds = get(localBotsWithoutErrorsSelector);
+ return botProjectIds.map((projectId: string) => {
+ return {
+ projectId,
+ name: get(botDisplayNameState(projectId)),
+ };
});
},
});
@@ -91,3 +104,14 @@ export const jsonSchemaFilesByProjectIdSelector = selector({
return result;
},
});
+
+export const skillsProjectIdSelector = selector({
+ key: 'skillsProjectIdSelector',
+ get: ({ get }) => {
+ const botIds = get(botProjectIdsState);
+ return botIds.filter((projectId: string) => {
+ const { isRootBot } = get(projectMetaDataState(projectId));
+ return !isRootBot;
+ });
+ },
+});
diff --git a/Composer/packages/client/src/recoilModel/selectors/skills.ts b/Composer/packages/client/src/recoilModel/selectors/skills.ts
new file mode 100644
index 0000000000..60a64cdef5
--- /dev/null
+++ b/Composer/packages/client/src/recoilModel/selectors/skills.ts
@@ -0,0 +1,45 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { Skill } from '@bfc/shared';
+import { selector } from 'recoil';
+
+import {
+ skillManifestsState,
+ currentSkillManifestIndexState,
+ botNameIdentifierState,
+ botDisplayNameState,
+ projectMetaDataState,
+} from '../atoms';
+
+import { skillsProjectIdSelector } from './project';
+
+// Actions
+export const skillsStateSelector = selector({
+ key: 'skillsStateSelector',
+ get: ({ get }) => {
+ const skillsProjectIds = get(skillsProjectIdSelector);
+ const skills: Record = skillsProjectIds.reduce((result, skillId: string) => {
+ const manifests = get(skillManifestsState(skillId));
+ const currentSkillManifestIndex = get(currentSkillManifestIndexState(skillId));
+ const skillNameIdentifier = get(botNameIdentifierState(skillId));
+ const botName = get(botDisplayNameState(skillId));
+ let manifest;
+ if (manifests[currentSkillManifestIndex]) {
+ manifest = manifests[currentSkillManifestIndex].content;
+ }
+
+ const { isRemote } = get(projectMetaDataState(skillId));
+ if (skillNameIdentifier) {
+ result[skillNameIdentifier] = {
+ id: skillId,
+ manifest,
+ name: botName,
+ remote: isRemote,
+ };
+ }
+ return result;
+ }, {});
+ return skills;
+ },
+});
diff --git a/Composer/packages/client/src/recoilModel/types.ts b/Composer/packages/client/src/recoilModel/types.ts
index b22608fec3..969105fa03 100644
--- a/Composer/packages/client/src/recoilModel/types.ts
+++ b/Composer/packages/client/src/recoilModel/types.ts
@@ -53,7 +53,7 @@ export interface RuntimeTemplate {
startCommand: string;
}
-export interface BotLoadError {
+export interface BotRuntimeError {
title: string;
message: string;
linkAfterMessage?: { url: string; text: string };
diff --git a/Composer/packages/client/src/router.tsx b/Composer/packages/client/src/router.tsx
index fda81b41a4..3450f24a21 100644
--- a/Composer/packages/client/src/router.tsx
+++ b/Composer/packages/client/src/router.tsx
@@ -25,7 +25,6 @@ const LGPage = React.lazy(() => import('./pages/language-generation/LGPage'));
const SettingPage = React.lazy(() => import('./pages/setting/SettingsPage'));
const Notifications = React.lazy(() => import('./pages/notifications/Notifications'));
const Publish = React.lazy(() => import('./pages/publish/Publish'));
-const Skills = React.lazy(() => import('./pages/skills'));
const BotCreationFlowRouter = React.lazy(() => import('./components/CreationFlow/CreationFlow'));
const FormDialogPage = React.lazy(() => import('./pages/form-dialog/FormDialogPage'));
@@ -57,7 +56,6 @@ const Routes = (props) => {
-
diff --git a/Composer/packages/client/src/shell/useShell.ts b/Composer/packages/client/src/shell/useShell.ts
index 3c4fb1d397..f09e256887 100644
--- a/Composer/packages/client/src/shell/useShell.ts
+++ b/Composer/packages/client/src/shell/useShell.ts
@@ -17,7 +17,6 @@ import {
schemasState,
validateDialogsSelectorFamily,
focusPathState,
- skillsState,
localeState,
qnaFilesState,
designPageLocationState,
@@ -29,6 +28,7 @@ import {
rootBotProjectIdSelector,
} from '../recoilModel';
import { undoFunctionState } from '../recoilModel/undo/history';
+import { skillsStateSelector } from '../recoilModel/selectors';
import { useLgApi } from './lgApi';
import { useLuApi } from './luApi';
@@ -66,7 +66,7 @@ export function useShell(source: EventSource, projectId: string): Shell {
const schemas = useRecoilValue(schemasState(projectId));
const dialogs = useRecoilValue(validateDialogsSelectorFamily(projectId));
const focusPath = useRecoilValue(focusPathState(projectId));
- const skills = useRecoilValue(skillsState(projectId));
+ const skills = useRecoilValue(skillsStateSelector);
const locale = useRecoilValue(localeState(projectId));
const qnaFiles = useRecoilValue(qnaFilesState(projectId));
const undoFunction = useRecoilValue(undoFunctionState(projectId));
@@ -92,12 +92,11 @@ export function useShell(source: EventSource, projectId: string): Shell {
selectTo,
setVisualEditorSelection,
setVisualEditorClipboard,
- addSkillDialogBegin,
onboardingAddCoachMarkRef,
updateUserSettings,
setMessage,
displayManifestModal,
- updateSkill,
+ updateSkillsDataInBotProjectFile: updateEndpointInBotProjectFile,
updateZoomRate,
} = useRecoilValue(dispatcherState);
@@ -217,13 +216,6 @@ export function useShell(source: EventSource, projectId: string): Shell {
);
});
},
- addSkillDialog: () => {
- return new Promise((resolve) => {
- addSkillDialogBegin((newSkill: { manifestUrl: string; name: string } | null) => {
- resolve(newSkill);
- }, projectId);
- });
- },
undo,
redo,
commitChanges,
@@ -234,7 +226,9 @@ export function useShell(source: EventSource, projectId: string): Shell {
updateDialogSchema: async (dialogSchema: DialogSchemaFile) => {
updateDialogSchema(dialogSchema, projectId);
},
- updateSkillSetting: (...params) => updateSkill(projectId, ...params),
+ updateSkill: async (skillId: string, skillsData) => {
+ updateEndpointInBotProjectFile(skillId, skillsData.skill, skillsData.selectedEndpointIndex);
+ },
updateFlowZoomRate,
...lgApi,
...luApi,
diff --git a/Composer/packages/client/src/utils/botRuntimeUtils.ts b/Composer/packages/client/src/utils/botRuntimeUtils.ts
new file mode 100644
index 0000000000..922f14a6cc
--- /dev/null
+++ b/Composer/packages/client/src/utils/botRuntimeUtils.ts
@@ -0,0 +1,27 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+import { BotStatus, BotStatusesCopy } from '../constants';
+
+export const getBotStatusText = (currentBotStatus) => {
+ switch (currentBotStatus) {
+ case BotStatus.failed:
+ return BotStatusesCopy.failed;
+
+ case BotStatus.published:
+ return BotStatusesCopy.published;
+
+ case BotStatus.reloading:
+ return BotStatusesCopy.loading;
+
+ case BotStatus.connected: {
+ return BotStatusesCopy.connected;
+ }
+ case BotStatus.publishing:
+ return BotStatusesCopy.publishing;
+
+ default:
+ case BotStatus.unConnected:
+ return BotStatusesCopy.unConnected;
+ }
+};
diff --git a/Composer/packages/client/src/utils/buildUtil.ts b/Composer/packages/client/src/utils/buildUtil.ts
index e4470f9d30..74c18c8fbc 100644
--- a/Composer/packages/client/src/utils/buildUtil.ts
+++ b/Composer/packages/client/src/utils/buildUtil.ts
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import { DialogInfo, LuFile } from '@bfc/shared';
+import { DialogInfo, LuFile, SDKKinds } from '@bfc/shared';
import { LuisConfig, QnaConfig } from '../constants';
@@ -63,4 +63,22 @@ export function needsBuild(dialogs) {
return dialogs.some((dialog) => typeof dialog.content.recognizer === 'string');
}
-export function createRecognizerTypeMap(dialogs: DialogInfo[]) {}
+//ToDo: every recognizer need to get recognizer type from the dialog's recognizer field.
+//now CrossTrainedRecognizerSet and LuisRecognizer's recognizer is abbreviated
+//as recognizer: '***.lu.qna'
+export function getRecognizerTypes(dialogs: DialogInfo[]) {
+ return dialogs.reduce((result, { id, content }) => {
+ const { recognizer } = content;
+ if (typeof recognizer === 'string') {
+ if (recognizer.endsWith('.lu.qna')) {
+ result[id] = SDKKinds.CrossTrainedRecognizerSet;
+ }
+ if (recognizer.endsWith('.lu')) {
+ result[id] = SDKKinds.LuisRecognizer;
+ }
+ } else {
+ result[id] = '';
+ }
+ return result;
+ }, {});
+}
diff --git a/Composer/packages/client/src/utils/hooks.ts b/Composer/packages/client/src/utils/hooks.ts
index d206829d53..662bad635b 100644
--- a/Composer/packages/client/src/utils/hooks.ts
+++ b/Composer/packages/client/src/utils/hooks.ts
@@ -1,12 +1,13 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import { useState, useEffect, useRef, useMemo } from 'react';
+import { useState, useEffect, useRef, useMemo, MutableRefObject } from 'react';
import { globalHistory } from '@reach/router';
import replace from 'lodash/replace';
import find from 'lodash/find';
import { useRecoilValue } from 'recoil';
import { FeatureFlagKey } from '@bfc/shared';
+import isFunction from 'lodash/isFunction';
import {
designPageLocationState,
@@ -92,21 +93,52 @@ export const useProjectIdCache = () => {
return projectId;
};
-export const useInterval = (callback, delay) => {
- const savedCallback = useRef<() => void>();
+export function useInterval(callback: Function, delay: number | null) {
+ const savedCallback: MutableRefObject = useRef();
+ // Remember the latest callback.
useEffect(() => {
savedCallback.current = callback;
}, [callback]);
+ // Set up the interval.
useEffect(() => {
- if (delay !== null) {
- const interval = setInterval(() => {
- if (typeof savedCallback.current === 'function') {
- savedCallback.current();
- }
- }, delay);
- return () => clearInterval(interval);
+ function tick() {
+ if (isFunction(savedCallback.current)) {
+ savedCallback.current();
+ }
+ }
+ if (delay != null) {
+ const id = setInterval(tick, delay);
+ return () => clearInterval(id);
}
}, [delay]);
-};
+}
+
+export function useClickOutside(ref: MutableRefObject, callback: Function) {
+ const savedCallback: MutableRefObject = useRef();
+
+ const handleEvent = (e) => {
+ if (ref?.current && !ref.current.contains(e.target)) {
+ if (isFunction(callback)) {
+ callback();
+ }
+ }
+ };
+
+ useEffect(() => {
+ savedCallback.current = callback;
+ }, [callback]);
+
+ useEffect(() => {
+ document.addEventListener('click', handleEvent);
+ document.addEventListener('mousedown', handleEvent);
+ document.addEventListener('touchstart', handleEvent);
+
+ return () => {
+ document.removeEventListener('click', handleEvent);
+ document.removeEventListener('mousedown', handleEvent);
+ document.removeEventListener('touchstart', handleEvent);
+ };
+ }, [ref, callback]);
+}
diff --git a/Composer/packages/client/src/utils/pageLinks.ts b/Composer/packages/client/src/utils/pageLinks.ts
index 088108f95b..e152bef987 100644
--- a/Composer/packages/client/src/utils/pageLinks.ts
+++ b/Composer/packages/client/src/utils/pageLinks.ts
@@ -62,13 +62,6 @@ export const topLinks = (
exact: true,
disabled: !botLoaded,
},
- {
- to: `/bot/${projectId}/skills`,
- iconName: 'PlugDisconnected',
- labelName: formatMessage('Skills'),
- exact: true,
- disabled: !botLoaded,
- },
...(showFormDialog
? [
{
diff --git a/Composer/packages/lib/indexers/src/index.ts b/Composer/packages/lib/indexers/src/index.ts
index 3b159c1ed0..35b5bf9550 100644
--- a/Composer/packages/lib/indexers/src/index.ts
+++ b/Composer/packages/lib/indexers/src/index.ts
@@ -9,7 +9,6 @@ import { jsonSchemaFileIndexer } from './jsonSchemaFileIndexer';
import { lgIndexer } from './lgIndexer';
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';
@@ -80,7 +79,7 @@ class Indexer {
return lgImportResolverGenerator(lgFiles, '.lg', locale);
};
- public index(files: FileInfo[], botName: string, locale: string, skillContent: any, settings: DialogSetting) {
+ public index(files: FileInfo[], botName: string, locale: string, settings: DialogSetting) {
const result = this.classifyFile(files);
const luFeatures = settings.luFeatures;
const { dialogs, recognizers } = this.separateDialogsAndRecognizers(result[FileExtensions.Dialog]);
@@ -92,7 +91,6 @@ class Indexer {
luFiles: luIndexer.index(result[FileExtensions.Lu], luFeatures),
qnaFiles: qnaIndexer.index(result[FileExtensions.QnA]),
skillManifestFiles: skillManifestIndexer.index(skillManifestFiles),
- skills: skillIndexer.index(skillContent, settings.skill),
botProjectSpaceFiles: botProjectSpaceIndexer.index(result[FileExtensions.BotProjectSpace]),
jsonSchemaFiles: jsonSchemaFileIndexer.index(result[FileExtensions.Json]),
formDialogSchemas: formDialogSchemaIndexer.index(result[FileExtensions.FormDialog]),
diff --git a/Composer/packages/lib/indexers/src/skillManifestIndexer.ts b/Composer/packages/lib/indexers/src/skillManifestIndexer.ts
index c833ed3553..5d6331ecf8 100644
--- a/Composer/packages/lib/indexers/src/skillManifestIndexer.ts
+++ b/Composer/packages/lib/indexers/src/skillManifestIndexer.ts
@@ -1,13 +1,13 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import { FileInfo, SkillManifestInfo } from '@bfc/shared';
+import { FileInfo, SkillManifestFile } from '@bfc/shared';
import has from 'lodash/has';
import { getBaseName } from './utils/help';
const index = (skillManifestFiles: FileInfo[]) => {
- return skillManifestFiles.reduce((manifests: SkillManifestInfo[], { content, name, lastModified }) => {
+ return skillManifestFiles.reduce((manifests: SkillManifestFile[], { content, name, lastModified }) => {
try {
const jsonContent = JSON.parse(content);
@@ -22,7 +22,7 @@ const index = (skillManifestFiles: FileInfo[]) => {
} catch (error) {
return manifests;
}
- }, [] as SkillManifestInfo[]);
+ }, [] as SkillManifestFile[]);
};
export const skillManifestIndexer = {
diff --git a/Composer/packages/lib/shared/src/skillsUtils/index.ts b/Composer/packages/lib/shared/src/skillsUtils/index.ts
index 958a3ae7a4..e14c4d62b1 100644
--- a/Composer/packages/lib/shared/src/skillsUtils/index.ts
+++ b/Composer/packages/lib/shared/src/skillsUtils/index.ts
@@ -3,7 +3,17 @@
import get from 'lodash/get';
import keyBy from 'lodash/keyBy';
-import { DialogSetting, Skill } from '@botframework-composer/types';
+import { BotProjectSpace, DialogSetting, SkillSetting } from '@botframework-composer/types';
+import formatMessage from 'format-message';
+import camelCase from 'lodash/camelCase';
+
+// eslint-disable-next-line security/detect-unsafe-regex
+const localhostRegex = /^https?:\/\/(localhost|127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}|::1)/;
+
+export const VIRTUAL_LOCAL_ENDPOINT = {
+ key: -1,
+ name: formatMessage('Local Composer'),
+};
export function fetchFromSettings(path: string, settings: DialogSetting): string {
if (path) {
@@ -13,8 +23,8 @@ export function fetchFromSettings(path: string, settings: DialogSetting): string
return '';
}
-export const convertSkillsToDictionary = (skills: Skill[]) => {
- const mappedSkills = skills.map(({ msAppId, endpointUrl, manifestUrl, name }: Skill) => {
+export const convertSkillsToDictionary = (skills: any[]) => {
+ const mappedSkills = skills.map(({ msAppId, endpointUrl, manifestUrl, name }) => {
return {
name,
msAppId,
@@ -23,7 +33,7 @@ export const convertSkillsToDictionary = (skills: Skill[]) => {
};
});
- return keyBy(mappedSkills, 'name');
+ return keyBy(mappedSkills, (item) => camelCase(item.name));
};
export const getSkillNameFromSetting = (value?: string) => {
@@ -40,3 +50,53 @@ export const getEndpointNameGivenUrl = (manifestData: any, urlToMatch: string) =
const matchedEndpoint = manifestData?.endpoints.find(({ endpointUrl }) => endpointUrl === urlToMatch);
return matchedEndpoint ? matchedEndpoint.name : '';
};
+
+export const getManifestNameFromUrl = (manifestUrl: string) => {
+ const manifestNameIndex = manifestUrl.lastIndexOf('/') + 1;
+ if (!manifestNameIndex) {
+ return manifestUrl;
+ }
+ return manifestUrl.substring(manifestNameIndex);
+};
+
+export const migrateSkillsForExistingBots = (botProjectFile: BotProjectSpace, rootBotSkill: any) => {
+ const updatedSkillSetting: Record = {};
+ if (Object.keys(botProjectFile.skills).length === 0 && Object.keys(rootBotSkill).length > 0) {
+ for (const skillName in rootBotSkill) {
+ const currentSkill = rootBotSkill[skillName];
+ const skillNameIdentifier = camelCase(skillName);
+ updatedSkillSetting[skillNameIdentifier] = {
+ endpointUrl: currentSkill.endpointUrl,
+ msAppId: currentSkill.msAppId,
+ };
+ botProjectFile.skills[skillNameIdentifier] = {
+ manifest: currentSkill?.manifestUrl || '',
+ remote: true,
+ };
+ }
+ }
+ return {
+ skillSettings: updatedSkillSetting,
+ botProjectFile,
+ };
+};
+
+export const fetchEndpointNameForSkill = (
+ rootBotSettings: DialogSetting,
+ skillNameIdentifier: string,
+ manifestData
+) => {
+ const endpointUrl = get(rootBotSettings, `skill[${skillNameIdentifier}].endpointUrl`);
+ if (endpointUrl) {
+ const matchedEndpoint = getEndpointNameGivenUrl(manifestData, endpointUrl);
+ return matchedEndpoint;
+ }
+};
+
+export const isLocalhostUrl = (matchUrl: string) => {
+ return localhostRegex.test(matchUrl);
+};
+
+export const isSkillHostUpdateRequired = (skillHostEndpoint?: string) => {
+ return !skillHostEndpoint || isLocalhostUrl(skillHostEndpoint);
+};
diff --git a/Composer/packages/server/package.json b/Composer/packages/server/package.json
index 5a83f7aafd..7ae05ebdd8 100644
--- a/Composer/packages/server/package.json
+++ b/Composer/packages/server/package.json
@@ -80,6 +80,7 @@
"express-session": "^1.17.0",
"form-data": "^2.3.3",
"format-message": "^6.2.3",
+ "get-port": "^5.1.1",
"globby": "^11.0.1",
"http-errors": "^1.7.2",
"immer": "^5.2.0",
@@ -91,7 +92,6 @@
"morgan": "^1.9.1",
"passport": "^0.4.1",
"path-to-regexp": "^6.1.0",
- "portfinder": "1.0.25",
"rimraf": "^3.0.2",
"ts-md5": "^1.2.7",
"tslib": "^2.0.0",
diff --git a/Composer/packages/server/src/models/bot/botProject.ts b/Composer/packages/server/src/models/bot/botProject.ts
index b73383275d..9b2b0c9751 100644
--- a/Composer/packages/server/src/models/bot/botProject.ts
+++ b/Composer/packages/server/src/models/bot/botProject.ts
@@ -14,7 +14,6 @@ import {
IBotProject,
DialogSetting,
FileExtensions,
- Skill,
DialogUtils,
} from '@bfc/shared';
import merge from 'lodash/merge';
@@ -35,7 +34,6 @@ import { isCrossTrainConfig } from './botStructure';
import { Builder } from './builder';
import { IFileStorage } from './../storage/interface';
import { LocationRef, IBuildConfig } from './interface';
-import { retrieveSkillManifests } from './skillManager';
import { defaultFilePath, serializeFiles, parseFileName, isRecognizer } from './botStructure';
const debug = log.extend('bot-project');
@@ -63,7 +61,6 @@ export class BotProject implements IBotProject {
public defaultUISchema: {
[key: string]: string;
};
- public skills: Skill[] = [];
public diagnostics: Diagnostic[] = [];
public settingManager: ISettingManager;
public settings: DialogSetting | null = null;
@@ -179,9 +176,6 @@ export class BotProject implements IBotProject {
public init = async () => {
this.diagnostics = [];
this.settings = await this.getEnvSettings(false);
- const { skillManifests, diagnostics } = await retrieveSkillManifests(this.settings?.skill);
- this.skills = skillManifests;
- this.diagnostics.push(...diagnostics);
this.files = await this._getFiles();
};
@@ -191,7 +185,6 @@ export class BotProject implements IBotProject {
files: Array.from(this.files.values()),
location: this.dir,
schemas: this.getSchemas(),
- skills: this.skills,
diagnostics: this.diagnostics,
settings: this.settings,
filesWithoutRecognizers: Array.from(this.files.values()).filter(({ name }) => !isRecognizer(name)),
diff --git a/Composer/packages/server/src/models/bot/skillManager.ts b/Composer/packages/server/src/models/bot/skillManager.ts
index 88a5576af4..e09d08a847 100644
--- a/Composer/packages/server/src/models/bot/skillManager.ts
+++ b/Composer/packages/server/src/models/bot/skillManager.ts
@@ -2,8 +2,6 @@
// Licensed under the MIT License.
import * as msRest from '@azure/ms-rest-js';
-import { SkillSetting, Diagnostic, DiagnosticSeverity } from '@bfc/shared';
-import toPairs from 'lodash/toPairs';
import logger from './../../logger';
@@ -25,28 +23,3 @@ export const getSkillManifest = async (url: string): Promise => {
return typeof content === 'string' ? JSON.parse(content) : {};
};
-
-export const retrieveSkillManifests = async (skillSettings?: { [name: string]: SkillSetting } | SkillSetting[]) => {
- const skills = toPairs(skillSettings);
-
- const diagnostics: Diagnostic[] = [];
- const skillManifests: any = [];
-
- for (const [id, { manifestUrl }] of skills) {
- try {
- const content = await getSkillManifest(manifestUrl);
-
- skillManifests.push({ content, id, manifestUrl });
- } catch (error) {
- const notify = new Diagnostic(
- `Accessing skill manifest url error, ${manifestUrl}`,
- 'appsettings.json',
- DiagnosticSeverity.Warning
- );
- diagnostics.push(notify);
- skillManifests.push({ id, manifestUrl });
- }
- }
-
- return { diagnostics, skillManifests };
-};
diff --git a/Composer/packages/server/src/models/settings/defaultSettingManager.ts b/Composer/packages/server/src/models/settings/defaultSettingManager.ts
index cf6ca3de24..a02063424e 100644
--- a/Composer/packages/server/src/models/settings/defaultSettingManager.ts
+++ b/Composer/packages/server/src/models/settings/defaultSettingManager.ts
@@ -83,7 +83,8 @@ export class DefaultSettingManager extends FileSettingManager {
maxUtteranceAllowed: 15000,
},
skillConfiguration: {
- isSkill: false,
+ // TODO: Setting isSkill property to true for now. A runtime change is required to remove dependancy on isSkill prop #4501
+ isSkill: true,
allowedCallers: ['*'],
},
skill: {},
diff --git a/Composer/packages/server/src/server.ts b/Composer/packages/server/src/server.ts
index f71e91d744..2319710541 100644
--- a/Composer/packages/server/src/server.ts
+++ b/Composer/packages/server/src/server.ts
@@ -5,7 +5,8 @@ import 'dotenv/config';
import path from 'path';
import crypto from 'crypto';
-import { getPortPromise } from 'portfinder';
+import toNumber from 'lodash/toNumber';
+import getPort from 'get-port';
import express, { Express, Request, Response, NextFunction } from 'express';
import bodyParser from 'body-parser';
import morgan from 'morgan';
@@ -132,13 +133,13 @@ export async function start(electronContext?: ElectronContext): Promise {
diff --git a/Composer/packages/types/src/indexers.ts b/Composer/packages/types/src/indexers.ts
index 538d0d9bea..119fd652bc 100644
--- a/Composer/packages/types/src/indexers.ts
+++ b/Composer/packages/types/src/indexers.ts
@@ -166,15 +166,30 @@ export type LgFile = {
parseResult?: any;
};
+export type Manifest = {
+ name: string;
+ version: string;
+ description: string;
+ endpoints: ManifestEndpoint[];
+ // Other props of manifest are not used in Composer.
+ [prop: string]: any;
+};
+
+export type ManifestEndpoint = {
+ name: string;
+ endpointUrl: string;
+ msAppId: string;
+ description: string;
+ // Other skill endpoint fields in the schema that Composer is not using presently
+ [prop: string]: any;
+};
+
export type Skill = {
id: string;
- content: any;
+ manifest?: Manifest;
description?: string;
- endpoints: any[];
- endpointUrl: string;
- manifestUrl: string;
- msAppId: string;
name: string;
+ remote: boolean;
};
export type JsonSchemaFile = {
@@ -190,13 +205,7 @@ export type FileResolver = (id: string) => FileInfo | undefined;
export type MemoryResolver = (id: string) => string[] | undefined;
-export type SkillManifestInfo = {
- content: { [key: string]: any };
- lastModified: string;
- id: string;
-};
-
-export type SkillManifest = {
+export type SkillManifestFile = {
content: any;
id: string;
path?: string;
@@ -209,7 +218,7 @@ export type BotAssets = {
luFiles: LuFile[];
lgFiles: LgFile[];
qnaFiles: QnAFile[];
- skillManifests: SkillManifest[];
+ skillManifests: SkillManifestFile[];
setting: DialogSetting;
dialogSchemas: DialogSchemaFile[];
formDialogSchemas: FormDialogSchema[];
diff --git a/Composer/packages/types/src/server.ts b/Composer/packages/types/src/server.ts
index 08a3bed1b7..c2352edd86 100644
--- a/Composer/packages/types/src/server.ts
+++ b/Composer/packages/types/src/server.ts
@@ -1,6 +1,6 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import { Skill, FileInfo } from './indexers';
+import { FileInfo } from './indexers';
import { IDiagnostic } from './diagnostic';
import { DialogSetting } from './settings';
@@ -17,7 +17,6 @@ export type IBotProject = {
defaultUISchema: {
[key: string]: string;
};
- skills: Skill[];
diagnostics: IDiagnostic[];
settingManager: ISettingManager;
settings: DialogSetting | null;
@@ -26,7 +25,6 @@ export type IBotProject = {
files: FileInfo[];
location: string;
schemas: any;
- skills: Skill[];
diagnostics: IDiagnostic[];
settings: DialogSetting | null;
};
diff --git a/Composer/packages/types/src/settings.ts b/Composer/packages/types/src/settings.ts
index c420586d12..b375b807b8 100644
--- a/Composer/packages/types/src/settings.ts
+++ b/Composer/packages/types/src/settings.ts
@@ -23,8 +23,6 @@ export type AppUpdaterSettings = {
};
export type SkillSetting = {
- name: string;
- manifestUrl: string;
msAppId: string;
endpointUrl: string;
};
diff --git a/Composer/packages/types/src/shell.ts b/Composer/packages/types/src/shell.ts
index 849fc2df37..eed3ba268e 100644
--- a/Composer/packages/types/src/shell.ts
+++ b/Composer/packages/types/src/shell.ts
@@ -6,6 +6,7 @@ import type { DialogInfo, LuFile, LgFile, QnAFile, LuIntentSection, LgTemplate,
import type { ILUFeaturesConfig, SkillSetting, UserSettings } from './settings';
import type { JSONSchema7, SDKKinds } from './schema';
import { MicrosoftIDialog } from './sdk';
+import { Skill } from './indexers';
/** Recursively marks all properties as optional. */
type AllPartial = {
@@ -57,6 +58,10 @@ export type ApplicationContext = {
locale: string;
hosted: boolean;
userSettings: UserSettings;
+ skills: Record;
+ skillsSettings: Record;
+ // TODO: remove
+ schemas: BotSchemas;
flowZoomRate: ZoomInfo;
};
@@ -92,13 +97,12 @@ export type ProjectContextApi = {
updateIntentTrigger: (id: string, intentName: string, newIntentName: string) => void;
createDialog: (actions: any) => Promise;
commitChanges: () => void;
- addSkillDialog: () => Promise<{ manifestUrl: string; name: string } | null>;
displayManifestModal: (manifestId: string) => void;
updateDialogSchema: (_: DialogSchemaFile) => Promise;
createTrigger: (id: string, formData, autoSelected?: boolean) => void;
createQnATrigger: (id: string) => void;
- updateSkillSetting: (skillId: string, skillsData: SkillSetting) => Promise;
updateFlowZoomRate: (currentRate: number) => void;
+ updateSkill: (skillId: string, skillsData: { skill: Skill; selectedEndpointIndex: number }) => Promise;
};
export type ProjectContext = {
@@ -110,7 +114,7 @@ export type ProjectContext = {
luFiles: LuFile[];
luFeatures: ILUFeaturesConfig;
qnaFiles: QnAFile[];
- skills: any[];
+ skills: Record;
skillsSettings: Record;
schemas: BotSchemas;
forceDisabledActions: DisabledMenuActions[];
diff --git a/Composer/packages/ui-plugins/select-skill-dialog/src/ComboBoxField.tsx b/Composer/packages/ui-plugins/select-skill-dialog/src/ComboBoxField.tsx
index ee526c4dab..b386b6c781 100644
--- a/Composer/packages/ui-plugins/select-skill-dialog/src/ComboBoxField.tsx
+++ b/Composer/packages/ui-plugins/select-skill-dialog/src/ComboBoxField.tsx
@@ -11,7 +11,7 @@ import { IRenderFunction } from 'office-ui-fabric-react/lib/Utilities';
export const ADD_DIALOG = 'ADD_DIALOG';
interface ComboBoxFieldProps {
- comboboxTitle: string | null;
+ comboboxTitle?: string;
options: IComboBoxOption[];
onChange: any;
required?: boolean;
diff --git a/Composer/packages/ui-plugins/select-skill-dialog/src/SelectSkillDialogField.tsx b/Composer/packages/ui-plugins/select-skill-dialog/src/SelectSkillDialogField.tsx
index b553df3325..41fece0632 100644
--- a/Composer/packages/ui-plugins/select-skill-dialog/src/SelectSkillDialogField.tsx
+++ b/Composer/packages/ui-plugins/select-skill-dialog/src/SelectSkillDialogField.tsx
@@ -1,16 +1,13 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-import React, { useState } from 'react';
-import { IComboBoxOption, SelectableOptionMenuItemType } from 'office-ui-fabric-react/lib/ComboBox';
+import React, { FormEvent } from 'react';
import { FieldProps, useShellApi } from '@bfc/extension-client';
import formatMessage from 'format-message';
import { getSkillNameFromSetting, Skill } from '@bfc/shared';
import { Link } from 'office-ui-fabric-react/lib/components/Link/Link';
-
-import { ComboBoxField } from './ComboBoxField';
-
-const ADD_DIALOG = 'ADD_DIALOG';
+import { Dropdown, IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown';
+import { FieldLabel } from '@bfc/adaptive-form';
const referBySettings = (skillName: string, property: string) => {
return `=settings.skill['${skillName}'].${property}`;
@@ -23,64 +20,50 @@ export const settingReferences = (skillName: string) => ({
export const SelectSkillDialogField: React.FC = (props) => {
const { value, onChange } = props;
- const { shellApi, skills = [] } = useShellApi();
- const { addSkillDialog, displayManifestModal } = shellApi;
- const [comboboxTitle, setComboboxTitle] = useState(null);
- const skillId = getSkillNameFromSetting(value?.skillEndpoint);
- const { content, manifestUrl, name } = skills.find(({ id }) => id === skillId) || ({} as Skill);
+ const { shellApi, skills } = useShellApi();
+ const { displayManifestModal } = shellApi;
- const options: IComboBoxOption[] = skills.map(({ id, name }) => ({
- key: name,
- text: name,
- data: settingReferences(id),
- isSelected: id === skillId,
- }));
-
- options.push(
- {
- key: 'separator',
- itemType: SelectableOptionMenuItemType.Divider,
- text: '',
- },
- { key: ADD_DIALOG, text: formatMessage('Add a new Skill Dialog') }
- );
+ const skillNameIdentifier = getSkillNameFromSetting(value?.skillEndpoint);
+ const { manifest, name }: Skill = skills[skillNameIdentifier] || {};
- if (comboboxTitle) {
- options.push({ key: 'customTitle', text: comboboxTitle });
+ const options: IDropdownOption[] = [];
+ for (const key in skills) {
+ const skill = skills[key];
+ const option = {
+ key: key,
+ text: skill.name,
+ data: settingReferences(key),
+ isSelected: key === skillNameIdentifier,
+ };
+ options.push(option);
}
- const handleChange = (_, option: IComboBoxOption) => {
+ const handleChange = (event: FormEvent, option?: IDropdownOption | undefined) => {
if (option) {
- if (option.key === ADD_DIALOG) {
- setComboboxTitle(formatMessage('Add a new Skill Dialog'));
- addSkillDialog().then((skill) => {
- if (skill?.manifestUrl && skill?.name) {
- onChange({ ...value, ...settingReferences(skill.name) });
- }
- setComboboxTitle(null);
- });
- } else {
- onChange({ ...value, ...option.data });
- }
+ onChange({ ...value, ...option.data });
}
};
return (
-
+
manifestUrl && displayManifestModal(manifestUrl)}
+ onClick={() => manifest && displayManifestModal(skillNameIdentifier)}
>
{formatMessage('Show skill manifest')}
diff --git a/Composer/packages/ui-plugins/select-skill-dialog/src/SkillEndpointField.tsx b/Composer/packages/ui-plugins/select-skill-dialog/src/SkillEndpointField.tsx
index 6f3cc2d6a0..9c77437a1f 100644
--- a/Composer/packages/ui-plugins/select-skill-dialog/src/SkillEndpointField.tsx
+++ b/Composer/packages/ui-plugins/select-skill-dialog/src/SkillEndpointField.tsx
@@ -5,44 +5,79 @@ import React, { useMemo } from 'react';
import { Dropdown, IDropdownOption } from 'office-ui-fabric-react/lib/Dropdown';
import { FieldProps, useShellApi } from '@bfc/extension-client';
import { FieldLabel } from '@bfc/adaptive-form';
-import { getSkillNameFromSetting, Skill } from '@bfc/shared';
+import { getSkillNameFromSetting, Skill, VIRTUAL_LOCAL_ENDPOINT } from '@bfc/shared';
+import { SelectableOptionMenuItemType } from 'office-ui-fabric-react/lib/ComboBox';
export const SkillEndpointField: React.FC = (props) => {
const { description, label, required, uiOptions, value } = props;
- const { shellApi, skillsSettings, skills = [] } = useShellApi();
- const { updateSkillSetting } = shellApi;
+ const { shellApi, skillsSettings, skills } = useShellApi();
+ const { updateSkill } = shellApi;
const id = getSkillNameFromSetting(value?.skillEndpoint);
- const skill = skills.find(({ id: skillId }) => skillId === id) || ({} as Skill);
- const { endpointUrl, msAppId } = skillsSettings[id] || {};
-
- const { endpoints = [] } = skill;
-
- const options = useMemo(
- () =>
- endpoints.map(({ name, endpointUrl, msAppId }, key) => ({
- key,
- text: name,
- data: {
- endpointUrl,
- msAppId,
- },
- })),
- [endpoints]
- );
+ const skill: Skill = skills[id] || {};
+
+ const { endpointUrl: endpointUrlInSettings, msAppId: msAppIdInSettings } = skillsSettings[id] || {};
+
+ const endpoints = skill?.manifest?.endpoints || [];
+
+ const options = useMemo(() => {
+ const endpointsInManifest: any[] = endpoints.map(({ name, endpointUrl, msAppId }, key) => ({
+ key,
+ text: name,
+ data: {
+ endpointUrl,
+ msAppId,
+ name,
+ },
+ isManifestEndpoint: true,
+ }));
+
+ let localEndpoint: any[] = [];
+ if (Object.keys(skills).length > 0) {
+ if (!skill.remote) {
+ localEndpoint = [
+ {
+ key: 'localEndpointHeader',
+ itemType: SelectableOptionMenuItemType.Header,
+ text: 'Local Endpoints',
+ },
+ {
+ key: -1,
+ text: VIRTUAL_LOCAL_ENDPOINT.name,
+ data: {
+ endpointUrl: endpointUrlInSettings,
+ msAppId: msAppIdInSettings,
+ name: VIRTUAL_LOCAL_ENDPOINT.name,
+ },
+ },
+ ];
+ }
+ if (endpointsInManifest.length > 0) {
+ endpointsInManifest.unshift({
+ key: 'remoteEndpointHeader',
+ itemType: SelectableOptionMenuItemType.Header,
+ text: 'Manifest Endpoints',
+ });
+ }
+ }
+
+ return [...localEndpoint, ...endpointsInManifest];
+ }, [endpoints]);
- const { key } = options.find(({ data }) => data.endpointUrl === endpointUrl && data.msAppId === msAppId) || {};
+ const { key } = options.find(({ data, isManifestEndpoint }) => {
+ return isManifestEndpoint && data?.endpointUrl === endpointUrlInSettings && data?.msAppId === msAppIdInSettings;
+ }) || { key: -1 };
const handleChange = (_: React.FormEvent, option?: IDropdownOption) => {
if (option) {
- updateSkillSetting(skill.id, { ...skill, ...option.data });
+ updateSkill(id, { skill: { ...skill }, selectedEndpointIndex: Number(option.key) });
}
};
return (
-
+
);
};
diff --git a/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/SelectSkillDialog.test.tsx b/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/SelectSkillDialog.test.tsx
index 345331298d..2ce1574d13 100644
--- a/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/SelectSkillDialog.test.tsx
+++ b/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/SelectSkillDialog.test.tsx
@@ -2,67 +2,55 @@
// Licensed under the MIT License.
import React from 'react';
-import { act, fireEvent, getAllByRole, render } from '@botframework-composer/test-utils';
+import { render, fireEvent, getAllByRole } from '@botframework-composer/test-utils';
import { EditorExtension } from '@bfc/extension-client';
+import { ShellData } from '@bfc/shared';
+import { act } from '@botframework-composer/test-utils/lib/hooks';
import { SelectSkillDialogField, settingReferences } from '../SelectSkillDialogField';
import { skills } from './constants';
-const renderSelectSkillDialog = ({ addSkillDialog = jest.fn(), onChange = jest.fn() } = {}) => {
+const renderSelectSkillDialog = ({ onChange = jest.fn() } = {}) => {
const props = {
value: {},
onChange,
} as any;
- const shell = {
- addSkillDialog,
- };
+ const shell: any = {};
- const shellData = {
+ const shellData: ShellData = {
skills,
- };
+ } as ShellData;
return render(
-
+
);
};
describe('Select Skill Dialog', () => {
- it('should add a new skill', async () => {
- const addSkillDialog = jest.fn().mockImplementation(() => {
- return {
- then: (cb) => {
- cb({
- manifestUrl: 'https://skill',
- name: 'test-skill',
- msAppId: '0000-0000',
- endpointUrl: 'https://skill/api/messafes',
- });
- },
- };
- });
+ it('should display label', async () => {
+ const { findByText } = renderSelectSkillDialog();
+ await findByText('Skill Dialog Name');
+ });
+
+ it('should update the dialog file with the selected skill', async () => {
const onChange = jest.fn();
+ const keys = Object.keys(skills);
- const { baseElement, findByRole } = renderSelectSkillDialog({ addSkillDialog, onChange });
- const combobox = await findByRole('combobox');
+ const { baseElement, findByRole } = renderSelectSkillDialog({ onChange });
+ const combobox = await findByRole('listbox');
act(() => {
fireEvent.click(combobox);
});
- const dialogs = getAllByRole(baseElement, 'option');
+ const options = getAllByRole(baseElement, 'option');
act(() => {
- fireEvent.click(dialogs[dialogs.length - 1]);
+ fireEvent.click(options[options.length - 1]);
});
- expect(addSkillDialog).toHaveBeenCalled();
- expect(onChange).toHaveBeenCalledWith({ ...settingReferences('test-skill') });
- });
-
- it('should display label', async () => {
- const { findByText } = renderSelectSkillDialog();
- await findByText('Skill Dialog Name');
+ expect(onChange).toHaveBeenCalledWith({ ...settingReferences(keys[1]) });
});
});
diff --git a/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/SkillEndpointField.test.tsx b/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/SkillEndpointField.test.tsx
index 56ca59c14b..f77a6a20c5 100644
--- a/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/SkillEndpointField.test.tsx
+++ b/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/SkillEndpointField.test.tsx
@@ -5,7 +5,6 @@
import React from 'react';
import { fireEvent, getAllByRole, render } from '@botframework-composer/test-utils';
import { EditorExtension } from '@bfc/extension-client';
-import { convertSkillsToDictionary, Skill } from '@bfc/shared';
import { act } from '@botframework-composer/test-utils/lib/hooks';
import { SkillEndpointField } from '../SkillEndpointField';
@@ -14,18 +13,18 @@ import { skills } from './constants';
const projectId = '123.abc';
-const renderSkillEndpointField = ({ value = {}, updateSkillSetting = jest.fn() } = {}) => {
+const renderSkillEndpointField = ({ value = {}, updateSkill = jest.fn() } = {}) => {
const props = {
value,
} as any;
const shellData: any = {
- skillsSettings: convertSkillsToDictionary(skills as Skill[]),
+ skillsSettings: {},
skills,
};
const shell: any = {
- updateSkillSetting,
+ updateSkill,
};
return render(
@@ -36,11 +35,14 @@ const renderSkillEndpointField = ({ value = {}, updateSkillSetting = jest.fn() }
};
describe('Begin Skill Dialog', () => {
- it('should update the skill settings', async () => {
- const updateSkillSetting = jest.fn();
+ it('should call update skill with the correct parameters', async () => {
+ const updateSkill = jest.fn();
+ const keys = Object.keys(skills);
+ const selectedKeyIndex = 1;
+ const selectedSkill = skills[keys[selectedKeyIndex]];
const { baseElement, findByRole } = renderSkillEndpointField({
- updateSkillSetting,
- value: { skillEndpoint: `=settings.skill['${skills[0].id}'].endpointUrl` },
+ updateSkill,
+ value: { skillEndpoint: `=settings.skill['${keys[selectedKeyIndex]}'].endpointUrl` },
});
const listbox = await findByRole('listbox');
@@ -49,16 +51,70 @@ describe('Begin Skill Dialog', () => {
});
const endpoints = getAllByRole(baseElement, 'option');
+
act(() => {
fireEvent.click(endpoints[endpoints.length - 1]);
});
- expect(updateSkillSetting).toHaveBeenCalledWith(
- skills[0].id,
+ expect(updateSkill).toHaveBeenCalledWith(
+ keys[selectedKeyIndex],
expect.objectContaining({
- endpointUrl: skills[0].endpoints[0].endpointUrl,
- msAppId: skills[0].endpoints[0].msAppId,
+ selectedEndpointIndex: 1,
+ skill: selectedSkill,
})
);
});
+
+ it('should choose composer local endpoint', async () => {
+ const updateSkill = jest.fn();
+ const keys = Object.keys(skills);
+ const selectedKeyIndex = 1;
+ const selectedSkill = skills[keys[selectedKeyIndex]];
+ const { baseElement, findByRole } = renderSkillEndpointField({
+ updateSkill,
+ value: { skillEndpoint: `=settings.skill['${keys[selectedKeyIndex]}'].endpointUrl` },
+ });
+
+ const listbox = await findByRole('listbox');
+ act(() => {
+ fireEvent.click(listbox);
+ });
+
+ const endpoints = getAllByRole(baseElement, 'option');
+
+ act(() => {
+ fireEvent.click(endpoints[1]);
+ });
+
+ expect(updateSkill).toHaveBeenCalledWith(
+ keys[selectedKeyIndex],
+ expect.objectContaining({
+ selectedEndpointIndex: -1,
+ skill: selectedSkill,
+ })
+ );
+ });
+
+ it('should not fail if skill has no manifest', async () => {
+ const updateSkill = jest.fn();
+ const keys = Object.keys(skills);
+ const selectedKeyIndex = 1;
+ const selectedSkill = skills[keys[selectedKeyIndex]];
+ delete selectedSkill.manifest;
+ const { baseElement, findByRole } = renderSkillEndpointField({
+ updateSkill,
+ value: { skillEndpoint: `=settings.skill['${keys[selectedKeyIndex]}'].endpointUrl` },
+ });
+
+ const listbox = await findByRole('listbox');
+ act(() => {
+ fireEvent.click(listbox);
+ });
+
+ const endpoints = getAllByRole(baseElement, 'option').filter((endpoint) => {
+ return !!endpoint.title;
+ });
+
+ expect(endpoints.length).toBe(1);
+ });
});
diff --git a/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/constants.ts b/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/constants.ts
index 7b8d4f479f..cc303977cd 100644
--- a/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/constants.ts
+++ b/Composer/packages/ui-plugins/select-skill-dialog/src/__tests__/constants.ts
@@ -1,37 +1,54 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-export const skills = [
- {
- id: 'yuesuemailskill0207',
- manifestUrl: 'https://yuesuemailskill0207-gjvga67.azurewebsites.net/manifest/manifest-1.0.json',
- endpoints: [
- {
- name: 'production',
- protocol: 'BotFrameworkV3',
- description: 'Production endpoint for the Email Skill',
- endpointUrl: 'https://yuesuemailskill0207-gjvga67.azurewebsites.net/api/messages',
- msAppId: '79432da8-0f7e-4a16-8c23-ddbba30ae85d',
- },
- ],
- name: 'Email Skill',
+import { Skill } from '@bfc/extension-client';
+
+export const skills: Record = {
+ yuesuemailskill0207: {
+ id: '123-abc',
+ remote: false,
description: 'Production endpoint for the Email Skill',
- content: {},
+ name: 'yuesuemailskill0207',
+ manifest: {
+ version: '1.0',
+ name: 'yuesuemail-2.0.1-manifest',
+ description: 'Production endpoint for the Email Skill',
+ endpoints: [
+ {
+ name: 'production',
+ protocol: 'BotFrameworkV3',
+ description: 'Production endpoint for the Email Skill',
+ endpointUrl: 'https://yuesuemailskill0207-gjvga67.azurewebsites.net/api/messages',
+ msAppId: '79432da8-0f7e-4a16-8c23-ddbba30ae85d',
+ },
+ ],
+ },
},
- {
- id: 'sandwich',
- manifestUrl: 'https://ericv3skillssimplesandwichbot.azurewebsites.net/wwwroot/sandwich-bot-manifest.json',
- name: 'Sandwich Skill Bot',
- endpoints: [
- {
- name: 'YourSandwichBotName',
- protocol: 'BotFrameworkV3',
- description: 'Default endpoint for the skill',
- endpointUrl: 'https://ericv3skillssimplesandwichbot.azurewebsites.net/api/messages',
- msAppId: '94e29d0f-3f0d-46f0-aa78-00aed83698cf',
- },
- ],
- description: 'Default endpoint for the skill',
- content: {},
+ sandwichskill: {
+ id: '234-abc',
+ remote: false,
+ description: 'Production endpoint for the Email Skill',
+ name: 'sandwichskill0207',
+ manifest: {
+ version: '1.0',
+ name: 'sandwich-manifest',
+ description: 'Production endpoint for the Email Skill',
+ endpoints: [
+ {
+ name: 'YourSandwichBotName',
+ protocol: 'BotFrameworkV3',
+ description: 'Default endpoint for the skill',
+ endpointUrl: 'https://ericv3skillssimplesandwichbot.azurewebsites.net/api/messages',
+ msAppId: '94e29d0f-3f0d-46f0-aa78-00aed83698cf',
+ },
+ {
+ name: 'YourSandwichBotName2',
+ protocol: 'BotFrameworkV3',
+ description: 'Backup endpoint for the skill',
+ endpointUrl: 'https://ericv3skills2simplesandwichbot.azurewebsites.net/api/messages',
+ msAppId: '94e29d0f-3f0d-46f0-aa78-00aed83698cfd',
+ },
+ ],
+ },
},
-];
+};
diff --git a/Composer/yarn.lock b/Composer/yarn.lock
index 009eeed194..892fe092b9 100644
--- a/Composer/yarn.lock
+++ b/Composer/yarn.lock
@@ -15244,15 +15244,6 @@ pnp-webpack-plugin@1.5.0:
dependencies:
ts-pnp "^1.1.2"
-portfinder@1.0.25:
- version "1.0.25"
- resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.25.tgz#254fd337ffba869f4b9d37edc298059cb4d35eca"
- integrity sha512-6ElJnHBbxVA1XSLgBp7G1FiCkQdlqGzuF7DswL5tcea+E8UpuvPU7beVAjjRwCioTS9ZluNbu+ZyRvgTsmqEBg==
- dependencies:
- async "^2.6.2"
- debug "^3.1.1"
- mkdirp "^0.5.1"
-
portfinder@^1.0.26:
version "1.0.26"
resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.26.tgz#475658d56ca30bed72ac7f1378ed350bd1b64e70"
diff --git a/extensions/localPublish/package.json b/extensions/localPublish/package.json
index 1f0e7c4ee8..29b87d5954 100644
--- a/extensions/localPublish/package.json
+++ b/extensions/localPublish/package.json
@@ -14,9 +14,11 @@
"@botframework-composer/types": "latest",
"adm-zip": "^0.4.14",
"archiver": "^5.0.2",
+ "get-port": "^5.1.1",
"globby": "^11.0.0",
+ "kill-port": "^1.6.1",
+ "lodash": "^4.17.20",
"path": "^0.12.7",
- "portfinder": "^1.0.26",
"rimraf": "^3.0.2",
"uuid": "^7.0.1"
},
@@ -24,6 +26,7 @@
"bl": "^4.0.3"
},
"devDependencies": {
- "@types/node": "^14.11.8"
+ "@types/node": "^14.11.8",
+ "@types/lodash": "^4.14.162"
}
}
diff --git a/extensions/localPublish/src/index.ts b/extensions/localPublish/src/index.ts
index ee560c1a3f..ce43aab1ad 100644
--- a/extensions/localPublish/src/index.ts
+++ b/extensions/localPublish/src/index.ts
@@ -10,9 +10,12 @@ import rimraf from 'rimraf';
import archiver from 'archiver';
import { v4 as uuid } from 'uuid';
import AdmZip from 'adm-zip';
-import portfinder from 'portfinder';
-import { PublishPlugin } from '@botframework-composer/types';
+import { DialogSetting, PublishPlugin } from '@botframework-composer/types';
import { ExtensionRegistration } from '@bfc/extension';
+import killPort from 'kill-port';
+import map from 'lodash/map';
+import range from 'lodash/range';
+import getPort from 'get-port';
const stat = promisify(fs.stat);
const readDir = promisify(fs.readdir);
@@ -38,6 +41,16 @@ interface PublishConfig {
const isWin = process.platform === 'win32';
+const localhostRegex = /^https?:\/\/(localhost|127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}|::1)/;
+
+
+const isLocalhostUrl = (matchUrl: string) => {
+ return localhostRegex.test(matchUrl);
+};
+
+const isSkillHostUpdateRequired = (skillHostEndpoint?: string) => {
+ return !skillHostEndpoint || isLocalhostUrl(skillHostEndpoint);
+};
class LocalPublisher implements PublishPlugin {
public name = 'localpublish';
public description = 'Publish bot to local runtime';
@@ -94,7 +107,7 @@ class LocalPublisher implements PublishPlugin {
}
await this.setBot(botId, version, fullSettings, project);
} catch (error) {
- this.stopBot(botId);
+ await this.stopBot(botId);
this.setBotStatus(botId, {
status: 500,
result: {
@@ -156,17 +169,13 @@ class LocalPublisher implements PublishPlugin {
status: LocalPublisher.runningBots[botId].status,
result: LocalPublisher.runningBots[botId].result,
};
- if (LocalPublisher.runningBots[botId].status === 500) {
- // after we return the 500 status once, delete it out of the running bots list.
- delete LocalPublisher.runningBots[botId];
- }
return status;
}
} else {
return {
- status: 200,
+ status: 404,
result: {
- message: 'Ready',
+ message: 'Status cannot be obtained for this bot.',
},
};
}
@@ -223,6 +232,7 @@ class LocalPublisher implements PublishPlugin {
const botId = project.id;
const isExist = await this.botExist(botId);
// get runtime template
+
const runtime = this.composer.getRuntimeByProject(project);
try {
if (!isExist) {
@@ -242,7 +252,7 @@ class LocalPublisher implements PublishPlugin {
await runtime.build(runtimeDir, project);
} else {
// stop bot
- this.stopBot(botId);
+ await this.stopBot(botId);
// get previous settings
// when changing type of runtime
const settings = JSON.parse(
@@ -271,6 +281,13 @@ class LocalPublisher implements PublishPlugin {
await this.zipBot(dstPath, srcDir);
};
+ private getAvailablePorts = (): number[] => {
+ const excludePorts = map(LocalPublisher.runningBots, 'port');
+ const portRanges = range(3979, 5000);
+ const filtered = portRanges.filter((current) => !excludePorts.includes(current));
+ return filtered;
+ };
+
// start bot in current version
private setBot = async (botId: string, version: string, settings: any, project: any) => {
// get port, and stop previous bot if exist
@@ -280,10 +297,10 @@ class LocalPublisher implements PublishPlugin {
this.composer.log('Bot already running. Stopping bot...');
// this may or may not be set based on the status of the bot
port = LocalPublisher.runningBots[botId].port;
- this.stopBot(botId);
+ await this.stopBot(botId);
}
if (!port) {
- port = await portfinder.getPortPromise({ port: 3979, stopPort: 5000 });
+ port = await getPort({ port: this.getAvailablePorts() });
}
// if not using custom runtime, update assets in tmp older
@@ -305,7 +322,7 @@ class LocalPublisher implements PublishPlugin {
await this.startBot(botId, port, settings, project);
} catch (error) {
console.error('Error in startbot: ', error);
- this.stopBot(botId);
+ await this.stopBot(botId);
this.setBotStatus(botId, {
status: 500,
result: {
@@ -315,7 +332,7 @@ class LocalPublisher implements PublishPlugin {
}
};
- private startBot = async (botId: string, port: number, settings: any, project: any): Promise => {
+ private startBot = async (botId: string, port: number, settings: DialogSetting, project: any): Promise => {
const botDir = settings.runtime?.customRuntime === true ? settings.runtime.path : this.getBotRuntimeDir(botId);
const commandAndArgs =
settings.runtime?.customRuntime === true
@@ -331,11 +348,20 @@ class LocalPublisher implements PublishPlugin {
// take the 0th item off the array, leaving just the args
this.composer.log('Starting bot on port %d. (%s)', port, commandAndArgs.join(' '));
const startCommand = commandAndArgs.shift();
+
+ let config: any[] = [];
+ let skillHostEndpoint;
+ if (isSkillHostUpdateRequired(settings?.skillHostEndpoint)) {
+ // Update skillhost endpoint only if ngrok url not set meaning empty or localhost url
+ skillHostEndpoint = `http://127.0.0.1:${port}/api/skills`;
+
+ }
+ config = this.getConfig(settings, skillHostEndpoint)
let spawnProcess;
try {
spawnProcess = spawn(
startCommand,
- [...commandAndArgs, '--port', port, `--urls`, `http://0.0.0.0:${port}`, ...this.getConfig(settings)],
+ [...commandAndArgs, '--port', port, `--urls`, `http://0.0.0.0:${port}`, ...config],
{
cwd: botDir,
stdio: ['ignore', 'pipe', 'pipe'],
@@ -343,22 +369,23 @@ class LocalPublisher implements PublishPlugin {
}
);
this.composer.log('Started process %d', spawnProcess.pid);
+ this.setBotStatus(botId, {
+ process: spawnProcess,
+ port: port,
+ status: 202,
+ result: { message: 'Runtime process started. Waiting for communication from runtime' },
+ });
+ const processLog = this.composer.log.extend(spawnProcess.pid);
+ this.addListeners(spawnProcess, botId, processLog);
+ resolve();
} catch (err) {
- return reject(err);
+ reject(err);
+ throw err;
}
- this.setBotStatus(botId, {
- process: spawnProcess,
- port: port,
- status: 200,
- result: { message: 'Runtime started' },
- });
- const processLog = this.composer.log.extend(spawnProcess.pid);
- this.addListeners(spawnProcess, botId, processLog);
- resolve();
});
};
- private getConfig = (config: any) => {
+ private getConfig = (config: DialogSetting, skillHostEndpointUrl?: string): string[] => {
const configList: string[] = [];
if (config.MicrosoftAppPassword) {
configList.push('--MicrosoftAppPassword');
@@ -372,8 +399,12 @@ class LocalPublisher implements PublishPlugin {
configList.push('--qna:endpointKey');
configList.push(config.qna.endpointKey);
}
- // console.log(config.qna);
- // console.log(configList);
+
+ if(skillHostEndpointUrl) {
+ configList.push('--SkillHostEndpoint');
+ configList.push(skillHostEndpointUrl);
+ }
+
return configList;
};
@@ -395,7 +426,13 @@ class LocalPublisher implements PublishPlugin {
let erroutput = '';
child.stdout &&
child.stdout.on('data', (data: any) => {
- logger('%s', data);
+ if(!erroutput && LocalPublisher.runningBots[botId].status === 202) {
+ this.setBotStatus(botId, {
+ status: 200,
+ result: { message: 'Runtime has started'},
+ });
+ }
+ logger('%s', data.toString());
});
child.stderr &&
@@ -414,7 +451,6 @@ class LocalPublisher implements PublishPlugin {
});
child.on('error', (err) => {
- logger('error: %s', err.message);
this.setBotStatus(botId, {
status: 500,
result: { message: err.message },
@@ -469,24 +505,27 @@ class LocalPublisher implements PublishPlugin {
};
// make it public, so that able to stop runtime before switch ejected runtime.
- public stopBot = (botId: string) => {
+ public stopBot = async (botId: string) => {
const proc = LocalPublisher.runningBots[botId]?.process;
-
- if (proc) {
- this.composer.log('Killing process %d', -proc.pid);
- // Kill the bot process AND all child processes
- try {
- this.removeListener(proc);
- process.kill(isWin ? proc.pid : -proc.pid);
- } catch (err) {
- // ESRCH means pid not found
- // this throws an error but doesn't indicate failure for us
- if (err.code !== 'ESRCH') {
- throw err;
- }
- }
+ const port = LocalPublisher.runningBots[botId]?.port;
+
+ if (port) {
+ this.composer.log('Killing process at port %d', port);
+
+ await new Promise((resolve, reject) => {
+ setTimeout(async () => {
+ killPort(port)
+ .then(() => {
+ this.removeListener(proc);
+ delete LocalPublisher.runningBots[botId];
+ resolve();
+ })
+ .catch((err) => {
+ reject(err);
+ });
+ }, 1000);
+ });
}
- delete LocalPublisher.runningBots[botId];
};
private copyDir = async (srcDir: string, dstDir: string) => {
diff --git a/extensions/localPublish/yarn.lock b/extensions/localPublish/yarn.lock
index 7d85128f91..fd123a94c6 100644
--- a/extensions/localPublish/yarn.lock
+++ b/extensions/localPublish/yarn.lock
@@ -84,6 +84,11 @@
"@types/qs" "*"
"@types/serve-static" "*"
+"@types/lodash@^4.14.162":
+ version "4.14.163"
+ resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.163.tgz#6026f73c8267a0b7d41c7c8aadacfa2a5255774f"
+ integrity sha512-BeZM/FZaV53emqyHxn9L39Oz6XbHMBRLA1b1quROku48J/1kYYxPmVOJ/qSQheb81on4BI7H6QDo6bkUuRaDNQ==
+
"@types/mime@*":
version "2.0.3"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-2.0.3.tgz#c893b73721db73699943bfc3653b1deb7faa4a3a"
@@ -163,13 +168,6 @@ array-union@^2.1.0:
resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d"
integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==
-async@^2.6.2:
- version "2.6.3"
- resolved "https://registry.yarnpkg.com/async/-/async-2.6.3.tgz#d72625e2344a3656e3a3ad4fa749fa83299d82ff"
- integrity sha512-zflvls11DCy+dQWzTW2dzuilv8Z5X/pjfmZOWba6TNIVDm+2UDaJmXSOXlasHKfNBs8oo3M0aT50fDEWfKZjXg==
- dependencies:
- lodash "^4.17.14"
-
async@^3.2.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720"
@@ -267,13 +265,6 @@ crc@^3.4.4:
dependencies:
buffer "^5.1.0"
-debug@^3.1.1:
- version "3.2.6"
- resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
- integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
- dependencies:
- ms "^2.1.1"
-
debug@^4.1.1:
version "4.2.0"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.2.0.tgz#7f150f93920e94c58f5574c2fd01a3110effe7f1"
@@ -348,6 +339,16 @@ fs.realpath@^1.0.0:
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
+get-port@^5.1.1:
+ version "5.1.1"
+ resolved "https://registry.yarnpkg.com/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193"
+ integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==
+
+get-them-args@1.3.2:
+ version "1.3.2"
+ resolved "https://registry.yarnpkg.com/get-them-args/-/get-them-args-1.3.2.tgz#74a20ba8a4abece5ae199ad03f2bcc68fdfc9ba5"
+ integrity sha1-dKILqKSr7OWuGZrQPyvMaP38m6U=
+
glob-parent@^5.1.0:
version "5.1.1"
resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.1.tgz#b6c1ef417c4e5663ea498f1c45afac6916bbc229"
@@ -448,6 +449,14 @@ jsonfile@^6.0.1:
optionalDependencies:
graceful-fs "^4.1.6"
+kill-port@^1.6.1:
+ version "1.6.1"
+ resolved "https://registry.yarnpkg.com/kill-port/-/kill-port-1.6.1.tgz#560fe79484583bdf3a5e908557dae614447618aa"
+ integrity sha512-un0Y55cOM7JKGaLnGja28T38tDDop0AQ8N0KlAdyh+B1nmMoX8AnNmqPNZbS3mUMgiST51DCVqmbFT1gNJpVNw==
+ dependencies:
+ get-them-args "1.3.2"
+ shell-exec "1.0.2"
+
lazystream@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/lazystream/-/lazystream-1.0.0.tgz#f6995fe0f820392f61396be89462407bb77168e4"
@@ -480,7 +489,7 @@ lodash.union@^4.6.0:
resolved "https://registry.yarnpkg.com/lodash.union/-/lodash.union-4.6.0.tgz#48bb5088409f16f1821666641c44dd1aaae3cd88"
integrity sha1-SLtQiECfFvGCFmZkHETdGqrjzYg=
-lodash@^4.17.14:
+lodash@^4.17.20:
version "4.17.20"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52"
integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==
@@ -505,11 +514,6 @@ minimatch@^3.0.4:
dependencies:
brace-expansion "^1.1.7"
-minimist@^1.2.5:
- version "1.2.5"
- resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602"
- integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==
-
minipass@^3.0.0:
version "3.1.3"
resolved "https://registry.yarnpkg.com/minipass/-/minipass-3.1.3.tgz#7d42ff1f39635482e15f9cdb53184deebd5815fd"
@@ -525,19 +529,12 @@ minizlib@^2.1.1:
minipass "^3.0.0"
yallist "^4.0.0"
-mkdirp@^0.5.1:
- version "0.5.5"
- resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def"
- integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==
- dependencies:
- minimist "^1.2.5"
-
mkdirp@^1.0.3:
version "1.0.4"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
-ms@2.1.2, ms@^2.1.1:
+ms@2.1.2:
version "2.1.2"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
@@ -605,15 +602,6 @@ picomatch@^2.0.5, picomatch@^2.2.1:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad"
integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==
-portfinder@^1.0.26:
- version "1.0.26"
- resolved "https://registry.yarnpkg.com/portfinder/-/portfinder-1.0.26.tgz#475658d56ca30bed72ac7f1378ed350bd1b64e70"
- integrity sha512-Xi7mKxJHHMI3rIUrnm/jjUgwhbYMkp/XKEcZX3aG4BrumLpq3nmoQMX+ClYnDZnZ/New7IatC1no5RX0zo1vXQ==
- dependencies:
- async "^2.6.2"
- debug "^3.1.1"
- mkdirp "^0.5.1"
-
process-nextick-args@~2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
@@ -680,6 +668,11 @@ safe-buffer@~5.2.0:
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6"
integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
+shell-exec@1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/shell-exec/-/shell-exec-1.0.2.tgz#2e9361b0fde1d73f476c4b6671fa17785f696756"
+ integrity sha512-jyVd+kU2X+mWKMmGhx4fpWbPsjvD53k9ivqetutVW/BQ+WIZoDoP4d8vUMGezV6saZsiNoW2f9GIhg9Dondohg==
+
slash@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
diff --git a/runtime/dotnet/azurewebapp/Properties/launchSettings.json b/runtime/dotnet/azurewebapp/Properties/launchSettings.json
index bc05ba6986..940ce75182 100644
--- a/runtime/dotnet/azurewebapp/Properties/launchSettings.json
+++ b/runtime/dotnet/azurewebapp/Properties/launchSettings.json
@@ -24,4 +24,4 @@
}
}
}
-}
\ No newline at end of file
+}