Adds navigation flags to reload a page unconditionally (#128671)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Christiane (Tina) Heiligers 2022-04-01 07:58:00 -07:00 committed by GitHub
parent a7b239f8d9
commit efd5ce361e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 243 additions and 18 deletions

View file

@ -25,5 +25,5 @@ export interface ApplicationStart
| --- | --- |
| [getUrlForApp(appId, options)](./kibana-plugin-core-public.applicationstart.geturlforapp.md) | Returns the absolute path (or URL) to a given app, including the global base path.<!-- -->By default, it returns the absolute path of the application (e.g <code>/basePath/app/my-app</code>). Use the <code>absolute</code> option to generate an absolute url instead (e.g <code>http://host:port/basePath/app/my-app</code>)<!-- -->Note that when generating absolute urls, the origin (protocol, host and port) are determined from the browser's current location. |
| [navigateToApp(appId, options)](./kibana-plugin-core-public.applicationstart.navigatetoapp.md) | Navigate to a given app |
| [navigateToUrl(url)](./kibana-plugin-core-public.applicationstart.navigatetourl.md) | Navigate to given URL in a SPA friendly way when possible (when the URL will redirect to a valid application within the current basePath).<!-- -->The method resolves pathnames the same way browsers do when resolving a <code>&lt;a href&gt;</code> value. The provided <code>url</code> can be: - an absolute URL - an absolute path - a path relative to the current URL (window.location.href)<!-- -->If all these criteria are true for the given URL: - (only for absolute URLs) The origin of the URL matches the origin of the browser's current location - The resolved pathname of the provided URL/path starts with the current basePath (eg. /mybasepath/s/my-space) - The pathname segment after the basePath matches any known application route (eg. /app/<id>/ or any application's <code>appRoute</code> configuration)<!-- -->Then a SPA navigation will be performed using <code>navigateToApp</code> using the corresponding application and path. Otherwise, fallback to a full page reload to navigate to the url using <code>window.location.assign</code> |
| [navigateToUrl(url, options)](./kibana-plugin-core-public.applicationstart.navigatetourl.md) | Navigate to given URL in a SPA friendly way when possible (when the URL will redirect to a valid application within the current basePath).<!-- -->The method resolves pathnames the same way browsers do when resolving a <code>&lt;a href&gt;</code> value. The provided <code>url</code> can be: - an absolute URL - an absolute path - a path relative to the current URL (window.location.href)<!-- -->If all these criteria are true for the given URL: - (only for absolute URLs) The origin of the URL matches the origin of the browser's current location - The resolved pathname of the provided URL/path starts with the current basePath (eg. /mybasepath/s/my-space) - The pathname segment after the basePath matches any known application route (eg. /app/<id>/ or any application's <code>appRoute</code> configuration)<!-- -->Then a SPA navigation will be performed using <code>navigateToApp</code> using the corresponding application and path. Otherwise, fallback to a full page reload to navigate to the url using <code>window.location.assign</code> |

View file

@ -15,7 +15,7 @@ Then a SPA navigation will be performed using `navigateToApp` using the correspo
<b>Signature:</b>
```typescript
navigateToUrl(url: string): Promise<void>;
navigateToUrl(url: string, options?: NavigateToUrlOptions): Promise<void>;
```
## Parameters
@ -23,6 +23,7 @@ navigateToUrl(url: string): Promise<void>;
| Parameter | Type | Description |
| --- | --- | --- |
| url | string | an absolute URL, an absolute path or a relative path, to navigate to. |
| options | NavigateToUrlOptions | |
<b>Returns:</b>

View file

