diff --git a/packages/app/client/src/commands/uiCommands.ts b/packages/app/client/src/commands/uiCommands.ts index 097415bb6..7de4d18b6 100644 --- a/packages/app/client/src/commands/uiCommands.ts +++ b/packages/app/client/src/commands/uiCommands.ts @@ -176,14 +176,17 @@ export class UiCommands { // Azure sign in @Command(UI.SignInToAzure) protected signIntoAzure(serviceType: ServiceTypes) { - store.dispatch( - beginAzureAuthWorkflow( - AzureLoginPromptDialogContainer, - { serviceType }, - AzureLoginSuccessDialogContainer, - AzureLoginFailedDialogContainer - ) - ); + return new Promise(resolve => { + store.dispatch( + beginAzureAuthWorkflow( + AzureLoginPromptDialogContainer, + { serviceType }, + AzureLoginSuccessDialogContainer, + AzureLoginFailedDialogContainer, + resolve + ) + ); + }); } @Command(UI.ArmTokenReceivedOnStartup) diff --git a/packages/app/client/src/state/actions/azureAuthActions.ts b/packages/app/client/src/state/actions/azureAuthActions.ts index cf5f66d49..241d1ba8a 100644 --- a/packages/app/client/src/state/actions/azureAuthActions.ts +++ b/packages/app/client/src/state/actions/azureAuthActions.ts @@ -53,13 +53,15 @@ export interface AzureAuthWorkflow { promptDialogProps: { [propName: string]: any }; loginSuccessDialog: ComponentClass; loginFailedDialog: ComponentClass; + resolver?: Function; } export function beginAzureAuthWorkflow( promptDialog: ComponentClass, promptDialogProps: { [propName: string]: any }, loginSuccessDialog: ComponentClass, - loginFailedDialog: ComponentClass + loginFailedDialog: ComponentClass, + resolver?: Function ): AzureAuthAction { return { type: AZURE_BEGIN_AUTH_WORKFLOW, @@ -68,6 +70,7 @@ export function beginAzureAuthWorkflow( promptDialogProps, loginSuccessDialog, loginFailedDialog, + resolver, }, }; } diff --git a/packages/app/client/src/state/actions/connectedServiceActions.spec.ts b/packages/app/client/src/state/actions/connectedServiceActions.spec.ts index 2484c205e..172ca9289 100644 --- a/packages/app/client/src/state/actions/connectedServiceActions.spec.ts +++ b/packages/app/client/src/state/actions/connectedServiceActions.spec.ts @@ -85,8 +85,8 @@ describe('connected service actions', () => { }); it('should create an openAddServiceContextMenu action', () => { - const payload: any = {}; - const action = openAddServiceContextMenu(payload); + const payload: any = { resolver: jasmine.any(Function) }; + const action = openAddServiceContextMenu(payload, jasmine.any(Function) as any); expect(action.type).toBe(OPEN_ADD_CONNECTED_SERVICE_CONTEXT_MENU); expect(action.payload).toEqual(payload); diff --git a/packages/app/client/src/state/actions/connectedServiceActions.ts b/packages/app/client/src/state/actions/connectedServiceActions.ts index b8974ecb2..bfb43dcde 100644 --- a/packages/app/client/src/state/actions/connectedServiceActions.ts +++ b/packages/app/client/src/state/actions/connectedServiceActions.ts @@ -79,6 +79,10 @@ export interface ConnectedServicePickerPayload extends ConnectedServicePayload { progressIndicatorComponent?: ComponentClass; } +export interface OpenAddServiceContextMenuPayload extends ConnectedServicePickerPayload { + resolver: Function; +} + export function launchConnectedServicePicker( payload: ConnectedServicePickerPayload ): ConnectedServiceAction { @@ -108,11 +112,15 @@ export function openContextMenuForConnectedService( } export function openAddServiceContextMenu( - payload: ConnectedServicePickerPayload -): ConnectedServiceAction { + payload: ConnectedServicePickerPayload, + resolver: Function +): ConnectedServiceAction { return { type: OPEN_ADD_CONNECTED_SERVICE_CONTEXT_MENU, - payload, + payload: { + ...payload, + resolver, + }, }; } diff --git a/packages/app/client/src/state/actions/endpointServiceActions.ts b/packages/app/client/src/state/actions/endpointServiceActions.ts index 10f24f25d..9126a3d5a 100644 --- a/packages/app/client/src/state/actions/endpointServiceActions.ts +++ b/packages/app/client/src/state/actions/endpointServiceActions.ts @@ -46,6 +46,7 @@ export interface EndpointServiceAction extends Action { export interface EndpointServicePayload { endpointService: IEndpointService; focusExistingChatIfAvailable?: boolean; + resolver?: Function; } export interface EndpointEditorPayload extends EndpointServicePayload { @@ -54,11 +55,12 @@ export interface EndpointEditorPayload extends EndpointServicePayload { export function launchEndpointEditor( endpointEditorComponent: ComponentClass, - endpointService?: IEndpointService + endpointService?: IEndpointService, + resolver?: Function ): EndpointServiceAction { return { type: LAUNCH_ENDPOINT_EDITOR, - payload: { endpointEditorComponent, endpointService }, + payload: { endpointEditorComponent, endpointService, resolver }, }; } diff --git a/packages/app/client/src/state/sagas/azureAuthSaga.spec.ts b/packages/app/client/src/state/sagas/azureAuthSaga.spec.ts index 7365bb971..68d982b34 100644 --- a/packages/app/client/src/state/sagas/azureAuthSaga.spec.ts +++ b/packages/app/client/src/state/sagas/azureAuthSaga.spec.ts @@ -84,10 +84,13 @@ describe('The azureAuthSaga', () => { it('should contain a single step if the token in the store is valid', () => { store.dispatch(azureArmTokenDataChanged('a valid access_token')); + const it = azureAuthSagas() .next() - .value.FORK.args[1](); - let val = undefined; + .value.FORK.args[1]({ + payload: 'blargh', + }); + let val; let ct = 0; // eslint-disable-next-line no-constant-condition while (true) { diff --git a/packages/app/client/src/state/sagas/azureAuthSaga.ts b/packages/app/client/src/state/sagas/azureAuthSaga.ts index da06d08aa..cd3e75f11 100644 --- a/packages/app/client/src/state/sagas/azureAuthSaga.ts +++ b/packages/app/client/src/state/sagas/azureAuthSaga.ts @@ -51,32 +51,38 @@ export class AzureAuthSaga { private static commandService: CommandServiceImpl; public static *getArmToken(action: AzureAuthAction): IterableIterator { - let azureAuth: AzureAuthState = yield select(getArmTokenFromState); - if (azureAuth.access_token) { + const { resolver } = action.payload; + + try { + let azureAuth: AzureAuthState = yield select(getArmTokenFromState); + if (azureAuth.access_token) { + return azureAuth; + } + const result = yield DialogService.showDialog(action.payload.promptDialog, action.payload.promptDialogProps); + if (result !== 1) { + // Result must be 1 which is a confirmation to sign in to Azure + return result; + } + const { RetrieveArmToken, PersistAzureLoginChanged } = SharedConstants.Commands.Azure; + const { TrackEvent } = SharedConstants.Commands.Telemetry; + azureAuth = yield call([AzureAuthSaga.commandService, AzureAuthSaga.commandService.remoteCall], RetrieveArmToken); + if (azureAuth && !('error' in azureAuth)) { + const persistLogin = yield DialogService.showDialog(action.payload.loginSuccessDialog, azureAuth); + yield call( + AzureAuthSaga.commandService.remoteCall.bind(AzureAuthSaga.commandService), + PersistAzureLoginChanged, + persistLogin + ); + AzureAuthSaga.commandService.remoteCall(TrackEvent, 'signIn_success').catch(_e => void 0); + } else { + yield DialogService.showDialog(action.payload.loginFailedDialog); + AzureAuthSaga.commandService.remoteCall(TrackEvent, 'signIn_failure').catch(_e => void 0); + } + yield put(azureArmTokenDataChanged(azureAuth.access_token)); return azureAuth; + } finally { + resolver && resolver(); } - const result = yield DialogService.showDialog(action.payload.promptDialog, action.payload.promptDialogProps); - if (result !== 1) { - // Result must be 1 which is a confirmation to sign in to Azure - return result; - } - const { RetrieveArmToken, PersistAzureLoginChanged } = SharedConstants.Commands.Azure; - const { TrackEvent } = SharedConstants.Commands.Telemetry; - azureAuth = yield call([AzureAuthSaga.commandService, AzureAuthSaga.commandService.remoteCall], RetrieveArmToken); - if (azureAuth && !('error' in azureAuth)) { - const persistLogin = yield DialogService.showDialog(action.payload.loginSuccessDialog, azureAuth); - yield call( - AzureAuthSaga.commandService.remoteCall.bind(AzureAuthSaga.commandService), - PersistAzureLoginChanged, - persistLogin - ); - AzureAuthSaga.commandService.remoteCall(TrackEvent, 'signIn_success').catch(_e => void 0); - } else { - yield DialogService.showDialog(action.payload.loginFailedDialog); - AzureAuthSaga.commandService.remoteCall(TrackEvent, 'signIn_failure').catch(_e => void 0); - } - yield put(azureArmTokenDataChanged(azureAuth.access_token)); - return azureAuth; } } diff --git a/packages/app/client/src/state/sagas/endpointSagas.spec.ts b/packages/app/client/src/state/sagas/endpointSagas.spec.ts index 64326ce3d..55ebdb15c 100644 --- a/packages/app/client/src/state/sagas/endpointSagas.spec.ts +++ b/packages/app/client/src/state/sagas/endpointSagas.spec.ts @@ -34,24 +34,31 @@ import { applyMiddleware, combineReducers, createStore } from 'redux'; import sagaMiddlewareFactory from 'redux-saga'; import { Component } from 'react'; import { SharedConstants } from '@bfemulator/app-shared'; +import { takeEvery, takeLatest } from 'redux-saga/effects'; import { CommandServiceImpl, CommandServiceInstance } from '@bfemulator/sdk-shared'; +import { IEndpointService } from 'botframework-config'; import { bot } from '../reducers/bot'; import { load, setActive } from '../actions/botActions'; -import { launchEndpointEditor, openEndpointExplorerContextMenu } from '../actions/endpointServiceActions'; +import { + launchEndpointEditor, + openEndpointExplorerContextMenu, + LAUNCH_ENDPOINT_EDITOR, + OPEN_ENDPOINT_CONTEXT_MENU, + OPEN_ENDPOINT_IN_EMULATOR, + EndpointServicePayload, + EndpointServiceAction, +} from '../actions/endpointServiceActions'; import { DialogService } from '../../ui/dialogs/service'; +import { OPEN_ENDPOINT_EXPLORER_CONTEXT_MENU } from '../actions/endpointActions'; +import { executeCommand } from '../actions/commandActions'; -import { endpointSagas } from './endpointSagas'; +import { EndpointSagas, endpointSagas, getConnectedAbs } from './endpointSagas'; -const sagaMiddleWare = sagaMiddlewareFactory(); -const mockStore = createStore(combineReducers({ bot }), {}, applyMiddleware(sagaMiddleWare)); -sagaMiddleWare.run(endpointSagas); -const mockComponentClass = class extends Component<{}, {}> {}; -jest.mock('../store', () => ({ - get store() { - return mockStore; - }, +jest.mock('../../ui/dialogs', () => ({ + DialogService: { showDialog: () => Promise.resolve(true) }, })); + const mockBot = JSON.parse(`{ "name": "TestBot", "description": "", @@ -99,20 +106,77 @@ jest.mock('electron', () => ({ } ), })); +let mockRemoteCommandsCalled = []; + +const endpointService: IEndpointService = { + appId: 'appId', + name: 'service', + appPassword: 'password123', + endpoint: 'http://localendpoint', + channelService: 'channel service', +}; +const resolver = jest.fn(() => {}); + +const endpointPayload: EndpointServicePayload = { + endpointService, + resolver, +}; +const endpointServiceAction: EndpointServiceAction = { + type: OPEN_ENDPOINT_EXPLORER_CONTEXT_MENU, + payload: endpointPayload, +}; -describe('The endpoint sagas', () => { +describe('The endpointSagas', () => { let commandService: CommandServiceImpl; + let sagaMiddleware; + let mockStore; + let mockComponentClass; beforeAll(() => { const decorator = CommandServiceInstance(); const descriptor = decorator({ descriptor: {} }, 'none') as any; commandService = descriptor.descriptor.get(); + + commandService.remoteCall = async (commandName: string, ...args: any[]) => { + mockRemoteCommandsCalled.push({ commandName, args: args }); + + return Promise.resolve(true) as any; + }; }); beforeEach(() => { + sagaMiddleware = sagaMiddlewareFactory(); + mockStore = createStore(combineReducers({ bot }), {}, applyMiddleware(sagaMiddleware)); + sagaMiddleware.run(endpointSagas); + mockComponentClass = class extends Component<{}, {}> {}; + jest.mock('../store', () => ({ + get store() { + return mockStore; + }, + })); + mockRemoteCommandsCalled = []; mockStore.dispatch(load([mockBot])); mockStore.dispatch(setActive(mockBot)); }); + it('should initialize the root saga', () => { + const gen = endpointSagas(); + + expect(gen.next().value).toEqual(takeLatest(LAUNCH_ENDPOINT_EDITOR, EndpointSagas.launchEndpointEditor)); + expect(gen.next().value).toEqual(takeEvery(OPEN_ENDPOINT_CONTEXT_MENU, EndpointSagas.openEndpointContextMenu)); + expect(gen.next().value).toEqual(takeEvery(OPEN_ENDPOINT_IN_EMULATOR, EndpointSagas.openEndpointInEmulator)); + + expect(gen.next().done).toBe(true); + }); + + it('should launch an endpoint editor', () => { + const gen = EndpointSagas.launchEndpointEditor(endpointServiceAction); + gen.next(); + gen.next([endpointService]); + expect(gen.next().done).toBe(true); + expect(resolver).toHaveBeenCalledTimes(1); + expect(mockRemoteCommandsCalled.length).toEqual(1); + }); + it('should launch the endpoint editor and execute a command to save the edited services', async () => { const remoteCallSpy = jest.spyOn(commandService, 'remoteCall'); const dialogServiceSpy = jest.spyOn(DialogService, 'showDialog').mockResolvedValue(mockBot.services); @@ -134,15 +198,17 @@ describe('The endpoint sagas', () => { const { DisplayContextMenu, ShowMessageBox } = SharedConstants.Commands.Electron; const { NewLiveChat } = SharedConstants.Commands.Emulator; - it('should launch the endpoint editor when that menu option is chosen', () => { + it('should launch the endpoint editor when that menu option is chosen', async () => { const commandServiceSpy = jest.spyOn(commandService, 'remoteCall').mockResolvedValue({ id: 'edit' }); const dialogServiceSpy = jest.spyOn(DialogService, 'showDialog').mockResolvedValue(mockBot.services); - mockStore.dispatch(openEndpointExplorerContextMenu(mockComponentClass, mockBot.services[0])); + await mockStore.dispatch(openEndpointExplorerContextMenu(mockComponentClass, mockBot.services[0])); expect(commandServiceSpy).toHaveBeenCalledWith(DisplayContextMenu, menuItems); expect(dialogServiceSpy).toHaveBeenCalledWith(mockComponentClass, { endpointService: mockBot.services[0], }); + commandServiceSpy.mockClear(); + dialogServiceSpy.mockClear(); }); it('should open a deep link when that menu option is chosen', async () => { @@ -152,6 +218,8 @@ describe('The endpoint sagas', () => { await mockStore.dispatch(openEndpointExplorerContextMenu(mockComponentClass, mockBot.services[0])); expect(commandServiceRemoteCallSpy).toHaveBeenCalledWith(DisplayContextMenu, menuItems); expect(commandServiceCallSpy).toHaveBeenCalledWith(NewLiveChat, mockBot.services[0], false); + commandServiceRemoteCallSpy.mockClear(); + commandServiceCallSpy.mockClear(); }); it('should forget the service when that menu item is chosen', async () => { diff --git a/packages/app/client/src/state/sagas/endpointSagas.ts b/packages/app/client/src/state/sagas/endpointSagas.ts index 3e181d34d..3c3924787 100644 --- a/packages/app/client/src/state/sagas/endpointSagas.ts +++ b/packages/app/client/src/state/sagas/endpointSagas.ts @@ -60,7 +60,7 @@ export class EndpointSagas { private static commandService: CommandServiceImpl; public static *launchEndpointEditor(action: EndpointServiceAction): IterableIterator { - const { endpointEditorComponent, endpointService = {} } = action.payload; + const { endpointEditorComponent, endpointService = {}, resolver } = action.payload; const servicesToUpdate = yield DialogService.showDialog, IEndpointService[]>( endpointEditorComponent, { endpointService } @@ -85,6 +85,7 @@ export class EndpointSagas { ); } } + resolver && resolver(); } public static *openEndpointContextMenu( diff --git a/packages/app/client/src/state/sagas/servicesExplorerSagas.ts b/packages/app/client/src/state/sagas/servicesExplorerSagas.ts index a77f3c00a..af3cc9627 100644 --- a/packages/app/client/src/state/sagas/servicesExplorerSagas.ts +++ b/packages/app/client/src/state/sagas/servicesExplorerSagas.ts @@ -58,6 +58,7 @@ import { OPEN_CONNECTED_SERVICE_SORT_CONTEXT_MENU, OPEN_CONTEXT_MENU_FOR_CONNECTED_SERVICE, OPEN_SERVICE_DEEP_LINK, + OpenAddServiceContextMenuPayload, } from '../actions/connectedServiceActions'; import { sortExplorerContents } from '../actions/explorerActions'; import { SortCriteria } from '../reducers/explorer'; @@ -77,7 +78,7 @@ const getSortSelection = (state: RootState): { [paneldId: string]: SortCriteria export class ServicesExplorerSagas { @CommandServiceInstance() - private static commandService: CommandServiceImpl; + protected static commandService: CommandServiceImpl; public static *launchConnectedServicePicker( action: ConnectedServiceAction @@ -301,8 +302,9 @@ export class ServicesExplorerSagas { } public static *openAddConnectedServiceContextMenu( - action: ConnectedServiceAction + action: ConnectedServiceAction ): IterableIterator { + const { resolver } = action.payload; const menuItems = [ { label: 'Add Language Understanding (LUIS)', id: ServiceTypes.Luis }, { label: 'Add QnA Maker', id: ServiceTypes.QnA }, @@ -319,6 +321,7 @@ export class ServicesExplorerSagas { SharedConstants.Commands.Electron.DisplayContextMenu, menuItems ); + const { id: serviceType } = response; action.payload.serviceType = serviceType; if (serviceType === ServiceTypes.Generic || serviceType === ServiceTypes.AppInsights) { @@ -326,6 +329,8 @@ export class ServicesExplorerSagas { } else { yield* ServicesExplorerSagas.launchConnectedServicePicker(action); } + + resolver && resolver(); } public static *openSortContextMenu(action: ConnectedServiceAction): IterableIterator { diff --git a/packages/app/client/src/ui/editor/welcomePage/welcomePage.spec.tsx b/packages/app/client/src/ui/editor/welcomePage/welcomePage.spec.tsx index d0848650c..5d379341e 100644 --- a/packages/app/client/src/ui/editor/welcomePage/welcomePage.spec.tsx +++ b/packages/app/client/src/ui/editor/welcomePage/welcomePage.spec.tsx @@ -34,25 +34,28 @@ import { SharedConstants } from '@bfemulator/app-shared'; import { mount } from 'enzyme'; import * as React from 'react'; import { Provider } from 'react-redux'; -import { combineReducers, createStore } from 'redux'; +import { combineReducers, createStore, applyMiddleware } from 'redux'; +import sagaMiddlewareFactory from 'redux-saga'; import { azureArmTokenDataChanged } from '../../../state/actions/azureAuthActions'; import * as BotActions from '../../../state/actions/botActions'; import { azureAuth } from '../../../state/reducers/azureAuth'; import { clientAwareSettings } from '../../../state/reducers/clientAwareSettings'; import { bot } from '../../../state/reducers/bot'; -import { executeCommand } from '../../../state/actions/commandActions'; +import { commandSagas } from '../../../state/sagas/commandSagas'; +import { + executeCommand, + EXECUTE_COMMAND, + CommandAction, + CommandActionPayload, +} from '../../../state/actions/commandActions'; import { WelcomePage } from './welcomePage'; import { WelcomePageContainer } from './welcomePageContainer'; +import { HowToBuildABotContainer } from './howToBuildABotContainer'; +import { HowToBuildABot } from './howToBuildABot'; -const mockStore = createStore(combineReducers({ azureAuth, bot, clientAwareSettings })); jest.mock('../../dialogs', () => ({})); -jest.mock('../../../state/store', () => ({ - get store() { - return mockStore; - }, -})); jest.mock('electron', () => ({ ipcMain: new Proxy( @@ -79,6 +82,14 @@ jest.mock('electron', () => ({ ), })); +const sagaMiddleware = sagaMiddlewareFactory(); +const mockStore = createStore( + combineReducers({ azureAuth, bot, clientAwareSettings }), + {}, + applyMiddleware(sagaMiddleware) +); +sagaMiddleware.run(commandSagas); + const mockArmToken = 'bm90aGluZw==.eyJ1cG4iOiJnbGFzZ293QHNjb3RsYW5kLmNvbSJ9.7gjdshgfdsk98458205jfds9843fjds'; const bots = [ { @@ -94,7 +105,7 @@ const bots = [ chatsPath: '/Users/microsoft/Documents/testbot/dialogs', }, ]; -describe('The AzureLoginFailedDialogContainer component should', () => { +describe('The WelcomePageContainer component should', () => { let parent; let node; let instance: any; @@ -102,7 +113,20 @@ describe('The AzureLoginFailedDialogContainer component should', () => { beforeEach(() => { mockStore.dispatch(azureArmTokenDataChanged(mockArmToken)); mockStore.dispatch(BotActions.load(bots)); - mockDispatch = jest.spyOn(mockStore, 'dispatch'); + + mockDispatch = jest + .spyOn(mockStore, 'dispatch') + .mockImplementation((action: CommandAction) => { + if ( + action.type === EXECUTE_COMMAND && + action.payload.commandName === SharedConstants.Commands.UI.ShowOpenBotDialog + ) { + action.payload.resolver(); + } + + return action; + }); + parent = mount( @@ -125,8 +149,19 @@ describe('The AzureLoginFailedDialogContainer component should', () => { }); it('should call the appropriate command when onOpenBotClick is called', async () => { + const mockOnOpenBot = { + focus: jest.fn(() => { + return null; + }), + }; + + instance.openBotButtonRef = mockOnOpenBot; + await instance.onOpenBotClick(); - expect(mockDispatch).toHaveBeenCalledWith(executeCommand(false, SharedConstants.Commands.UI.ShowOpenBotDialog)); + expect(mockDispatch).toHaveBeenCalledWith( + executeCommand(false, SharedConstants.Commands.UI.ShowOpenBotDialog, jasmine.any(Function)) + ); + expect(mockOnOpenBot.focus).toHaveBeenCalledTimes(1); }); it('should call the appropriate command when openBotInspectorDocs is called', async () => { @@ -143,4 +178,54 @@ describe('The AzureLoginFailedDialogContainer component should', () => { executeCommand(true, SharedConstants.Commands.Electron.OpenExternal, null, 'http://blah') ); }); + + it('should set a button ref', () => { + const mockButtonRef: any = {}; + instance.setOpenBotButtonRef(mockButtonRef); + instance.setNewBotButtonRef(mockButtonRef); + instance.setSignInToAzureButtonRef(mockButtonRef); + + expect(instance.newBotButtonRef).toBe(mockButtonRef); + expect(instance.openBotButtonRef).toBe(mockButtonRef); + expect(instance.signIntoAzureButtonRef).toBe(mockButtonRef); + }); +}); + +describe('The HowToBuildABotContainer', () => { + let parent; + let node; + let instance: any; + let mockDispatch; + beforeEach(() => { + mockStore.dispatch(azureArmTokenDataChanged(mockArmToken)); + mockStore.dispatch(BotActions.load(bots)); + + mockDispatch = jest + .spyOn(mockStore, 'dispatch') + .mockImplementation((action: CommandAction) => { + if ( + action.type === EXECUTE_COMMAND && + action.payload.commandName === SharedConstants.Commands.UI.ShowOpenBotDialog + ) { + action.payload.resolver(); + } + + return action; + }); + + parent = mount( + + + + ); + node = parent.find(HowToBuildABot); + instance = node.instance() as HowToBuildABot; + }); + + it('should call the appropriate command when onAnchorClick is called', () => { + instance.props.onAnchorClick('http://blah'); + expect(mockDispatch).toHaveBeenCalledWith( + executeCommand(true, SharedConstants.Commands.Electron.OpenExternal, null, 'http://blah') + ); + }); }); diff --git a/packages/app/client/src/ui/editor/welcomePage/welcomePage.tsx b/packages/app/client/src/ui/editor/welcomePage/welcomePage.tsx index bae694455..ba20d5da1 100644 --- a/packages/app/client/src/ui/editor/welcomePage/welcomePage.tsx +++ b/packages/app/client/src/ui/editor/welcomePage/welcomePage.tsx @@ -55,6 +55,10 @@ export interface WelcomePageProps { } export class WelcomePage extends React.Component { + private newBotButtonRef: HTMLButtonElement; + private openBotButtonRef: HTMLButtonElement; + private signIntoAzureButtonRef: HTMLButtonElement; + constructor(props: WelcomePageProps) { super(props); } @@ -107,24 +111,29 @@ export class WelcomePage extends React.Component { className={styles.openBot} text="Open Bot" onClick={this.onOpenBotClick} + buttonRef={this.setOpenBotButtonRef} /> If you don’t have a bot configuration,  - create a new bot configuration. + + create a new bot configuration. + ); } private get signInSection(): JSX.Element { - const { accessToken, signInWithAzure, signOutWithAzure } = this.props; + const { accessToken, signOutWithAzure } = this.props; return (
{accessToken && !accessToken.startsWith('invalid') ? ( Sign out ) : ( - Sign in with your Azure account. + + Sign in with your Azure account. + )}
); @@ -136,8 +145,34 @@ export class WelcomePage extends React.Component { this.props.switchToBot(bot.path); }; - private onOpenBotClick = () => { - this.props.showOpenBotDialog(); + private onOpenBotClick = async () => { + await this.props.showOpenBotDialog(); + + this.openBotButtonRef && this.openBotButtonRef.focus(); + }; + + private onNewBotClick = async () => { + await this.props.onNewBotClick(); + + this.newBotButtonRef && this.newBotButtonRef.focus(); + }; + + private signInToAzure = async () => { + await this.props.signInWithAzure(); + + this.signIntoAzureButtonRef && this.signIntoAzureButtonRef.focus(); + }; + + private setNewBotButtonRef = (ref: HTMLButtonElement): void => { + this.newBotButtonRef = ref; + }; + + private setOpenBotButtonRef = (ref: HTMLButtonElement): void => { + this.openBotButtonRef = ref; + }; + + private setSignInToAzureButtonRef = (ref: HTMLButtonElement): void => { + this.signIntoAzureButtonRef = ref; }; private onWorkingLocallyLinkClick = this.createAnchorClickHandler( diff --git a/packages/app/client/src/ui/editor/welcomePage/welcomePageContainer.ts b/packages/app/client/src/ui/editor/welcomePage/welcomePageContainer.ts index 6b6843878..206366881 100644 --- a/packages/app/client/src/ui/editor/welcomePage/welcomePageContainer.ts +++ b/packages/app/client/src/ui/editor/welcomePage/welcomePageContainer.ts @@ -54,9 +54,11 @@ function mapDispatchToProps(dispatch: (action: Action) => void): WelcomePageProp onAnchorClick: (url: string) => { dispatch(executeCommand(true, SharedConstants.Commands.Electron.OpenExternal, null, url)); }, - onNewBotClick: () => dispatch(executeCommand(false, Commands.UI.ShowBotCreationDialog)), - showOpenBotDialog: () => dispatch(executeCommand(false, SharedConstants.Commands.UI.ShowOpenBotDialog)), - signInWithAzure: () => dispatch(executeCommand(false, Commands.UI.SignInToAzure)), + onNewBotClick: () => + new Promise(resolve => dispatch(executeCommand(false, Commands.UI.ShowBotCreationDialog, resolve))), + showOpenBotDialog: () => + new Promise(resolve => dispatch(executeCommand(false, SharedConstants.Commands.UI.ShowOpenBotDialog, resolve))), + signInWithAzure: () => new Promise(resolve => dispatch(executeCommand(false, Commands.UI.SignInToAzure, resolve))), signOutWithAzure: () => { dispatch(executeCommand(true, Commands.Azure.SignUserOutOfAzure)); dispatch(executeCommand(false, Commands.UI.InvalidateAzureArmToken)); diff --git a/packages/app/client/src/ui/shell/explorer/botExplorerBar/botExplorerBar.spec.tsx b/packages/app/client/src/ui/shell/explorer/botExplorerBar/botExplorerBar.spec.tsx new file mode 100644 index 000000000..35a4d9a6c --- /dev/null +++ b/packages/app/client/src/ui/shell/explorer/botExplorerBar/botExplorerBar.spec.tsx @@ -0,0 +1,96 @@ +// +// Copyright (c) Microsoft. All rights reserved. +// Licensed under the MIT license. +// +// Microsoft Bot Framework: http://botframework.com +// +// Bot Framework Emulator Github: +// https://github.com/Microsoft/BotFramwork-Emulator +// +// Copyright (c) Microsoft Corporation +// All rights reserved. +// +// MIT License: +// Permission is hereby granted, free of charge, to any person obtaining +// a copy of this software and associated documentation files (the +// "Software"), to deal in the Software without restriction, including +// without limitation the rights to use, copy, modify, merge, publish, +// distribute, sublicense, and/or sell copies of the Software, and to +// permit persons to whom the Software is furnished to do so, subject to +// the following conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED ""AS IS"", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +// LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +// OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +// WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +import { mount } from 'enzyme'; +import * as React from 'react'; +import { Provider } from 'react-redux'; +import { combineReducers, createStore } from 'redux'; + +import { bot } from '../../../../state/reducers/bot'; +import { chat } from '../../../../state/reducers/chat'; + +import BotExplorerBar from './botExplorerBar'; +import { BotExplorerBarContainer } from './botExplorerBarContainer'; + +jest.mock('../../../dialogs', () => ({})); + +jest.mock('electron', () => ({ + ipcMain: new Proxy( + {}, + { + get(): any { + return () => ({}); + }, + has() { + return true; + }, + } + ), + ipcRenderer: new Proxy( + {}, + { + get(): any { + return () => ({}); + }, + has() { + return true; + }, + } + ), +})); + +const mockStore = createStore(combineReducers({ bot, chat })); + +describe('The BotExplorerBotContainer component', () => { + let parent; + let node; + beforeEach(() => { + parent = mount( + + + + ); + node = parent.find(BotExplorerBar); + }); + + it('should render deeply', () => { + expect(parent.find(BotExplorerBarContainer)).not.toBe(null); + expect(parent.find(BotExplorerBar)).not.toBe(null); + }); + + it('should set a button ref', () => { + const mockButtonRef: any = {}; + node.instance().setOpenBotSettingsRef(mockButtonRef); + + expect(node.instance().openBotSettingsButtonRef).toBe(mockButtonRef); + }); +}); diff --git a/packages/app/client/src/ui/shell/explorer/botExplorerBar/botExplorerBar.tsx b/packages/app/client/src/ui/shell/explorer/botExplorerBar/botExplorerBar.tsx index 4ecde03ef..848051136 100644 --- a/packages/app/client/src/ui/shell/explorer/botExplorerBar/botExplorerBar.tsx +++ b/packages/app/client/src/ui/shell/explorer/botExplorerBar/botExplorerBar.tsx @@ -57,6 +57,8 @@ export default class BotExplorerBar extends React.Component; @@ -68,7 +70,8 @@ export default class BotExplorerBar extends React.Component @@ -79,4 +82,13 @@ export default class BotExplorerBar extends React.Component ); } + + private openBotSettingsClick = async () => { + await this.props.openBotSettings(); + this.openBotSettingsButtonRef.focus(); + }; + + private setOpenBotSettingsRef = (ref: HTMLButtonElement): void => { + this.openBotSettingsButtonRef = ref; + }; } diff --git a/packages/app/client/src/ui/shell/explorer/botExplorerBar/botExplorerBarContainer.ts b/packages/app/client/src/ui/shell/explorer/botExplorerBar/botExplorerBarContainer.ts index c41b51f4b..81949dbdf 100644 --- a/packages/app/client/src/ui/shell/explorer/botExplorerBar/botExplorerBarContainer.ts +++ b/packages/app/client/src/ui/shell/explorer/botExplorerBar/botExplorerBarContainer.ts @@ -40,11 +40,7 @@ import BotExplorerBar from './botExplorerBar'; const mapStateToProps = (state: RootState) => { return { - openBotSettings: () => { - DialogService.showDialog(BotSettingsEditorContainer, { - bot: state.bot.activeBot, - }).catch(); - }, + openBotSettings: () => DialogService.showDialog(BotSettingsEditorContainer, { bot: state.bot.activeBot }).catch(), }; }; export const BotExplorerBarContainer = connect(mapStateToProps)(BotExplorerBar); diff --git a/packages/app/client/src/ui/shell/explorer/botNotOpenExplorer/botNotOpenExplorer.spec.tsx b/packages/app/client/src/ui/shell/explorer/botNotOpenExplorer/botNotOpenExplorer.spec.tsx index 641aaa795..2a52eeaa3 100644 --- a/packages/app/client/src/ui/shell/explorer/botNotOpenExplorer/botNotOpenExplorer.spec.tsx +++ b/packages/app/client/src/ui/shell/explorer/botNotOpenExplorer/botNotOpenExplorer.spec.tsx @@ -35,26 +35,24 @@ import { newNotification, SharedConstants } from '@bfemulator/app-shared'; import { mount } from 'enzyme'; import * as React from 'react'; import { Provider } from 'react-redux'; -import { combineReducers, createStore } from 'redux'; +import { applyMiddleware, combineReducers, createStore } from 'redux'; +import sagaMiddlewareFactory from 'redux-saga'; +import { ActiveBotHelper } from '../../../helpers/activeBotHelper'; import { beginAdd } from '../../../../state/actions/notificationActions'; import { bot } from '../../../../state/reducers/bot'; import { chat } from '../../../../state/reducers/chat'; -import { ActiveBotHelper } from '../../../helpers/activeBotHelper'; -import { executeCommand } from '../../../../state/actions/commandActions'; +import { commandSagas } from '../../../../state/sagas/commandSagas'; +import { + executeCommand, + EXECUTE_COMMAND, + CommandAction, + CommandActionPayload, +} from '../../../../state/actions/commandActions'; import { BotNotOpenExplorer } from './botNotOpenExplorer'; import { BotNotOpenExplorerContainer } from './botNotOpenExplorerContainer'; -const mockStore = createStore(combineReducers({ bot, chat }), {}); - -jest.mock('../../../dialogs', () => ({ - DialogService: { - showDialog: () => Promise.resolve(true), - hideDialog: () => Promise.resolve(false), - }, -})); - jest.mock('electron', () => ({ ipcMain: new Proxy( {}, @@ -80,6 +78,10 @@ jest.mock('electron', () => ({ ), })); +const sagaMiddleware = sagaMiddlewareFactory(); +const mockStore = createStore(combineReducers({ bot, chat }), {}, applyMiddleware(sagaMiddleware)); +sagaMiddleware.run(commandSagas); + jest.mock('../../../../state/store', () => ({ get store() { return mockStore; @@ -93,7 +95,19 @@ describe('The EndpointExplorer component should', () => { let instance; beforeEach(() => { - mockDispatch = jest.spyOn(mockStore, 'dispatch'); + mockDispatch = jest + .spyOn(mockStore, 'dispatch') + .mockImplementation((action: CommandAction) => { + if ( + action.type === EXECUTE_COMMAND && + action.payload.commandName === SharedConstants.Commands.UI.ShowBotCreationDialog + ) { + action.payload.resolver(); + } + + return action; + }); + parent = mount( @@ -104,10 +118,18 @@ describe('The EndpointExplorer component should', () => { }); it('should make the appropriate calls when onCreateNewBotClick in called', async () => { + const mockCreateNewBotButton = { + focus: jest.fn(() => { + return null; + }), + }; + instance.createNewBotButtonRef = mockCreateNewBotButton; + await instance.onCreateNewBotClick(); - expect(mockDispatch).toHaveBeenLastCalledWith( - executeCommand(false, SharedConstants.Commands.UI.ShowBotCreationDialog) + expect(mockDispatch).toHaveBeenCalledWith( + executeCommand(false, SharedConstants.Commands.UI.ShowBotCreationDialog, jasmine.any(Function)) ); + expect(mockCreateNewBotButton.focus).toHaveBeenCalledTimes(1); }); it('should make the appropriate calls when onOpenBotFileClick in called', async () => { diff --git a/packages/app/client/src/ui/shell/explorer/botNotOpenExplorer/botNotOpenExplorer.tsx b/packages/app/client/src/ui/shell/explorer/botNotOpenExplorer/botNotOpenExplorer.tsx index 8651abbc0..0690a5446 100644 --- a/packages/app/client/src/ui/shell/explorer/botNotOpenExplorer/botNotOpenExplorer.tsx +++ b/packages/app/client/src/ui/shell/explorer/botNotOpenExplorer/botNotOpenExplorer.tsx @@ -43,6 +43,8 @@ export interface BotNotOpenExplorerProps { } export class BotNotOpenExplorer extends React.Component { + private createNewBotButtonRef: HTMLButtonElement; + public render() { const label = 'Services Not Available'; return ( @@ -56,7 +58,11 @@ export class BotNotOpenExplorer extends React.Component {` or `} - + create a new bot configuration . @@ -69,10 +75,16 @@ export class BotNotOpenExplorer extends React.Component { - this.props.showCreateNewBotDialog(); + await this.props.showCreateNewBotDialog(); + + this.createNewBotButtonRef.focus(); }; private onOpenBotFileClick = async () => { await this.props.openBotFile(); }; + + private setCreateNewBotButtonRef = (ref: HTMLButtonElement): void => { + this.createNewBotButtonRef = ref; + }; } diff --git a/packages/app/client/src/ui/shell/explorer/botNotOpenExplorer/botNotOpenExplorerContainer.ts b/packages/app/client/src/ui/shell/explorer/botNotOpenExplorer/botNotOpenExplorerContainer.ts index 1355fa9c8..4ff7abcdb 100644 --- a/packages/app/client/src/ui/shell/explorer/botNotOpenExplorer/botNotOpenExplorerContainer.ts +++ b/packages/app/client/src/ui/shell/explorer/botNotOpenExplorer/botNotOpenExplorerContainer.ts @@ -54,7 +54,10 @@ const mapDispatchToProps = (dispatch: (action: Action) => void): BotNotOpenExplo dispatch(beginAdd(newNotification(`An Error occurred on the Bot Not Open Explorer: ${e}`))); } }, - showCreateNewBotDialog: () => dispatch(executeCommand(false, SharedConstants.Commands.UI.ShowBotCreationDialog)), + showCreateNewBotDialog: () => + new Promise(resolve => { + dispatch(executeCommand(false, SharedConstants.Commands.UI.ShowBotCreationDialog, resolve)); + }), }); export const BotNotOpenExplorerContainer = connect( diff --git a/packages/app/client/src/ui/shell/explorer/endpointExplorer/endpointExplorer.spec.tsx b/packages/app/client/src/ui/shell/explorer/endpointExplorer/endpointExplorer.spec.tsx index d2f9b755c..25672dc56 100644 --- a/packages/app/client/src/ui/shell/explorer/endpointExplorer/endpointExplorer.spec.tsx +++ b/packages/app/client/src/ui/shell/explorer/endpointExplorer/endpointExplorer.spec.tsx @@ -30,6 +30,7 @@ // OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // + import { mount } from 'enzyme'; import * as React from 'react'; import { Provider } from 'react-redux'; @@ -93,7 +94,7 @@ const mockBot = { }, ], }; -describe('The EndpointExplorer component should', () => { +describe('The EndpointExplorer component', () => { let parent; let node; let mockDispatch; @@ -140,4 +141,11 @@ describe('The EndpointExplorer component should', () => { expect(mockDispatch).toHaveBeenCalledWith(openEndpointInEmulator(mockBot.services[0] as any, true)); }); + + it('should set a button ref', () => { + const mockButtonRef: any = {}; + node.instance().setAddIconButtonRef(mockButtonRef); + + expect(node.instance().addIconButtonRef).toBe(mockButtonRef); + }); }); diff --git a/packages/app/client/src/ui/shell/explorer/endpointExplorer/endpointExplorer.tsx b/packages/app/client/src/ui/shell/explorer/endpointExplorer/endpointExplorer.tsx index 0de8bfe20..fa1d9bc1c 100644 --- a/packages/app/client/src/ui/shell/explorer/endpointExplorer/endpointExplorer.tsx +++ b/packages/app/client/src/ui/shell/explorer/endpointExplorer/endpointExplorer.tsx @@ -43,7 +43,7 @@ import * as styles from './endpointExplorer.scss'; export interface EndpointProps extends ServicePaneProps { endpointServices?: IEndpointService[]; - launchEndpointEditor: (endpointEditor: ComponentClass) => void; + launchEndpointEditor: (endpointEditor: ComponentClass) => Promise; openEndpointInEmulator: (endpointService: IEndpointService) => void; } @@ -109,11 +109,16 @@ export class EndpointExplorer extends ServicePane { this.props.openContextMenuForService(new EndpointService(endpointService), EndpointEditorContainer); } - protected onAddIconClick = (_event: SyntheticEvent): void => { - this.props.launchEndpointEditor(EndpointEditorContainer); + protected onAddIconClick = async (_event: SyntheticEvent): Promise => { + await this.props.launchEndpointEditor(EndpointEditorContainer); + this.addIconButtonRef && this.addIconButtonRef.focus(); }; protected onSortClick = (_event: SyntheticEvent): void => { // TODO - Implement this. }; + + protected setAddIconButtonRef = (ref: HTMLButtonElement): void => { + this.addIconButtonRef = ref; + }; } diff --git a/packages/app/client/src/ui/shell/explorer/endpointExplorer/endpointExplorerContainer.ts b/packages/app/client/src/ui/shell/explorer/endpointExplorer/endpointExplorerContainer.ts index dc96ca97c..a3d6059a9 100644 --- a/packages/app/client/src/ui/shell/explorer/endpointExplorer/endpointExplorerContainer.ts +++ b/packages/app/client/src/ui/shell/explorer/endpointExplorer/endpointExplorerContainer.ts @@ -57,7 +57,9 @@ const mapStateToProps = (state: RootState, ...ownProps: any[]) => { const mapDispatchToProps = dispatch => { return { launchEndpointEditor: (endpointEditor: ComponentClass, endpointService: IEndpointService) => - dispatch(launchEndpointEditor(endpointEditor, endpointService)), + new Promise(resolve => { + dispatch(launchEndpointEditor(endpointEditor, endpointService, resolve)); + }), openEndpointInEmulator: (endpointService: IEndpointService) => dispatch(openEndpointInEmulator(endpointService, true)), openContextMenuForService: (endpointService: IEndpointService, endpointEditor: ComponentClass) => diff --git a/packages/app/client/src/ui/shell/explorer/servicePane/servicePane.tsx b/packages/app/client/src/ui/shell/explorer/servicePane/servicePane.tsx index 501c51c30..b201a81f7 100644 --- a/packages/app/client/src/ui/shell/explorer/servicePane/servicePane.tsx +++ b/packages/app/client/src/ui/shell/explorer/servicePane/servicePane.tsx @@ -56,6 +56,8 @@ export abstract class ServicePane< T extends ServicePaneProps, S extends ServicePaneState = ServicePaneState > extends Component { + protected addIconButtonRef: HTMLButtonElement; + protected abstract onLinkClick: (event: SyntheticEvent) => void; // bound protected abstract onSortClick: (event: SyntheticEvent) => void; // bound protected onAddIconClick: (event: SyntheticEvent) => void; // bound @@ -88,6 +90,7 @@ export abstract class ServicePane< onKeyPress={this.onControlKeyPress} onClick={this.onAddIconClick} className={`${styles.addIconButton} ${styles.serviceIcon}`} + ref={this.setAddIconButtonRef} > @@ -185,4 +188,8 @@ export abstract class ServicePane< // so that the key press doesn't bubble up to the expand collapse and toggle expanded state ev.stopPropagation(); } + + protected setAddIconButtonRef = (ref: HTMLButtonElement): void => { + this.addIconButtonRef = ref; + }; } diff --git a/packages/app/client/src/ui/shell/explorer/servicesExplorer/connectedServiceEditor/connectedServiceEditor.scss b/packages/app/client/src/ui/shell/explorer/servicesExplorer/connectedServiceEditor/connectedServiceEditor.scss index c24c8c9e0..4bbbbfcd1 100644 --- a/packages/app/client/src/ui/shell/explorer/servicesExplorer/connectedServiceEditor/connectedServiceEditor.scss +++ b/packages/app/client/src/ui/shell/explorer/servicesExplorer/connectedServiceEditor/connectedServiceEditor.scss @@ -74,7 +74,7 @@ ul.kv-pair-container { border: none; } -.dialog-link { +.link { color: var(--dialog-link-color); text-decoration: none; line-height: 20px; @@ -84,3 +84,7 @@ ul.kv-pair-container { position: absolute; top: -9999px; } + +.kv-spacing { + margin-right: 15px; +} diff --git a/packages/app/client/src/ui/shell/explorer/servicesExplorer/connectedServiceEditor/connectedServiceEditor.scss.d.ts b/packages/app/client/src/ui/shell/explorer/servicesExplorer/connectedServiceEditor/connectedServiceEditor.scss.d.ts index b0941276d..806737d36 100644 --- a/packages/app/client/src/ui/shell/explorer/servicesExplorer/connectedServiceEditor/connectedServiceEditor.scss.d.ts +++ b/packages/app/client/src/ui/shell/explorer/servicesExplorer/connectedServiceEditor/connectedServiceEditor.scss.d.ts @@ -3,5 +3,6 @@ export const connectedServiceEditor: string; export const header: string; export const kvPairContainer: string; export const noBorder: string; -export const dialogLink: string; +export const link: string; export const alert: string; +export const kvSpacing: string; diff --git a/packages/app/client/src/ui/shell/explorer/servicesExplorer/connectedServiceEditor/connectedServiceEditor.tsx b/packages/app/client/src/ui/shell/explorer/servicesExplorer/connectedServiceEditor/connectedServiceEditor.tsx index 9ba1b249b..7c6585a03 100644 --- a/packages/app/client/src/ui/shell/explorer/servicesExplorer/connectedServiceEditor/connectedServiceEditor.tsx +++ b/packages/app/client/src/ui/shell/explorer/servicesExplorer/connectedServiceEditor/connectedServiceEditor.tsx @@ -242,7 +242,7 @@ export class ConnectedServiceEditor extends Component {`You can find your LUIS app ID and subscription key in ${portalMap[serviceType]}. `} - + {textString}

@@ -264,7 +264,7 @@ export class ConnectedServiceEditor extends Component {`You can find your knowledge base ID and subscription key in the `} - + Azure Portal.

@@ -277,7 +277,7 @@ export class ConnectedServiceEditor extends Component {`You can find the information below in the `} - + Azure Portal.
@@ -320,7 +320,7 @@ export class ConnectedServiceEditor extends Component @@ -333,7 +333,7 @@ export class ConnectedServiceEditor extends Component @@ -346,7 +346,7 @@ export class ConnectedServiceEditor extends Component @@ -359,7 +359,7 @@ export class ConnectedServiceEditor extends Component @@ -373,7 +373,7 @@ export class ConnectedServiceEditor extends Component @@ -386,7 +386,7 @@ export class ConnectedServiceEditor extends Component diff --git a/packages/app/client/src/ui/shell/explorer/servicesExplorer/connectedServiceEditor/kvPair.tsx b/packages/app/client/src/ui/shell/explorer/servicesExplorer/connectedServiceEditor/kvPair.tsx index 7ae037fad..402eee720 100644 --- a/packages/app/client/src/ui/shell/explorer/servicesExplorer/connectedServiceEditor/kvPair.tsx +++ b/packages/app/client/src/ui/shell/explorer/servicesExplorer/connectedServiceEditor/kvPair.tsx @@ -31,7 +31,7 @@ // WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. // -import { TextField } from '@bfemulator/ui-react'; +import { LinkButton, TextField } from '@bfemulator/ui-react'; import * as React from 'react'; import { ChangeEvent, Component, ReactNode } from 'react'; @@ -71,17 +71,21 @@ export class KvPair extends Component {
  • {this.getTextFieldPair(pair.key, pair.value, index)}
  • ))} - - +
    {this.alert} ); diff --git a/packages/app/client/src/ui/shell/explorer/servicesExplorer/servicesExplorer.spec.tsx b/packages/app/client/src/ui/shell/explorer/servicesExplorer/servicesExplorer.spec.tsx index b4aee117e..ed1bbd77f 100644 --- a/packages/app/client/src/ui/shell/explorer/servicesExplorer/servicesExplorer.spec.tsx +++ b/packages/app/client/src/ui/shell/explorer/servicesExplorer/servicesExplorer.spec.tsx @@ -35,7 +35,8 @@ import { LuisService } from 'botframework-config/lib/models'; import { mount } from 'enzyme'; import * as React from 'react'; import { Provider } from 'react-redux'; -import { combineReducers, createStore } from 'redux'; +import { combineReducers, createStore, applyMiddleware } from 'redux'; +import sagaMiddlewareFactory from 'redux-saga'; import { load, setActive } from '../../../../state/actions/botActions'; import { @@ -43,6 +44,8 @@ import { openContextMenuForConnectedService, openServiceDeepLink, openSortContextMenu, + OPEN_ADD_CONNECTED_SERVICE_CONTEXT_MENU, + OPEN_CONTEXT_MENU_FOR_CONNECTED_SERVICE, } from '../../../../state/actions/connectedServiceActions'; import { bot } from '../../../../state/reducers/bot'; import { explorer } from '../../../../state/reducers/explorer'; @@ -51,34 +54,34 @@ import { AzureLoginSuccessDialogContainer, ConnectServicePromptDialogContainer, GetStartedWithCSDialogContainer, + ProgressIndicatorContainer, } from '../../../dialogs'; -import { executeCommand } from '../../../../state/actions/commandActions'; +import { executeCommand, CommandAction, CommandActionPayload } from '../../../../state/actions/commandActions'; +import { ServicesExplorerSagas, servicesExplorerSagas } from '../../../../state/sagas/servicesExplorerSagas'; import { ConnectedServiceEditorContainer } from './connectedServiceEditor'; import { ConnectedServicePickerContainer } from './connectedServicePicker/connectedServicePickerContainer'; import { ServicesExplorer } from './servicesExplorer'; import { ServicesExplorerContainer } from './servicesExplorerContainer'; -jest.mock('../../../dialogs', () => ({ - DialogService: { - showDialog: () => Promise.resolve(true), - hideDialog: () => Promise.resolve(false), - }, -})); jest.mock('./servicesExplorer.scss', () => ({})); jest.mock('../servicePane/servicePane.scss', () => ({})); jest.mock('./connectedServicePicker/connectedServicePicker.scss', () => ({})); jest.mock('./connectedServiceEditor/connectedServiceEditor.scss', () => ({})); jest.mock('./servicesExplorer.scss', () => ({})); -describe('The ServicesExplorer component should', () => { +describe('The ServicesExplorer component', () => { + const sagaMiddleware = sagaMiddlewareFactory(); let parent; let node; let mockStore; let mockBot; let mockDispatch; + let instance; + beforeEach(() => { - mockStore = createStore(combineReducers({ bot, explorer })); + mockStore = createStore(combineReducers({ bot, explorer }), {}, applyMiddleware(sagaMiddleware)); + sagaMiddleware.run(servicesExplorerSagas); mockBot = JSON.parse(`{ "name": "TestBot", "description": "", @@ -95,13 +98,25 @@ describe('The ServicesExplorer component should', () => { mockBot.services[0] = new LuisService(mockBot.services[0]); mockStore.dispatch(load([mockBot])); mockStore.dispatch(setActive(mockBot)); - mockDispatch = jest.spyOn(mockStore, 'dispatch'); + const originalDispatch = mockStore.dispatch.bind(mockStore); + mockDispatch = jest + .spyOn(mockStore, 'dispatch') + .mockImplementation((action: CommandAction) => { + if (action.type === OPEN_ADD_CONNECTED_SERVICE_CONTEXT_MENU) { + action.payload.resolver(); + + return action; + } + + return originalDispatch(action); + }); parent = mount( ); node = parent.find(ServicesExplorer); + instance = node.instance(); }); it('should render deeply', () => { @@ -110,13 +125,11 @@ describe('The ServicesExplorer component should', () => { }); it('should dispatch a request to open a luis deep link when a service is clicked', () => { - const instance = node.instance(); instance.onLinkClick({ currentTarget: { dataset: { index: 0 } } }); expect(mockDispatch).toHaveBeenCalledWith(openServiceDeepLink(mockBot.services[0])); }); it('should dispatch a request to open the context menu when right clicking on a luis service', () => { - const instance = node.instance(); const mockLi = document.createElement('li'); mockLi.setAttribute('data-index', '0'); @@ -126,22 +139,35 @@ describe('The ServicesExplorer component should', () => { ); }); - it('should dispatch a request to open the connected service picker when the add icon is clicked', () => { - const instance = node.instance(); - instance.onAddIconClick(null); + it('should dispatch a request to open the connected service picker when the add icon is clicked', async () => { + const mockOnAddIconClick = { + focus: jest.fn(() => { + return null; + }), + }; + + instance.addIconButtonRef = mockOnAddIconClick; + + await instance.onAddIconClick(); expect(mockDispatch).toHaveBeenCalledWith( - openAddServiceContextMenu({ - azureAuthWorkflowComponents: { - loginFailedDialog: AzureLoginFailedDialogContainer, - loginSuccessDialog: AzureLoginSuccessDialogContainer, - promptDialog: ConnectServicePromptDialogContainer, + openAddServiceContextMenu( + { + azureAuthWorkflowComponents: { + loginFailedDialog: AzureLoginFailedDialogContainer, + loginSuccessDialog: AzureLoginSuccessDialogContainer, + promptDialog: ConnectServicePromptDialogContainer, + }, + getStartedDialog: GetStartedWithCSDialogContainer, + editorComponent: ConnectedServiceEditorContainer, + pickerComponent: ConnectedServicePickerContainer, + progressIndicatorComponent: ProgressIndicatorContainer, }, - getStartedDialog: GetStartedWithCSDialogContainer, - editorComponent: ConnectedServiceEditorContainer, - pickerComponent: ConnectedServicePickerContainer, - }) + jasmine.any(Function) as any + ) ); + + expect(mockOnAddIconClick.focus).toHaveBeenCalled(); }); it('should dispatch to the store when a request to open the sort context menu is made', () => { diff --git a/packages/app/client/src/ui/shell/explorer/servicesExplorer/servicesExplorer.tsx b/packages/app/client/src/ui/shell/explorer/servicesExplorer/servicesExplorer.tsx index a941b0b6c..8c9c050fc 100644 --- a/packages/app/client/src/ui/shell/explorer/servicesExplorer/servicesExplorer.tsx +++ b/packages/app/client/src/ui/shell/explorer/servicesExplorer/servicesExplorer.tsx @@ -39,6 +39,7 @@ import { ICosmosDBService, } from 'botframework-config/lib/schema'; import * as React from 'react'; +import { ComponentClass } from 'react'; import { MouseEventHandler, SyntheticEvent } from 'react'; import { LinkButton } from '@bfemulator/ui-react'; @@ -72,8 +73,9 @@ const iconMap = { export interface ServicesExplorerProps extends ServicePaneProps { services?: IConnectedService[]; toAnimate?: { [serviceId: string]: boolean }; + launchEndpointEditor: (serverEditor: ComponentClass) => Promise; onAnchorClick: (url: string) => void; - openAddServiceContextMenu: (payload: ConnectedServicePickerPayload) => void; + openAddServiceContextMenu: (payload: ConnectedServicePickerPayload) => Promise; openSortContextMenu: () => void; openServiceDeepLink: (service: IConnectedService) => void; } @@ -217,8 +219,8 @@ export class ServicesExplorer extends ServicePane { this.props.openSortContextMenu(); }; - protected onAddIconClick = (_event: SyntheticEvent): void => { - this.props.openAddServiceContextMenu({ + protected onAddIconClick = async (_event: SyntheticEvent): Promise => { + await this.props.openAddServiceContextMenu({ azureAuthWorkflowComponents: { loginFailedDialog: AzureLoginFailedDialogContainer, loginSuccessDialog: AzureLoginSuccessDialogContainer, @@ -229,5 +231,11 @@ export class ServicesExplorer extends ServicePane { pickerComponent: ConnectedServicePickerContainer, progressIndicatorComponent: ProgressIndicatorContainer, }); + + this.addIconButtonRef && this.addIconButtonRef.focus(); + }; + + protected setAddIconButtonRef = (ref: HTMLButtonElement): void => { + this.addIconButtonRef = ref; }; } diff --git a/packages/app/client/src/ui/shell/explorer/servicesExplorer/servicesExplorerContainer.ts b/packages/app/client/src/ui/shell/explorer/servicesExplorer/servicesExplorerContainer.ts index edb1a7709..6b6d69841 100644 --- a/packages/app/client/src/ui/shell/explorer/servicesExplorer/servicesExplorerContainer.ts +++ b/packages/app/client/src/ui/shell/explorer/servicesExplorer/servicesExplorerContainer.ts @@ -74,8 +74,8 @@ const mapDispatchToProps = (dispatch): Partial => { onAnchorClick: (url: string) => { dispatch(executeCommand(true, SharedConstants.Commands.Electron.OpenExternal, null, url)); }, - openAddServiceContextMenu: (payload: ConnectedServicePickerPayload) => dispatch(openAddServiceContextMenu(payload)), - + openAddServiceContextMenu: (payload: ConnectedServicePickerPayload) => + new Promise(resolve => dispatch(openAddServiceContextMenu(payload, resolve))), openServiceDeepLink: (connectedService: IConnectedService) => dispatch(openServiceDeepLink(connectedService)), openContextMenuForService: ( diff --git a/packages/app/main/src/state/actions/azureAuthActions.ts b/packages/app/main/src/state/actions/azureAuthActions.ts index e9c294ac7..ddcb3555f 100644 --- a/packages/app/main/src/state/actions/azureAuthActions.ts +++ b/packages/app/main/src/state/actions/azureAuthActions.ts @@ -52,13 +52,15 @@ export interface AzureAuthWorkflow { promptDialogProps: { [propName: string]: any }; loginSuccessDialog: any; loginFailedDialog: any; + resolver?: Function; } export function beginAzureAuthWorkflow( promptDialog: any, promptDialogProps: { [propName: string]: any }, loginSuccessDialog: any, - loginFailedDialog: any + loginFailedDialog: any, + resolver: Function ): AzureAuthAction { return { type: AZURE_BEGIN_AUTH_WORKFLOW, @@ -67,6 +69,7 @@ export function beginAzureAuthWorkflow( promptDialogProps, loginSuccessDialog, loginFailedDialog, + resolver, }, }; } diff --git a/packages/app/main/src/state/actions/endpointServiceActions.ts b/packages/app/main/src/state/actions/endpointServiceActions.ts index 10f24f25d..9126a3d5a 100644 --- a/packages/app/main/src/state/actions/endpointServiceActions.ts +++ b/packages/app/main/src/state/actions/endpointServiceActions.ts @@ -46,6 +46,7 @@ export interface EndpointServiceAction extends Action { export interface EndpointServicePayload { endpointService: IEndpointService; focusExistingChatIfAvailable?: boolean; + resolver?: Function; } export interface EndpointEditorPayload extends EndpointServicePayload { @@ -54,11 +55,12 @@ export interface EndpointEditorPayload extends EndpointServicePayload { export function launchEndpointEditor( endpointEditorComponent: ComponentClass, - endpointService?: IEndpointService + endpointService?: IEndpointService, + resolver?: Function ): EndpointServiceAction { return { type: LAUNCH_ENDPOINT_EDITOR, - payload: { endpointEditorComponent, endpointService }, + payload: { endpointEditorComponent, endpointService, resolver }, }; } diff --git a/packages/sdk/ui-react/src/widget/button/button.spec.tsx b/packages/sdk/ui-react/src/widget/button/button.spec.tsx index bd9fa2881..bef137d72 100644 --- a/packages/sdk/ui-react/src/widget/button/button.spec.tsx +++ b/packages/sdk/ui-react/src/widget/button/button.spec.tsx @@ -34,7 +34,51 @@ import * as React from 'react'; import { mount } from 'enzyme'; +import { DefaultButton } from './defaultButton'; import { LinkButton } from './linkButton'; +import { PrimaryButton } from './PrimaryButton'; + +describe('The DefaultButton component', () => { + let parent; + let node; + beforeEach(() => { + parent = mount(Learn more); + node = parent.find(DefaultButton); + }); + + it('should render without any errors', () => { + expect(node.html()).not.toBeFalsy(); + }); + + it('should have an role property', () => { + expect(typeof (node.props() as any).role).not.toBeFalsy(); + }); + + it('should have an buttonRef property', () => { + expect(typeof (node.props() as any).buttonRef).not.toBeFalsy(); + }); +}); + +describe('The PrimaryButton component', () => { + let parent; + let node; + beforeEach(() => { + parent = mount(Learn more); + node = parent.find(PrimaryButton); + }); + + it('should render without any errors', () => { + expect(node.html()).not.toBeFalsy(); + }); + + it('should have an role property', () => { + expect(typeof (node.props() as any).role).not.toBeFalsy(); + }); + + it('should have an buttonRef property', () => { + expect(typeof (node.props() as any).buttonRef).not.toBeFalsy(); + }); +}); describe('The LinkButton component', () => { let parent; @@ -55,4 +99,14 @@ describe('The LinkButton component', () => { it('should have an role property', () => { expect(typeof (node.props() as any).role).not.toBeFalsy(); }); + + it('should have an buttonRef property', () => { + expect(typeof (node.props() as any).buttonRef).not.toBeFalsy(); + }); +}); + +describe('The empty LinkButton component', () => { + it('should not render due to errors', () => { + expect(() => mount()).toThrowError(' { ariaLabel?: string; + buttonRef?: (ref: HTMLButtonElement) => void; linkRole?: boolean; text?: string; } @@ -49,7 +50,7 @@ export interface LinkButtonProps extends React.ButtonHTMLAttributes { public render(): React.ReactNode { - const { className: propsClassName = '', ariaLabel, linkRole = false, text, ...buttonProps } = this.props; + const { className: propsClassName = '', ariaLabel, buttonRef, linkRole = false, text, ...buttonProps } = this.props; const className = `${propsClassName} ${styles.linkButton}`; const ariaLabelText = ariaLabel || text || (typeof this.props.children === 'string' && this.props.children); @@ -57,10 +58,21 @@ export class LinkButton extends React.Component { if (!ariaLabelText) throw new Error(' + ); } + + private setButtonRef = (ref: HTMLButtonElement): void => { + const { buttonRef } = this.props; + buttonRef && buttonRef(ref); + }; } diff --git a/packages/sdk/ui-react/src/widget/button/primaryButton.tsx b/packages/sdk/ui-react/src/widget/button/primaryButton.tsx index f5501cb88..7a8840c7a 100644 --- a/packages/sdk/ui-react/src/widget/button/primaryButton.tsx +++ b/packages/sdk/ui-react/src/widget/button/primaryButton.tsx @@ -35,18 +35,24 @@ import * as React from 'react'; import * as styles from './button.scss'; export interface PrimaryButtonProps extends React.ButtonHTMLAttributes { + buttonRef?: (ref: HTMLButtonElement) => void; text?: string; } export class PrimaryButton extends React.Component { public render(): React.ReactNode { - const { className: propsClassName = '', text, ...buttonProps } = this.props; + const { className: propsClassName = '', text, buttonRef, ...buttonProps } = this.props; const className = `${propsClassName} ${styles.button} ${styles.primaryButton}`; return ( - ); } + + private setButtonRef = (ref: HTMLButtonElement): void => { + const { buttonRef } = this.props; + buttonRef && buttonRef(ref); + }; }