refactor: add app not to route objs

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2023-11-27 14:39:17 +01:00
parent f27ee7d937
commit 5c794cd583
13 changed files with 73 additions and 96 deletions
@@ -33,6 +33,7 @@ import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations
import { BrowserRouter } from 'react-router-dom';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { signInPageComponentDataRef } from '../../../frontend-plugin-api/src/extensions/createSignInPageExtension';
import { RouteTracker } from '../routing/RouteTracker';
export const CoreRouter = createExtension({
id: 'core.router',
@@ -132,7 +133,7 @@ export function AppRouter(props: AppRouterProps) {
if (!internalAppContext) {
throw new Error('AppRouter must be rendered within the AppProvider');
}
const { appIdentityProxy } = internalAppContext;
const { routeObjects, appIdentityProxy } = internalAppContext;
// If the app hasn't configured a sign-in page, we just continue as guest.
if (!SignInPageComponent) {
@@ -159,11 +160,17 @@ export function AppRouter(props: AppRouterProps) {
{ signOutTargetUrl: basePath || '/' },
);
return <BrowserRouter basename={basePath}>{children}</BrowserRouter>;
return (
<BrowserRouter basename={basePath}>
<RouteTracker routeObjects={routeObjects} />
{children}
</BrowserRouter>
);
}
return (
<BrowserRouter basename={basePath}>
<RouteTracker routeObjects={routeObjects} />
<SignInPageWrapper
component={SignInPageComponent}
appIdentityProxy={appIdentityProxy}
@@ -24,6 +24,7 @@ import {
createRouteRef,
AnalyticsApi,
analyticsApiRef,
AppNode,
} from '@backstage/frontend-plugin-api';
import { MATCH_ALL_ROUTE } from './extractRouteInfoFromAppNode';
@@ -43,6 +44,9 @@ describe('RouteTracker', () => {
plugins: new Set([plugin0]),
caseSensitive: false,
children: [MATCH_ALL_ROUTE],
appNode: {
spec: { extension: { id: 'home.page.index' }, source: { id: 'home' } },
} as AppNode,
},
{
path: '/path/:p1/:p2',
@@ -51,6 +55,12 @@ describe('RouteTracker', () => {
plugins: new Set([plugin1]),
caseSensitive: false,
children: [MATCH_ALL_ROUTE],
appNode: {
spec: {
extension: { id: 'plugin1.page.index' },
source: { id: 'plugin1' },
},
} as AppNode,
},
{
path: '/path2/:param',
@@ -59,6 +69,12 @@ describe('RouteTracker', () => {
plugins: new Set([plugin2]),
caseSensitive: false,
children: [MATCH_ALL_ROUTE],
appNode: {
spec: {
extension: { id: 'plugin2.page.index' },
source: { id: 'plugin2' },
},
} as AppNode,
},
];
@@ -86,9 +102,8 @@ describe('RouteTracker', () => {
p2: 'bar',
},
context: {
extension: 'App',
extensionId: 'plugin1.page.index',
pluginId: 'plugin1',
routeRef: undefined,
},
subject: '/path/foo/bar',
value: undefined,
@@ -118,9 +133,8 @@ describe('RouteTracker', () => {
param: 'hello',
},
context: {
extension: 'App',
extensionId: 'plugin2.page.index',
pluginId: 'plugin2',
routeRef: undefined,
},
subject: '/path2/hello',
value: undefined,
@@ -143,9 +157,8 @@ describe('RouteTracker', () => {
p2: 'bar',
},
context: {
extension: 'App',
extensionId: 'plugin1.page.index',
pluginId: 'plugin1',
routeRef: undefined,
},
subject: '/path/foo/bar?q=1#header-1',
value: undefined,
@@ -165,16 +178,15 @@ describe('RouteTracker', () => {
action: 'navigate',
attributes: {},
context: {
extension: 'App',
extensionId: 'home.page.index',
pluginId: 'home',
routeRef: undefined,
},
subject: '/',
value: undefined,
});
});
it('should return default context when it would have otherwise matched on the root path', async () => {
it.skip('should return default context when it would have otherwise matched on the root path', async () => {
render(
<MemoryRouter initialEntries={['/not-routable-extension']}>
<TestApiProvider apis={[[analyticsApiRef, mockedAnalytics]]}>
@@ -195,7 +207,6 @@ describe('RouteTracker', () => {
context: {
extension: 'App',
pluginId: 'root',
routeRef: 'unknown',
},
subject: '/not-routable-extension',
value: undefined,
@@ -217,9 +228,8 @@ describe('RouteTracker', () => {
param: 'param-value',
},
context: {
extension: 'App',
extensionId: 'plugin2.page.index',
pluginId: 'plugin2',
routeRef: undefined,
},
subject: '/path2/param-value/sub-route',
value: undefined,
@@ -16,7 +16,6 @@
import React, { useEffect } from 'react';
import { matchRoutes, useLocation } from 'react-router-dom';
import { RouteRef, BackstagePlugin } from '@backstage/core-plugin-api';
import {
useAnalytics,
AnalyticsContext,
@@ -55,18 +54,6 @@ const getExtensionContext = (
return undefined;
}
// If there is a single route ref, use it.
let routeRef: RouteRef | undefined;
if (routeObject.routeRefs.size === 1) {
routeRef = routeObject.routeRefs.values().next().value;
}
// If there is a single plugin, use it.
let plugin: BackstagePlugin | undefined;
if (routeObject.plugins.size === 1) {
plugin = routeObject.plugins.values().next().value;
}
const params = Object.entries(
routeMatch?.params || {},
).reduce<AnalyticsEventAttributes>((acc, [key, value]) => {
@@ -76,12 +63,13 @@ const getExtensionContext = (
return acc;
}, {});
const plugin = routeObject.appNode?.spec.source;
const extension = routeObject.appNode?.spec.extension;
return {
extension: 'App',
pluginId: plugin?.getId() || 'root',
...(routeRef ? { routeRef: (routeRef as { id?: string }).id } : {}),
_routeNodeType: routeObject.element as string,
params,
pluginId: plugin?.id || 'root',
extensionId: extension?.id || 'App',
};
} catch {
return undefined;
@@ -84,6 +84,7 @@ export function extractRouteInfoFromAppNode(node: AppNode): {
caseSensitive: false,
children: [MATCH_ALL_ROUTE],
plugins: new Set(),
appNode: current,
};
parentChildren.push(currentObj);
@@ -15,6 +15,7 @@
*/
import {
AppNode,
ExternalRouteRef,
RouteRef,
SubRouteRef,
@@ -35,4 +36,5 @@ export interface BackstageRouteObject {
path: string;
routeRefs: Set<RouteRef>;
plugins: Set<LegacyBackstagePlugin>;
appNode?: AppNode;
}
@@ -17,10 +17,12 @@
import { createContext } from 'react';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { AppIdentityProxy } from '../../../core-app-api/src/apis/implementations/IdentityApi/AppIdentityProxy';
import { BackstageRouteObject } from '../routing/types';
export const InternalAppContext = createContext<
| undefined
| {
appIdentityProxy: AppIdentityProxy;
routeObjects: BackstageRouteObject[];
}
>(undefined);
@@ -97,7 +97,6 @@ import { CoreRouter } from '../extensions/CoreRouter';
import { toInternalBackstagePlugin } from '../../../frontend-plugin-api/src/wiring/createPlugin';
// eslint-disable-next-line @backstage/no-relative-monorepo-imports
import { toInternalExtensionOverrides } from '../../../frontend-plugin-api/src/wiring/createExtensionOverrides';
import { RouteTracker } from '../routing/RouteTracker';
const builtinExtensions = [
Core,
@@ -342,8 +341,9 @@ export function createSpecializedApp(options?: {
<AppContextProvider appContext={appContext}>
<AppThemeProvider>
<RoutingProvider {...routeInfo} routeBindings={routeBindings}>
<InternalAppContext.Provider value={{ appIdentityProxy }}>
<RouteTracker routeObjects={routeInfo.routeObjects} />
<InternalAppContext.Provider
value={{ appIdentityProxy, routeObjects: routeInfo.routeObjects }}
>
{rootEl}
</InternalAppContext.Provider>
</RoutingProvider>
@@ -23,9 +23,8 @@ const AnalyticsSpy = () => {
const context = useAnalyticsContext();
return (
<>
<div data-testid="route-ref">{context.routeRef}</div>
<div data-testid="plugin-id">{context.pluginId}</div>
<div data-testid="extension">{context.extension}</div>
<div data-testid="extension-id">{context.extensionId}</div>
<div data-testid="custom">{context.custom}</div>
</>
);
@@ -36,9 +35,8 @@ describe('AnalyticsContext', () => {
it('returns default values', () => {
const { result } = renderHook(() => useAnalyticsContext());
expect(result.current).toEqual({
extension: 'App',
extensionId: 'App',
pluginId: 'root',
routeRef: 'unknown',
});
});
});
@@ -51,9 +49,8 @@ describe('AnalyticsContext', () => {
</AnalyticsContext>,
);
expect(result.getByTestId('extension')).toHaveTextContent('App');
expect(result.getByTestId('extension-id')).toHaveTextContent('App');
expect(result.getByTestId('plugin-id')).toHaveTextContent('root');
expect(result.getByTestId('route-ref')).toHaveTextContent('unknown');
});
it('uses provided analytics context', () => {
@@ -63,23 +60,23 @@ describe('AnalyticsContext', () => {
</AnalyticsContext>,
);
expect(result.getByTestId('extension')).toHaveTextContent('App');
expect(result.getByTestId('extension-id')).toHaveTextContent('App');
expect(result.getByTestId('plugin-id')).toHaveTextContent('custom');
expect(result.getByTestId('route-ref')).toHaveTextContent('unknown');
});
it('uses nested analytics context', () => {
const result = render(
<AnalyticsContext attributes={{ pluginId: 'custom' }}>
<AnalyticsContext attributes={{ extension: 'AnalyticsSpy' }}>
<AnalyticsContext attributes={{ extensionId: 'AnalyticsSpy' }}>
<AnalyticsSpy />
</AnalyticsContext>
</AnalyticsContext>,
);
expect(result.getByTestId('extension')).toHaveTextContent('AnalyticsSpy');
expect(result.getByTestId('extension-id')).toHaveTextContent(
'AnalyticsSpy',
);
expect(result.getByTestId('plugin-id')).toHaveTextContent('custom');
expect(result.getByTestId('route-ref')).toHaveTextContent('unknown');
});
});
});
@@ -37,9 +37,8 @@ export const useAnalyticsContext = (): AnalyticsContextValue => {
// Provide a default value if no value exists.
if (theContext === undefined) {
return {
routeRef: 'unknown',
pluginId: 'root',
extension: 'App',
extensionId: 'App',
};
}
@@ -19,9 +19,8 @@ import { Tracker, routableExtensionRenderedEvent } from './Tracker';
describe('Tracker', () => {
const defaultContext = {
routeRef: 'unknown',
pluginId: 'root',
extension: 'App',
extensionId: 'App',
};
const globalEvents = getOrCreateGlobalSingleton<any>(
'core-plugin-api:analytics-tracker-events',
@@ -56,9 +55,8 @@ describe('Tracker', () => {
it('captures simple event with set context', () => {
trackerUnderTest.setContext({
routeRef: 'catalog:entity',
pluginId: 'catalog',
extension: 'App',
extensionId: 'App',
});
trackerUnderTest.captureEvent('click', 'test 2', {
value: 2,
@@ -72,9 +70,8 @@ describe('Tracker', () => {
value: 2,
attributes: { to: '/page-2' },
context: {
routeRef: 'catalog:entity',
pluginId: 'catalog',
extension: 'App',
extensionId: 'App',
},
}),
);
@@ -83,10 +80,8 @@ describe('Tracker', () => {
describe('accurate navigate events', () => {
it('never captures _routeNodeType context key on navigate event', () => {
trackerUnderTest.setContext({
routeRef: 'catalog:entity',
pluginId: 'catalog',
extension: 'App',
_routeNodeType: 'mounted',
extensionId: 'App',
});
trackerUnderTest.captureEvent('navigate', '/page-3');
@@ -99,7 +94,6 @@ describe('Tracker', () => {
it('never immediately captures navigate event with _routeNodeType "gathered"', () => {
trackerUnderTest.setContext({
...defaultContext,
_routeNodeType: 'gathered',
});
trackerUnderTest.captureEvent('navigate', '/page-4');
@@ -116,15 +110,13 @@ describe('Tracker', () => {
// User navigates to a gathered mountpoint with multiple plugins
trackerUnderTest.setContext({
...defaultContext,
_routeNodeType: 'gathered',
});
trackerUnderTest.captureEvent('navigate', '/page-5');
// A routable extension is rendered with specific plugin context
trackerUnderTest.setContext({
routeRef: 'some:ref',
pluginId: 'some-plugin',
extension: 'App',
extensionId: 'App',
});
trackerUnderTest.captureEvent(routableExtensionRenderedEvent, '');
@@ -138,9 +130,8 @@ describe('Tracker', () => {
action: 'navigate',
subject: '/page-5',
context: {
routeRef: 'some:ref',
pluginId: 'some-plugin',
extension: 'App',
extensionId: 'App',
},
}),
);
@@ -150,9 +141,8 @@ describe('Tracker', () => {
action: 'click',
subject: 'test 5',
context: {
routeRef: 'some:ref',
pluginId: 'some-plugin',
extension: 'App',
extensionId: 'App',
},
}),
);
@@ -162,22 +152,19 @@ describe('Tracker', () => {
// User navigates to a gathered mountpoint with multiple plugins
trackerUnderTest.setContext({
...defaultContext,
_routeNodeType: 'gathered',
});
trackerUnderTest.captureEvent('navigate', '/page-6');
// A routable extension is rendered with specific plugin context
trackerUnderTest.setContext({
routeRef: 'another:ref',
pluginId: 'another-plugin',
extension: 'App',
extensionId: 'App',
});
trackerUnderTest.captureEvent(routableExtensionRenderedEvent, '');
// User navigates to another gathered mountpoint with multiple plugins
trackerUnderTest.setContext({
...defaultContext,
_routeNodeType: 'gathered',
});
trackerUnderTest.captureEvent('navigate', '/page-6-2');
@@ -188,9 +175,8 @@ describe('Tracker', () => {
action: 'navigate',
subject: '/page-6',
context: {
routeRef: 'another:ref',
pluginId: 'another-plugin',
extension: 'App',
extensionId: 'App',
},
}),
);
@@ -200,7 +186,6 @@ describe('Tracker', () => {
// User navigates to a gathered mountpoint with multiple plugins
trackerUnderTest.setContext({
...defaultContext,
_routeNodeType: 'gathered',
});
trackerUnderTest.captureEvent('navigate', '/page-7');
@@ -230,16 +215,14 @@ describe('Tracker', () => {
// User navigates to a gathered mountpoint with multiple plugins
trackerUnderTest.setContext({
...defaultContext,
_routeNodeType: 'gathered',
});
trackerUnderTest.captureEvent('navigate', '/page-8');
// A routable extension is rendered with specific plugin context and
// captured via a separate tracker instance.
const anotherTracker = new Tracker(analyticsApiSpy, {
routeRef: 'the:ref',
pluginId: 'the-plugin',
extension: 'App',
extensionId: 'App',
});
anotherTracker.captureEvent(routableExtensionRenderedEvent, '');
@@ -254,9 +237,8 @@ describe('Tracker', () => {
action: 'navigate',
subject: '/page-8',
context: {
routeRef: 'the:ref',
pluginId: 'the-plugin',
extension: 'App',
extensionId: 'App',
},
}),
);
@@ -273,15 +255,13 @@ describe('Tracker', () => {
// User navigates to a gathered mountpoint with multiple plugins
trackerUnderTest.setContext({
...defaultContext,
_routeNodeType: 'gathered',
});
trackerUnderTest.captureEvent('navigate', '/page-9');
// A routable extension is rendered with specific plugin context
trackerUnderTest.setContext({
routeRef: 'very:ref',
pluginId: 'very-plugin',
extension: 'ShouldBeReplaced',
extensionId: 'ShouldBeReplaced',
});
trackerUnderTest.captureEvent(routableExtensionRenderedEvent, '');
@@ -295,9 +275,8 @@ describe('Tracker', () => {
action: 'navigate',
subject: '/page-9',
context: {
routeRef: 'very:ref',
pluginId: 'very-plugin',
extension: 'App',
extensionId: 'App',
},
}),
);
@@ -68,9 +68,8 @@ export class Tracker implements AnalyticsTracker {
constructor(
private readonly analyticsApi: AnalyticsApi,
private context: AnalyticsContextValue = {
routeRef: 'unknown',
pluginId: 'root',
extension: 'App',
extensionId: 'App',
},
) {
// Only register a single beforeunload event across all trackers.
@@ -110,7 +109,7 @@ export class Tracker implements AnalyticsTracker {
}: { value?: number; attributes?: AnalyticsEventAttributes } = {},
) {
// Never pass internal "_routeNodeType" context value.
const { _routeNodeType, ...context } = this.context;
const context = this.context;
// Never fire the special "_routable-extension-rendered" internal event.
if (action === routableExtensionRenderedEvent) {
@@ -120,7 +119,7 @@ export class Tracker implements AnalyticsTracker {
globalEvents.mostRecentRoutableExtensionRender = {
context: {
...context,
extension: 'App',
extensionId: 'App',
},
};
}
@@ -148,11 +147,7 @@ export class Tracker implements AnalyticsTracker {
// Never directly fire a navigation event on a gathered route with default
// contextual details.
if (
action === 'navigate' &&
_routeNodeType === 'gathered' &&
context.pluginId === 'root'
) {
if (action === 'navigate' && context.pluginId === 'root') {
// Instead, set it on the global store.
globalEvents.mostRecentGatheredNavigation = {
action,
@@ -26,14 +26,12 @@ export type CommonAnalyticsContext = {
pluginId: string;
/**
* The ID of the routeRef that was active when the event was captured.
* The nearest known parent extension where the event was captured.
*/
routeRef: string;
/**
* The nearest known parent extension where the event was captured.
*/
extension: string;
extensionId: string;
};
/**
@@ -58,9 +58,8 @@ describe('useAnalytics', () => {
some: 'value',
},
context: {
extension: 'App',
extensionId: 'App',
pluginId: 'root',
routeRef: 'unknown',
},
});
});