Skip to content

Commit eed5f72

Browse files
authored
Implement ScopedHistory.block (#91099)
* implements ScopedHistory.block * add FTR tests * fix test plugin id * update generated doc * deprecates AppMountParameters.onAppLeave * typo fix * add new FTR test * fix added test
1 parent e1d0cd5 commit eed5f72

19 files changed

+598
-19
lines changed

docs/development/core/public/kibana-plugin-core-public.appleavehandler.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44

55
## AppLeaveHandler type
66

7+
> Warning: This API is now obsolete.
8+
>
9+
> [AppMountParameters.onAppLeave](./kibana-plugin-core-public.appmountparameters.onappleave.md) has been deprecated in favor of [ScopedHistory.block](./kibana-plugin-core-public.scopedhistory.block.md)
10+
>
11+
712
A handler that will be executed before leaving the application, either when going to another application or when closing the browser tab or manually changing the url. Should return `confirm` to to prompt a message to the user before leaving the page, or `default` to keep the default behavior (doing nothing).
813

914
See [AppMountParameters](./kibana-plugin-core-public.appmountparameters.md) for detailed usage examples.

docs/development/core/public/kibana-plugin-core-public.appmountparameters.onappleave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,11 @@
44

55
## AppMountParameters.onAppLeave property
66

7+
> Warning: This API is now obsolete.
8+
>
9+
> [ScopedHistory.block](./kibana-plugin-core-public.scopedhistory.block.md) should be used instead.
10+
>
11+
712
A function that can be used to register a handler that will be called when the user is leaving the current application, allowing to prompt a confirmation message before actually changing the page.
813

914
This will be called either when the user goes to another application, or when trying to close the tab or manually changing the url.

docs/development/core/public/kibana-plugin-core-public.scopedhistory.block.md

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,10 @@
44

55
## ScopedHistory.block property
66

7-
Not supported. Use [AppMountParameters.onAppLeave](./kibana-plugin-core-public.appmountparameters.onappleave.md)<!-- -->.
7+
Add a block prompt requesting user confirmation when navigating away from the current page.
88

99
<b>Signature:</b>
1010

1111
```typescript
1212
block: (prompt?: string | boolean | History.TransitionPromptHook<HistoryLocationState> | undefined) => UnregisterCallback;
1313
```
14-
15-
## Remarks
16-
17-
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.
18-

docs/development/core/public/kibana-plugin-core-public.scopedhistory.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export declare class ScopedHistory<HistoryLocationState = unknown> implements Hi
2727
| Property | Modifiers | Type | Description |
2828
| --- | --- | --- | --- |
2929
| [action](./kibana-plugin-core-public.scopedhistory.action.md) | | <code>Action</code> | The last action dispatched on the history stack. |
30-
| [block](./kibana-plugin-core-public.scopedhistory.block.md) | | <code>(prompt?: string &#124; boolean &#124; History.TransitionPromptHook&lt;HistoryLocationState&gt; &#124; undefined) =&gt; UnregisterCallback</code> | Not supported. Use [AppMountParameters.onAppLeave](./kibana-plugin-core-public.appmountparameters.onappleave.md)<!-- -->. |
30+
| [block](./kibana-plugin-core-public.scopedhistory.block.md) | | <code>(prompt?: string &#124; boolean &#124; History.TransitionPromptHook&lt;HistoryLocationState&gt; &#124; undefined) =&gt; UnregisterCallback</code> | Add a block prompt requesting user confirmation when navigating away from the current page. |
3131
| [createHref](./kibana-plugin-core-public.scopedhistory.createhref.md) | | <code>(location: LocationDescriptorObject&lt;HistoryLocationState&gt;, { prependBasePath }?: {</code><br/><code> prependBasePath?: boolean &#124; undefined;</code><br/><code> }) =&gt; Href</code> | Creates an href (string) to the location. If <code>prependBasePath</code> is true (default), it will prepend the location's path with the scoped history basePath. |
3232
| [createSubHistory](./kibana-plugin-core-public.scopedhistory.createsubhistory.md) | | <code>&lt;SubHistoryLocationState = unknown&gt;(basePath: string) =&gt; ScopedHistory&lt;SubHistoryLocationState&gt;</code> | Creates a <code>ScopedHistory</code> for a subpath of this <code>ScopedHistory</code>. Useful for applications that may have sub-apps that do not need access to the containing application's history. |
3333
| [go](./kibana-plugin-core-public.scopedhistory.go.md) | | <code>(n: number) =&gt; void</code> | Send the user forward or backwards in the history stack. |

src/core/public/application/application_service.tsx

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
import React from 'react';
1010
import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs';
11-
import { map, shareReplay, takeUntil, distinctUntilChanged, filter } from 'rxjs/operators';
11+
import { map, shareReplay, takeUntil, distinctUntilChanged, filter, take } from 'rxjs/operators';
1212
import { createBrowserHistory, History } from 'history';
1313

1414
import { MountPoint } from '../types';
@@ -31,6 +31,7 @@ import {
3131
NavigateToAppOptions,
3232
} from './types';
3333
import { getLeaveAction, isConfirmAction } from './application_leave';
34+
import { getUserConfirmationHandler } from './navigation_confirm';
3435
import { appendAppPath, parseAppUrl, relativeToAbsolute, getAppInfo } from './utils';
3536

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

9698
public setup({
9799
http: { basePath },
@@ -101,7 +103,14 @@ export class ApplicationService {
101103
history,
102104
}: SetupDeps): InternalApplicationSetup {
103105
const basename = basePath.get();
104-
this.history = history || createBrowserHistory({ basename });
106+
this.history =
107+
history ||
108+
createBrowserHistory({
109+
basename,
110+
getUserConfirmation: getUserConfirmationHandler({
111+
overlayPromise: this.overlayStart$.pipe(take(1)).toPromise(),
112+
}),
113+
});
105114

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

185+
this.overlayStart$.next(overlays);
186+
176187
const httpLoadingCount$ = new BehaviorSubject(0);
177188
http.addLoadingCountSource(httpLoadingCount$);
178189

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0 and the Server Side Public License, v 1; you may not use this file except
5+
* in compliance with, at your election, the Elastic License 2.0 or the Server
6+
* Side Public License, v 1.
7+
*/
8+
9+
import { OverlayStart } from '../overlays';
10+
import { overlayServiceMock } from '../overlays/overlay_service.mock';
11+
import { getUserConfirmationHandler, ConfirmHandler } from './navigation_confirm';
12+
13+
const nextTick = () => new Promise((resolve) => setImmediate(resolve));
14+
15+
describe('getUserConfirmationHandler', () => {
16+
let overlayStart: ReturnType<typeof overlayServiceMock.createStartContract>;
17+
let overlayPromise: Promise<OverlayStart>;
18+
let resolvePromise: Function;
19+
let rejectPromise: Function;
20+
let fallbackHandler: jest.MockedFunction<ConfirmHandler>;
21+
let handler: ConfirmHandler;
22+
23+
beforeEach(() => {
24+
overlayStart = overlayServiceMock.createStartContract();
25+
overlayPromise = new Promise((resolve, reject) => {
26+
resolvePromise = () => resolve(overlayStart);
27+
rejectPromise = () => reject('some error');
28+
});
29+
fallbackHandler = jest.fn().mockImplementation((message, callback) => {
30+
callback(true);
31+
});
32+
33+
handler = getUserConfirmationHandler({
34+
overlayPromise,
35+
fallbackHandler,
36+
});
37+
});
38+
39+
it('uses the fallback handler if the promise is not resolved yet', () => {
40+
const callback = jest.fn();
41+
handler('foo', callback);
42+
43+
expect(fallbackHandler).toHaveBeenCalledTimes(1);
44+
expect(fallbackHandler).toHaveBeenCalledWith('foo', callback);
45+
});
46+
47+
it('calls the callback with the value returned by the fallback handler', async () => {
48+
const callback = jest.fn();
49+
handler('foo', callback);
50+
51+
expect(fallbackHandler).toHaveBeenCalledTimes(1);
52+
expect(fallbackHandler).toHaveBeenCalledWith('foo', callback);
53+
54+
expect(callback).toHaveBeenCalledTimes(1);
55+
expect(callback).toHaveBeenCalledWith(true);
56+
});
57+
58+
it('uses the overlay handler once the promise is resolved', async () => {
59+
resolvePromise();
60+
await nextTick();
61+
62+
const callback = jest.fn();
63+
handler('foo', callback);
64+
65+
expect(fallbackHandler).not.toHaveBeenCalled();
66+
67+
expect(overlayStart.openConfirm).toHaveBeenCalledTimes(1);
68+
expect(overlayStart.openConfirm).toHaveBeenCalledWith('foo', expect.any(Object));
69+
});
70+
71+
it('calls the callback with the value returned by `openConfirm`', async () => {
72+
overlayStart.openConfirm.mockResolvedValue(true);
73+
74+
resolvePromise();
75+
await nextTick();
76+
77+
const callback = jest.fn();
78+
handler('foo', callback);
79+
80+
await nextTick();
81+
82+
expect(callback).toHaveBeenCalledTimes(1);
83+
expect(callback).toHaveBeenCalledWith(true);
84+
});
85+
86+
it('uses the fallback handler if the promise rejects', async () => {
87+
rejectPromise();
88+
await nextTick();
89+
90+
const callback = jest.fn();
91+
handler('foo', callback);
92+
93+
expect(fallbackHandler).toHaveBeenCalledTimes(1);
94+
expect(overlayStart.openConfirm).not.toHaveBeenCalled();
95+
});
96+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the Elastic License
4+
* 2.0 and the Server Side Public License, v 1; you may not use this file except
5+
* in compliance with, at your election, the Elastic License 2.0 or the Server
6+
* Side Public License, v 1.
7+
*/
8+
9+
import { OverlayStart } from 'kibana/public';
10+
11+
export type ConfirmHandlerCallback = (result: boolean) => void;
12+
export type ConfirmHandler = (message: string, callback: ConfirmHandlerCallback) => void;
13+
14+
interface GetUserConfirmationHandlerParams {
15+
overlayPromise: Promise<OverlayStart>;
16+
fallbackHandler?: ConfirmHandler;
17+
}
18+
19+
export const getUserConfirmationHandler = ({
20+
overlayPromise,
21+
fallbackHandler = windowConfirm,
22+
}: GetUserConfirmationHandlerParams): ConfirmHandler => {
23+
let overlayConfirm: ConfirmHandler;
24+
25+
overlayPromise.then(
26+
(overlay) => {
27+
overlayConfirm = getOverlayConfirmHandler(overlay);
28+
},
29+
() => {
30+
// should never append, but even if it does, we don't need to do anything,
31+
// and will just use the default window confirm instead
32+
}
33+
);
34+
35+
return (message: string, callback: ConfirmHandlerCallback) => {
36+
if (overlayConfirm) {
37+
overlayConfirm(message, callback);
38+
} else {
39+
fallbackHandler(message, callback);
40+
}
41+
};
42+
};
43+
44+
const windowConfirm: ConfirmHandler = (message: string, callback: ConfirmHandlerCallback) => {
45+
const confirmed = window.confirm(message);
46+
callback(confirmed);
47+
};
48+
49+
const getOverlayConfirmHandler = (overlay: OverlayStart): ConfirmHandler => {
50+
return (message: string, callback: ConfirmHandlerCallback) => {
51+
overlay
52+
.openConfirm(message, { title: ' ', 'data-test-subj': 'navigationBlockConfirmModal' })
53+
.then(
54+
(confirmed) => {
55+
callback(confirmed);
56+
},
57+
() => {
58+
callback(false);
59+
}
60+
);
61+
};
62+
};

0 commit comments

Comments
 (0)