[shared-ux] Migrate redirect app links component (#124197)

This commit is contained in:
Rachel Shen 2022-03-14 11:26:15 -06:00 committed by GitHub
parent 865539befd
commit 9d8f95f390
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 719 additions and 3 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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