[Shared UX] Redirect App Link to package (#131575)

* [Shared UX] Redirect App Link to package

* Fix types
This commit is contained in:
Clint Andrew Hall 2022-05-09 11:53:11 -05:00 committed by GitHub
parent 53d170aeca
commit 5bdad23a7e
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
35 changed files with 1273 additions and 850 deletions

View file

@ -180,6 +180,7 @@
"@kbn/server-route-repository": "link:bazel-bin/packages/kbn-server-route-repository",
"@kbn/shared-ux-button-exit-full-screen": "link:bazel-bin/packages/shared-ux/button/exit_full_screen",
"@kbn/shared-ux-components": "link:bazel-bin/packages/kbn-shared-ux-components",
"@kbn/shared-ux-link-redirect-app": "link:bazel-bin/packages/shared-ux/link/redirect_app",
"@kbn/shared-ux-services": "link:bazel-bin/packages/kbn-shared-ux-services",
"@kbn/shared-ux-storybook": "link:bazel-bin/packages/kbn-shared-ux-storybook",
"@kbn/shared-ux-utility": "link:bazel-bin/packages/kbn-shared-ux-utility",
@ -667,6 +668,7 @@
"@types/kbn__server-route-repository": "link:bazel-bin/packages/kbn-server-route-repository/npm_module_types",
"@types/kbn__shared-ux-button-exit-full-screen": "link:bazel-bin/packages/shared-ux/button/exit_full_screen/npm_module_types",
"@types/kbn__shared-ux-components": "link:bazel-bin/packages/kbn-shared-ux-components/npm_module_types",
"@types/kbn__shared-ux-link-redirect-app": "link:bazel-bin/packages/shared-ux/link/redirect_app/npm_module_types",
"@types/kbn__shared-ux-services": "link:bazel-bin/packages/kbn-shared-ux-services/npm_module_types",
"@types/kbn__shared-ux-storybook": "link:bazel-bin/packages/kbn-shared-ux-storybook/npm_module_types",
"@types/kbn__shared-ux-utility": "link:bazel-bin/packages/kbn-shared-ux-utility/npm_module_types",

View file

@ -110,6 +110,7 @@ filegroup(
"//packages/kbn-utility-types:build",
"//packages/kbn-utils:build",
"//packages/shared-ux/button/exit_full_screen:build",
"//packages/shared-ux/link/redirect_app:build",
],
)
@ -203,6 +204,7 @@ filegroup(
"//packages/kbn-utility-types:build_types",
"//packages/kbn-utils:build_types",
"//packages/shared-ux/button/exit_full_screen:build_types",
"//packages/shared-ux/link/redirect_app:build_types",
],
)

View file

