Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement ScopedHistory.block #91099

Merged
merged 9 commits into from
Feb 22, 2021
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions src/core/public/application/application_service.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import React from 'react';
import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs';
import { map, shareReplay, takeUntil, distinctUntilChanged, filter } from 'rxjs/operators';
import { map, shareReplay, takeUntil, distinctUntilChanged, filter, take } from 'rxjs/operators';
import { createBrowserHistory, History } from 'history';

import { MountPoint } from '../types';
Expand All @@ -31,6 +31,7 @@ import {
NavigateToAppOptions,
} from './types';
import { getLeaveAction, isConfirmAction } from './application_leave';
import { getUserConfirmationHandler } from './navigation_confirm';
import { appendAppPath, parseAppUrl, relativeToAbsolute, getAppInfo } from './utils';

interface SetupDeps {
Expand Down Expand Up @@ -92,6 +93,7 @@ export class ApplicationService {
private history?: History<any>;
private navigate?: (url: string, state: unknown, replace: boolean) => void;
private redirectTo?: (url: string) => void;
private overlayStart$ = new Subject<OverlayStart>();

public setup({
http: { basePath },
Expand All @@ -101,7 +103,14 @@ export class ApplicationService {
history,
}: SetupDeps): InternalApplicationSetup {
const basename = basePath.get();
this.history = history || createBrowserHistory({ basename });
this.history =
history ||
createBrowserHistory({
basename,
getUserConfirmation: getUserConfirmationHandler({
overlayPromise: this.overlayStart$.pipe(take(1)).toPromise(),
}),
});

this.navigate = (url, state, replace) => {
// basePath not needed here because `history` is configured with basename
Expand Down Expand Up @@ -173,6 +182,8 @@ export class ApplicationService {
throw new Error('ApplicationService#setup() must be invoked before start.');
}

this.overlayStart$.next(overlays);

const httpLoadingCount$ = new BehaviorSubject(0);
http.addLoadingCountSource(httpLoadingCount$);

Expand Down
96 changes: 96 additions & 0 deletions src/core/public/application/navigation_confirm.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { OverlayStart } from '../overlays';
import { overlayServiceMock } from '../overlays/overlay_service.mock';
import { getUserConfirmationHandler, ConfirmHandler } from './navigation_confirm';

const nextTick = () => new Promise((resolve) => setImmediate(resolve));

describe('getUserConfirmationHandler', () => {
let overlayStart: ReturnType<typeof overlayServiceMock.createStartContract>;
let overlayPromise: Promise<OverlayStart>;
let resolvePromise: Function;
let rejectPromise: Function;
let fallbackHandler: jest.MockedFunction<ConfirmHandler>;
let handler: ConfirmHandler;

beforeEach(() => {
overlayStart = overlayServiceMock.createStartContract();
overlayPromise = new Promise((resolve, reject) => {
resolvePromise = () => resolve(overlayStart);
rejectPromise = () => reject('some error');
});
fallbackHandler = jest.fn().mockImplementation((message, callback) => {
callback(true);
});

handler = getUserConfirmationHandler({
overlayPromise,
fallbackHandler,
});
});

it('uses the fallback handler if the promise is not resolved yet', () => {
const callback = jest.fn();
handler('foo', callback);

expect(fallbackHandler).toHaveBeenCalledTimes(1);
expect(fallbackHandler).toHaveBeenCalledWith('foo', callback);
});

it('calls the callback with the value returned by the fallback handler', async () => {
const callback = jest.fn();
handler('foo', callback);

expect(fallbackHandler).toHaveBeenCalledTimes(1);
expect(fallbackHandler).toHaveBeenCalledWith('foo', callback);

expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(true);
});

it('uses the overlay handler once the promise is resolved', async () => {
resolvePromise();
await nextTick();

const callback = jest.fn();
handler('foo', callback);

expect(fallbackHandler).not.toHaveBeenCalled();

expect(overlayStart.openConfirm).toHaveBeenCalledTimes(1);
expect(overlayStart.openConfirm).toHaveBeenCalledWith('foo', expect.any(Object));
});

it('calls the callback with the value returned by `openConfirm`', async () => {
overlayStart.openConfirm.mockResolvedValue(true);

resolvePromise();
await nextTick();

const callback = jest.fn();
handler('foo', callback);

await nextTick();

expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith(true);
});

it('uses the fallback handler if the promise rejects', async () => {
rejectPromise();
await nextTick();

const callback = jest.fn();
handler('foo', callback);

expect(fallbackHandler).toHaveBeenCalledTimes(1);
expect(overlayStart.openConfirm).not.toHaveBeenCalled();
});
});
62 changes: 62 additions & 0 deletions src/core/public/application/navigation_confirm.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/

import { OverlayStart } from 'kibana/public';

export type ConfirmHandlerCallback = (result: boolean) => void;
export type ConfirmHandler = (message: string, callback: ConfirmHandlerCallback) => void;

interface GetUserConfirmationHandlerParams {
overlayPromise: Promise<OverlayStart>;
fallbackHandler?: ConfirmHandler;
}

export const getUserConfirmationHandler = ({
overlayPromise,
fallbackHandler = windowConfirm,
}: GetUserConfirmationHandlerParams): ConfirmHandler => {
let overlayConfirm: ConfirmHandler;

overlayPromise.then(
(overlay) => {
overlayConfirm = getOverlayConfirmHandler(overlay);
},
() => {
// should never append, but even if it does, we don't need to do anything,
// and will just use the default window confirm instead
}
);

return (message: string, callback: ConfirmHandlerCallback) => {
if (overlayConfirm) {
overlayConfirm(message, callback);
} else {
fallbackHandler(message, callback);
}
};
};

const windowConfirm: ConfirmHandler = (message: string, callback: ConfirmHandlerCallback) => {
const confirmed = window.confirm(message);
callback(confirmed);
};

const getOverlayConfirmHandler = (overlay: OverlayStart): ConfirmHandler => {
return (message: string, callback: ConfirmHandlerCallback) => {
overlay
.openConfirm(message, { title: ' ', 'data-test-subj': 'navigationBlockConfirmModAl' })
.then(
(confirmed) => {
callback(confirmed);
},
() => {
callback(false);
}
);
};
};
152 changes: 151 additions & 1 deletion src/core/public/application/scoped_history.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
*/

import { ScopedHistory } from './scoped_history';
import { createMemoryHistory } from 'history';
import { createMemoryHistory, History } from 'history';
import type { ConfirmHandler } from './navigation_confirm';

describe('ScopedHistory', () => {
describe('construction', () => {
Expand Down Expand Up @@ -336,4 +337,153 @@ describe('ScopedHistory', () => {
expect(gh.length).toBe(4);
});
});

describe('block', () => {
let gh: History;
let h: ScopedHistory;

const initHistory = ({
initialPath = '/app/wow',
scopedHistoryPath = '/app/wow',
confirmHandler,
}: {
initialPath?: string;
scopedHistoryPath?: string;
confirmHandler?: ConfirmHandler;
} = {}) => {
gh = createMemoryHistory({
getUserConfirmation: confirmHandler,
});
gh.push(initialPath);
h = new ScopedHistory(gh, scopedHistoryPath);
};

it('calls block on the global history', () => {
initHistory();

const blockSpy = jest.spyOn(gh, 'block');
h.block('confirm');

expect(blockSpy).toHaveBeenCalledTimes(1);
expect(blockSpy).toHaveBeenCalledWith('confirm');
});

it('returns a wrapped unregister function', () => {
initHistory();

const blockSpy = jest.spyOn(gh, 'block');
const unregister = jest.fn();
blockSpy.mockReturnValue(unregister);

const wrapperUnregister = h.block('confirm');

expect(unregister).not.toHaveBeenCalled();

wrapperUnregister();

expect(unregister).toHaveBeenCalledTimes(1);
});

it('calls the block handler when navigating to another app', () => {
initHistory();

const blockHandler = jest.fn().mockReturnValue(true);

h.block(blockHandler);

gh.push('/app/other');

expect(blockHandler).toHaveBeenCalledTimes(1);
expect(gh.location.pathname).toEqual('/app/other');
});

it('calls the block handler when navigating inside the current app', () => {
initHistory();

const blockHandler = jest.fn().mockReturnValue(true);

h.block(blockHandler);

gh.push('/app/wow/another-page');

expect(blockHandler).toHaveBeenCalledTimes(1);
expect(gh.location.pathname).toEqual('/app/wow/another-page');
});

it('can block the navigation', () => {
initHistory();

const blockHandler = jest.fn().mockReturnValue(false);

h.block(blockHandler);

gh.push('/app/other');

expect(blockHandler).toHaveBeenCalledTimes(1);
expect(gh.location.pathname).toEqual('/app/wow');
});

it('no longer blocks the navigation when unregistered', () => {
initHistory();

const blockHandler = jest.fn().mockReturnValue(false);

const unregister = h.block(blockHandler);

gh.push('/app/other');

expect(gh.location.pathname).toEqual('/app/wow');

unregister();

gh.push('/app/other');

expect(gh.location.pathname).toEqual('/app/other');
});

it('throws if the history is no longer active', () => {
initHistory();

gh.push('/app/other');

expect(() => h.block()).toThrowErrorMatchingInlineSnapshot(
`"ScopedHistory instance has fell out of navigation scope for basePath: /app/wow"`
);
});

it('unregisters the block handler when the history is no longer active', () => {
initHistory();

const blockSpy = jest.spyOn(gh, 'block');
const unregister = jest.fn();
blockSpy.mockReturnValue(unregister);

h.block('confirm');

expect(unregister).not.toHaveBeenCalled();

gh.push('/app/other');

expect(unregister).toHaveBeenCalledTimes(1);
});

it('calls the defined global history confirm handler', () => {
const confirmHandler: jest.MockedFunction<ConfirmHandler> = jest
.fn()
.mockImplementation((message, callback) => {
callback(true);
});

initHistory({
confirmHandler,
});

h.block('are you sure');

gh.push('/app/other');

expect(confirmHandler).toHaveBeenCalledTimes(1);
expect(gh.location.pathname).toEqual('/app/other');
});
});
});
Loading