Instrument core-components Link to capture 'click' events on click

Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
Eric Peterson
2021-09-16 11:27:05 +02:00
parent d9fd798cc8
commit f139fed1ac
3 changed files with 103 additions and 20 deletions
@@ -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 };