@ -85,6 +85,7 @@ The plugin integrates with the core system via lifecycle events: `setup`<!-- -->
| [IHttpResponseInterceptorOverrides](./kibana-plugin-core-public.ihttpresponseinterceptoroverrides.md) | Properties that can be returned by HttpInterceptor.request to override the response. |
| [IUiSettingsClient](./kibana-plugin-core-public.iuisettingsclient.md) | Client-side client that provides access to the advanced settings stored in elasticsearch. The settings provide control over the behavior of the Kibana application. For example, a user can specify how to display numeric or date fields. Users can adjust the settings via Management UI. [IUiSettingsClient](./kibana-plugin-core-public.iuisettingsclient.md) |
| [NavigateToAppOptions](./kibana-plugin-core-public.navigatetoappoptions.md) | Options for the [navigateToApp API](./kibana-plugin-core-public.applicationstart.navigatetoapp.md) |
| [NavigateToUrlOptions](./kibana-plugin-core-public.navigatetourloptions.md) | Options for the [navigateToUrl API](./kibana-plugin-core-public.applicationstart.navigatetourl.md) |
| [NotificationsSetup](./kibana-plugin-core-public.notificationssetup.md) | |
| [NotificationsStart](./kibana-plugin-core-public.notificationsstart.md) | |
| [OverlayBannersStart](./kibana-plugin-core-public.overlaybannersstart.md) | |

View file

