Merge pull request #17480 from backstage/vinzscam/forward-route-params-to-navigate-event
Forward route params to navigate event
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-app-api': minor
|
||||
---
|
||||
|
||||
The analytics' `navigate` event will now include the route parameters as attributes of the navigate event
|
||||
@@ -54,7 +54,7 @@ installed, may be captured.
|
||||
|
||||
| Action | Subject | Other Notes |
|
||||
| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `navigate` | The URL of the page that was navigated to. | |
|
||||
| `navigate` | The URL of the page that was navigated to. | The parameters of the current route will be included as attributes |
|
||||
| `click` | The text of the link that was clicked on. | The `to` attribute represents the URL clicked to. |
|
||||
| `create` | The `name` of the software being created; if no `name` property is requested by the given Software Template, then the string `new {templateName}` is used instead. | The context holds an `entityRef`, set to the template's ref (e.g. `template:default/template-name`). |
|
||||
| `search` | The search term entered in any search bar component. | The context holds `searchTypes`, representing `types` constraining the search. The `value` represents the total number of search results for the query. This may not be visible if the permission framework is being used. |
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright 2023 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
import { TestApiProvider } from '@backstage/test-utils';
|
||||
import React from 'react';
|
||||
import { BackstageRouteObject } from './types';
|
||||
import { fireEvent, render } from '@testing-library/react';
|
||||
import { RouteTracker } from './RouteTracker';
|
||||
import { Link, MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import {
|
||||
AnalyticsApi,
|
||||
analyticsApiRef,
|
||||
createRouteRef,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
describe('RouteTracker', () => {
|
||||
const routeRef1 = createRouteRef({
|
||||
id: 'route1',
|
||||
});
|
||||
const routeRef2 = createRouteRef({
|
||||
id: 'route2',
|
||||
});
|
||||
|
||||
const routeObjects: BackstageRouteObject[] = [
|
||||
{
|
||||
path: '/path/:p1/:p2',
|
||||
element: <Link to="/path2/hello">go</Link>,
|
||||
routeRefs: new Set([routeRef1]),
|
||||
caseSensitive: false,
|
||||
},
|
||||
{
|
||||
path: '/path2/:param',
|
||||
element: <div>hi there</div>,
|
||||
routeRefs: new Set([routeRef2]),
|
||||
caseSensitive: false,
|
||||
},
|
||||
];
|
||||
|
||||
const mockedAnalytics: jest.Mocked<AnalyticsApi> = {
|
||||
captureEvent: jest.fn(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should capture the navigate event on load', async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/path/foo/bar']}>
|
||||
<TestApiProvider apis={[[analyticsApiRef, mockedAnalytics]]}>
|
||||
<RouteTracker routeObjects={routeObjects} />
|
||||
</TestApiProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
expect(mockedAnalytics.captureEvent).toHaveBeenCalledWith({
|
||||
action: 'navigate',
|
||||
attributes: {
|
||||
p1: 'foo',
|
||||
p2: 'bar',
|
||||
},
|
||||
context: {
|
||||
extension: 'App',
|
||||
pluginId: 'root',
|
||||
routeRef: 'route1',
|
||||
},
|
||||
subject: '/path/foo/bar',
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it('should capture the navigate event on route change', async () => {
|
||||
const { getByText } = render(
|
||||
<MemoryRouter initialEntries={['/path/foo/bar']}>
|
||||
<TestApiProvider apis={[[analyticsApiRef, mockedAnalytics]]}>
|
||||
<RouteTracker routeObjects={routeObjects} />
|
||||
|
||||
<Routes>
|
||||
{routeObjects.map(({ routeRefs, ...props }) => (
|
||||
<Route {...props} key={props.path} />
|
||||
))}
|
||||
</Routes>
|
||||
</TestApiProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
fireEvent.click(getByText('go'));
|
||||
|
||||
expect(mockedAnalytics.captureEvent).toHaveBeenCalledWith({
|
||||
action: 'navigate',
|
||||
attributes: {
|
||||
param: 'hello',
|
||||
},
|
||||
context: {
|
||||
extension: 'App',
|
||||
pluginId: 'root',
|
||||
routeRef: 'route2',
|
||||
},
|
||||
subject: '/path2/hello',
|
||||
value: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -19,8 +19,8 @@ import { matchRoutes, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
useAnalytics,
|
||||
AnalyticsContext,
|
||||
CommonAnalyticsContext,
|
||||
RouteRef,
|
||||
AnalyticsEventAttributes,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { BackstageRouteObject } from './types';
|
||||
|
||||
@@ -31,22 +31,23 @@ import { BackstageRouteObject } from './types';
|
||||
const getExtensionContext = (
|
||||
pathname: string,
|
||||
routes: BackstageRouteObject[],
|
||||
): CommonAnalyticsContext | {} => {
|
||||
) => {
|
||||
try {
|
||||
// Find matching routes for the given path name.
|
||||
const matches = matchRoutes(routes, { pathname }) as
|
||||
| { route: BackstageRouteObject }[]
|
||||
| null;
|
||||
const matches = matchRoutes(routes, { pathname });
|
||||
|
||||
// Of the matching routes, get the last (e.g. most specific) instance of
|
||||
// the BackstageRouteObject.
|
||||
const routeObject = matches
|
||||
|
||||
const routeMatch = matches
|
||||
?.filter(match => match?.route.routeRefs?.size > 0)
|
||||
.pop()?.route;
|
||||
.pop();
|
||||
|
||||
const routeObject = routeMatch?.route;
|
||||
|
||||
// If there is no route object, then allow inheritance of default context.
|
||||
if (!routeObject) {
|
||||
return {};
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If there is a single route ref, return it.
|
||||
@@ -56,13 +57,23 @@ const getExtensionContext = (
|
||||
routeRef = routeObject.routeRefs.values().next().value;
|
||||
}
|
||||
|
||||
const params = Object.entries(
|
||||
routeMatch?.params || {},
|
||||
).reduce<AnalyticsEventAttributes>((acc, [key, value]) => {
|
||||
if (value !== undefined) {
|
||||
acc[key] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return {
|
||||
extension: 'App',
|
||||
pluginId: routeObject.plugin?.getId() || 'root',
|
||||
...(routeRef ? { routeRef: (routeRef as { id?: string }).id } : {}),
|
||||
params,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,16 +84,19 @@ const TrackNavigation = ({
|
||||
pathname,
|
||||
search,
|
||||
hash,
|
||||
attributes,
|
||||
}: {
|
||||
pathname: string;
|
||||
search: string;
|
||||
hash: string;
|
||||
attributes?: AnalyticsEventAttributes;
|
||||
}) => {
|
||||
const analytics = useAnalytics();
|
||||
|
||||
useEffect(() => {
|
||||
analytics.captureEvent('navigate', `${pathname}${search}${hash}`);
|
||||
}, [analytics, pathname, search, hash]);
|
||||
analytics.captureEvent('navigate', `${pathname}${search}${hash}`, {
|
||||
attributes,
|
||||
});
|
||||
}, [analytics, pathname, search, hash, attributes]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -98,9 +112,19 @@ export const RouteTracker = ({
|
||||
}) => {
|
||||
const { pathname, search, hash } = useLocation();
|
||||
|
||||
const { params, ...attributes } = getExtensionContext(
|
||||
pathname,
|
||||
routeObjects,
|
||||
) || { params: {} };
|
||||
|
||||
return (
|
||||
<AnalyticsContext attributes={getExtensionContext(pathname, routeObjects)}>
|
||||
<TrackNavigation pathname={pathname} search={search} hash={hash} />
|
||||
<AnalyticsContext attributes={attributes}>
|
||||
<TrackNavigation
|
||||
pathname={pathname}
|
||||
search={search}
|
||||
hash={hash}
|
||||
attributes={params}
|
||||
/>
|
||||
</AnalyticsContext>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -53,6 +53,8 @@ export function useAnalytics(): AnalyticsTracker {
|
||||
}
|
||||
|
||||
const tracker = getTracker();
|
||||
// this is not ideal, but it allows to memoize the tracker
|
||||
// without explicitly set the context as dependency.
|
||||
tracker.setContext(context);
|
||||
|
||||
return tracker;
|
||||
|
||||
Reference in New Issue
Block a user