@ -40,8 +40,9 @@ NPM_MODULE_EXTRA_FILES = [
# "@npm//name-of-package"
# eg. "@npm//lodash"
RUNTIME_DEPS = [
"//packages/kbn-i18n",
"//packages/kbn-i18n-react",
"//packages/kbn-i18n",
"//packages/shared-ux/link/redirect_app",
"//packages/kbn-shared-ux-services",
"//packages/kbn-shared-ux-storybook",
"//packages/kbn-shared-ux-utility",
@ -51,6 +52,7 @@ RUNTIME_DEPS = [
"@npm//classnames",
"@npm//react-use",
"@npm//react",
"@npm//rxjs",
"@npm//url-loader",
]
@ -64,12 +66,13 @@ RUNTIME_DEPS = [
#
# References to NPM packages work the same as RUNTIME_DEPS
TYPES_DEPS = [
"//packages/kbn-i18n:npm_module_types",
"//packages/kbn-ambient-ui-types",
"//packages/kbn-i18n-react:npm_module_types",
"//packages/kbn-i18n:npm_module_types",
"//packages/shared-ux/link/redirect_app:npm_module_types",
"//packages/kbn-shared-ux-services:npm_module_types",
"//packages/kbn-shared-ux-storybook:npm_module_types",
"//packages/kbn-shared-ux-utility:npm_module_types",
"//packages/kbn-ambient-ui-types",
"@npm//@types/node",
"@npm//@types/jest",
"@npm//@types/react",
@ -78,6 +81,7 @@ TYPES_DEPS = [
"@npm//@emotion/css",
"@npm//@elastic/eui",
"@npm//react-use",
"@npm//rxjs",
]
jsts_transpiler(

View file

@ -15,8 +15,6 @@ export const LazyToolbarButton = React.lazy(() =>
}))
);
export const RedirectAppLinks = React.lazy(() => import('./redirect_app_links'));
/**
* A `ToolbarButton` component that is wrapped by the `withSuspense` HOC. This component can
* be used directly by consumers and will load the `LazyToolbarButton` component lazily with

View file

@ -1,112 +1,48 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ElasticAgentCardComponent props button 1`] = `
<RedirectAppLinks
currentAppId$={
Observable {
"source": Subject {
"closed": false,
"currentObservers": null,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
},
}
}
navigateToUrl={[MockFunction]}
>
<NoDataCard
button="Button"
description="Use Elastic Agent for a simple, unified way to collect data from your machines."
image="test-file-stub"
title="Add Elastic Agent"
/>
</RedirectAppLinks>
<NoDataCard
button="Button"
description="Use Elastic Agent for a simple, unified way to collect data from your machines."
image="test-file-stub"
title="Add Elastic Agent"
/>
`;
exports[`ElasticAgentCardComponent props href 1`] = `
<RedirectAppLinks
currentAppId$={
Observable {
"source": Subject {
"closed": false,
"currentObservers": null,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
},
}
}
navigateToUrl={[MockFunction]}
>
<NoDataCard
description="Use Elastic Agent for a simple, unified way to collect data from your machines."
href="some path"
image="test-file-stub"
title="Add Elastic Agent"
/>
</RedirectAppLinks>
<NoDataCard
description="Use Elastic Agent for a simple, unified way to collect data from your machines."
href="some path"
image="test-file-stub"
title="Add Elastic Agent"
/>
`;
exports[`ElasticAgentCardComponent renders 1`] = `
<RedirectAppLinks
currentAppId$={
Observable {
"source": Subject {
"closed": false,
"currentObservers": null,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
},
}
}
navigateToUrl={[MockFunction]}
>
<NoDataCard
description="Use Elastic Agent for a simple, unified way to collect data from your machines."
image="test-file-stub"
title="Add Elastic Agent"
/>
</RedirectAppLinks>
<NoDataCard
description="Use Elastic Agent for a simple, unified way to collect data from your machines."
image="test-file-stub"
title="Add Elastic Agent"
/>
`;
exports[`ElasticAgentCardComponent renders with canAccessFleet false 1`] = `
<RedirectAppLinks
currentAppId$={
Observable {
"source": Subject {
"closed": false,
"currentObservers": null,
"hasError": false,
"isStopped": false,
"observers": Array [],
"thrownError": null,
},
}
<NoDataCard
description={
<EuiTextColor
color="default"
>
This integration is not yet enabled. Your administrator has the required permissions to turn it on.
</EuiTextColor>
}
navigateToUrl={[MockFunction]}
>
<NoDataCard
description={
<EuiTextColor
color="default"
>
This integration is not yet enabled. Your administrator has the required permissions to turn it on.
</EuiTextColor>
}
image="test-file-stub"
isDisabled={true}
title={
<EuiTextColor
color="default"
>
Contact your administrator
</EuiTextColor>
}
/>
</RedirectAppLinks>
image="test-file-stub"
isDisabled={true}
title={
<EuiTextColor
color="default"
>
Contact your administrator
</EuiTextColor>
}
/>
`;

View file

@ -4,7 +4,9 @@ exports[`ElasticAgentCard renders 1`] = `
<SharedUxServicesProvider
application={
Object {
"currentAppId$": Observable {},
"currentAppId$": Observable {
"_subscribe": [Function],
},
"navigateToUrl": [Function],
}
}
@ -157,223 +159,235 @@ exports[`ElasticAgentCard renders 1`] = `
}
>
<ElasticAgentCard>
<ElasticAgentCardComponent
canAccessFleet={true}
currentAppId$={Observable {}}
href="/app/integrations/browse"
<RedirectAppLinks
currentAppId="abc123"
navigateToUrl={[Function]}
>
<RedirectAppLinks
currentAppId$={Observable {}}
<RedirectAppLinksProvider
currentAppId="abc123"
navigateToUrl={[Function]}
>
<div>
<NoDataCard
description="Use Elastic Agent for a simple, unified way to collect data from your machines."
href="/app/integrations/browse"
image="test-file-stub"
title="Add Elastic Agent"
<RedirectAppLinks>
<RedirectAppLinks
currentAppId="abc123"
navigateToUrl={[Function]}
>
<EuiCard
css={
Object {
"marginInline": "auto",
"maxWidth": 400,
}
}
description="Use Elastic Agent for a simple, unified way to collect data from your machines."
footer={
<EuiButton
fill={true}
>
Add Elastic Agent
</EuiButton>
}
href="/app/integrations/browse"
image="test-file-stub"
paddingSize="l"
title="Add Elastic Agent"
<div
onClick={[Function]}
>
<EuiPanel
css="unknown styles"
element="div"
hasShadow={true}
onClick={[Function]}
paddingSize="l"
<ElasticAgentCardComponent
canAccessFleet={true}
href="/app/integrations/browse"
>
<Insertion
cache={
Object {
"insert": [Function],
"inserted": Object {
"1hu4pg0-EuiCard": true,
},
"key": "css",
"nonce": undefined,
"registered": Object {
"css-1hu4pg0-EuiCard": "max-width:400px;margin-inline:auto;;label:EuiCard;",
},
"sheet": StyleSheet {
"_alreadyInsertedOrderInsensitiveRule": true,
"_insertTag": [Function],
"before": null,
"container": <head>
<style
data-emotion="css"
data-s=""
>
.css-1hu4pg0-EuiCard{max-width:400px;margin-inline:auto;}
</style>
<style
data-styled="active"
data-styled-version="5.1.0"
/>
</head>,
"ctr": 1,
"insertionPoint": undefined,
"isSpeedy": false,
"key": "css",
"nonce": undefined,
"prepend": undefined,
"tags": Array [
<style
data-emotion="css"
data-s=""
>
.css-1hu4pg0-EuiCard{max-width:400px;margin-inline:auto;}
</style>,
],
},
}
}
isStringTag={false}
serialized={
Object {
"map": undefined,
"name": "1hu4pg0-EuiCard",
"next": undefined,
"styles": "max-width:400px;margin-inline:auto;;label:EuiCard;",
"toString": [Function],
}
}
/>
<EuiPanel
className="euiCard euiCard--centerAligned euiCard--isClickable css-1hu4pg0-EuiCard"
element="div"
hasShadow={true}
onClick={[Function]}
paddingSize="l"
<NoDataCard
description="Use Elastic Agent for a simple, unified way to collect data from your machines."
href="/app/integrations/browse"
image="test-file-stub"
title="Add Elastic Agent"
>
<div
className="euiPanel euiPanel--paddingLarge euiPanel--borderRadiusMedium euiPanel--plain euiPanel--hasShadow euiPanel--isClickable euiCard euiCard--centerAligned euiCard--isClickable css-1hu4pg0-EuiCard"
onClick={[Function]}
>
<div
className="euiCard__top"
>
<div
className="euiCard__image"
>
<img
alt=""
src="test-file-stub"
/>
</div>
</div>
<div
className="euiCard__content"
>
<EuiTitle
className="euiCard__title"
id="generated-idTitle"
size="s"
>
<span
className="euiTitle euiTitle--small euiCard__title"
id="generated-idTitle"
>
<a
aria-describedby="generated-idDescription"
className="euiCard__titleAnchor"
href="/app/integrations/browse"
rel="noreferrer"
>
Add Elastic Agent
</a>
</span>
</EuiTitle>
<EuiText
className="euiCard__description"
id="generated-idDescription"
size="s"
>
<div
className="euiText euiText--small euiCard__description"
id="generated-idDescription"
>
<p>
Use Elastic Agent for a simple, unified way to collect data from your machines.
</p>
</div>
</EuiText>
</div>
<div
className="euiCard__footer"
>
<EuiCard
css={
Object {
"marginInline": "auto",
"maxWidth": 400,
}
}
description="Use Elastic Agent for a simple, unified way to collect data from your machines."
footer={
<EuiButton
fill={true}
>
<EuiButtonDisplay
baseClassName="euiButton"
disabled={false}
element="button"
fill={true}
isDisabled={false}
type="button"
Add Elastic Agent
</EuiButton>
}
href="/app/integrations/browse"
image="test-file-stub"
paddingSize="l"
title="Add Elastic Agent"
>
<EuiPanel
css="unknown styles"
element="div"
hasShadow={true}
onClick={[Function]}
paddingSize="l"
>
<Insertion
cache={
Object {
"insert": [Function],
"inserted": Object {
"1hu4pg0-EuiCard": true,
},
"key": "css",
"nonce": undefined,
"registered": Object {
"css-1hu4pg0-EuiCard": "max-width:400px;margin-inline:auto;;label:EuiCard;",
},
"sheet": StyleSheet {
"_alreadyInsertedOrderInsensitiveRule": true,
"_insertTag": [Function],
"before": null,
"container": <head>
<style
data-emotion="css"
data-s=""
>
.css-1hu4pg0-EuiCard{max-width:400px;margin-inline:auto;}
</style>
<style
data-styled="active"
data-styled-version="5.1.0"
/>
</head>,
"ctr": 1,
"insertionPoint": undefined,
"isSpeedy": false,
"key": "css",
"nonce": undefined,
"prepend": undefined,
"tags": Array [
<style
data-emotion="css"
data-s=""
>
.css-1hu4pg0-EuiCard{max-width:400px;margin-inline:auto;}
</style>,
],
},
}
}
isStringTag={false}
serialized={
Object {
"map": undefined,
"name": "1hu4pg0-EuiCard",
"next": undefined,
"styles": "max-width:400px;margin-inline:auto;;label:EuiCard;",
"toString": [Function],
}
}
/>
<EuiPanel
className="euiCard euiCard--centerAligned euiCard--isClickable css-1hu4pg0-EuiCard"
element="div"
hasShadow={true}
onClick={[Function]}
paddingSize="l"
>
<div
className="euiPanel euiPanel--paddingLarge euiPanel--borderRadiusMedium euiPanel--plain euiPanel--hasShadow euiPanel--isClickable euiCard euiCard--centerAligned euiCard--isClickable css-1hu4pg0-EuiCard"
onClick={[Function]}
>
<button
className="euiButton euiButton--primary euiButton--fill"
disabled={false}
style={
Object {
"minWidth": undefined,
}
}
type="button"
<div
className="euiCard__top"
>
<EuiButtonContent
className="euiButton__content"
iconSide="left"
textProps={
Object {
"className": "euiButton__text",
}
}
<div
className="euiCard__image"
>
<img
alt=""
src="test-file-stub"
/>
</div>
</div>
<div
className="euiCard__content"
>
<EuiTitle
className="euiCard__title"
id="generated-idTitle"
size="s"
>
<span
className="euiButtonContent euiButton__content"
className="euiTitle euiTitle--small euiCard__title"
id="generated-idTitle"
>
<span
className="euiButton__text"
<a
aria-describedby="generated-idDescription"
className="euiCard__titleAnchor"
href="/app/integrations/browse"
rel="noreferrer"
>
Add Elastic Agent
</span>
</a>
</span>
</EuiButtonContent>
</button>
</EuiButtonDisplay>
</EuiButton>
</div>
</div>
</EuiPanel>
</EuiPanel>
</EuiCard>
</NoDataCard>
</div>
</RedirectAppLinks>
</ElasticAgentCardComponent>
</EuiTitle>
<EuiText
className="euiCard__description"
id="generated-idDescription"
size="s"
>
<div
className="euiText euiText--small euiCard__description"
id="generated-idDescription"
>
<p>
Use Elastic Agent for a simple, unified way to collect data from your machines.
</p>
</div>
</EuiText>
</div>
<div
className="euiCard__footer"
>
<EuiButton
fill={true}
>
<EuiButtonDisplay
baseClassName="euiButton"
disabled={false}
element="button"
fill={true}
isDisabled={false}
type="button"
>
<button
className="euiButton euiButton--primary euiButton--fill"
disabled={false}
style={
Object {
"minWidth": undefined,
}
}
type="button"
>
<EuiButtonContent
className="euiButton__content"
iconSide="left"
textProps={
Object {
"className": "euiButton__text",
}
}
>
<span
className="euiButtonContent euiButton__content"
>
<span
className="euiButton__text"
>
Add Elastic Agent
</span>
</span>
</EuiButtonContent>
</button>
</EuiButtonDisplay>
</EuiButton>
</div>
</div>
</EuiPanel>
</EuiPanel>
</EuiCard>
</NoDataCard>
</ElasticAgentCardComponent>
</div>
</RedirectAppLinks>
</RedirectAppLinks>
</RedirectAppLinksProvider>
</RedirectAppLinks>
</ElasticAgentCard>
</SharedUxServicesProvider>
`;

View file

@ -10,31 +10,15 @@ import { shallow } from 'enzyme';
import React from 'react';
import { ElasticAgentCardComponent } from './elastic_agent_card.component';
import { NoDataCard } from './no_data_card';
import { Subject } from 'rxjs';
describe('ElasticAgentCardComponent', () => {
const navigateToUrl = jest.fn();
const currentAppId$ = new Subject<string | undefined>().asObservable();
test('renders', () => {
const component = shallow(
<ElasticAgentCardComponent
canAccessFleet={true}
navigateToUrl={navigateToUrl}
currentAppId$={currentAppId$}
/>
);
const component = shallow(<ElasticAgentCardComponent canAccessFleet={true} />);
expect(component).toMatchSnapshot();
});
test('renders with canAccessFleet false', () => {
const component = shallow(
<ElasticAgentCardComponent
canAccessFleet={false}
navigateToUrl={navigateToUrl}
currentAppId$={currentAppId$}
/>
);
const component = shallow(<ElasticAgentCardComponent canAccessFleet={false} />);
expect(component.find(NoDataCard).props().isDisabled).toBe(true);
expect(component).toMatchSnapshot();
});
@ -42,12 +26,7 @@ describe('ElasticAgentCardComponent', () => {
describe('props', () => {
test('button', () => {
const component = shallow(
<ElasticAgentCardComponent
button="Button"
canAccessFleet={true}
navigateToUrl={navigateToUrl}
currentAppId$={currentAppId$}
/>
<ElasticAgentCardComponent button="Button" canAccessFleet={true} />
);
expect(component.find(NoDataCard).props().button).toBe('Button');
expect(component).toMatchSnapshot();
@ -55,12 +34,7 @@ describe('ElasticAgentCardComponent', () => {
test('href', () => {
const component = shallow(
<ElasticAgentCardComponent
canAccessFleet={true}
href={'some path'}
navigateToUrl={navigateToUrl}
currentAppId$={currentAppId$}
/>
<ElasticAgentCardComponent canAccessFleet={true} href={'some path'} />
);
expect(component.find(NoDataCard).props().href).toBe('some path');
expect(component).toMatchSnapshot();

View file

@ -9,16 +9,12 @@
import React, { FunctionComponent } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiTextColor } from '@elastic/eui';
import { Observable } from 'rxjs';
import { ElasticAgentCardProps } from './types';
import { NoDataCard } from './no_data_card';
import ElasticAgentCardIllustration from './assets/elastic_agent_card.svg';
import { RedirectAppLinks } from '../../../redirect_app_links';
export type ElasticAgentCardComponentProps = ElasticAgentCardProps & {
canAccessFleet: boolean;
navigateToUrl: (url: string) => Promise<void>;
currentAppId$: Observable<string | undefined>;
};
const noPermissionTitle = i18n.translate(
@ -54,32 +50,19 @@ const elasticAgentCardDescription = i18n.translate(
*/
export const ElasticAgentCardComponent: FunctionComponent<ElasticAgentCardComponentProps> = ({
canAccessFleet,
title,
navigateToUrl,
currentAppId$,
title = elasticAgentCardTitle,
...cardRest
}) => {
const noAccessCard = (
<NoDataCard
image={ElasticAgentCardIllustration}
title={<EuiTextColor color="default">{noPermissionTitle}</EuiTextColor>}
description={<EuiTextColor color="default">{noPermissionDescription}</EuiTextColor>}
isDisabled
{...cardRest}
/>
);
const card = (
<NoDataCard
image={ElasticAgentCardIllustration}
title={title || elasticAgentCardTitle}
description={elasticAgentCardDescription}
{...cardRest}
/>
);
const props = canAccessFleet
? {
title,
description: elasticAgentCardDescription,
}
: {
title: <EuiTextColor color="default">{noPermissionTitle}</EuiTextColor>,
description: <EuiTextColor color="default">{noPermissionDescription}</EuiTextColor>,
isDisabled: true,
};
return (
<RedirectAppLinks navigateToUrl={navigateToUrl} currentAppId$={currentAppId$}>
{canAccessFleet ? card : noAccessCard}
</RedirectAppLinks>
);
return <NoDataCard image={ElasticAgentCardIllustration} {...props} {...cardRest} />;
};

View file

@ -7,29 +7,23 @@
*/
import React from 'react';
import { applicationServiceFactory } from '@kbn/shared-ux-storybook';
import {
ElasticAgentCardComponent,
ElasticAgentCardComponentProps,
ElasticAgentCardComponent as Component,
ElasticAgentCardComponentProps as ComponentProps,
} from './elastic_agent_card.component';
import { ElasticAgentCard } from './elastic_agent_card';
export default {
title: 'Page Template/No Data/Elastic Agent Data Card',
description: 'A solution-specific wrapper around NoDataCard, to be used on NoData page',
};
type Params = Pick<ElasticAgentCardComponentProps, 'canAccessFleet'>;
type Params = Pick<ComponentProps, 'canAccessFleet'>;
export const PureComponent = (params: Params) => {
const { currentAppId$, navigateToUrl } = applicationServiceFactory();
return (
<ElasticAgentCardComponent
{...params}
currentAppId$={currentAppId$}
navigateToUrl={navigateToUrl}
/>
);
return <Component {...params} />;
};
PureComponent.argTypes = {
@ -38,3 +32,7 @@ PureComponent.argTypes = {
defaultValue: true,
},
};
export const ConnectedComponent = () => {
return <ElasticAgentCard href="#" />;
};

View file

@ -6,8 +6,10 @@
* Side Public License, v 1.
*/
import React from 'react';
import React, { useMemo } from 'react';
import { useApplication, useHttp, usePermissions } from '@kbn/shared-ux-services';
import { RedirectAppLinks } from '@kbn/shared-ux-link-redirect-app';
import useObservable from 'react-use/lib/useObservable';
import { ElasticAgentCardProps } from './types';
import { ElasticAgentCardComponent } from './elastic_agent_card.component';
@ -16,27 +18,28 @@ export const ElasticAgentCard = (props: ElasticAgentCardProps) => {
const { canAccessFleet } = usePermissions();
const { addBasePath } = useHttp();
const { navigateToUrl, currentAppId$ } = useApplication();
const currentAppId = useObservable(currentAppId$);
const createHref = () => {
const { href, category } = props;
if (href) {
return href;
const { href: srcHref, category } = props;
const href = useMemo(() => {
if (srcHref) {
return srcHref;
}
// TODO: get this URL from a locator
const prefix = '/app/integrations/browse';
if (category) {
return addBasePath(`${prefix}/${category}`);
}
return addBasePath(prefix);
};
}, [addBasePath, srcHref, category]);
return (
<ElasticAgentCardComponent
{...props}
href={createHref()}
canAccessFleet={canAccessFleet}
navigateToUrl={navigateToUrl}
currentAppId$={currentAppId$}
/>
<RedirectAppLinks {...{ currentAppId, navigateToUrl }}>
<ElasticAgentCardComponent {...{ ...props, href, canAccessFleet }} />
</RedirectAppLinks>
);
};

View file

@ -1,48 +0,0 @@
/*
* 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 { getClosestLink, hasActiveModifierKey } from '@kbn/shared-ux-utility';
interface CreateCrossAppClickHandlerOptions {
navigateToUrl(url: string): Promise<void>;
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

@ -1,18 +0,0 @@
/*
* 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

@ -1,12 +0,0 @@
---
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

@ -1,43 +0,0 @@
/*
* 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

@ -1,249 +0,0 @@
/*
* 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 { BehaviorSubject } from 'rxjs';
import { RedirectAppLinks } from './redirect_app_links';
export type UnmountCallback = () => void;
export type MountPoint<T extends HTMLElement = HTMLElement> = (element: T) => UnmountCallback;
const createServiceMock = () => {
const currentAppId$ = new BehaviorSubject<string>('currentApp');
return {
currentAppId$: currentAppId$.asObservable(),
navigateToApp: jest.fn(),
navigateToUrl: jest.fn(),
};
};
/* eslint-disable jsx-a11y/click-events-have-key-events */
describe('RedirectAppLinks', () => {
let application = createServiceMock();
beforeEach(() => {
application = createServiceMock();
});
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

@ -1,68 +0,0 @@
/*
* 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, { useRef, useMemo } from 'react';
import type { HTMLAttributes, DetailedHTMLProps, FC } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { Observable } from 'rxjs';
import { createNavigateToUrlClickHandler } from './click_handler';
type DivProps = DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>;
/**
* TODO: this interface recreates props from the `ApplicationStart` interface.
* see: https://github.com/elastic/kibana/issues/127695
*/
export interface RedirectAppLinksProps extends DivProps {
currentAppId$: Observable<string | undefined>;
navigateToUrl(url: string): Promise<void>;
}
/**
* 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: FC<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

@ -4,7 +4,9 @@ exports[`<IconButtonGroup /> is rendered 1`] = `
<SharedUxServicesProvider
application={
Object {
"currentAppId$": Observable {},
"currentAppId$": Observable {
"_subscribe": [Function],
},
"navigateToUrl": [Function],
}
}

View file

@ -4,7 +4,9 @@ exports[`<ToolbarButton /> is rendered 1`] = `
<SharedUxServicesProvider
application={
Object {
"currentAppId$": Observable {},
"currentAppId$": Observable {
"_subscribe": [Function],
},
"navigateToUrl": [Function],
}
}

View file

@ -17,5 +17,7 @@ export type MockApplicationServiceFactory = ServiceFactory<SharedUxApplicationSe
*/
export const applicationServiceFactory: MockApplicationServiceFactory = () => ({
navigateToUrl: () => Promise.resolve(),
currentAppId$: new Observable(),
currentAppId$: new Observable((subscriber) => {
subscriber.next('abc123');
}),
});

View file

@ -16,8 +16,8 @@ export type ApplicationServiceFactory = ServiceFactory<SharedUxApplicationServic
* A factory function for creating for creating a storybook implementation of `SharedUXApplicationService`.
*/
export const applicationServiceFactory: ApplicationServiceFactory = () => ({
navigateToUrl: () => {
action('NavigateToUrl');
navigateToUrl: (url) => {
action('navigateToUrl')(url);
return Promise.resolve();
},
currentAppId$: new BehaviorSubject('123'),

View file

@ -0,0 +1,140 @@
load("@npm//@bazel/typescript:index.bzl", "ts_config")
load("@build_bazel_rules_nodejs//:index.bzl", "js_library")
load("//src/dev/bazel:index.bzl", "jsts_transpiler", "pkg_npm", "pkg_npm_types", "ts_project")
PKG_DIRNAME = "redirect_app"
PKG_REQUIRE_NAME = "@kbn/shared-ux-link-redirect-app"
SOURCE_FILES = glob(
[
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.mdx",
],
exclude = [
"**/*.test.*",
],
)
SRCS = SOURCE_FILES
filegroup(
name = "srcs",
srcs = SRCS,
)
NPM_MODULE_EXTRA_FILES = [
"package.json",
]
# In this array place runtime dependencies, including other packages and NPM packages
# which must be available for this code to run.
#
# To reference other packages use:
# "//repo/relative/path/to/package"
# eg. "//packages/kbn-utils"
#
# To reference a NPM package use:
# "@npm//name-of-package"
# eg. "@npm//lodash"
RUNTIME_DEPS = [
"@npm//@elastic/eui",
"@npm//@storybook/addon-actions",
"@npm//react-use",
"@npm//react",
"@npm//rxjs",
"//packages/kbn-shared-ux-utility",
]
# In this array place dependencies necessary to build the types, which will include the
# :npm_module_types target of other packages and packages from NPM, including @types/*
# packages.
#
# To reference the types for another package use:
# "//repo/relative/path/to/package:npm_module_types"
# eg. "//packages/kbn-utils:npm_module_types"
#
# References to NPM packages work the same as RUNTIME_DEPS
TYPES_DEPS = [
"@npm//@elastic/eui",
"@npm//@storybook/addon-actions",
"@npm//@types/jest",
"@npm//@types/node",
"@npm//@types/react",
"@npm//rxjs",
"@npm//react-use",
"//packages/kbn-ambient-ui-types",
"//packages/kbn-shared-ux-utility:npm_module_types",
]
jsts_transpiler(
name = "target_node",
srcs = SRCS,
build_pkg_name = package_name(),
)
jsts_transpiler(
name = "target_web",
srcs = SRCS,
build_pkg_name = package_name(),
web = True,
additional_args = [
"--copy-files",
"--quiet"
],
)
ts_config(
name = "tsconfig",
src = "tsconfig.json",
deps = [
"//:tsconfig.base.json",
"//:tsconfig.bazel.json",
],
)
ts_project(
name = "tsc_types",
args = ['--pretty'],
srcs = SRCS,
deps = TYPES_DEPS,
declaration = True,
emit_declaration_only = True,
out_dir = "target_types",
root_dir = "src",
tsconfig = ":tsconfig",
)
js_library(
name = PKG_DIRNAME,
srcs = NPM_MODULE_EXTRA_FILES,
deps = RUNTIME_DEPS + [":target_node", ":target_web"],
package_name = PKG_REQUIRE_NAME,
visibility = ["//visibility:public"],
)
pkg_npm(
name = "npm_module",
deps = [":" + PKG_DIRNAME],
)
filegroup(
name = "build",
srcs = [":npm_module"],
visibility = ["//visibility:public"],
)
pkg_npm_types(
name = "npm_module_types",
srcs = SRCS,
deps = [":tsc_types"],
package_name = PKG_REQUIRE_NAME,
tsconfig = ":tsconfig",
visibility = ["//visibility:public"],
)
filegroup(
name = "build_types",
srcs = [":npm_module_types"],
visibility = ["//visibility:public"],
)

View file

@ -0,0 +1,86 @@
---
id: sharedUX/Components/AppLink
slug: /shared-ux/components/redirect-app-links
title: Redirect App Links
summary: A component for redirecting links contained within it to the appropriate Kibana solution without a page refresh.
tags: ['shared-ux', 'component']
date: 2022-05-04
---
## Description
This component is an "area of effect" component, which produces a container that intercepts actions for specific elements within it. In this case, the container intercepts clicks on anchor elements and redirects them to Kibana solutions without a page refresh.
## Pure Component
The pure component allows you create a container to intercept clicks without contextual services, (e.g. Kibana Core). This likely does not have much utility for solutions in Kibana, but rather is useful for shared components where we want to ensure clicks are redirected correctly.
```tsx
import { RedirectAppLinksComponent as RedirectAppLinks } from '@kbn/shared-ux-links-redirect-app';
<RedirectAppLinks currentAppId="appId" navigateToUrl={(url) => { ... }}>
<a href="/base-path/app/another-app/some-path">Go to another-app</a>
</RedirectAppLinks>
```
## Connected Component
The connected component uses a React Context to access services that provide the current app id and a function to navigate to a new url. This is useful in that a solution can wrap their entire application in the context and use `RedirectAppLinks` in specific areas.
```tsx
import { RedirectAppLinksContainer as RedirectAppLinks, RedirectAppLinksProvider } from '@kbn/shared-ux-links-redirect-app';
<RedirectAppLinksProvider currentAppId="appId" navigateToUrl={(url) => { ... }}>
.
{/* other components that don't need to redirect */}
.
<RedirectAppLinks>
<a href="/base-path/app/another-app/some-path">Go to another-app</a>
</RedirectAppLinks>
.
.
.
</RedirectAppLinksProvider>
```
You can also use the Kibana provider:
```tsx
import {
RedirectAppLinksContainer as RedirectAppLinks,
RedirectAppLinksKibanaProvider as RedirectAppLinksProvider
} from '@kbn/shared-ux-links-redirect-app';
<RedirectAppLinksProvider {...coreStart}>
.
{/* other components that don't need to redirect */}
.
<RedirectAppLinks>
<a href="/base-path/app/another-app/some-path">Go to another-app</a>
</RedirectAppLinks>
.
.
</RedirectAppLinksProvider>
```
## Top-level Component
This is the component is likely the most useful to solutions in Kibana. It assumes an entire solution needs this redirect functionality, and combines the context provider with the container. This top-level component can be used with either pure props or with Kibana services.
```tsx
import { RedirectAppLinks } from '@kbn/shared-ux-links-redirect-app';
<RedirectAppLinksProvider currentAppId="appId" navigateToUrl={(url) => { ... }}>
.
<a href="/base-path/app/another-app/some-path">Go to another-app</a>
.
</RedirectAppLinksProvider>
{/* OR */}
<RedirectAppLinksProvider {...coreStart}>
.
<a href="/base-path/app/another-app/some-path">Go to another-app</a>
.
</RedirectAppLinksProvider>
```

View file

@ -0,0 +1,14 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../..',
roots: ['<rootDir>/packages/shared-ux/link/redirect_app'],
verbose: true,
};

View file

@ -0,0 +1,8 @@
{
"name": "@kbn/shared-ux-link-redirect-app",
"private": true,
"version": "1.0.0",
"main": "./target_node/index.js",
"browser": "./target_web/index.js",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -7,7 +7,7 @@
*/
import { MouseEvent } from 'react';
import { createNavigateToUrlClickHandler } from './click_handler';
import { navigateToUrlClickHandler } from './click_handler';
const createLink = ({
href = '/base-path/app/targetApp',
@ -43,27 +43,59 @@ const createEvent = ({
type NavigateToURLFn = (url: string) => Promise<void>;
describe('createNavigateToUrlClickHandler', () => {
describe('navigateToUrlClickHandler', () => {
let container: HTMLElement;
let navigateToUrl: jest.MockedFunction<NavigateToURLFn>;
const currentAppId = 'abc123';
const createHandler = () =>
createNavigateToUrlClickHandler({
const handler = (event: MouseEvent<HTMLElement>): void => {
navigateToUrlClickHandler({
event,
currentAppId,
container,
navigateToUrl,
});
};
beforeEach(() => {
container = document.createElement('div');
navigateToUrl = jest.fn();
});
it('calls `navigateToUrl` with the link url', () => {
const handler = createHandler();
it("doesn't call `navigateToUrl` without a container", () => {
const event = createEvent({
target: createLink({ href: '/base-path/app/targetApp' }),
});
navigateToUrlClickHandler({
event,
currentAppId,
container: null,
navigateToUrl,
});
expect(event.preventDefault).toHaveBeenCalledTimes(0);
});
it("doesn't call `navigateToUrl` without a `currentAppId`", () => {
const event = createEvent({
target: createLink({ href: '/base-path/app/targetApp' }),
});
navigateToUrlClickHandler({
event,
container,
navigateToUrl,
});
expect(event.preventDefault).toHaveBeenCalledTimes(0);
});
it('calls `navigateToUrl` with the link url', () => {
const event = createEvent({
target: createLink({ href: '/base-path/app/targetApp' }),
});
handler(event);
expect(event.preventDefault).toHaveBeenCalledTimes(1);
@ -71,13 +103,12 @@ describe('createNavigateToUrlClickHandler', () => {
});
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);
@ -85,13 +116,12 @@ describe('createNavigateToUrlClickHandler', () => {
});
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();
@ -99,11 +129,10 @@ describe('createNavigateToUrlClickHandler', () => {
});
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();
@ -111,11 +140,10 @@ describe('createNavigateToUrlClickHandler', () => {
});
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();
@ -124,6 +152,7 @@ describe('createNavigateToUrlClickHandler', () => {
event = createEvent({
target: createLink({ target: 'some-target' }),
});
handler(event);
expect(event.preventDefault).not.toHaveBeenCalled();
@ -132,6 +161,7 @@ describe('createNavigateToUrlClickHandler', () => {
event = createEvent({
target: createLink({ target: '_self' }),
});
handler(event);
expect(event.preventDefault).toHaveBeenCalledTimes(1);
@ -140,6 +170,7 @@ describe('createNavigateToUrlClickHandler', () => {
event = createEvent({
target: createLink({ target: '' }),
});
handler(event);
expect(event.preventDefault).toHaveBeenCalledTimes(1);
@ -147,11 +178,10 @@ describe('createNavigateToUrlClickHandler', () => {
});
it('is only triggered from left clicks', () => {
const handler = createHandler();
let event = createEvent({
button: 1,
});
handler(event);
expect(event.preventDefault).not.toHaveBeenCalled();
@ -160,6 +190,7 @@ describe('createNavigateToUrlClickHandler', () => {
event = createEvent({
button: 12,
});
handler(event);
expect(event.preventDefault).not.toHaveBeenCalled();
@ -168,6 +199,7 @@ describe('createNavigateToUrlClickHandler', () => {
event = createEvent({
button: 0,
});
handler(event);
expect(event.preventDefault).toHaveBeenCalledTimes(1);
@ -175,11 +207,10 @@ describe('createNavigateToUrlClickHandler', () => {
});
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();
@ -188,6 +219,7 @@ describe('createNavigateToUrlClickHandler', () => {
event = createEvent({
defaultPrevented: false,
});
handler(event);
expect(event.preventDefault).toHaveBeenCalledTimes(1);
@ -195,15 +227,15 @@ describe('createNavigateToUrlClickHandler', () => {
});
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);

View file

@ -0,0 +1,57 @@
/*
* 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 { getClosestLink, hasActiveModifierKey } from '@kbn/shared-ux-utility';
import { NavigateToUrl } from './types';
interface CreateCrossAppClickHandlerOptions {
event: MouseEvent<HTMLElement>;
navigateToUrl: NavigateToUrl;
container: HTMLElement | null;
currentAppId?: string;
}
/**
* Constructs a click handler that will redirect the user using `navigateToUrl` if the
* correct conditions are met.
*/
export const navigateToUrlClickHandler = ({
event,
container,
navigateToUrl,
currentAppId,
}: CreateCrossAppClickHandlerOptions) => {
if (!container || !currentAppId) {
return;
}
// see https://github.com/DefinitelyTyped/DefinitelyTyped/pull/12239
const target = event.target as HTMLElement;
const link = getClosestLink(target, container);
if (!link) {
return;
}
const isNotEmptyHref = link.href;
const hasNoTarget = link.target === '' || link.target === '_self';
const isLeftClickOnly = event.button === 0;
if (
isNotEmptyHref &&
hasNoTarget &&
isLeftClickOnly &&
!event.defaultPrevented &&
!hasActiveModifierKey(event)
) {
event.preventDefault();
navigateToUrl(link.href);
}
};

View file

@ -0,0 +1,39 @@
/*
* 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.
*/
export { RedirectAppLinks as RedirectAppLinksContainer } from './redirect_app_links';
export { RedirectAppLinks as RedirectAppLinksComponent } from './redirect_app_links';
export { RedirectAppLinksKibanaProvider, RedirectAppLinksProvider } from './services';
import React, { FC } from 'react';
import { RedirectAppLinks as RedirectAppLinksContainer } from './redirect_app_links';
import {
Services,
KibanaServices,
RedirectAppLinksKibanaProvider,
RedirectAppLinksProvider,
} from './services';
const isKibanaContract = (services: any): services is KibanaServices => {
return typeof services.coreStart !== 'undefined';
};
/**
* This component composes `RedirectAppLinksContainer` with either `RedirectAppLinksProvider` or
* `RedirectAppLinksKibanaProvider` based on the services provided, creating a single component
* with which consumers can wrap their components or solutions.
*/
export const RedirectAppLinks: FC<Services | KibanaServices> = ({ children, ...services }) => {
const container = <RedirectAppLinksContainer>{children}</RedirectAppLinksContainer>;
return isKibanaContract(services) ? (
<RedirectAppLinksKibanaProvider {...services}>{container}</RedirectAppLinksKibanaProvider>
) : (
<RedirectAppLinksProvider {...services}>{container}</RedirectAppLinksProvider>
);
};

View file

@ -0,0 +1,57 @@
/*
* 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, { useRef, MouseEventHandler, useCallback } from 'react';
import type { HTMLAttributes, DetailedHTMLProps, FC } from 'react';
import { navigateToUrlClickHandler } from './click_handler';
import { NavigateToUrl } from './types';
export interface Props extends DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement> {
navigateToUrl: NavigateToUrl;
currentAppId?: string | undefined;
}
/**
* Utility component that will intercept click events on children anchor (`<a>`) elements to call
* `navigateToUrl` with the link's href. This will trigger SPA friendly navigation when the link points
* to a valid Kibana app.
*
* @example
* ```tsx
* <RedirectAppLinks currentAppId="appId" navigateToUrl={(url) => { ... }}>
* <a href="/base-path/app/another-app/some-path">Go to another-app</a>
* </RedirectAppLinks>
* ```
*/
export const RedirectAppLinks: FC<Props> = ({
children,
navigateToUrl,
currentAppId,
...otherProps
}) => {
const containerRef = useRef<HTMLDivElement>(null);
const handleClick: MouseEventHandler<HTMLDivElement> = useCallback(
(event) =>
navigateToUrlClickHandler({
event,
currentAppId,
navigateToUrl,
container: containerRef.current,
}),
[currentAppId, navigateToUrl]
);
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events
<div {...otherProps} ref={containerRef} onClick={handleClick}>
{children}
</div>
);
};

View file

@ -0,0 +1,71 @@
/*
* 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, EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
import React from 'react';
import { action } from '@storybook/addon-actions';
import { RedirectAppLinks } from '.';
import mdx from '../README.mdx';
export default {
title: 'Redirect App Links',
description:
'An "area of effect" component which intercepts clicks on anchor elements and redirects them to Kibana solutions without a page refresh.',
parameters: {
docs: {
page: mdx,
},
},
};
export const Component = () => {
const navigateToUrl = async (url: string) => {
action('navigateToUrl')(url);
};
const currentAppId = 'abc123';
return (
<>
<RedirectAppLinks {...{ currentAppId, navigateToUrl }}>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="storybookButton"
iconType="plusInCircle"
href="/some-test-url"
>
Button with URL
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="storybookButton"
iconType="plusInCircle"
onClick={action('onClick')}
>
Button without URL
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</RedirectAppLinks>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="storybookButton"
iconType="plusInCircle"
href="/?path=/story/redirect-app-links--component"
>
Button outside RedirectAppLinks
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</>
);
};

View file

@ -0,0 +1,292 @@
/*
* 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 jsx-a11y/click-events-have-key-events */
import React, { MouseEvent } from 'react';
import { mount as enzymeMount, ReactWrapper } from 'enzyme';
import { RedirectAppLinksKibanaProvider, RedirectAppLinksProvider } from './services';
import { RedirectAppLinks } from './redirect_app_links';
import { RedirectAppLinks as ComposedWrapper } from '.';
import { Observable } from 'rxjs';
export type UnmountCallback = () => void;
export type MountPoint<T extends HTMLElement = HTMLElement> = (element: T) => UnmountCallback;
type Mount = (
node: React.ReactElement
) => ReactWrapper<any, Readonly<{}>, React.Component<{}, {}, any>>;
const commonTests = (name: string, mount: Mount, navigateToUrl: jest.Mock) => {
beforeEach(() => {
navigateToUrl.mockReset();
});
describe(`RedirectAppLinks with ${name}`, () => {
it('intercept click events on children link elements', () => {
let event: MouseEvent;
const component = mount(
<div
onClick={(e) => {
event = e;
}}
>
<RedirectAppLinks>
<div>
<a href="/mocked-anyway">content</a>
</div>
</RedirectAppLinks>
</div>
);
component.find('a').simulate('click', { button: 0, defaultPrevented: false });
expect(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>
<div>
<a href="/mocked-anyway">
<span>content</span>
</a>
</div>
</RedirectAppLinks>
</div>
);
component.find('span').simulate('click', { button: 0, defaultPrevented: false });
expect(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>
<span>
<a href="/mocked-anyway">content</a>
</span>
</RedirectAppLinks>
</div>
);
component.find('span').simulate('click', { button: 0, defaultPrevented: false });
expect(navigateToUrl).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>
<a href="/mocked-anyway" target="_blank">
content
</a>
</RedirectAppLinks>
</div>
);
component.find('a').simulate('click', { button: 0, defaultPrevented: false });
expect(navigateToUrl).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>
<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(navigateToUrl).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>
<a href="/mocked-anyway" target="_blank" onClick={(e) => e.stopPropagation()}>
content
</a>
</RedirectAppLinks>
</div>
);
component.find('a').simulate('click', { button: 0, defaultPrevented: false });
expect(navigateToUrl).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>
<div>
<a href="/mocked-anyway">content</a>
</div>
</RedirectAppLinks>
</div>
);
component.find('a').simulate('click', { button: 1, defaultPrevented: false });
expect(navigateToUrl).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>
<div>
<a href="/mocked-anyway">content</a>
</div>
</RedirectAppLinks>
</div>
);
component.find('a').simulate('click', { button: 0, ctrlKey: true, defaultPrevented: false });
expect(navigateToUrl).not.toHaveBeenCalled();
expect(event!.defaultPrevented).toBe(false);
});
});
};
const targetedTests = (name: string, mount: Mount, navigateToUrl: jest.Mock) => {
beforeEach(() => {
navigateToUrl.mockReset();
});
describe(`${name} with isolated areas of effect`, () => {
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>
<span>content</span>
</RedirectAppLinks>
</a>
</div>
);
component.find('span').simulate('click', { button: 0, defaultPrevented: false });
expect(navigateToUrl).not.toHaveBeenCalled();
expect(event!.defaultPrevented).toBe(false);
});
});
};
describe('RedirectAppLinks', () => {
const navigateToUrl = jest.fn();
beforeEach(() => {
navigateToUrl.mockReset();
});
const kibana = {
coreStart: {
application: {
currentAppId$: new Observable<string>((subscriber) => {
subscriber.next('123');
}),
navigateToUrl,
},
},
};
const services = {
currentAppId: 'abc123',
navigateToUrl,
};
const provider = (node: React.ReactElement) =>
enzymeMount(<RedirectAppLinksProvider {...services}>{node}</RedirectAppLinksProvider>);
const kibanaProvider = (node: React.ReactElement) =>
enzymeMount(
<RedirectAppLinksKibanaProvider {...kibana}>{node}</RedirectAppLinksKibanaProvider>
);
const composedProvider = (node: React.ReactElement) =>
enzymeMount(<ComposedWrapper {...services}>{node}</ComposedWrapper>);
const composedKibanaProvider = (node: React.ReactElement) =>
enzymeMount(<ComposedWrapper {...kibana}>{node}</ComposedWrapper>);
describe('Test all Providers', () => {
commonTests('RedirectAppLinksProvider', provider, navigateToUrl);
targetedTests('RedirectAppLinksProvider', provider, navigateToUrl);
commonTests('RedirectAppLinksKibanaProvider', kibanaProvider, navigateToUrl);
targetedTests('RedirectAppLinksKibanaProvider', kibanaProvider, navigateToUrl);
commonTests('Provider Props', composedProvider, navigateToUrl);
commonTests('Kibana Props', composedKibanaProvider, navigateToUrl);
});
});

View file

@ -0,0 +1,30 @@
/*
* 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 { useServices } from './services';
import {
RedirectAppLinks as Component,
Props as ComponentProps,
} from './redirect_app_links.component';
type Props = Omit<ComponentProps, 'navigateToUrl' | 'currentAppId'>;
/**
* A service-enabled component that provides Kibana-specific functionality to the `RedirectAppLinks`
* pure component.
*
* @example
* ```tsx
* <RedirectAppLinks>
* <a href="/base-path/app/another-app/some-path">Go to another-app</a>
* </RedirectAppLinks>
* ```
*/
export const RedirectAppLinks = (props: Props) => <Component {...useServices()} {...props} />;

View file

@ -0,0 +1,79 @@
/*
* 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, { FC, useContext } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { Observable } from 'rxjs';
import { NavigateToUrl } from './types';
/**
* Contextual services for this component.
*/
export interface Services {
navigateToUrl: NavigateToUrl;
currentAppId?: string;
}
const RedirectAppLinksContext = React.createContext<Services | null>(null);
/**
* Contextual services Provider.
*/
export const RedirectAppLinksProvider: FC<Services> = ({ children, ...services }) => {
return (
<RedirectAppLinksContext.Provider value={{ ...services }}>
{children}
</RedirectAppLinksContext.Provider>
);
};
/**
* Kibana-specific contextual services to be adapted for this component.
*/
export interface KibanaServices {
coreStart: {
application: {
currentAppId$: Observable<string | undefined>;
navigateToUrl: NavigateToUrl;
};
};
}
/**
* Kibana-specific contextual services Provider.
*/
export const RedirectAppLinksKibanaProvider: FC<KibanaServices> = ({ children, coreStart }) => {
const { navigateToUrl, currentAppId$ } = coreStart.application;
const currentAppId = useObservable(currentAppId$, undefined);
return (
<RedirectAppLinksContext.Provider
value={{
navigateToUrl,
currentAppId,
}}
>
{children}
</RedirectAppLinksContext.Provider>
);
};
/**
* React hook for accessing pre-wired services.
*/
export function useServices() {
const context = useContext(RedirectAppLinksContext);
if (!context) {
throw new Error(
'RedirectAppLinksContext is missing. Ensure your component or React root is wrapped with RedirectAppLinksProvider.'
);
}
return context;
}

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export type NavigateToUrl = (url: string) => Promise<void> | void;

View file

@ -0,0 +1,19 @@
{
"extends": "../../../../tsconfig.bazel.json",
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": true,
"outDir": "target_types",
"rootDir": "src",
"stripInternal": false,
"types": [
"jest",
"node",
"react",
"@kbn/ambient-ui-types"
]
},
"include": [
"src/**/*"
]
}

View file

@ -3212,6 +3212,10 @@
version "0.0.0"
uid ""
"@kbn/shared-ux-link-redirect-app@link:bazel-bin/packages/shared-ux/link/redirect_app":
version "0.0.0"
uid ""
"@kbn/shared-ux-services@link:bazel-bin/packages/kbn-shared-ux-services":
version "0.0.0"
uid ""
@ -6300,6 +6304,10 @@
version "0.0.0"
uid ""
"@types/kbn__shared-ux-link-redirect-app@link:bazel-bin/packages/shared-ux/link/redirect_app/npm_module_types":
version "0.0.0"
uid ""
"@types/kbn__shared-ux-services@link:bazel-bin/packages/kbn-shared-ux-services/npm_module_types":
version "0.0.0"
uid ""