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:
Pierre Gayvallet 2020-06-12 13:02:01 +02:00 committed by GitHub
parent c307c8622f
commit b7057b7b22
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 1255 additions and 139 deletions

View file

@ -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 {
/**

View file

@ -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;

View 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);
});
});

View 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);
}
};
};

View 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';

View file

@ -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);
});
});

View file

@ -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>
);
};

View 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);
});
});

View 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;
};

View file

@ -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() {

View file

@ -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>
`;

View file

@ -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>
);
}

View file

@ -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'),

View file

@ -0,0 +1,7 @@
{
"id": "app_link_test",
"version": "0.0.1",
"kibanaVersion": "kibana",
"server": false,
"ui": true
}

View 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"
}
}

View 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);
};

View 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();

View file

@ -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']>;

View file

@ -0,0 +1,14 @@
{
"extends": "../../../../tsconfig.json",
"compilerOptions": {
"outDir": "./target",
"skipLibCheck": true
},
"include": [
"index.ts",
"public/**/*.ts",
"public/**/*.tsx",
"../../../../typings/**/*",
],
"exclude": []
}

View file

@ -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'));
});
}

View file

@ -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);
});
});
}