frontend-plugin-api: infer ExtensionBoundary routable prop from outputs

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2024-07-18 12:08:15 +02:00
parent a236f099db
commit 9b89b82f66
8 changed files with 91 additions and 14 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/frontend-plugin-api': patch
---
The `ExtensionBoundary` now by default infers whether its routable from whether it outputs a route path.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog-react': patch
---
Internal refactor to remove unnecessary `routable` prop in the implementation of the `createEntityContentExtension` alpha export.
@@ -337,7 +337,7 @@ Similar to plugins the `ErrorBoundary` for extension allows to pass in a fallbac
### Analytics
Analytics information are provided through the `AnalyticsContext`, which will give `extensionId` & `pluginId` as context to analytics event fired inside of the extension. Additionally `RouteTracker` will capture an analytics event for routable extension to inform which extension metadata gets associated with a navigation event when the route navigated to is a gathered `mountPoint`.
Analytics information are provided through the `AnalyticsContext`, which will give `extensionId` & `pluginId` as context to analytics event fired inside of the extension. Additionally `RouteTracker` will capture an analytics event for routable extension to inform which extension metadata gets associated with a navigation event when the route navigated to is a gathered `mountPoint`. Whether an extension is routable is inferred from its outputs, but you can also explicitly control this behavior by passing the `routable` prop to `ExtensionBoundary`.
The `ExtensionBoundary` can be used like the following in an extension creator:
@@ -359,7 +359,7 @@ export function createSomeExtension<
path: config.path,
routeRef: options.routeRef,
element: (
<ExtensionBoundary node={node} routable>
<ExtensionBoundary node={node}>
<ExtensionComponent />
</ExtensionBoundary>
),
@@ -844,7 +844,6 @@ export interface ExtensionBoundaryProps {
children: ReactNode;
// (undocumented)
node: AppNode;
// (undocumented)
routable?: boolean;
}
@@ -15,15 +15,24 @@
*/
import React, { useEffect } from 'react';
import { screen, waitFor } from '@testing-library/react';
import { MockAnalyticsApi, TestApiProvider } from '@backstage/test-utils';
import { act, screen, waitFor } from '@testing-library/react';
import {
MockAnalyticsApi,
TestApiProvider,
withLogCollector,
} from '@backstage/test-utils';
import { ExtensionBoundary } from './ExtensionBoundary';
import { coreExtensionData, createExtension } from '../wiring';
import { analyticsApiRef, useAnalytics } from '@backstage/core-plugin-api';
import {
analyticsApiRef,
createApiFactory,
useAnalytics,
} from '@backstage/core-plugin-api';
import { createRouteRef } from '../routing';
import { createExtensionTester } from '@backstage/frontend-test-utils';
import { createApiExtension } from '../extensions';
const wrapInBoundaryExtension = (element: JSX.Element) => {
const wrapInBoundaryExtension = (element?: JSX.Element) => {
const routeRef = createRouteRef();
return createExtension({
name: 'test',
@@ -54,12 +63,25 @@ describe('ExtensionBoundary', () => {
});
it('should show app error component when an error is thrown', async () => {
const error = 'Something went wrong';
const errorMsg = 'Something went wrong';
const ErrorComponent = () => {
throw new Error(error);
throw new Error(errorMsg);
};
createExtensionTester(wrapInBoundaryExtension(<ErrorComponent />)).render();
await waitFor(() => expect(screen.getByText(error)).toBeInTheDocument());
const { error } = await withLogCollector(['error'], async () => {
createExtensionTester(
wrapInBoundaryExtension(<ErrorComponent />),
).render();
await waitFor(() =>
expect(screen.getByText(errorMsg)).toBeInTheDocument(),
);
});
expect(error).toEqual(
expect.arrayContaining([
expect.objectContaining({
message: expect.stringContaining(errorMsg),
}),
]),
);
});
it('should wrap children with analytics context', async () => {
@@ -97,4 +119,38 @@ describe('ExtensionBoundary', () => {
});
});
});
// TODO(Rugvip): It's annoying to test the inverse of this currently, because the extension tester overrides the subject to always output a path
it('should emit analytics events if routable', async () => {
const Emitter = () => {
const analytics = useAnalytics();
useEffect(() => {
analytics.captureEvent('dummy', 'dummy');
});
return null;
};
const analyticsApiMock = new MockAnalyticsApi();
await act(async () => {
createExtensionTester(wrapInBoundaryExtension(<Emitter />))
.add(
createApiExtension({
factory: createApiFactory(analyticsApiRef, analyticsApiMock),
}),
)
.render();
});
expect(analyticsApiMock.getEvents()).toEqual([
expect.objectContaining({
action: 'navigate',
subject: '/',
context: expect.objectContaining({
pluginId: 'root',
extensionId: 'test',
}),
}),
expect.objectContaining({ action: 'dummy' }),
]);
});
});
@@ -26,6 +26,7 @@ import { ErrorBoundary } from './ErrorBoundary';
import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/analytics/Tracker';
import { AppNode, useComponentRef } from '../apis';
import { coreComponentRefs } from './coreComponentRefs';
import { coreExtensionData } from '../wiring';
type RouteTrackerProps = PropsWithChildren<{
disableTracking?: boolean;
@@ -50,6 +51,11 @@ const RouteTracker = (props: RouteTrackerProps) => {
/** @public */
export interface ExtensionBoundaryProps {
node: AppNode;
/**
* This explicitly marks the extension as routable for the purpose of
* capturing analytics events. If not provided, the extension boundary will be
* marked as routable if it outputs a routePath.
*/
routable?: boolean;
children: ReactNode;
}
@@ -58,6 +64,10 @@ export interface ExtensionBoundaryProps {
export function ExtensionBoundary(props: ExtensionBoundaryProps) {
const { node, routable, children } = props;
const doesOutputRoutePath = Boolean(
node.instance?.getData(coreExtensionData.routePath),
);
const plugin = node.spec.source;
const Progress = useComponentRef(coreComponentRefs.progress);
const fallback = useComponentRef(coreComponentRefs.errorBoundaryFallback);
@@ -72,7 +82,9 @@ export function ExtensionBoundary(props: ExtensionBoundaryProps) {
<Suspense fallback={<Progress />}>
<ErrorBoundary plugin={plugin} Fallback={fallback}>
<AnalyticsContext attributes={attributes}>
<RouteTracker disableTracking={!routable}>{children}</RouteTracker>
<RouteTracker disableTracking={!(routable ?? doesOutputRoutePath)}>
{children}
</RouteTracker>
</AnalyticsContext>
</ErrorBoundary>
</Suspense>
@@ -87,7 +87,7 @@ export function createPageExtension<
path: config.path,
routeRef: options.routeRef,
element: (
<ExtensionBoundary node={node} routable>
<ExtensionBoundary node={node}>
<ExtensionComponent />
</ExtensionBoundary>
),
+1 -1
View File
@@ -166,7 +166,7 @@ export function createEntityContentExtension<
title: config.title,
routeRef: options.routeRef,
element: (
<ExtensionBoundary node={node} routable>
<ExtensionBoundary node={node}>
<ExtensionComponent />
</ExtensionBoundary>
),