Skip to content

Commit

Permalink
[#1741] Restore focus after dialog has been closed (#1901)
Browse files Browse the repository at this point in the history
* Styling fix / refactor of connectedServiceEditor

* Add buttonRef to button components

* Add resolvers

* Add buttonrefs and manual focus

* Fix tests

* Review cleanup

* Add button component test

* Add tests
  • Loading branch information
corinagum authored Oct 8, 2019
1 parent 19a9b3b commit 558fab3
Show file tree
Hide file tree
Showing 35 changed files with 664 additions and 160 deletions.
19 changes: 11 additions & 8 deletions packages/app/client/src/commands/uiCommands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion packages/app/client/src/state/actions/azureAuthActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,15 @@ export interface AzureAuthWorkflow {
promptDialogProps: { [propName: string]: any };
loginSuccessDialog: ComponentClass<any>;
loginFailedDialog: ComponentClass<any>;
resolver?: Function;
}

export function beginAzureAuthWorkflow(
promptDialog: ComponentClass<any>,
promptDialogProps: { [propName: string]: any },
loginSuccessDialog: ComponentClass<any>,
loginFailedDialog: ComponentClass<any>
loginFailedDialog: ComponentClass<any>,
resolver?: Function
): AzureAuthAction<AzureAuthWorkflow> {
return {
type: AZURE_BEGIN_AUTH_WORKFLOW,
Expand All @@ -68,6 +70,7 @@ export function beginAzureAuthWorkflow(
promptDialogProps,
loginSuccessDialog,
loginFailedDialog,
resolver,
},
};
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
14 changes: 11 additions & 3 deletions packages/app/client/src/state/actions/connectedServiceActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,10 @@ export interface ConnectedServicePickerPayload extends ConnectedServicePayload {
progressIndicatorComponent?: ComponentClass<any>;
}

export interface OpenAddServiceContextMenuPayload extends ConnectedServicePickerPayload {
resolver: Function;
}

export function launchConnectedServicePicker(
payload: ConnectedServicePickerPayload
): ConnectedServiceAction<ConnectedServicePickerPayload> {
Expand Down Expand Up @@ -108,11 +112,15 @@ export function openContextMenuForConnectedService<T>(
}

export function openAddServiceContextMenu(
payload: ConnectedServicePickerPayload
): ConnectedServiceAction<ConnectedServicePickerPayload> {
payload: ConnectedServicePickerPayload,
resolver: Function
): ConnectedServiceAction<OpenAddServiceContextMenuPayload> {
return {
type: OPEN_ADD_CONNECTED_SERVICE_CONTEXT_MENU,
payload,
payload: {
...payload,
resolver,
},
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ export interface EndpointServiceAction<T> extends Action {
export interface EndpointServicePayload {
endpointService: IEndpointService;
focusExistingChatIfAvailable?: boolean;
resolver?: Function;
}

export interface EndpointEditorPayload extends EndpointServicePayload {
Expand All @@ -54,11 +55,12 @@ export interface EndpointEditorPayload extends EndpointServicePayload {

export function launchEndpointEditor(
endpointEditorComponent: ComponentClass<any>,
endpointService?: IEndpointService
endpointService?: IEndpointService,
resolver?: Function
): EndpointServiceAction<EndpointEditorPayload> {
return {
type: LAUNCH_ENDPOINT_EDITOR,
payload: { endpointEditorComponent, endpointService },
payload: { endpointEditorComponent, endpointService, resolver },
};
}

Expand Down
7 changes: 5 additions & 2 deletions packages/app/client/src/state/sagas/azureAuthSaga.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
54 changes: 30 additions & 24 deletions packages/app/client/src/state/sagas/azureAuthSaga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,32 +51,38 @@ export class AzureAuthSaga {
private static commandService: CommandServiceImpl;

public static *getArmToken(action: AzureAuthAction<AzureAuthWorkflow>): IterableIterator<any> {
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;
}
}

Expand Down
94 changes: 81 additions & 13 deletions packages/app/client/src/state/sagas/endpointSagas.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "",
Expand Down Expand Up @@ -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<EndpointServicePayload> = {
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);
Expand All @@ -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 () => {
Expand All @@ -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 () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/app/client/src/state/sagas/endpointSagas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export class EndpointSagas {
private static commandService: CommandServiceImpl;

public static *launchEndpointEditor(action: EndpointServiceAction<EndpointEditorPayload>): IterableIterator<any> {
const { endpointEditorComponent, endpointService = {} } = action.payload;
const { endpointEditorComponent, endpointService = {}, resolver } = action.payload;
const servicesToUpdate = yield DialogService.showDialog<ComponentClass<any>, IEndpointService[]>(
endpointEditorComponent,
{ endpointService }
Expand All @@ -85,6 +85,7 @@ export class EndpointSagas {
);
}
}
resolver && resolver();
}

public static *openEndpointContextMenu(
Expand Down
Loading

0 comments on commit 558fab3

Please sign in to comment.