From f139fed1ac4106f41d8a6609f65fe54da61eefa7 Mon Sep 17 00:00:00 2001 From: Eric Peterson Date: Thu, 16 Sep 2021 11:27:05 +0200 Subject: [PATCH] Instrument core-components Link to capture 'click' events on click Signed-off-by: Eric Peterson --- ...ytics-haunts-dismembered-constellations.md | 23 +++++++ .../src/components/Link/Link.test.tsx | 34 +++++++++- .../src/components/Link/Link.tsx | 66 +++++++++++++------ 3 files changed, 103 insertions(+), 20 deletions(-) create mode 100644 .changeset/analytics-haunts-dismembered-constellations.md diff --git a/.changeset/analytics-haunts-dismembered-constellations.md b/.changeset/analytics-haunts-dismembered-constellations.md new file mode 100644 index 0000000000..cc685d68f0 --- /dev/null +++ b/.changeset/analytics-haunts-dismembered-constellations.md @@ -0,0 +1,23 @@ +--- +'@backstage/core-components': patch +--- + +The `` 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`. diff --git a/packages/core-components/src/components/Link/Link.test.tsx b/packages/core-components/src/components/Link/Link.test.tsx index 97a71c5760..0be4749f0a 100644 --- a/packages/core-components/src/components/Link/Link.test.tsx +++ b/packages/core-components/src/components/Link/Link.test.tsx @@ -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('', () => { it('navigates using react-router', async () => { @@ -40,6 +42,36 @@ describe('', () => { 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( + + + {linkText} + + , + ), + ); + + 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://'], diff --git a/packages/core-components/src/components/Link/Link.tsx b/packages/core-components/src/components/Link/Link.tsx index 0d301dccf9..b985fcb922 100644 --- a/packages/core-components/src/components/Link/Link.tsx +++ b/packages/core-components/src/components/Link/Link.tsx @@ -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 | ((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((props, ref) => { - const to = String(props.to); - const external = isExternalUri(to); - const newWindow = external && !!/^https?:/.exec(to); - return external ? ( - // External links - - ) : ( - // Interact with React Router for internal links - - ); -}); +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) => { + if (onClick !== undefined) { + onClick(event); + } + analytics.captureEvent('click', to); + }; + + return external ? ( + // External links + + ) : ( + // Interact with React Router for internal links + + ); + }, + { componentName: 'Link' }, +); + +export const WrappedLink = React.forwardRef((props, 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 };