diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 8d1da6907f..69b0c159be 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -13,7 +13,7 @@ jobs: ci: name: Unit Tests runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 30 defaults: run: working-directory: Composer diff --git a/.vscode/launch.json b/.vscode/launch.json index e1e676abe3..9f416d1b4d 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -88,7 +88,12 @@ "DEBUG": "composer*", "COMPOSER_ENABLE_ONEAUTH": "false" }, - "outputCapture": "std" + "outputCapture": "std", + "outFiles": [ + "${workspaceRoot}/Composer/packages/electron-server/build/**/*.js", + "${workspaceRoot}/Composer/packages/server/build/**/*.js", + "${workspaceRoot}/extensions/**/*.js" + ] }, { "name": "Debug current jest test", diff --git a/Composer/packages/client/__tests__/pages/publish/Publish.test.tsx b/Composer/packages/client/__tests__/pages/publish/Publish.test.tsx index e42d04f5b8..38a25ad4e2 100644 --- a/Composer/packages/client/__tests__/pages/publish/Publish.test.tsx +++ b/Composer/packages/client/__tests__/pages/publish/Publish.test.tsx @@ -46,6 +46,7 @@ const state = { publish: true, status: true, rollback: true, + pull: true, }, }, ], diff --git a/Composer/packages/client/src/components/BotRuntimeController/BotController.tsx b/Composer/packages/client/src/components/BotRuntimeController/BotController.tsx index 608369b8f6..777c65735b 100644 --- a/Composer/packages/client/src/components/BotRuntimeController/BotController.tsx +++ b/Composer/packages/client/src/components/BotRuntimeController/BotController.tsx @@ -3,7 +3,7 @@ /** @jsx jsx */ import { jsx } from '@emotion/core'; -import React, { useCallback, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { DefaultButton, IconButton } from 'office-ui-fabric-react/lib/Button'; import { IContextualMenuItem } from 'office-ui-fabric-react/lib/ContextualMenu'; import { useRecoilValue } from 'recoil'; @@ -34,6 +34,13 @@ const iconSectionContainer = css` } `; +const disabledStyle = css` + &:before { + opacity: 0.4; + } + pointer-events: none; +`; + const startPanelsContainer = css` display: flex; flex-direction: 'row'; @@ -47,6 +54,7 @@ const BotController: React.FC = () => { const [isControllerHidden, setControllerVisibility] = useState(true); const { onboardingAddCoachMarkRef } = useRecoilValue(dispatcherState); const onboardRef = useCallback((startBot) => onboardingAddCoachMarkRef({ startBot }), []); + const [disableStartBots, setDisableOnStartBotsWidget] = useState(false); const target = useRef(null); const botControllerMenuTarget = useRef(null); @@ -55,6 +63,14 @@ const BotController: React.FC = () => { setControllerVisibility(true); }); + useEffect(() => { + if (projectCollection.length === 0) { + setDisableOnStartBotsWidget(true); + return; + } + setDisableOnStartBotsWidget(false); + }, [projectCollection]); + const running = useMemo(() => !projectCollection.every(({ status }) => status === BotStatus.unConnected), [ projectCollection, ]); @@ -93,11 +109,19 @@ const BotController: React.FC = () => { {projectCollection.map(({ projectId }) => { return ; })} -
+
null} styles={{ root: { @@ -115,9 +139,10 @@ const BotController: React.FC = () => { > {buttonText} -
+
= () => { schemaUrl: formData.schemaUrl, appLocale, qnaKbUrls, + templateDir: formData.templateDir, + eTag: formData.eTag, + urlSuffix: formData.urlSuffix, + alias: formData.alias, + preserveRoot: formData.preserveRoot, }; createNewBot(newBotData); }; @@ -199,6 +205,7 @@ const CreationFlow: React.FC = () => { path="create/vaCore/*" onDismiss={handleDismiss} /> + diff --git a/Composer/packages/client/src/components/CreationFlow/DefineConversation.tsx b/Composer/packages/client/src/components/CreationFlow/DefineConversation.tsx index 4197a28de0..a284a557bf 100644 --- a/Composer/packages/client/src/components/CreationFlow/DefineConversation.tsx +++ b/Composer/packages/client/src/components/CreationFlow/DefineConversation.tsx @@ -8,16 +8,20 @@ import { DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; import formatMessage from 'format-message'; import { PrimaryButton, DefaultButton } from 'office-ui-fabric-react/lib/Button'; import { Stack, StackItem } from 'office-ui-fabric-react/lib/Stack'; -import React, { Fragment, useEffect, useCallback, useMemo } from 'react'; +import React, { Fragment, useEffect, useCallback, useMemo, useState } from 'react'; import { TextField } from 'office-ui-fabric-react/lib/TextField'; import { RouteComponentProps } from '@reach/router'; import querystring from 'query-string'; import { FontWeights } from '@uifabric/styling'; import { DialogWrapper, DialogTypes } from '@bfc/ui-shared'; +import { useRecoilValue } from 'recoil'; import { DialogCreationCopy, QnABotTemplateId, nameRegex } from '../../constants'; import { FieldConfig, useForm } from '../../hooks/useForm'; import { StorageFolder } from '../../recoilModel/types'; +import { createNotification } from '../../recoilModel/dispatchers/notification'; +import { ImportSuccessNotificationWrapper } from '../ImportModal/ImportSuccessNotification'; +import { dispatcherState } from '../../recoilModel'; import { LocationSelectContent } from './LocationSelectContent'; @@ -66,6 +70,12 @@ interface DefineConversationFormData { description: string; schemaUrl: string; location?: string; + + templateDir?: string; // location of the imported template + eTag?: string; // e tag used for content sync between composer and imported bot content + urlSuffix?: string; // url to deep link to after creation + alias?: string; // identifier that is used to track bots between imports + preserveRoot?: boolean; // identifier that is used to determine ay project file renames upon creation } interface DefineConversationProps @@ -109,18 +119,19 @@ const DefineConversation: React.FC = (props) => { ); return defaultName; }; + const { addNotification } = useRecoilValue(dispatcherState); const formConfig: FieldConfig = { name: { required: true, validate: (value) => { - if (!value || !nameRegex.test(value)) { + if (!value || !nameRegex.test(`${value}`)) { return formatMessage('Spaces and special characters are not allowed. Use letters, numbers, -, or _.'); } const newBotPath = focusedStorageFolder !== null && Object.keys(focusedStorageFolder as Record).length - ? Path.join(focusedStorageFolder.parent, focusedStorageFolder.name, value) + ? Path.join(focusedStorageFolder.parent, focusedStorageFolder.name, `${value}`) : ''; if ( files.some((bot) => { @@ -146,6 +157,14 @@ const DefineConversation: React.FC = (props) => { }, }; const { formData, formErrors, hasErrors, updateField, updateForm } = useForm(formConfig); + const [isImported, setIsImported] = useState(false); + + useEffect(() => { + if (props.location?.state) { + const { imported } = props.location.state; + setIsImported(imported); + } + }, [props.location?.state]); useEffect(() => { const formData: DefineConversationFormData = { @@ -189,9 +208,35 @@ const DefineConversation: React.FC = (props) => { return; } + // handle extra properties in the case of an imported bot project + const dataToSubmit = { + ...formData, + }; + if (props.location?.state) { + const { alias, eTag, imported, templateDir, urlSuffix } = props.location.state; + + if (imported) { + dataToSubmit.templateDir = templateDir; + dataToSubmit.eTag = eTag; + dataToSubmit.urlSuffix = urlSuffix; + dataToSubmit.alias = alias; + dataToSubmit.preserveRoot = true; + + // create a notification to indicate import success + const notification = createNotification({ + type: 'success', + title: '', + onRenderCardContent: ImportSuccessNotificationWrapper({ + importedToExisting: false, + }), + }); + addNotification(notification); + } + } + onSubmit( { - ...formData, + ...dataToSubmit, }, templateId || '' ); @@ -223,15 +268,11 @@ const DefineConversation: React.FC = (props) => { /> ); }, [focusedStorageFolder]); + const dialogCopy = isImported ? DialogCreationCopy.IMPORT_BOT_PROJECT : DialogCreationCopy.DEFINE_BOT_PROJECT; return ( - +
diff --git a/Composer/packages/client/src/components/ImportModal/ImportFailedModal.tsx b/Composer/packages/client/src/components/ImportModal/ImportFailedModal.tsx new file mode 100644 index 0000000000..5b22ce0448 --- /dev/null +++ b/Composer/packages/client/src/components/ImportModal/ImportFailedModal.tsx @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import { Dialog, DialogFooter, DialogType } from 'office-ui-fabric-react/lib/Dialog'; +import { DefaultButton } from 'office-ui-fabric-react/lib/Button'; +import React from 'react'; +import formatMessage from 'format-message'; +import { generateUniqueId } from '@bfc/shared'; + +import { boldText, boldBlueText, dialogContent } from './style'; + +type ImportFailedModalProps = { + botName: string; + error?: Error | string; + onDismiss: () => any; +}; + +const BoldBlue = ({ children }) => ( + + {children} + +); + +export const ImportFailedModal: React.FC = (props) => { + const { botName, error, onDismiss } = props; + + return ( + + ); +}; diff --git a/Composer/packages/client/src/components/ImportModal/ImportModal.tsx b/Composer/packages/client/src/components/ImportModal/ImportModal.tsx new file mode 100644 index 0000000000..cb3798b7e4 --- /dev/null +++ b/Composer/packages/client/src/components/ImportModal/ImportModal.tsx @@ -0,0 +1,289 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** @jsx jsx */ +import { jsx } from '@emotion/core'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { navigate, RouteComponentProps } from '@reach/router'; +import { Dialog, DialogType } from 'office-ui-fabric-react/lib/Dialog'; +import { ExternalContentProviderType } from '@botframework-composer/types'; +import { useRecoilValue } from 'recoil'; +import axios from 'axios'; + +import { dispatcherState } from '../../recoilModel'; +import { createNotification } from '../../recoilModel/dispatchers/notification'; + +import { ImportStatus } from './ImportStatus'; +import { ImportSuccessNotificationWrapper } from './ImportSuccessNotification'; +import { ImportPromptToSaveModal } from './ImportPromptToSaveModal'; +import { ImportFailedModal } from './ImportFailedModal'; + +type ImportedProjectInfo = { + alias?: string; + description?: string; + eTag: string; + name?: string; + source: string; + templateDir: string; + urlSuffix?: string; +}; + +type ImportPayload = { + name: string; + description?: string; +}; + +type ExistingProjectInfo = { + id: string; + location: string; + name: string; +}; + +type ImportModalState = + | 'copyingContent' // after selecting import to existing project + | 'connecting' // before login prompt shows + | 'downloadingContent' // after logging in + | 'failed' // error + | 'promptingToSave' // when an existing project is found under the same alias + | 'signingIn'; // when login prompt is showing + +const CONNECTING_STATUS_DISPLAY_TIME = 2000; + +export const ImportModal: React.FC = (props) => { + const { location } = props; + const [importSource, setImportSource] = useState(undefined); + const [importPayload, setImportPayload] = useState({ name: '', description: '' }); + const [importedProjectInfo, setImportedProjectInfo] = useState(undefined); + const [modalState, setModalState] = useState('connecting'); + const [existingProject, setExistingProject] = useState(undefined); + const [error, setError] = useState(undefined); + const [backupLocation, setBackupLocation] = useState(''); + const { addNotification } = useRecoilValue(dispatcherState); + + const importAsNewProject = useCallback((info: ImportedProjectInfo) => { + // navigate to creation flow with template selected + const { alias, description, eTag, name, source, templateDir, urlSuffix } = info; + const state = { + alias, + eTag, + imported: true, + templateDir, + urlSuffix, + }; + let creationUrl = `/projects/create/${encodeURIComponent(source)}`; + + const searchParams = new URLSearchParams(); + if (name) { + searchParams.set('name', encodeURIComponent(name)); + } + if (description) { + searchParams.set('description', encodeURIComponent(description)); + } + if (searchParams.toString()) { + creationUrl += `?${searchParams.toString()}`; + } + + navigate(creationUrl, { state }); + }, []); + + const importToExistingProject = useCallback(async () => { + if (importedProjectInfo && existingProject) { + setModalState('copyingContent'); + try { + // call server to do backup and then save to existing project + let res = await axios.post<{ path: string }>(`/api/projects/${existingProject.id}/backup`); + const { path } = res.data; + setBackupLocation(path); + + const { eTag, templateDir } = importedProjectInfo; + res = await axios.post( + `/api/projects/${existingProject.id}/copyTemplateToExisting`, + { eTag, templateDir }, + { headers: { 'Content-Type': 'application/json' } } + ); + + // open project and create a notification saying that import was complete + if (res.status === 200) { + const notification = createNotification({ + type: 'success', + title: '', + onRenderCardContent: ImportSuccessNotificationWrapper({ + importedToExisting: true, + location: path, + }), + }); + addNotification(notification); + navigate(`/bot/${existingProject?.id}`); + } else { + const err = res.data ? res.data : res.statusText; + throw err; + } + } catch (e) { + console.error('Something went wrong while saving bot to existing project: ', e); + setError(e); + setModalState('failed'); + } + } + }, [existingProject, importedProjectInfo]); + + useEffect(() => { + if (modalState === 'downloadingContent') { + const importBotContent = async () => { + if (location && location.href) { + try { + const { description, name } = importPayload; + + const res = await axios.post<{ alias: string; eTag: string; templateDir: string; urlSuffix: string }>( + `/api/import/${importSource}?payload=${encodeURIComponent(JSON.stringify(importPayload))}` + ); + const { alias, eTag, templateDir, urlSuffix } = res.data; + const projectInfo = { + description, + name, + templateDir, + urlSuffix, + eTag, + source: importSource as ExternalContentProviderType, + alias, + }; + setImportedProjectInfo(projectInfo); + + if (alias) { + // check to see if Composer currently has a bot project corresponding to the alias + const aliasRes = await axios.get(`/api/projects/alias/${alias}`, { + validateStatus: (status) => { + // a 404 should fall through + if (status === 404) { + return true; + } + return status >= 200 && status < 300; + }, + }); + if (aliasRes.status === 200) { + const project = aliasRes.data; + setExistingProject(project); + // ask user if they want to save to existing, or save as a new project + setModalState('promptingToSave'); + return; + } + } + importAsNewProject(projectInfo); + } catch (e) { + // something went wrong, abort and navigate to the home page + console.error(`Something went wrong during import: ${e}`); + navigate('/home'); + } + } + }; + importBotContent(); + } + }, [modalState, importPayload, importSource]); + + useEffect(() => { + if (modalState === 'signingIn') { + const signIn = async () => { + try { + await axios.post( + `/api/import/${importSource}/authenticate?payload=${encodeURIComponent(JSON.stringify(importPayload))}` + ); + setModalState('downloadingContent'); + } catch (e) { + // something went wrong, abort and navigate to the home page + console.error(`Something went wrong during authenticating import: ${e}`); + navigate('/home'); + } + }; + signIn(); + } + }, [modalState, importSource, importPayload]); + + useEffect(() => { + if (location && location.href) { + try { + // parse data from url and store in state + const url = new URL(location.href); + const source = url.searchParams.get('source'); + const payload = url.searchParams.get('payload'); + if (!source || !payload) { + throw new Error('Missing source or payload.'); + } + setImportSource(source as ExternalContentProviderType); + setImportPayload(JSON.parse(payload)); + setTimeout(() => { + setModalState('signingIn'); + }, CONNECTING_STATUS_DISPLAY_TIME); + } catch (e) { + console.error('Aborting import: ', e); + navigate('/home'); + } + } + }, []); + + const cancel = useCallback(() => { + navigate('/home'); + }, []); + + const openExistingProject = useCallback(() => { + if (existingProject) { + navigate(`/bot/${existingProject.id}`); + } + }, [existingProject]); + + const createNewProxy = useCallback(() => { + if (importedProjectInfo) { + importAsNewProject(importedProjectInfo); + } + }, [importedProjectInfo, importAsNewProject]); + + const modalContent = useMemo(() => { + switch (modalState) { + case 'connecting': + return ; + + case 'downloadingContent': + return ; + + case 'copyingContent': + case 'promptingToSave': { + const isCopyingContent = modalState === 'copyingContent'; + return ( + + ); + } + + case 'failed': + return ; + + case 'signingIn': + // block but don't show anything other than the login window + return ( +