diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 0d977d104951..5e999ff94b9c 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -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'; @@ -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 { @@ -92,6 +93,7 @@ export class ApplicationService { private history?: History; private navigate?: (url: string, state: unknown, replace: boolean) => void; private redirectTo?: (url: string) => void; + private overlayStart$ = new Subject(); public setup({ http: { basePath }, @@ -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 @@ -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$); diff --git a/src/core/public/application/navigation_confirm.test.ts b/src/core/public/application/navigation_confirm.test.ts new file mode 100644 index 000000000000..d31f25fd94c9 --- /dev/null +++ b/src/core/public/application/navigation_confirm.test.ts @@ -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; + let overlayPromise: Promise; + let resolvePromise: Function; + let rejectPromise: Function; + let fallbackHandler: jest.MockedFunction; + 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(); + }); +}); diff --git a/src/core/public/application/navigation_confirm.ts b/src/core/public/application/navigation_confirm.ts new file mode 100644 index 000000000000..4f68a48eb8fc --- /dev/null +++ b/src/core/public/application/navigation_confirm.ts @@ -0,0 +1,60 @@ +/* + * 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; + 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: ' ' }).then( + (confirmed) => { + callback(confirmed); + }, + () => { + callback(false); + } + ); + }; +}; diff --git a/src/core/public/application/scoped_history.test.ts b/src/core/public/application/scoped_history.test.ts index 9e25809d6700..2c8c66d447c5 100644 --- a/src/core/public/application/scoped_history.test.ts +++ b/src/core/public/application/scoped_history.test.ts @@ -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', () => { @@ -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 = 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'); + }); + }); }); diff --git a/src/core/public/application/scoped_history.ts b/src/core/public/application/scoped_history.ts index daf0aee79218..b932465f800c 100644 --- a/src/core/public/application/scoped_history.ts +++ b/src/core/public/application/scoped_history.ts @@ -51,6 +51,10 @@ export class ScopedHistory * The key of the current position of the window in the history stack. */ private currentLocationKeyIndex: number = 0; + /** + * Array of the current {@link block} unregister callbacks + */ + private blockUnregisterCallbacks: Set = new Set(); constructor(private readonly parentHistory: History, private readonly basePath: string) { const parentPath = this.parentHistory.location.pathname; @@ -176,18 +180,20 @@ export class ScopedHistory }; /** - * Not supported. Use {@link AppMountParameters.onAppLeave}. - * - * @remarks - * We prefer that applications use the `onAppLeave` API because it supports a more graceful experience that prefers - * a modal when possible, falling back to a confirm dialog box in the beforeunload case. + * Add a block prompt requesting user confirmation when navigating away from the current page. */ public block = ( prompt?: boolean | string | TransitionPromptHook ): UnregisterCallback => { - throw new Error( - `history.block is not supported. Please use the AppMountParameters.onAppLeave API.` - ); + this.verifyActive(); + + const unregisterCallback = this.parentHistory.block(prompt); + this.blockUnregisterCallbacks.add(unregisterCallback); + + return () => { + this.blockUnregisterCallbacks.delete(unregisterCallback); + unregisterCallback(); + }; }; /** @@ -290,6 +296,12 @@ export class ScopedHistory if (!location.pathname.startsWith(this.basePath)) { unlisten(); this.isActive = false; + + for (const unregisterBlock of this.blockUnregisterCallbacks) { + unregisterBlock(); + } + this.blockUnregisterCallbacks.clear(); + return; }