Instrument core-components Link to capture 'click' events on click
Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
The `<Link />` component now automatically instruments all link clicks using
|
||||
the new Analytics API. Each click triggers a `click` event, containing the
|
||||
location the user clicked to. In addition, these events inherit plugin-level
|
||||
metadata, allowing clicks to be attributed to the plugin containing the link:
|
||||
|
||||
```json
|
||||
{
|
||||
"verb": "click",
|
||||
"noun": "/value/of-the/to-prop/passed-to-the-link",
|
||||
"domain": {
|
||||
"componentName": "Link",
|
||||
"pluginId": "plugin-in-which-link-was-clicked",
|
||||
"routeRef": "any-associated-route-ref-id"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
These events can be identified and handled by checking for the verb `click`
|
||||
and the componentName `Link`.
|
||||
@@ -16,10 +16,12 @@
|
||||
|
||||
import React from 'react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
import { wrapInTestApp } from '@backstage/test-utils';
|
||||
import { MockAnalyticsApi, wrapInTestApp } from '@backstage/test-utils';
|
||||
import { ApiProvider, ApiRegistry } from '@backstage/core-app-api';
|
||||
import { isExternalUri, Link } from './Link';
|
||||
import { Route, Routes } from 'react-router';
|
||||
import { act } from 'react-dom/test-utils';
|
||||
import { analyticsApiRef } from '../../../../core-plugin-api/src';
|
||||
|
||||
describe('<Link />', () => {
|
||||
it('navigates using react-router', async () => {
|
||||
@@ -40,6 +42,36 @@ describe('<Link />', () => {
|
||||
expect(getByText(testString)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('captures click using analytics api', async () => {
|
||||
const linkText = 'Navigate!';
|
||||
const analyticsApi = new MockAnalyticsApi();
|
||||
const customOnClick = jest.fn();
|
||||
|
||||
const { getByText } = render(
|
||||
wrapInTestApp(
|
||||
<ApiProvider apis={ApiRegistry.from([[analyticsApiRef, analyticsApi]])}>
|
||||
<Link to="/test" onClick={customOnClick}>
|
||||
{linkText}
|
||||
</Link>
|
||||
</ApiProvider>,
|
||||
),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(getByText(linkText));
|
||||
});
|
||||
|
||||
// Analytics event should have been fired.
|
||||
expect(analyticsApi.getEvents()[0]).toMatchObject({
|
||||
verb: 'click',
|
||||
noun: '/test',
|
||||
domain: { componentName: 'Link' },
|
||||
});
|
||||
|
||||
// Custom onClick handler should have still been fired too.
|
||||
expect(customOnClick).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe('isExternalUri', () => {
|
||||
it.each([
|
||||
[true, 'http://'],
|
||||
|
||||
@@ -14,16 +14,19 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useAnalytics, withAnalyticsDomain } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
Link as MaterialLink,
|
||||
LinkProps as MaterialLinkProps,
|
||||
} from '@material-ui/core';
|
||||
import React, { ElementType } from 'react';
|
||||
import React, { ElementType, MutableRefObject } from 'react';
|
||||
import {
|
||||
Link as RouterLink,
|
||||
LinkProps as RouterLinkProps,
|
||||
} from 'react-router-dom';
|
||||
|
||||
type OptionalRef = MutableRefObject<any> | ((instance: any) => void) | null;
|
||||
|
||||
export const isExternalUri = (uri: string) => /^([a-z+.-]+):/.test(uri);
|
||||
|
||||
export type LinkProps = MaterialLinkProps &
|
||||
@@ -36,28 +39,53 @@ declare function LinkType(props: LinkProps): JSX.Element;
|
||||
/**
|
||||
* Thin wrapper on top of material-ui's Link component
|
||||
* Makes the Link to utilise react-router
|
||||
* Thin wrapper on top of material-ui's Link component, which...
|
||||
* - Makes the Link use react-router
|
||||
* - Captures Link clicks as analytics events.
|
||||
*/
|
||||
const ActualLink = React.forwardRef<any, LinkProps>((props, ref) => {
|
||||
const to = String(props.to);
|
||||
const external = isExternalUri(to);
|
||||
const newWindow = external && !!/^https?:/.exec(to);
|
||||
return external ? (
|
||||
// External links
|
||||
<MaterialLink
|
||||
ref={ref}
|
||||
href={to}
|
||||
{...(newWindow ? { target: '_blank', rel: 'noopener' } : {})}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
// Interact with React Router for internal links
|
||||
<MaterialLink ref={ref} component={RouterLink} {...props} />
|
||||
);
|
||||
});
|
||||
const ActualLink = withAnalyticsDomain(
|
||||
({ inputRef, onClick, ...props }: LinkProps & { inputRef: OptionalRef }) => {
|
||||
const analytics = useAnalytics();
|
||||
const to = String(props.to);
|
||||
const external = isExternalUri(to);
|
||||
const newWindow = external && !!/^https?:/.exec(to);
|
||||
|
||||
const handleClick = (event: React.MouseEvent<any, MouseEvent>) => {
|
||||
if (onClick !== undefined) {
|
||||
onClick(event);
|
||||
}
|
||||
analytics.captureEvent('click', to);
|
||||
};
|
||||
|
||||
return external ? (
|
||||
// External links
|
||||
<MaterialLink
|
||||
ref={inputRef}
|
||||
href={to}
|
||||
onClick={handleClick}
|
||||
{...(newWindow ? { target: '_blank', rel: 'noopener' } : {})}
|
||||
{...props}
|
||||
/>
|
||||
) : (
|
||||
// Interact with React Router for internal links
|
||||
<MaterialLink
|
||||
ref={inputRef}
|
||||
component={RouterLink}
|
||||
onClick={handleClick}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
{ componentName: 'Link' },
|
||||
);
|
||||
|
||||
export const WrappedLink = React.forwardRef<any, LinkProps>((props, ref) => (
|
||||
<ActualLink {...props} inputRef={ref} />
|
||||
));
|
||||
|
||||
// TODO(Rugvip): We use this as a workaround to make the exported type be a
|
||||
// function, which makes our API reference docs much nicer.
|
||||
// The first type to be exported gets priority, but it will
|
||||
// be thrown away when compiling to JS.
|
||||
// @ts-ignore
|
||||
export { LinkType as Link, ActualLink as Link };
|
||||
export { LinkType as Link, WrappedLink as Link };
|
||||
|
||||
Reference in New Issue
Block a user