mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
add RedirectAppLinks component in kibana_react (#67595)
* implements RedirectCrossAppLinks component * update doc * review comments * use `RedirectCrossAppLinks` in SOM SO table page * update snapshots due to merge * do not filter current app * rename component * fix snapshots * add FTR tests * review comments * remove the `parseAppUrl` unused core API * fix snapshots * fix test plugin ts version * add newline
This commit is contained in:
parent
c307c8622f
commit
b7057b7b22
21 changed files with 1255 additions and 139 deletions
|
@ -574,6 +574,12 @@ export type Mounter<T = App | LegacyApp> = SelectivePartial<
|
|||
T extends LegacyApp ? never : 'unmountBeforeMounting'
|
||||
>;
|
||||
|
||||
/** @internal */
|
||||
export interface ParsedAppUrl {
|
||||
app: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
/** @public */
|
||||
export interface ApplicationSetup {
|
||||
/**
|
||||
|
|
|
@ -18,12 +18,7 @@
|
|||
*/
|
||||
|
||||
import { IBasePath } from '../http';
|
||||
import { App, LegacyApp, PublicAppInfo, PublicLegacyAppInfo } from './types';
|
||||
|
||||
export interface AppUrlInfo {
|
||||
app: string;
|
||||
path?: string;
|
||||
}
|
||||
import { App, LegacyApp, PublicAppInfo, PublicLegacyAppInfo, ParsedAppUrl } from './types';
|
||||
|
||||
/**
|
||||
* Utility to remove trailing, leading or duplicate slashes.
|
||||
|
@ -94,7 +89,7 @@ export const parseAppUrl = (
|
|||
basePath: IBasePath,
|
||||
apps: Map<string, App<unknown> | LegacyApp>,
|
||||
getOrigin: () => string = () => window.location.origin
|
||||
): AppUrlInfo | undefined => {
|
||||
): ParsedAppUrl | undefined => {
|
||||
url = removeBasePath(url, basePath, getOrigin());
|
||||
if (!url.startsWith('/')) {
|
||||
return undefined;
|
||||
|
|
222
src/plugins/kibana_react/public/app_links/click_handler.test.ts
Normal file
222
src/plugins/kibana_react/public/app_links/click_handler.test.ts
Normal file
|
@ -0,0 +1,222 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { MouseEvent } from 'react';
|
||||
import { ApplicationStart } from 'src/core/public';
|
||||
import { createNavigateToUrlClickHandler } from './click_handler';
|
||||
|
||||
const createLink = ({
|
||||
href = '/base-path/app/targetApp',
|
||||
target = '',
|
||||
}: { href?: string; target?: string } = {}): HTMLAnchorElement => {
|
||||
const el = document.createElement('a');
|
||||
if (href) {
|
||||
el.href = href;
|
||||
}
|
||||
el.target = target;
|
||||
return el;
|
||||
};
|
||||
|
||||
const createEvent = ({
|
||||
target = createLink(),
|
||||
button = 0,
|
||||
defaultPrevented = false,
|
||||
modifierKey = false,
|
||||
}: {
|
||||
target?: HTMLElement;
|
||||
button?: number;
|
||||
defaultPrevented?: boolean;
|
||||
modifierKey?: boolean;
|
||||
}): MouseEvent<HTMLElement> => {
|
||||
return ({
|
||||
target,
|
||||
button,
|
||||
defaultPrevented,
|
||||
ctrlKey: modifierKey,
|
||||
preventDefault: jest.fn(),
|
||||
} as unknown) as MouseEvent<HTMLElement>;
|
||||
};
|
||||
|
||||
describe('createNavigateToUrlClickHandler', () => {
|
||||
let container: HTMLElement;
|
||||
let navigateToUrl: jest.MockedFunction<ApplicationStart['navigateToUrl']>;
|
||||
|
||||
const createHandler = () =>
|
||||
createNavigateToUrlClickHandler({
|
||||
container,
|
||||
navigateToUrl,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement('div');
|
||||
navigateToUrl = jest.fn();
|
||||
});
|
||||
|
||||
it('calls `navigateToUrl` with the link url', () => {
|
||||
const handler = createHandler();
|
||||
|
||||
const event = createEvent({
|
||||
target: createLink({ href: '/base-path/app/targetApp' }),
|
||||
});
|
||||
handler(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(navigateToUrl).toHaveBeenCalledWith('http://localhost/base-path/app/targetApp');
|
||||
});
|
||||
|
||||
it('is triggered if a non-link target has a parent link', () => {
|
||||
const handler = createHandler();
|
||||
|
||||
const link = createLink();
|
||||
const target = document.createElement('span');
|
||||
link.appendChild(target);
|
||||
|
||||
const event = createEvent({ target });
|
||||
handler(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(navigateToUrl).toHaveBeenCalledWith('http://localhost/base-path/app/targetApp');
|
||||
});
|
||||
|
||||
it('is not triggered if a non-link target has no parent link', () => {
|
||||
const handler = createHandler();
|
||||
|
||||
const parent = document.createElement('div');
|
||||
const target = document.createElement('span');
|
||||
parent.appendChild(target);
|
||||
|
||||
const event = createEvent({ target });
|
||||
handler(event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
expect(navigateToUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is not triggered when the link has no href', () => {
|
||||
const handler = createHandler();
|
||||
|
||||
const event = createEvent({
|
||||
target: createLink({ href: '' }),
|
||||
});
|
||||
handler(event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
expect(navigateToUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('is only triggered when the link does not have an external target', () => {
|
||||
const handler = createHandler();
|
||||
|
||||
let event = createEvent({
|
||||
target: createLink({ target: '_blank' }),
|
||||
});
|
||||
handler(event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
expect(navigateToUrl).not.toHaveBeenCalled();
|
||||
|
||||
event = createEvent({
|
||||
target: createLink({ target: 'some-target' }),
|
||||
});
|
||||
handler(event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
expect(navigateToUrl).not.toHaveBeenCalled();
|
||||
|
||||
event = createEvent({
|
||||
target: createLink({ target: '_self' }),
|
||||
});
|
||||
handler(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(navigateToUrl).toHaveBeenCalledTimes(1);
|
||||
|
||||
event = createEvent({
|
||||
target: createLink({ target: '' }),
|
||||
});
|
||||
handler(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(navigateToUrl).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('is only triggered from left clicks', () => {
|
||||
const handler = createHandler();
|
||||
|
||||
let event = createEvent({
|
||||
button: 1,
|
||||
});
|
||||
handler(event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
expect(navigateToUrl).not.toHaveBeenCalled();
|
||||
|
||||
event = createEvent({
|
||||
button: 12,
|
||||
});
|
||||
handler(event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
expect(navigateToUrl).not.toHaveBeenCalled();
|
||||
|
||||
event = createEvent({
|
||||
button: 0,
|
||||
});
|
||||
handler(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(navigateToUrl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('is not triggered if the event default is prevented', () => {
|
||||
const handler = createHandler();
|
||||
|
||||
let event = createEvent({
|
||||
defaultPrevented: true,
|
||||
});
|
||||
handler(event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
expect(navigateToUrl).not.toHaveBeenCalled();
|
||||
|
||||
event = createEvent({
|
||||
defaultPrevented: false,
|
||||
});
|
||||
handler(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(navigateToUrl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('is not triggered if any modifier key is pressed', () => {
|
||||
const handler = createHandler();
|
||||
|
||||
let event = createEvent({ modifierKey: true });
|
||||
handler(event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
expect(navigateToUrl).not.toHaveBeenCalled();
|
||||
|
||||
event = createEvent({ modifierKey: false });
|
||||
handler(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledTimes(1);
|
||||
expect(navigateToUrl).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
56
src/plugins/kibana_react/public/app_links/click_handler.ts
Normal file
56
src/plugins/kibana_react/public/app_links/click_handler.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ApplicationStart } from 'src/core/public';
|
||||
import { getClosestLink, hasActiveModifierKey } from './utils';
|
||||
|
||||
interface CreateCrossAppClickHandlerOptions {
|
||||
container: HTMLElement;
|
||||
navigateToUrl: ApplicationStart['navigateToUrl'];
|
||||
}
|
||||
|
||||
export const createNavigateToUrlClickHandler = ({
|
||||
container,
|
||||
navigateToUrl,
|
||||
}: CreateCrossAppClickHandlerOptions): React.MouseEventHandler<HTMLElement> => {
|
||||
return (e) => {
|
||||
if (container == null) {
|
||||
return;
|
||||
}
|
||||
// see https://github.com/DefinitelyTyped/DefinitelyTyped/pull/12239
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
const link = getClosestLink(target, container);
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
link.href && // ignore links with empty hrefs
|
||||
(link.target === '' || link.target === '_self') && // ignore links having a target
|
||||
e.button === 0 && // ignore everything but left clicks
|
||||
!e.defaultPrevented && // ignore default prevented events
|
||||
!hasActiveModifierKey(e) // ignore clicks with modifier keys
|
||||
) {
|
||||
e.preventDefault();
|
||||
navigateToUrl(link.href);
|
||||
}
|
||||
};
|
||||
};
|
20
src/plugins/kibana_react/public/app_links/index.ts
Normal file
20
src/plugins/kibana_react/public/app_links/index.ts
Normal file
|
@ -0,0 +1,20 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
export { RedirectAppLinks } from './redirect_app_link';
|
|
@ -0,0 +1,242 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import React, { MouseEvent } from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { applicationServiceMock } from '../../../../core/public/mocks';
|
||||
import { RedirectAppLinks } from './redirect_app_link';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
/* eslint-disable jsx-a11y/click-events-have-key-events */
|
||||
|
||||
describe('RedirectAppLinks', () => {
|
||||
let application: ReturnType<typeof applicationServiceMock.createStartContract>;
|
||||
|
||||
beforeEach(() => {
|
||||
application = applicationServiceMock.createStartContract();
|
||||
application.currentAppId$ = new BehaviorSubject<string>('currentApp');
|
||||
});
|
||||
|
||||
it('intercept click events on children link elements', () => {
|
||||
let event: MouseEvent;
|
||||
|
||||
const component = mount(
|
||||
<div
|
||||
onClick={(e) => {
|
||||
event = e;
|
||||
}}
|
||||
>
|
||||
<RedirectAppLinks application={application}>
|
||||
<div>
|
||||
<a href="/mocked-anyway">content</a>
|
||||
</div>
|
||||
</RedirectAppLinks>
|
||||
</div>
|
||||
);
|
||||
|
||||
component.find('a').simulate('click', { button: 0, defaultPrevented: false });
|
||||
|
||||
expect(application.navigateToUrl).toHaveBeenCalledTimes(1);
|
||||
expect(event!.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('intercept click events on children inside link elements', async () => {
|
||||
let event: MouseEvent;
|
||||
|
||||
const component = mount(
|
||||
<div
|
||||
onClick={(e) => {
|
||||
event = e;
|
||||
}}
|
||||
>
|
||||
<RedirectAppLinks application={application}>
|
||||
<a href="/mocked-anyway">
|
||||
<span>content</span>
|
||||
</a>
|
||||
</RedirectAppLinks>
|
||||
</div>
|
||||
);
|
||||
|
||||
component.find('span').simulate('click', { button: 0, defaultPrevented: false });
|
||||
|
||||
expect(application.navigateToUrl).toHaveBeenCalledTimes(1);
|
||||
expect(event!.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('does not intercept click events when the target is not inside a link', () => {
|
||||
let event: MouseEvent;
|
||||
|
||||
const component = mount(
|
||||
<div
|
||||
onClick={(e) => {
|
||||
event = e;
|
||||
}}
|
||||
>
|
||||
<RedirectAppLinks application={application}>
|
||||
<span>
|
||||
<a href="/mocked-anyway">content</a>
|
||||
</span>
|
||||
</RedirectAppLinks>
|
||||
</div>
|
||||
);
|
||||
|
||||
component.find('span').simulate('click', { button: 0, defaultPrevented: false });
|
||||
|
||||
expect(application.navigateToApp).not.toHaveBeenCalled();
|
||||
expect(event!.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('does not intercept click events when the link is a parent of the container', () => {
|
||||
let event: MouseEvent;
|
||||
|
||||
const component = mount(
|
||||
<div
|
||||
onClick={(e) => {
|
||||
event = e;
|
||||
}}
|
||||
>
|
||||
<a href="/mocked-anyway">
|
||||
<RedirectAppLinks application={application}>
|
||||
<span>content</span>
|
||||
</RedirectAppLinks>
|
||||
</a>
|
||||
</div>
|
||||
);
|
||||
|
||||
component.find('span').simulate('click', { button: 0, defaultPrevented: false });
|
||||
|
||||
expect(application.navigateToApp).not.toHaveBeenCalled();
|
||||
expect(event!.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('does not intercept click events when the link has an external target', () => {
|
||||
let event: MouseEvent;
|
||||
|
||||
const component = mount(
|
||||
<div
|
||||
onClick={(e) => {
|
||||
event = e;
|
||||
}}
|
||||
>
|
||||
<RedirectAppLinks application={application}>
|
||||
<a href="/mocked-anyway" target="_blank">
|
||||
content
|
||||
</a>
|
||||
</RedirectAppLinks>
|
||||
</div>
|
||||
);
|
||||
|
||||
component.find('a').simulate('click', { button: 0, defaultPrevented: false });
|
||||
|
||||
expect(application.navigateToApp).not.toHaveBeenCalled();
|
||||
expect(event!.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('does not intercept click events when the event is already defaultPrevented', () => {
|
||||
let event: MouseEvent;
|
||||
|
||||
const component = mount(
|
||||
<div
|
||||
onClick={(e) => {
|
||||
event = e;
|
||||
}}
|
||||
>
|
||||
<RedirectAppLinks application={application}>
|
||||
<a href="/mocked-anyway" target="_blank">
|
||||
<span onClick={(e) => e.preventDefault()}>content</span>
|
||||
</a>
|
||||
</RedirectAppLinks>
|
||||
</div>
|
||||
);
|
||||
|
||||
component.find('span').simulate('click', { button: 0, defaultPrevented: false });
|
||||
|
||||
expect(application.navigateToApp).not.toHaveBeenCalled();
|
||||
expect(event!.defaultPrevented).toBe(true);
|
||||
});
|
||||
|
||||
it('does not intercept click events when the event propagation is stopped', () => {
|
||||
let event: MouseEvent;
|
||||
|
||||
const component = mount(
|
||||
<div
|
||||
onClick={(e) => {
|
||||
event = e;
|
||||
}}
|
||||
>
|
||||
<RedirectAppLinks application={application}>
|
||||
<a href="/mocked-anyway" target="_blank" onClick={(e) => e.stopPropagation()}>
|
||||
content
|
||||
</a>
|
||||
</RedirectAppLinks>
|
||||
</div>
|
||||
);
|
||||
|
||||
component.find('a').simulate('click', { button: 0, defaultPrevented: false });
|
||||
|
||||
expect(application.navigateToApp).not.toHaveBeenCalled();
|
||||
expect(event!).toBe(undefined);
|
||||
});
|
||||
|
||||
it('does not intercept click events when the event is not triggered from the left button', () => {
|
||||
let event: MouseEvent;
|
||||
|
||||
const component = mount(
|
||||
<div
|
||||
onClick={(e) => {
|
||||
event = e;
|
||||
}}
|
||||
>
|
||||
<RedirectAppLinks application={application}>
|
||||
<div>
|
||||
<a href="/mocked-anyway">content</a>
|
||||
</div>
|
||||
</RedirectAppLinks>
|
||||
</div>
|
||||
);
|
||||
|
||||
component.find('a').simulate('click', { button: 1, defaultPrevented: false });
|
||||
|
||||
expect(application.navigateToApp).not.toHaveBeenCalled();
|
||||
expect(event!.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('does not intercept click events when the event has a modifier key enabled', () => {
|
||||
let event: MouseEvent;
|
||||
|
||||
const component = mount(
|
||||
<div
|
||||
onClick={(e) => {
|
||||
event = e;
|
||||
}}
|
||||
>
|
||||
<RedirectAppLinks application={application}>
|
||||
<div>
|
||||
<a href="/mocked-anyway">content</a>
|
||||
</div>
|
||||
</RedirectAppLinks>
|
||||
</div>
|
||||
);
|
||||
|
||||
component.find('a').simulate('click', { button: 0, ctrlKey: true, defaultPrevented: false });
|
||||
|
||||
expect(application.navigateToApp).not.toHaveBeenCalled();
|
||||
expect(event!.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,80 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import React, { FunctionComponent, useRef, useMemo } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
import { ApplicationStart } from 'src/core/public';
|
||||
import { createNavigateToUrlClickHandler } from './click_handler';
|
||||
|
||||
interface RedirectCrossAppLinksProps {
|
||||
application: ApplicationStart;
|
||||
className?: string;
|
||||
'data-test-subj'?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Utility component that will intercept click events on children anchor (`<a>`) elements to call
|
||||
* `application.navigateToUrl` with the link's href. This will trigger SPA friendly navigation
|
||||
* when the link points to a valid Kibana app.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <RedirectCrossAppLinks application={application}>
|
||||
* <a href="/base-path/app/another-app/some-path">Go to another-app</a>
|
||||
* </RedirectCrossAppLinks>
|
||||
* ```
|
||||
*
|
||||
* @remarks
|
||||
* It is recommended to use the component at the highest possible level of the component tree that would
|
||||
* require to handle the links. A good practice is to consider it as a context provider and to use it
|
||||
* at the root level of an application or of the page that require the feature.
|
||||
*/
|
||||
export const RedirectAppLinks: FunctionComponent<RedirectCrossAppLinksProps> = ({
|
||||
application,
|
||||
children,
|
||||
className,
|
||||
...otherProps
|
||||
}) => {
|
||||
const currentAppId = useObservable(application.currentAppId$, undefined);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const clickHandler = useMemo(
|
||||
() =>
|
||||
containerRef.current && currentAppId
|
||||
? createNavigateToUrlClickHandler({
|
||||
container: containerRef.current,
|
||||
navigateToUrl: application.navigateToUrl,
|
||||
})
|
||||
: undefined,
|
||||
[containerRef.current, application, currentAppId]
|
||||
);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={classNames(className, 'kbnRedirectCrossAppLinks')}
|
||||
onClick={clickHandler}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
52
src/plugins/kibana_react/public/app_links/utils.test.ts
Normal file
52
src/plugins/kibana_react/public/app_links/utils.test.ts
Normal file
|
@ -0,0 +1,52 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { getClosestLink } from './utils';
|
||||
|
||||
const createBranch = (...tags: string[]): HTMLElement[] => {
|
||||
const elements: HTMLElement[] = [];
|
||||
let parent: HTMLElement | undefined;
|
||||
for (const tag of tags) {
|
||||
const element = document.createElement(tag);
|
||||
elements.push(element);
|
||||
if (parent) {
|
||||
parent.appendChild(element);
|
||||
}
|
||||
parent = element;
|
||||
}
|
||||
|
||||
return elements;
|
||||
};
|
||||
|
||||
describe('getClosestLink', () => {
|
||||
it(`returns the element itself if it's a link`, () => {
|
||||
const [target] = createBranch('A');
|
||||
expect(getClosestLink(target)).toBe(target);
|
||||
});
|
||||
|
||||
it('returns the closest parent that is a link', () => {
|
||||
const [, , link, , target] = createBranch('A', 'DIV', 'A', 'DIV', 'SPAN');
|
||||
expect(getClosestLink(target)).toBe(link);
|
||||
});
|
||||
|
||||
it('returns undefined if the closest link is further than the container', () => {
|
||||
const [, container, target] = createBranch('A', 'DIV', 'SPAN');
|
||||
expect(getClosestLink(target, container)).toBe(undefined);
|
||||
});
|
||||
});
|
48
src/plugins/kibana_react/public/app_links/utils.ts
Normal file
48
src/plugins/kibana_react/public/app_links/utils.ts
Normal file
|
@ -0,0 +1,48 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Returns true if any modifier key is active on the event, false otherwise.
|
||||
*/
|
||||
export const hasActiveModifierKey = (event: React.MouseEvent): boolean => {
|
||||
return event.metaKey || event.altKey || event.ctrlKey || event.shiftKey;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the closest anchor (`<a>`) element in the element parents (self included) up to the given container (excluded), or undefined if none is found.
|
||||
*/
|
||||
export const getClosestLink = (
|
||||
element: HTMLElement,
|
||||
container?: HTMLElement
|
||||
): HTMLAnchorElement | undefined => {
|
||||
let current = element;
|
||||
while (true) {
|
||||
if (current.tagName.toLowerCase() === 'a') {
|
||||
return current as HTMLAnchorElement;
|
||||
}
|
||||
const parent = current.parentElement;
|
||||
if (!parent || parent === document.body || parent === container) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
return undefined;
|
||||
};
|
|
@ -32,6 +32,7 @@ export { Markdown, MarkdownSimple } from './markdown';
|
|||
export { reactToUiComponent, uiToReactComponent } from './adapters';
|
||||
export { useUrlTracker } from './use_url_tracker';
|
||||
export { toMountPoint } from './util';
|
||||
export { RedirectAppLinks } from './app_links';
|
||||
|
||||
/** dummy plugin, we just want kibanaReact to have its own bundle */
|
||||
export function plugin() {
|
||||
|
|
|
@ -210,121 +210,162 @@ exports[`SavedObjectsTable should render normally 1`] = `
|
|||
<EuiSpacer
|
||||
size="xs"
|
||||
/>
|
||||
<Table
|
||||
actionRegistry={
|
||||
<RedirectAppLinks
|
||||
application={
|
||||
Object {
|
||||
"getAll": [MockFunction],
|
||||
"has": [MockFunction],
|
||||
"applications$": BehaviorSubject {
|
||||
"_isScalar": false,
|
||||
"_value": Map {},
|
||||
"closed": false,
|
||||
"hasError": false,
|
||||
"isStopped": false,
|
||||
"observers": Array [],
|
||||
"thrownError": null,
|
||||
},
|
||||
"capabilities": Object {
|
||||
"catalogue": Object {},
|
||||
"management": Object {},
|
||||
"navLinks": Object {},
|
||||
"savedObjectsManagement": Object {
|
||||
"delete": false,
|
||||
"edit": false,
|
||||
"read": true,
|
||||
},
|
||||
},
|
||||
"currentAppId$": Observable {
|
||||
"_isScalar": false,
|
||||
"source": Subject {
|
||||
"_isScalar": false,
|
||||
"closed": false,
|
||||
"hasError": false,
|
||||
"isStopped": false,
|
||||
"observers": Array [],
|
||||
"thrownError": null,
|
||||
},
|
||||
},
|
||||
"getUrlForApp": [MockFunction],
|
||||
"navigateToApp": [MockFunction],
|
||||
"navigateToUrl": [MockFunction],
|
||||
"registerMountContext": [MockFunction],
|
||||
}
|
||||
}
|
||||
basePath={
|
||||
BasePath {
|
||||
"basePath": "",
|
||||
"get": [Function],
|
||||
"prepend": [Function],
|
||||
"remove": [Function],
|
||||
"serverBasePath": "",
|
||||
>
|
||||
<Table
|
||||
actionRegistry={
|
||||
Object {
|
||||
"getAll": [MockFunction],
|
||||
"has": [MockFunction],
|
||||
}
|
||||
}
|
||||
}
|
||||
canDelete={false}
|
||||
canGoInApp={[Function]}
|
||||
filterOptions={
|
||||
Array [
|
||||
Object {
|
||||
"name": "index-pattern",
|
||||
"value": "index-pattern",
|
||||
"view": "index-pattern (0)",
|
||||
},
|
||||
Object {
|
||||
"name": "visualization",
|
||||
"value": "visualization",
|
||||
"view": "visualization (0)",
|
||||
},
|
||||
Object {
|
||||
"name": "dashboard",
|
||||
"value": "dashboard",
|
||||
"view": "dashboard (0)",
|
||||
},
|
||||
Object {
|
||||
"name": "search",
|
||||
"value": "search",
|
||||
"view": "search (0)",
|
||||
},
|
||||
]
|
||||
}
|
||||
goInspectObject={[Function]}
|
||||
isSearching={false}
|
||||
itemId="id"
|
||||
items={
|
||||
Array [
|
||||
Object {
|
||||
"id": "1",
|
||||
"meta": Object {
|
||||
"editUrl": "#/management/kibana/indexPatterns/patterns/1",
|
||||
"icon": "indexPatternApp",
|
||||
"inAppUrl": Object {
|
||||
"path": "/management/kibana/indexPatterns/patterns/1",
|
||||
"uiCapabilitiesPath": "management.kibana.index_patterns",
|
||||
},
|
||||
"title": "MyIndexPattern*",
|
||||
},
|
||||
"type": "index-pattern",
|
||||
},
|
||||
Object {
|
||||
"id": "2",
|
||||
"meta": Object {
|
||||
"editUrl": "/management/kibana/objects/savedSearches/2",
|
||||
"icon": "search",
|
||||
"inAppUrl": Object {
|
||||
"path": "/discover/2",
|
||||
"uiCapabilitiesPath": "discover.show",
|
||||
},
|
||||
"title": "MySearch",
|
||||
},
|
||||
"type": "search",
|
||||
},
|
||||
Object {
|
||||
"id": "3",
|
||||
"meta": Object {
|
||||
"editUrl": "/management/kibana/objects/savedDashboards/3",
|
||||
"icon": "dashboardApp",
|
||||
"inAppUrl": Object {
|
||||
"path": "/dashboard/3",
|
||||
"uiCapabilitiesPath": "dashboard.show",
|
||||
},
|
||||
"title": "MyDashboard",
|
||||
},
|
||||
"type": "dashboard",
|
||||
},
|
||||
Object {
|
||||
"id": "4",
|
||||
"meta": Object {
|
||||
"editUrl": "/management/kibana/objects/savedVisualizations/4",
|
||||
"icon": "visualizeApp",
|
||||
"inAppUrl": Object {
|
||||
"path": "/edit/4",
|
||||
"uiCapabilitiesPath": "visualize.show",
|
||||
},
|
||||
"title": "MyViz",
|
||||
},
|
||||
"type": "visualization",
|
||||
},
|
||||
]
|
||||
}
|
||||
onDelete={[Function]}
|
||||
onExport={[Function]}
|
||||
onQueryChange={[Function]}
|
||||
onShowRelationships={[Function]}
|
||||
onTableChange={[Function]}
|
||||
pageIndex={0}
|
||||
pageSize={15}
|
||||
selectedSavedObjects={Array []}
|
||||
selectionConfig={
|
||||
Object {
|
||||
"onSelectionChange": [Function],
|
||||
basePath={
|
||||
BasePath {
|
||||
"basePath": "",
|
||||
"get": [Function],
|
||||
"prepend": [Function],
|
||||
"remove": [Function],
|
||||
"serverBasePath": "",
|
||||
}
|
||||
}
|
||||
}
|
||||
totalItemCount={4}
|
||||
/>
|
||||
canDelete={false}
|
||||
canGoInApp={[Function]}
|
||||
filterOptions={
|
||||
Array [
|
||||
Object {
|
||||
"name": "index-pattern",
|
||||
"value": "index-pattern",
|
||||
"view": "index-pattern (0)",
|
||||
},
|
||||
Object {
|
||||
"name": "visualization",
|
||||
"value": "visualization",
|
||||
"view": "visualization (0)",
|
||||
},
|
||||
Object {
|
||||
"name": "dashboard",
|
||||
"value": "dashboard",
|
||||
"view": "dashboard (0)",
|
||||
},
|
||||
Object {
|
||||
"name": "search",
|
||||
"value": "search",
|
||||
"view": "search (0)",
|
||||
},
|
||||
]
|
||||
}
|
||||
goInspectObject={[Function]}
|
||||
isSearching={false}
|
||||
itemId="id"
|
||||
items={
|
||||
Array [
|
||||
Object {
|
||||
"id": "1",
|
||||
"meta": Object {
|
||||
"editUrl": "#/management/kibana/indexPatterns/patterns/1",
|
||||
"icon": "indexPatternApp",
|
||||
"inAppUrl": Object {
|
||||
"path": "/management/kibana/indexPatterns/patterns/1",
|
||||
"uiCapabilitiesPath": "management.kibana.index_patterns",
|
||||
},
|
||||
"title": "MyIndexPattern*",
|
||||
},
|
||||
"type": "index-pattern",
|
||||
},
|
||||
Object {
|
||||
"id": "2",
|
||||
"meta": Object {
|
||||
"editUrl": "/management/kibana/objects/savedSearches/2",
|
||||
"icon": "search",
|
||||
"inAppUrl": Object {
|
||||
"path": "/discover/2",
|
||||
"uiCapabilitiesPath": "discover.show",
|
||||
},
|
||||
"title": "MySearch",
|
||||
},
|
||||
"type": "search",
|
||||
},
|
||||
Object {
|
||||
"id": "3",
|
||||
"meta": Object {
|
||||
"editUrl": "/management/kibana/objects/savedDashboards/3",
|
||||
"icon": "dashboardApp",
|
||||
"inAppUrl": Object {
|
||||
"path": "/dashboard/3",
|
||||
"uiCapabilitiesPath": "dashboard.show",
|
||||
},
|
||||
"title": "MyDashboard",
|
||||
},
|
||||
"type": "dashboard",
|
||||
},
|
||||
Object {
|
||||
"id": "4",
|
||||
"meta": Object {
|
||||
"editUrl": "/management/kibana/objects/savedVisualizations/4",
|
||||
"icon": "visualizeApp",
|
||||
"inAppUrl": Object {
|
||||
"path": "/edit/4",
|
||||
"uiCapabilitiesPath": "visualize.show",
|
||||
},
|
||||
"title": "MyViz",
|
||||
},
|
||||
"type": "visualization",
|
||||
},
|
||||
]
|
||||
}
|
||||
onDelete={[Function]}
|
||||
onExport={[Function]}
|
||||
onQueryChange={[Function]}
|
||||
onShowRelationships={[Function]}
|
||||
onTableChange={[Function]}
|
||||
pageIndex={0}
|
||||
pageSize={15}
|
||||
selectedSavedObjects={Array []}
|
||||
selectionConfig={
|
||||
Object {
|
||||
"onSelectionChange": [Function],
|
||||
}
|
||||
}
|
||||
totalItemCount={4}
|
||||
/>
|
||||
</RedirectAppLinks>
|
||||
</EuiPageContent>
|
||||
`;
|
||||
|
|
|
@ -55,6 +55,7 @@ import {
|
|||
NotificationsStart,
|
||||
ApplicationStart,
|
||||
} from 'src/core/public';
|
||||
import { RedirectAppLinks } from '../../../../kibana_react/public';
|
||||
import { IndexPatternsContract } from '../../../../data/public';
|
||||
import {
|
||||
parseQuery,
|
||||
|
@ -734,27 +735,29 @@ export class SavedObjectsTable extends Component<SavedObjectsTableProps, SavedOb
|
|||
filteredCount={filteredItemCount}
|
||||
/>
|
||||
<EuiSpacer size="xs" />
|
||||
<Table
|
||||
basePath={http.basePath}
|
||||
itemId={'id'}
|
||||
actionRegistry={this.props.actionRegistry}
|
||||
selectionConfig={selectionConfig}
|
||||
selectedSavedObjects={selectedSavedObjects}
|
||||
onQueryChange={this.onQueryChange}
|
||||
onTableChange={this.onTableChange}
|
||||
filterOptions={filterOptions}
|
||||
onExport={this.onExport}
|
||||
canDelete={applications.capabilities.savedObjectsManagement.delete as boolean}
|
||||
onDelete={this.onDelete}
|
||||
goInspectObject={this.props.goInspectObject}
|
||||
pageIndex={page}
|
||||
pageSize={perPage}
|
||||
items={savedObjects}
|
||||
totalItemCount={filteredItemCount}
|
||||
isSearching={isSearching}
|
||||
onShowRelationships={this.onShowRelationships}
|
||||
canGoInApp={this.props.canGoInApp}
|
||||
/>
|
||||
<RedirectAppLinks application={applications}>
|
||||
<Table
|
||||
basePath={http.basePath}
|
||||
itemId={'id'}
|
||||
actionRegistry={this.props.actionRegistry}
|
||||
selectionConfig={selectionConfig}
|
||||
selectedSavedObjects={selectedSavedObjects}
|
||||
onQueryChange={this.onQueryChange}
|
||||
onTableChange={this.onTableChange}
|
||||
filterOptions={filterOptions}
|
||||
onExport={this.onExport}
|
||||
canDelete={applications.capabilities.savedObjectsManagement.delete as boolean}
|
||||
onDelete={this.onDelete}
|
||||
goInspectObject={this.props.goInspectObject}
|
||||
pageIndex={page}
|
||||
pageSize={perPage}
|
||||
items={savedObjects}
|
||||
totalItemCount={filteredItemCount}
|
||||
isSearching={isSearching}
|
||||
onShowRelationships={this.onShowRelationships}
|
||||
canGoInApp={this.props.canGoInApp}
|
||||
/>
|
||||
</RedirectAppLinks>
|
||||
</EuiPageContent>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -38,6 +38,7 @@ export default async function ({ readConfigFile }) {
|
|||
require.resolve('./test_suites/core_plugins'),
|
||||
require.resolve('./test_suites/management'),
|
||||
require.resolve('./test_suites/doc_views'),
|
||||
require.resolve('./test_suites/application_links'),
|
||||
],
|
||||
services: {
|
||||
...functionalConfig.get('services'),
|
||||
|
|
7
test/plugin_functional/plugins/app_link_test/kibana.json
Normal file
7
test/plugin_functional/plugins/app_link_test/kibana.json
Normal file
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"id": "app_link_test",
|
||||
"version": "0.0.1",
|
||||
"kibanaVersion": "kibana",
|
||||
"server": false,
|
||||
"ui": true
|
||||
}
|
17
test/plugin_functional/plugins/app_link_test/package.json
Normal file
17
test/plugin_functional/plugins/app_link_test/package.json
Normal file
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "app_link_test",
|
||||
"version": "1.0.0",
|
||||
"main": "target/test/plugin_functional/plugins/app_link_test",
|
||||
"kibana": {
|
||||
"version": "kibana",
|
||||
"templateVersion": "1.0.0"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"kbn": "node ../../../../scripts/kbn.js",
|
||||
"build": "rm -rf './target' && tsc"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "3.9.5"
|
||||
}
|
||||
}
|
88
test/plugin_functional/plugins/app_link_test/public/app.tsx
Normal file
88
test/plugin_functional/plugins/app_link_test/public/app.tsx
Normal file
|
@ -0,0 +1,88 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { AppMountParameters, IBasePath, ApplicationStart } from 'kibana/public';
|
||||
import { RedirectAppLinks } from '../../../../../src/plugins/kibana_react/public';
|
||||
|
||||
const FooApp = ({
|
||||
appId,
|
||||
targetAppId,
|
||||
basePath,
|
||||
application,
|
||||
}: {
|
||||
appId: string;
|
||||
targetAppId: string;
|
||||
basePath: IBasePath;
|
||||
application: ApplicationStart;
|
||||
}) => (
|
||||
<div data-test-subj={`app-${appId}`}>
|
||||
<RedirectAppLinks application={application}>
|
||||
<h1>{appId}</h1>
|
||||
<div>
|
||||
<a data-test-subj="applink-basic-test" href={basePath.prepend(`/app/${targetAppId}`)}>
|
||||
A with text
|
||||
</a>
|
||||
<br />
|
||||
<a href={basePath.prepend(`/app/${targetAppId}/some-path`)}>
|
||||
<div data-test-subj="applink-path-test">A link with a path in a nested div</div>
|
||||
</a>
|
||||
<br />
|
||||
<a
|
||||
data-test-subj="applink-hash-test"
|
||||
href={basePath.prepend(`/app/${targetAppId}/some-path#/some/hash`)}
|
||||
>
|
||||
<div>A link with a hash</div>
|
||||
</a>
|
||||
<br />
|
||||
<a href={basePath.prepend(`/app/${targetAppId}#bang`)}>
|
||||
<span data-test-subj="applink-nested-test">A text with a hash in a nested span</span>
|
||||
</a>
|
||||
<br />
|
||||
<a data-test-subj="applink-intra-test" href={basePath.prepend(`/app/${appId}/some-path`)}>
|
||||
Link to the same app
|
||||
</a>
|
||||
</div>
|
||||
</RedirectAppLinks>
|
||||
</div>
|
||||
);
|
||||
|
||||
interface AppOptions {
|
||||
appId: string;
|
||||
targetAppId: string;
|
||||
basePath: IBasePath;
|
||||
application: ApplicationStart;
|
||||
}
|
||||
|
||||
export const renderApp = (
|
||||
{ appId, basePath, targetAppId, application }: AppOptions,
|
||||
{ element }: AppMountParameters
|
||||
) => {
|
||||
ReactDOM.render(
|
||||
<FooApp
|
||||
appId={appId}
|
||||
targetAppId={targetAppId}
|
||||
basePath={basePath}
|
||||
application={application}
|
||||
/>,
|
||||
element
|
||||
);
|
||||
return () => ReactDOM.unmountComponentAtNode(element);
|
||||
};
|
24
test/plugin_functional/plugins/app_link_test/public/index.ts
Normal file
24
test/plugin_functional/plugins/app_link_test/public/index.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { PluginInitializer } from 'kibana/public';
|
||||
import { CoreAppLinkPlugin, CoreAppLinkPluginSetup, CoreAppLinkPluginStart } from './plugin';
|
||||
|
||||
export const plugin: PluginInitializer<CoreAppLinkPluginSetup, CoreAppLinkPluginStart> = () =>
|
||||
new CoreAppLinkPlugin();
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Plugin, CoreSetup, AppMountParameters } from 'kibana/public';
|
||||
import { renderApp } from './app';
|
||||
|
||||
export class CoreAppLinkPlugin implements Plugin<CoreAppLinkPluginSetup, CoreAppLinkPluginStart> {
|
||||
public async setup(core: CoreSetup, deps: {}) {
|
||||
core.application.register({
|
||||
id: 'applink_start',
|
||||
title: 'AppLink Start',
|
||||
mount: async (params: AppMountParameters) => {
|
||||
const [{ application }] = await core.getStartServices();
|
||||
return renderApp(
|
||||
{
|
||||
appId: 'applink_start',
|
||||
targetAppId: 'applink_end',
|
||||
basePath: core.http.basePath,
|
||||
application,
|
||||
},
|
||||
params
|
||||
);
|
||||
},
|
||||
});
|
||||
core.application.register({
|
||||
id: 'applink_end',
|
||||
title: 'AppLink End',
|
||||
mount: async (params: AppMountParameters) => {
|
||||
const [{ application }] = await core.getStartServices();
|
||||
return renderApp(
|
||||
{
|
||||
appId: 'applink_end',
|
||||
targetAppId: 'applink_start',
|
||||
basePath: core.http.basePath,
|
||||
application,
|
||||
},
|
||||
params
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
public start() {
|
||||
return {};
|
||||
}
|
||||
|
||||
public stop() {}
|
||||
}
|
||||
|
||||
export type CoreAppLinkPluginSetup = ReturnType<CoreAppLinkPlugin['setup']>;
|
||||
export type CoreAppLinkPluginStart = ReturnType<CoreAppLinkPlugin['start']>;
|
14
test/plugin_functional/plugins/app_link_test/tsconfig.json
Normal file
14
test/plugin_functional/plugins/app_link_test/tsconfig.json
Normal file
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "./target",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"index.ts",
|
||||
"public/**/*.ts",
|
||||
"public/**/*.tsx",
|
||||
"../../../../typings/**/*",
|
||||
],
|
||||
"exclude": []
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { PluginFunctionalProviderContext } from '../../services';
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function ({ loadTestFile }: PluginFunctionalProviderContext) {
|
||||
describe('application links', () => {
|
||||
loadTestFile(require.resolve('./redirect_app_links'));
|
||||
});
|
||||
}
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* Licensed to Elasticsearch B.V. under one or more contributor
|
||||
* license agreements. See the NOTICE file distributed with
|
||||
* this work for additional information regarding copyright
|
||||
* ownership. Elasticsearch B.V. licenses this file to you under
|
||||
* the Apache License, Version 2.0 (the "License"); you may
|
||||
* not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import url from 'url';
|
||||
import expect from '@kbn/expect';
|
||||
import { PluginFunctionalProviderContext } from '../../services';
|
||||
import '../../plugins/core_app_status/public/types';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__nonReloadedFlag?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const getPathWithHash = (absoluteUrl: string) => {
|
||||
const parsed = url.parse(absoluteUrl);
|
||||
return `${parsed.path}${parsed.hash ?? ''}`;
|
||||
};
|
||||
|
||||
// eslint-disable-next-line import/no-default-export
|
||||
export default function ({ getService, getPageObjects }: PluginFunctionalProviderContext) {
|
||||
const PageObjects = getPageObjects(['common']);
|
||||
const browser = getService('browser');
|
||||
const testSubjects = getService('testSubjects');
|
||||
|
||||
const setNonReloadedFlag = () => {
|
||||
return browser.executeAsync(async (cb: Function) => {
|
||||
window.__nonReloadedFlag = true;
|
||||
cb();
|
||||
});
|
||||
};
|
||||
const wasReloaded = (): Promise<boolean> => {
|
||||
return browser.executeAsync<boolean>(async (cb) => {
|
||||
const reloaded = window.__nonReloadedFlag !== true;
|
||||
cb(reloaded);
|
||||
return reloaded;
|
||||
});
|
||||
};
|
||||
|
||||
describe('app links', () => {
|
||||
beforeEach(async () => {
|
||||
await PageObjects.common.navigateToApp('applink_start');
|
||||
await setNonReloadedFlag();
|
||||
});
|
||||
|
||||
it('navigates to another app without performing a full page refresh', async () => {
|
||||
await testSubjects.click('applink-basic-test');
|
||||
|
||||
expect(await testSubjects.exists('app-applink_end')).to.eql(true);
|
||||
expect(getPathWithHash(await browser.getCurrentUrl())).to.eql('/app/applink_end');
|
||||
expect(await wasReloaded()).to.eql(false);
|
||||
});
|
||||
|
||||
it('handles the path of the link', async () => {
|
||||
await testSubjects.click('applink-path-test');
|
||||
|
||||
expect(await testSubjects.exists('app-applink_end')).to.eql(true);
|
||||
expect(getPathWithHash(await browser.getCurrentUrl())).to.eql('/app/applink_end/some-path');
|
||||
expect(await wasReloaded()).to.eql(false);
|
||||
});
|
||||
|
||||
it('handles hash in urls', async () => {
|
||||
await testSubjects.click('applink-hash-test');
|
||||
|
||||
expect(await testSubjects.exists('app-applink_end')).to.eql(true);
|
||||
expect(getPathWithHash(await browser.getCurrentUrl())).to.eql(
|
||||
'/app/applink_end/some-path#/some/hash'
|
||||
);
|
||||
expect(await wasReloaded()).to.eql(false);
|
||||
});
|
||||
|
||||
it('works in a nested dom structure', async () => {
|
||||
await testSubjects.click('applink-nested-test');
|
||||
|
||||
expect(await testSubjects.exists('app-applink_end')).to.eql(true);
|
||||
expect(getPathWithHash(await browser.getCurrentUrl())).to.eql('/app/applink_end#bang');
|
||||
expect(await wasReloaded()).to.eql(false);
|
||||
});
|
||||
|
||||
it('works for intra-app links', async () => {
|
||||
await testSubjects.click('applink-intra-test');
|
||||
|
||||
expect(await testSubjects.exists('app-applink_start')).to.eql(true);
|
||||
expect(getPathWithHash(await browser.getCurrentUrl())).to.eql('/app/applink_start/some-path');
|
||||
expect(await wasReloaded()).to.eql(false);
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue