mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[shared-ux] Migrate redirect app links component (#124197)
This commit is contained in:
parent
865539befd
commit
9d8f95f390
13 changed files with 719 additions and 3 deletions
|
@ -4,7 +4,7 @@ slug: /kibana-dev-docs/api/pluginA
|
|||
title: "pluginA"
|
||||
image: https://source.unsplash.com/400x175/?github
|
||||
summary: API docs for the pluginA plugin
|
||||
date: 2020-11-16
|
||||
date: 2022-02-14
|
||||
tags: ['contributor', 'dev', 'apidocs', 'kibana', 'pluginA']
|
||||
warning: This document is auto-generated and is meant to be viewed inside our experimental, new docs system. Reach out in #docs-engineering for more info.
|
||||
---
|
||||
|
|
|
@ -4,7 +4,7 @@ slug: /kibana-dev-docs/api/pluginA-foo
|
|||
title: "pluginA.foo"
|
||||
image: https://source.unsplash.com/400x175/?github
|
||||
summary: API docs for the pluginA.foo plugin
|
||||
date: 2020-11-16
|
||||
date: 2022-02-14
|
||||
tags: ['contributor', 'dev', 'apidocs', 'kibana', 'pluginA.foo']
|
||||
warning: This document is auto-generated and is meant to be viewed inside our experimental, new docs system. Reach out in #docs-engineering for more info.
|
||||
---
|
||||
|
|
|
@ -4,7 +4,7 @@ slug: /kibana-dev-docs/api/pluginB
|
|||
title: "pluginB"
|
||||
image: https://source.unsplash.com/400x175/?github
|
||||
summary: API docs for the pluginB plugin
|
||||
date: 2020-11-16
|
||||
date: 2022-02-14
|
||||
tags: ['contributor', 'dev', 'apidocs', 'kibana', 'pluginB']
|
||||
warning: This document is auto-generated and is meant to be viewed inside our experimental, new docs system. Reach out in #docs-engineering for more info.
|
||||
---
|
||||
|
|
|
@ -25,6 +25,8 @@ export const LazySolutionToolbarButton = React.lazy(() =>
|
|||
}))
|
||||
);
|
||||
|
||||
export const RedirectAppLinks = React.lazy(() => import('./redirect_app_links'));
|
||||
|
||||
/**
|
||||
* A `ExitFullScreenButton` component that is wrapped by the `withSuspense` HOC. This component can
|
||||
* be used directly by consumers and will load the `LazyExitFullScreenButton` component lazily with
|
||||
|
|
|
@ -0,0 +1,211 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { 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);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { ApplicationStart } from 'src/core/public';
|
||||
import { getClosestLink, hasActiveModifierKey } from '../utility/utils';
|
||||
|
||||
interface CreateCrossAppClickHandlerOptions {
|
||||
navigateToUrl: ApplicationStart['navigateToUrl'];
|
||||
container?: HTMLElement;
|
||||
}
|
||||
|
||||
export const createNavigateToUrlClickHandler = ({
|
||||
container,
|
||||
navigateToUrl,
|
||||
}: CreateCrossAppClickHandlerOptions): React.MouseEventHandler<HTMLElement> => {
|
||||
return (e) => {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
// see https://github.com/DefinitelyTyped/DefinitelyTyped/pull/12239
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
const link = getClosestLink(target, container);
|
||||
if (!link) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isNotEmptyHref = link.href;
|
||||
const hasNoTarget = link.target === '' || link.target === '_self';
|
||||
const isLeftClickOnly = e.button === 0;
|
||||
|
||||
if (
|
||||
isNotEmptyHref &&
|
||||
hasNoTarget &&
|
||||
isLeftClickOnly &&
|
||||
!e.defaultPrevented &&
|
||||
!hasActiveModifierKey(e)
|
||||
) {
|
||||
e.preventDefault();
|
||||
navigateToUrl(link.href);
|
||||
}
|
||||
};
|
||||
};
|
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
/* eslint-disable import/no-default-export */
|
||||
|
||||
import { RedirectAppLinks } from './redirect_app_links';
|
||||
export type { RedirectAppLinksProps } from './redirect_app_links';
|
||||
|
||||
export { RedirectAppLinks } from './redirect_app_links';
|
||||
|
||||
/**
|
||||
* Exporting the RedirectAppLinks component as a default export so it can be
|
||||
* loaded by React.lazy.
|
||||
*/
|
||||
export default RedirectAppLinks;
|
|
@ -0,0 +1,12 @@
|
|||
---
|
||||
id: sharedUX/Components/AppLink
|
||||
slug: /shared-ux/components/redirect-app-link
|
||||
title: Redirect App Link
|
||||
summary: The component for redirect links.
|
||||
tags: ['shared-ux', 'component']
|
||||
date: 2022-02-01
|
||||
---
|
||||
|
||||
> This documentation is in progress.
|
||||
|
||||
**This component has been refactored.** Instead of requiring the entire `application`, it instead takes just `navigateToUrl` and `currentAppId$`. This makes the component more lightweight.
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { EuiButton } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import { RedirectAppLinks } from './redirect_app_links';
|
||||
import mdx from './redirect_app_links.mdx';
|
||||
|
||||
export default {
|
||||
title: 'Redirect App Links',
|
||||
description: 'app links component that takes in an application id and navigation url.',
|
||||
parameters: {
|
||||
docs: {
|
||||
page: mdx,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Component = () => {
|
||||
return (
|
||||
<RedirectAppLinks
|
||||
navigateToUrl={() => Promise.resolve()}
|
||||
currentAppId$={new BehaviorSubject('test')}
|
||||
>
|
||||
<EuiButton
|
||||
data-test-subj="storybookButton"
|
||||
fill
|
||||
iconType="plusInCircle"
|
||||
onClick={action('button pressed')}
|
||||
>
|
||||
Test link
|
||||
</EuiButton>
|
||||
</RedirectAppLinks>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,237 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { MouseEvent } from 'react';
|
||||
import { mount } from 'enzyme';
|
||||
import { applicationServiceMock } from '../../../../../core/public/mocks';
|
||||
import { RedirectAppLinks } from './redirect_app_links';
|
||||
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
|
||||
navigateToUrl={application.navigateToUrl}
|
||||
currentAppId$={application.currentAppId$}
|
||||
>
|
||||
<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
|
||||
navigateToUrl={application.navigateToUrl}
|
||||
currentAppId$={application.currentAppId$}
|
||||
>
|
||||
<div>
|
||||
<a href="/mocked-anyway">
|
||||
<span>content</span>
|
||||
</a>
|
||||
</div>
|
||||
</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 navigateToUrl={jest.fn()} currentAppId$={application.currentAppId$}>
|
||||
<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 navigateToUrl={jest.fn()} currentAppId$={application.currentAppId$}>
|
||||
<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 navigateToUrl={jest.fn()} currentAppId$={application.currentAppId$}>
|
||||
<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 navigateToUrl={jest.fn()} currentAppId$={application.currentAppId$}>
|
||||
<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 navigateToUrl={jest.fn()} currentAppId$={application.currentAppId$}>
|
||||
<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 navigateToUrl={jest.fn()} currentAppId$={application.currentAppId$}>
|
||||
<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 navigateToUrl={jest.fn()} currentAppId$={application.currentAppId$}>
|
||||
<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,65 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import React, { FunctionComponent, useRef, useMemo } from 'react';
|
||||
import useObservable from 'react-use/lib/useObservable';
|
||||
import { ApplicationStart } from 'src/core/public';
|
||||
import { createNavigateToUrlClickHandler } from './click_handler';
|
||||
|
||||
type Props = React.HTMLAttributes<HTMLDivElement> &
|
||||
Pick<ApplicationStart, 'navigateToUrl' | 'currentAppId$'>;
|
||||
|
||||
export interface RedirectAppLinksProps extends Props {
|
||||
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
|
||||
* <RedirectAppLinks navigateToUrl={() => url} currentAppId$={observableAppId}>
|
||||
* <a href="/base-path/app/another-app/some-path">Go to another-app</a>
|
||||
* </RedirectAppLinks>
|
||||
* ```
|
||||
*
|
||||
* @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<RedirectAppLinksProps> = ({
|
||||
navigateToUrl,
|
||||
currentAppId$,
|
||||
children,
|
||||
...otherProps
|
||||
}) => {
|
||||
const currentAppId = useObservable(currentAppId$, undefined);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const clickHandler = useMemo(
|
||||
() =>
|
||||
containerRef.current && currentAppId
|
||||
? createNavigateToUrlClickHandler({
|
||||
container: containerRef.current,
|
||||
navigateToUrl,
|
||||
})
|
||||
: undefined,
|
||||
[currentAppId, navigateToUrl]
|
||||
);
|
||||
|
||||
return (
|
||||
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
|
||||
<div ref={containerRef} {...otherProps} onClick={clickHandler}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -0,0 +1,41 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import { 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);
|
||||
});
|
||||
});
|
37
src/plugins/shared_ux/public/components/utility/utils.ts
Normal file
37
src/plugins/shared_ux/public/components/utility/utils.ts
Normal file
|
@ -0,0 +1,37 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the Elastic License
|
||||
* 2.0 and the Server Side Public License, v 1; you may not use this file except
|
||||
* in compliance with, at your election, the Elastic License 2.0 or the Server
|
||||
* Side Public License, v 1.
|
||||
*/
|
||||
|
||||
import 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 | null | undefined,
|
||||
container?: HTMLElement
|
||||
): HTMLAnchorElement | undefined => {
|
||||
let current = element;
|
||||
do {
|
||||
if (current?.tagName.toLowerCase() === 'a') {
|
||||
return current as HTMLAnchorElement;
|
||||
}
|
||||
const parent = current?.parentElement;
|
||||
if (!parent || parent === document.body || parent === container) {
|
||||
break;
|
||||
}
|
||||
current = parent;
|
||||
} while (parent || parent !== document.body || parent !== container);
|
||||
return undefined;
|
||||
};
|
Loading…
Add table
Add a link
Reference in a new issue