@ -20,5 +20,6 @@ export interface NavigateToAppOptions
| [openInNewTab?](./kibana-plugin-core-public.navigatetoappoptions.openinnewtab.md) | boolean | <i>(Optional)</i> if true, will open the app in new tab, will share session information via window.open if base |
| [path?](./kibana-plugin-core-public.navigatetoappoptions.path.md) | string | <i>(Optional)</i> optional path inside application to deep link to. If undefined, will use [the app's default path](./kibana-plugin-core-public.app.defaultpath.md) as default. |
| [replace?](./kibana-plugin-core-public.navigatetoappoptions.replace.md) | boolean | <i>(Optional)</i> if true, will not create a new history entry when navigating (using <code>replace</code> instead of <code>push</code>) |
| [skipAppLeave?](./kibana-plugin-core-public.navigatetoappoptions.skipappleave.md) | boolean | <i>(Optional)</i> if true, will bypass the default onAppLeave behavior |
| [state?](./kibana-plugin-core-public.navigatetoappoptions.state.md) | unknown | <i>(Optional)</i> optional state to forward to the application |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [NavigateToAppOptions](./kibana-plugin-core-public.navigatetoappoptions.md) &gt; [skipAppLeave](./kibana-plugin-core-public.navigatetoappoptions.skipappleave.md)
## NavigateToAppOptions.skipAppLeave property
if true, will bypass the default onAppLeave behavior
<b>Signature:</b>
```typescript
skipAppLeave?: boolean;
```

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [NavigateToUrlOptions](./kibana-plugin-core-public.navigatetourloptions.md) &gt; [forceRedirect](./kibana-plugin-core-public.navigatetourloptions.forceredirect.md)
## NavigateToUrlOptions.forceRedirect property
if true, will redirect directly to the url
<b>Signature:</b>
```typescript
forceRedirect?: boolean;
```

View file

@ -0,0 +1,21 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [NavigateToUrlOptions](./kibana-plugin-core-public.navigatetourloptions.md)
## NavigateToUrlOptions interface
Options for the [navigateToUrl API](./kibana-plugin-core-public.applicationstart.navigatetourl.md)
<b>Signature:</b>
```typescript
export interface NavigateToUrlOptions
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [forceRedirect?](./kibana-plugin-core-public.navigatetourloptions.forceredirect.md) | boolean | <i>(Optional)</i> if true, will redirect directly to the url |
| [skipAppLeave?](./kibana-plugin-core-public.navigatetourloptions.skipappleave.md) | boolean | <i>(Optional)</i> if true, will bypass the default onAppLeave behavior |

View file

@ -0,0 +1,13 @@
<!-- Do not edit this file. It is automatically generated by API Documenter. -->
[Home](./index.md) &gt; [kibana-plugin-core-public](./kibana-plugin-core-public.md) &gt; [NavigateToUrlOptions](./kibana-plugin-core-public.navigatetourloptions.md) &gt; [skipAppLeave](./kibana-plugin-core-public.navigatetourloptions.skipappleave.md)
## NavigateToUrlOptions.skipAppLeave property
if true, will bypass the default onAppLeave behavior
<b>Signature:</b>
```typescript
skipAppLeave?: boolean;
```

View file

@ -898,10 +898,9 @@ describe('#start()', () => {
it('should call private function shouldNavigate with overlays and the nextAppId', async () => {
service.setup(setupDeps);
const shouldNavigateSpy = jest.spyOn(service as any, 'shouldNavigate');
const { navigateToApp } = await service.start(startDeps);
await navigateToApp('myTestApp');
expect(shouldNavigateSpy).toHaveBeenCalledWith(startDeps.overlays, 'myTestApp');
@ -909,6 +908,14 @@ describe('#start()', () => {
expect(shouldNavigateSpy).toHaveBeenCalledWith(startDeps.overlays, 'myOtherApp');
});
it('should call private function shouldNavigate with overlays, nextAppId and skipAppLeave', async () => {
service.setup(setupDeps);
const shouldNavigateSpy = jest.spyOn(service as any, 'shouldNavigate');
const { navigateToApp } = await service.start(startDeps);
await navigateToApp('myTestApp', { skipAppLeave: true });
expect(shouldNavigateSpy).not.toHaveBeenCalledWith(startDeps.overlays, 'myTestApp');
});
describe('when `replace` option is true', () => {
it('use `history.replace` instead of `history.push`', async () => {
service.setup(setupDeps);
@ -1117,6 +1124,63 @@ describe('#start()', () => {
expect(MockHistory.push).toHaveBeenCalledWith('/app/foo/some-path', undefined);
expect(setupDeps.redirectTo).not.toHaveBeenCalled();
});
describe('navigateToUrl with options', () => {
let addListenerSpy: jest.SpyInstance;
let removeListenerSpy: jest.SpyInstance;
beforeEach(() => {
addListenerSpy = jest.spyOn(window, 'addEventListener');
removeListenerSpy = jest.spyOn(window, 'removeEventListener');
});
afterEach(() => {
jest.restoreAllMocks();
});
it('calls `navigateToApp` with `skipAppLeave` option', async () => {
parseAppUrlMock.mockReturnValue({ app: 'foo', path: '/some-path' });
service.setup(setupDeps);
const { navigateToUrl } = await service.start(startDeps);
await navigateToUrl('/an-app-path', { skipAppLeave: true });
expect(MockHistory.push).toHaveBeenCalledWith('/app/foo/some-path', undefined);
expect(setupDeps.redirectTo).not.toHaveBeenCalled();
});
it('calls `redirectTo` when `forceRedirect` option is true', async () => {
parseAppUrlMock.mockReturnValue({ app: 'foo', path: '/some-path' });
service.setup(setupDeps);
const { navigateToUrl } = await service.start(startDeps);
await navigateToUrl('/an-app-path', { forceRedirect: true });
expect(addListenerSpy).toHaveBeenCalledTimes(1);
expect(addListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function));
expect(setupDeps.redirectTo).toHaveBeenCalledWith('/an-app-path');
expect(MockHistory.push).not.toHaveBeenCalled();
});
it('removes the beforeunload listener and calls `redirectTo` when `forceRedirect` and `skipAppLeave` option are both true', async () => {
parseAppUrlMock.mockReturnValue({ app: 'foo', path: '/some-path' });
service.setup(setupDeps);
const { navigateToUrl } = await service.start(startDeps);
await navigateToUrl('/an-app-path', { skipAppLeave: true, forceRedirect: true });
expect(addListenerSpy).toHaveBeenCalledTimes(1);
expect(addListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function));
const handler = addListenerSpy.mock.calls[0][1];
expect(MockHistory.push).toHaveBeenCalledTimes(0);
expect(setupDeps.redirectTo).toHaveBeenCalledWith('/an-app-path');
expect(removeListenerSpy).toHaveBeenCalledTimes(1);
expect(removeListenerSpy).toHaveBeenCalledWith('beforeunload', handler);
});
});
});
});

View file

@ -31,6 +31,7 @@ import {
InternalApplicationStart,
Mounter,
NavigateToAppOptions,
NavigateToUrlOptions,
} from './types';
import { getLeaveAction, isConfirmAction } from './application_leave';
import { getUserConfirmationHandler } from './navigation_confirm';
@ -234,13 +235,19 @@ export class ApplicationService {
const navigateToApp: InternalApplicationStart['navigateToApp'] = async (
appId,
{ deepLinkId, path, state, replace = false, openInNewTab = false }: NavigateToAppOptions = {}
{
deepLinkId,
path,
state,
replace = false,
openInNewTab = false,
skipAppLeave = false,
}: NavigateToAppOptions = {}
) => {
const currentAppId = this.currentAppId$.value;
const navigatingToSameApp = currentAppId === appId;
const shouldNavigate = navigatingToSameApp
? true
: await this.shouldNavigate(overlays, appId);
const shouldNavigate =
navigatingToSameApp || skipAppLeave ? true : await this.shouldNavigate(overlays, appId);
const targetApp = applications$.value.get(appId);
@ -304,13 +311,20 @@ export class ApplicationService {
return absolute ? relativeToAbsolute(relUrl) : relUrl;
},
navigateToApp,
navigateToUrl: async (url) => {
navigateToUrl: async (
url: string,
{ skipAppLeave = false, forceRedirect = false }: NavigateToUrlOptions = {}
) => {
const appInfo = parseAppUrl(url, http.basePath, this.apps);
if (appInfo) {
return navigateToApp(appInfo.app, { path: appInfo.path });
} else {
if ((forceRedirect || !appInfo) === true) {
if (skipAppLeave) {
window.removeEventListener('beforeunload', this.onBeforeUnload);
}
return this.redirectTo!(url);
}
if (appInfo) {
return navigateToApp(appInfo.app, { path: appInfo.path, skipAppLeave });
}
},
getComponent: () => {
if (!this.history) {

View file

@ -28,6 +28,7 @@ export type {
AppLeaveDefaultAction,
AppLeaveConfirmAction,
NavigateToAppOptions,
NavigateToUrlOptions,
PublicAppInfo,
PublicAppDeepLinkInfo,
// Internal types

View file

@ -170,7 +170,28 @@ describe('ApplicationService', () => {
'/app/app1/deep-link',
]);
});
////
it('handles `skipOnAppLeave` option', async () => {
const { register } = service.setup(setupDeps);
register(Symbol(), {
id: 'app1',
title: 'App1',
mount: async ({}: AppMountParameters) => {
return () => undefined;
},
});
const { navigateToApp } = await service.start(startDeps);
await navigateToApp('app1', { path: '/foo' });
await navigateToApp('app1', { path: '/bar', skipAppLeave: true });
expect(history.entries.map((entry) => entry.pathname)).toEqual([
'/',
'/app/app1/foo',
'/app/app1/bar',
]);
});
});
});
@ -249,6 +270,38 @@ describe('ApplicationService', () => {
expect(history.entries[2].pathname).toEqual('/app/app2');
});
it('does not trigger the action if `skipAppLeave` is true', async () => {
const { register } = service.setup(setupDeps);
register(Symbol(), {
id: 'app1',
title: 'App1',
mount: ({ onAppLeave }: AppMountParameters) => {
onAppLeave((actions) => actions.confirm('confirmation-message', 'confirmation-title'));
return () => undefined;
},
});
register(Symbol(), {
id: 'app2',
title: 'App2',
mount: ({}: AppMountParameters) => {
return () => undefined;
},
});
const { navigateToApp, getComponent } = await service.start(startDeps);
update = createRenderer(getComponent());
await act(async () => {
await navigate('/app/app1');
await navigateToApp('app2', { skipAppLeave: true });
});
expect(startDeps.overlays.openConfirm).toHaveBeenCalledTimes(0);
expect(history.entries.length).toEqual(3);
expect(history.entries[1].pathname).toEqual('/app/app1');
});
it('blocks navigation to the new app if action is confirm and user declined', async () => {
startDeps.overlays.openConfirm.mockResolvedValue(false);

View file

@ -740,6 +740,26 @@ export interface NavigateToAppOptions {
* if true, will open the app in new tab, will share session information via window.open if base
*/
openInNewTab?: boolean;
/**
* if true, will bypass the default onAppLeave behavior
*/
skipAppLeave?: boolean;
}
/**
* Options for the {@link ApplicationStart.navigateToUrl | navigateToUrl API}
* @public
*/
export interface NavigateToUrlOptions {
/**
* if true, will bypass the default onAppLeave behavior
*/
skipAppLeave?: boolean;
/**
* if true will force a full page reload/refresh/assign, overriding the outcome of other url checks against current the location (effectively using `window.location.assign` instead of `push`)
*/
forceRedirect?: boolean;
}
/** @public */
@ -781,7 +801,7 @@ export interface ApplicationStart {
* - The pathname segment after the basePath matches any known application route (eg. /app/<id>/ or any application's `appRoute` configuration)
*
* Then a SPA navigation will be performed using `navigateToApp` using the corresponding application and path.
* Otherwise, fallback to a full page reload to navigate to the url using `window.location.assign`
* Otherwise, fallback to a full page reload to navigate to the url using `window.location.assign`.
*
* @example
* ```ts
@ -802,8 +822,7 @@ export interface ApplicationStart {
*
* @param url - an absolute URL, an absolute path or a relative path, to navigate to.
*/
navigateToUrl(url: string): Promise<void>;
navigateToUrl(url: string, options?: NavigateToUrlOptions): Promise<void>;
/**
* Returns the absolute path (or URL) to a given app, including the global base path.
*

View file

@ -91,6 +91,7 @@ export type {
PublicAppInfo,
PublicAppDeepLinkInfo,
NavigateToAppOptions,
NavigateToUrlOptions,
} from './application';
export { SimpleSavedObject } from './saved_objects';

View file

@ -160,7 +160,7 @@ export interface ApplicationStart {
deepLinkId?: string;
}): string;
navigateToApp(appId: string, options?: NavigateToAppOptions): Promise<void>;
navigateToUrl(url: string): Promise<void>;
navigateToUrl(url: string, options?: NavigateToUrlOptions): Promise<void>;
}
// @public
@ -778,9 +778,16 @@ export interface NavigateToAppOptions {
openInNewTab?: boolean;
path?: string;
replace?: boolean;
skipAppLeave?: boolean;
state?: unknown;
}
// @public
export interface NavigateToUrlOptions {
forceRedirect?: boolean;
skipAppLeave?: boolean;
}
// Warning: (ae-missing-release-tag) "NavType" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)

View file

@ -18,11 +18,14 @@ import { SecurityPluginStart } from '../../../../../security/public';
import { HttpLogic } from '../http';
import { createHref, CreateHrefOptions } from '../react_router_helpers';
type RequiredFieldsOnly<T> = {
[K in keyof T as T[K] extends Required<T>[K] ? K : never]: T[K];
};
interface KibanaLogicProps {
config: { host?: string };
// Kibana core
history: History;
navigateToUrl: ApplicationStart['navigateToUrl'];
navigateToUrl: RequiredFieldsOnly<ApplicationStart['navigateToUrl']>;
setBreadcrumbs(crumbs: ChromeBreadcrumb[]): void;
setChromeIsVisible(isVisible: boolean): void;
setDocTitle(title: string): void;