From 0eaa698bb5a73a7f13b6bd4d1b51af84068d40f4 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 13 Oct 2023 09:38:33 +0200 Subject: [PATCH 01/16] chore(frontend-plugin-api): install frontend app api Signed-off-by: Camila Belo --- packages/frontend-plugin-api/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend-plugin-api/package.json b/packages/frontend-plugin-api/package.json index baf8cb4575..9cc3355ec3 100644 --- a/packages/frontend-plugin-api/package.json +++ b/packages/frontend-plugin-api/package.json @@ -24,7 +24,6 @@ }, "devDependencies": { "@backstage/cli": "workspace:^", - "@backstage/frontend-app-api": "workspace:^", "@backstage/test-utils": "workspace:^", "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^14.0.0", @@ -39,6 +38,7 @@ }, "dependencies": { "@backstage/core-plugin-api": "workspace:^", + "@backstage/frontend-app-api": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", "@types/react": "^16.13.1 || ^17.0.0", From ed71a018b1ece87f2e73e15eeac18a8555569308 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 12 Oct 2023 22:08:03 +0200 Subject: [PATCH 02/16] feat(frontend-plugin-api): improve extension boundary Signed-off-by: Camila Belo --- packages/frontend-plugin-api/package.json | 4 +- .../src/components/ErrorBoundary.tsx | 79 +++++++++++++++++++ .../src/components/ExtensionBoundary.tsx | 24 +++++- yarn.lock | 2 + 4 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 packages/frontend-plugin-api/src/components/ErrorBoundary.tsx diff --git a/packages/frontend-plugin-api/package.json b/packages/frontend-plugin-api/package.json index 9cc3355ec3..528d86e0d1 100644 --- a/packages/frontend-plugin-api/package.json +++ b/packages/frontend-plugin-api/package.json @@ -24,6 +24,7 @@ }, "devDependencies": { "@backstage/cli": "workspace:^", + "@backstage/frontend-app-api": "workspace:^", "@backstage/test-utils": "workspace:^", "@testing-library/jest-dom": "^6.0.0", "@testing-library/react": "^14.0.0", @@ -37,10 +38,11 @@ "react-router-dom": "6.0.0-beta.0 || ^6.3.0" }, "dependencies": { + "@backstage/core-components": "workspace:^", "@backstage/core-plugin-api": "workspace:^", - "@backstage/frontend-app-api": "workspace:^", "@backstage/types": "workspace:^", "@backstage/version-bridge": "workspace:^", + "@material-ui/core": "^4.12.4", "@types/react": "^16.13.1 || ^17.0.0", "lodash": "^4.17.21", "zod": "^3.21.4", diff --git a/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx b/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000000..f9ce9c9809 --- /dev/null +++ b/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx @@ -0,0 +1,79 @@ +/* + * 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 React, { Component, PropsWithChildren } from 'react'; +import { Button } from '@material-ui/core'; +import { BackstagePlugin } from '../wiring'; +import { ErrorPanel } from '@backstage/core-components'; + +type DefaultErrorBoundaryFallbackProps = PropsWithChildren<{ + plugin?: BackstagePlugin; + error: Error; + resetError: () => void; +}>; + +const DefaultErrorBoundaryFallback = ({ + plugin, + error, + resetError, +}: DefaultErrorBoundaryFallbackProps) => { + const title = `Error in ${plugin?.id}`; + + return ( + + + + ); +}; + +type ErrorBoundaryProps = PropsWithChildren<{ plugin?: BackstagePlugin }>; +type ErrorBoundaryState = { error?: Error }; + +/** @internal */ +export class ErrorBoundary extends Component< + ErrorBoundaryProps, + ErrorBoundaryState +> { + static getDerivedStateFromError(error: Error) { + return { error }; + } + + state: ErrorBoundaryState = { error: undefined }; + + handleErrorReset = () => { + this.setState({ error: undefined }); + }; + + render() { + const { error } = this.state; + const { plugin, children } = this.props; + + if (error) { + // TODO: use a configurable error boundary fallback + return ( + + ); + } + + return children; + } +} diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx index c6a218c15f..796908c619 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx @@ -15,15 +15,35 @@ */ import React, { ReactNode } from 'react'; +import { AnalyticsContext } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '../wiring'; +import { RouteRef } from '../routing'; +import { ErrorBoundary } from './ErrorBoundary'; +import { toInternalRouteRef } from '../routing/RouteRef'; /** @public */ export interface ExtensionBoundaryProps { - children: ReactNode; + id: string; source?: BackstagePlugin; + routeRef?: RouteRef; + children: ReactNode; } /** @public */ export function ExtensionBoundary(props: ExtensionBoundaryProps) { - return <>{props.children}; + const { id, source, routeRef, children } = props; + + const attributes = { + extension: id, + pluginId: source?.id, + routeRef: routeRef + ? toInternalRouteRef(routeRef).getDescription() + : undefined, + }; + + return ( + + {children} + + ); } diff --git a/yarn.lock b/yarn.lock index 1e2f6fdb5d..8999966568 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4246,11 +4246,13 @@ __metadata: resolution: "@backstage/frontend-plugin-api@workspace:packages/frontend-plugin-api" dependencies: "@backstage/cli": "workspace:^" + "@backstage/core-components": "workspace:^" "@backstage/core-plugin-api": "workspace:^" "@backstage/frontend-app-api": "workspace:^" "@backstage/test-utils": "workspace:^" "@backstage/types": "workspace:^" "@backstage/version-bridge": "workspace:^" + "@material-ui/core": ^4.12.4 "@testing-library/jest-dom": ^6.0.0 "@testing-library/react": ^14.0.0 "@types/react": ^16.13.1 || ^17.0.0 From b2dcb92c36e8c142f1b9bb386505b31368a3cfc7 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 13 Oct 2023 15:57:50 +0200 Subject: [PATCH 03/16] test(frontend-plugin-api): cover extension boundary component Signed-off-by: Camila Belo --- .../src/components/ExtensionBoundary.test.tsx | 136 ++++++++++++++++++ 1 file changed, 136 insertions(+) create mode 100644 packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx new file mode 100644 index 0000000000..150ace717c --- /dev/null +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx @@ -0,0 +1,136 @@ +/* + * 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 React, { useEffect } from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import { + MockAnalyticsApi, + MockConfigApi, + TestApiProvider, + renderWithEffects, +} from '@backstage/test-utils'; +import { ExtensionBoundary } from './ExtensionBoundary'; +import { + Extension, + coreExtensionData, + createExtension, + createPlugin, +} from '../wiring'; +import { analyticsApiRef, useAnalytics } from '@backstage/core-plugin-api'; +import { createApp } from '@backstage/frontend-app-api'; +import { JsonObject } from '@backstage/types'; +import { createRouteRef } from '../routing'; +import { toInternalRouteRef } from '../routing/RouteRef'; + +function renderExtensionInTestApp( + extension: Extension, + options?: { + config?: JsonObject; + }, +) { + const { config = {} } = options ?? {}; + + const app = createApp({ + features: [ + createPlugin({ + id: 'plugin', + extensions: [extension], + }), + ], + configLoader: async () => new MockConfigApi(config), + }); + + return renderWithEffects(app.createRoot()); +} + +const wrapInBoundaryExtension = (element: JSX.Element) => { + const id = 'plugin.extension'; + const routeRef = createRouteRef(); + toInternalRouteRef(routeRef).setId(id); + return createExtension({ + id, + attachTo: { id: 'core.routes', input: 'routes' }, + output: { + element: coreExtensionData.reactElement, + path: coreExtensionData.routePath, + routeRef: coreExtensionData.routeRef.optional(), + }, + factory({ bind, source }) { + bind({ + routeRef, + path: '/', + element: ( + + {element} + + ), + }); + }, + }); +}; + +describe('ExtensionBoundary', () => { + it('should render children when there is no error', async () => { + const text = 'Text Component'; + const TextComponent = () => { + return

{text}

; + }; + await renderExtensionInTestApp(wrapInBoundaryExtension()); + await waitFor(() => expect(screen.getByText(text)).toBeInTheDocument()); + }); + + it('should show app error component when an error is thrown', async () => { + const error = 'Something went wrong'; + const ErrorComponent = () => { + throw new Error(error); + }; + await renderExtensionInTestApp(wrapInBoundaryExtension()); + await waitFor(() => expect(screen.getByText(error)).toBeInTheDocument()); + }); + + it('should wrap children with analytics context', async () => { + const action = 'render'; + const subject = 'analytics'; + const analyticsApiMock = new MockAnalyticsApi(); + + const AnalyticsComponent = () => { + const analytics = useAnalytics(); + useEffect(() => { + analytics.captureEvent(action, subject); + }, [analytics]); + return null; + }; + + await renderExtensionInTestApp( + wrapInBoundaryExtension( + + + , + ), + ); + + await waitFor(() => + expect(analyticsApiMock.getEvents()[0]).toMatchObject({ + action, + subject, + context: { + extension: 'plugin.extension', + routeRef: 'plugin.extension', + }, + }), + ); + }); +}); From 2610774e4b281d5239788fced55ead61104ef02c Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 12 Oct 2023 22:08:32 +0200 Subject: [PATCH 04/16] feat(frontend-plugin-api): create extension suspense Signed-off-by: Camila Belo --- .../src/components/ExtensionSuspense.tsx | 33 +++++++++++++++++++ .../src/components/index.ts | 5 +++ 2 files changed, 38 insertions(+) create mode 100644 packages/frontend-plugin-api/src/components/ExtensionSuspense.tsx diff --git a/packages/frontend-plugin-api/src/components/ExtensionSuspense.tsx b/packages/frontend-plugin-api/src/components/ExtensionSuspense.tsx new file mode 100644 index 0000000000..e80f59f09c --- /dev/null +++ b/packages/frontend-plugin-api/src/components/ExtensionSuspense.tsx @@ -0,0 +1,33 @@ +/* + * 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 React, { ReactNode, Suspense } from 'react'; +import { useApp } from '@backstage/core-plugin-api'; + +/** @public */ +export interface ExtensionSuspenseProps { + children: ReactNode; +} + +/** @public */ +export function ExtensionSuspense(props: ExtensionSuspenseProps) { + const { children } = props; + + const app = useApp(); + const { Progress } = app.getComponents(); + + return }>{children}; +} diff --git a/packages/frontend-plugin-api/src/components/index.ts b/packages/frontend-plugin-api/src/components/index.ts index 7056a59271..fecd7f36cc 100644 --- a/packages/frontend-plugin-api/src/components/index.ts +++ b/packages/frontend-plugin-api/src/components/index.ts @@ -18,3 +18,8 @@ export { ExtensionBoundary, type ExtensionBoundaryProps, } from './ExtensionBoundary'; + +export { + ExtensionSuspense, + type ExtensionSuspenseProps, +} from './ExtensionSuspense'; From 055b7aec3da8b0b04801525d8180d47ea8978d36 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 13 Oct 2023 10:56:10 +0200 Subject: [PATCH 05/16] test(frontend-plugin-api): cover extension suspense component Signed-off-by: Camila Belo --- .../src/components/ExtensionSuspense.test.tsx | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 packages/frontend-plugin-api/src/components/ExtensionSuspense.test.tsx diff --git a/packages/frontend-plugin-api/src/components/ExtensionSuspense.test.tsx b/packages/frontend-plugin-api/src/components/ExtensionSuspense.test.tsx new file mode 100644 index 0000000000..166c57b83b --- /dev/null +++ b/packages/frontend-plugin-api/src/components/ExtensionSuspense.test.tsx @@ -0,0 +1,54 @@ +/* + * 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 React, { lazy } from 'react'; +import { screen, waitFor } from '@testing-library/react'; +import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; +import { ExtensionSuspense } from './ExtensionSuspense'; + +describe('ExtensionSuspense', () => { + it('should render the app progress component as fallback', async () => { + const LazyComponent = lazy(() => new Promise(() => {})); + + await renderWithEffects( + wrapInTestApp( + + + , + ), + ); + + expect(screen.getByTestId('progress')).toBeInTheDocument(); + }); + + it('should render the lazy loaded children component', async () => { + const LazyComponent = lazy(() => + Promise.resolve({ default: () =>
Lazy Component
}), + ); + + await renderWithEffects( + wrapInTestApp( + + + , + ), + ); + + await waitFor(() => + expect(screen.getByText('Lazy Component')).toBeInTheDocument(), + ); + }); +}); From 103b405746ada473d44cf9c01738c65010238592 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 19 Oct 2023 10:05:37 +0200 Subject: [PATCH 06/16] feat(frontend-plugin-api): use extension boundary and suspense Signed-off-by: Camila Belo --- .../src/extensions/createPageExtension.tsx | 53 +++++++++++++------ 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx b/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx index 217fb33ce5..ad7db1d222 100644 --- a/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx +++ b/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx @@ -14,18 +14,21 @@ * limitations under the License. */ -import React from 'react'; -import { ExtensionBoundary } from '../components'; +import React, { lazy, useEffect } from 'react'; +import { useAnalytics } from '@backstage/core-plugin-api'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/analytics/Tracker'; +import { ExtensionBoundary, ExtensionSuspense } from '../components'; import { createSchemaFromZod, PortableSchema } from '../schema'; import { coreExtensionData, createExtension, Extension, ExtensionInputValues, + AnyExtensionInputMap, } from '../wiring'; -import { AnyExtensionInputMap } from '../wiring/createExtension'; -import { Expand } from '../types'; import { RouteRef } from '../routing'; +import { Expand } from '../types'; /** * Helper for creating extensions for a routable React page component. @@ -55,6 +58,10 @@ export function createPageExtension< }) => Promise; }, ): Extension { + const { id, routeRef } = options; + + const attachTo = options.attachTo ?? { id: 'core.routes', input: 'routes' }; + const configSchema = 'configSchema' in options ? options.configSchema @@ -63,33 +70,49 @@ export function createPageExtension< ) as PortableSchema); return createExtension({ - id: options.id, - attachTo: options.attachTo ?? { id: 'core.routes', input: 'routes' }, + id, + attachTo, + configSchema, + inputs: options.inputs, disabled: options.disabled, output: { element: coreExtensionData.reactElement, path: coreExtensionData.routePath, routeRef: coreExtensionData.routeRef.optional(), }, - inputs: options.inputs, - configSchema, factory({ bind, config, inputs, source }) { - const LazyComponent = React.lazy(() => + const { path } = config; + + const PageComponent = lazy(() => options .loader({ config, inputs }) .then(element => ({ default: () => element })), ); + const ExtensionComponent = () => { + const analytics = useAnalytics(); + + // This event, never exposed to end-users of the analytics API, + // helps inform which extension metadata gets associated with a + // navigation event when the route navigated to is a gathered + // mountpoint. + useEffect(() => { + analytics.captureEvent(routableExtensionRenderedEvent, ''); + }, [analytics]); + + return ; + }; + bind({ - path: config.path, + path, + routeRef, element: ( - - - - + + + + ), - routeRef: options.routeRef, }); }, }); From 42a8ef3a4f2283de29d29f73eb3b94a6203f1d72 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 13 Oct 2023 20:58:03 +0200 Subject: [PATCH 07/16] test(frontend-plugin-api): cover create page extension Signed-off-by: Camila Belo --- .../extensions/createPageExtension.test.tsx | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/packages/frontend-plugin-api/src/extensions/createPageExtension.test.tsx b/packages/frontend-plugin-api/src/extensions/createPageExtension.test.tsx index 125bf3495d..37d97e7621 100644 --- a/packages/frontend-plugin-api/src/extensions/createPageExtension.test.tsx +++ b/packages/frontend-plugin-api/src/extensions/createPageExtension.test.tsx @@ -15,10 +15,23 @@ */ import React from 'react'; +import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; +import { useAnalytics } from '@backstage/core-plugin-api'; +import { waitFor } from '@testing-library/react'; import { PortableSchema } from '../schema'; -import { coreExtensionData, createExtensionInput } from '../wiring'; +import { + ExtensionInputValues, + coreExtensionData, + createExtensionInput, + createPlugin, +} from '../wiring'; import { createPageExtension } from './createPageExtension'; +jest.mock('@backstage/core-plugin-api', () => ({ + ...jest.requireActual('@backstage/core-plugin-api'), + useAnalytics: jest.fn(), +})); + describe('createPageExtension', () => { it('creates the extension properly', () => { const configSchema: PortableSchema<{ path: string }> = { @@ -100,4 +113,35 @@ describe('createPageExtension', () => { factory: expect.any(Function), }); }); + + it('capture page view event in analytics', async () => { + const captureEvent = jest.fn(); + + (useAnalytics as jest.Mock).mockReturnValue({ + captureEvent, + }); + + const extension = createPageExtension({ + id: 'plugin.page', + defaultPath: '/', + loader: async () =>
Component
, + }); + + extension.factory({ + bind: (values: ExtensionInputValues) => + renderWithEffects( + wrapInTestApp(values.element as unknown as JSX.Element), + ), + source: createPlugin({ id: 'plugin ' }), + config: { path: '/' }, + inputs: {}, + }); + + await waitFor(() => + expect(captureEvent).toHaveBeenCalledWith( + '_ROUTABLE-EXTENSION-RENDERED', + '', + ), + ); + }); }); From c1e10b6175e59af190d005454096b36dc712527a Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 13 Oct 2023 09:23:51 +0200 Subject: [PATCH 08/16] docs(frontend-plugin-api): update api reports Signed-off-by: Camila Belo --- packages/frontend-plugin-api/api-report.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index 3ed5965c99..b6f85e757b 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -338,6 +338,10 @@ export interface ExtensionBoundaryProps { // (undocumented) children: ReactNode; // (undocumented) + id: string; + // (undocumented) + routeRef?: RouteRef; + // (undocumented) source?: BackstagePlugin; } @@ -412,6 +416,17 @@ export interface ExtensionOverridesOptions { extensions: Extension[]; } +// @public (undocumented) +export function ExtensionSuspense( + props: ExtensionSuspenseProps, +): React_2.JSX.Element; + +// @public (undocumented) +export interface ExtensionSuspenseProps { + // (undocumented) + children: ReactNode; +} + // @public export interface ExternalRouteRef< TParams extends AnyRouteRefParams = AnyRouteRefParams, From 6af88a05ff23e66d1c792e8fa6ba42fed4996c19 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Sat, 14 Oct 2023 10:38:48 +0200 Subject: [PATCH 09/16] chore(frontend-plugin-api): add changeset file Signed-off-by: Camila Belo --- .changeset/sixty-tips-argue.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/sixty-tips-argue.md diff --git a/.changeset/sixty-tips-argue.md b/.changeset/sixty-tips-argue.md new file mode 100644 index 0000000000..9fc965a281 --- /dev/null +++ b/.changeset/sixty-tips-argue.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Improve the extension boundary component and create a default extension suspense component. From b51919e3eb85f68916de6d930e079fa70ae8b55b Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Fri, 13 Oct 2023 09:22:21 +0200 Subject: [PATCH 10/16] refactor(search-react): use extension boundary and suspense Signed-off-by: Camila Belo --- plugins/search-react/src/alpha.tsx | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/plugins/search-react/src/alpha.tsx b/plugins/search-react/src/alpha.tsx index 366112c95e..f28bc44b0d 100644 --- a/plugins/search-react/src/alpha.tsx +++ b/plugins/search-react/src/alpha.tsx @@ -14,18 +14,18 @@ * limitations under the License. */ -import React, { lazy, Suspense } from 'react'; +import React, { lazy } from 'react'; import { ListItemProps } from '@material-ui/core'; import { ExtensionBoundary, + ExtensionSuspense, PortableSchema, createExtension, createExtensionDataRef, createSchemaFromZod, } from '@backstage/frontend-plugin-api'; -import { Progress } from '@backstage/core-components'; import { SearchDocument, SearchResult } from '@backstage/plugin-search-common'; import { SearchResultListItemExtension } from './extensions'; @@ -87,6 +87,13 @@ export type SearchResultItemExtensionOptions< export function createSearchResultListItemExtension< TConfig extends { noTrack?: boolean }, >(options: SearchResultItemExtensionOptions) { + const id = `plugin.search.result.item.${options.id}`; + + const attachTo = options.attachTo ?? { + id: 'plugin.search.page', + input: 'items', + }; + const configSchema = 'configSchema' in options ? options.configSchema @@ -95,15 +102,16 @@ export function createSearchResultListItemExtension< noTrack: z.boolean().default(false), }), ) as PortableSchema); + return createExtension({ - id: `plugin.search.result.item.${options.id}`, - attachTo: options.attachTo ?? { id: 'plugin.search.page', input: 'items' }, + id, + attachTo, configSchema, output: { item: searchResultItemExtensionData, }, factory({ bind, config, source }) { - const LazyComponent = lazy(() => + const ExtensionComponent = lazy(() => options .component({ config }) .then(component => ({ default: component })), @@ -113,16 +121,16 @@ export function createSearchResultListItemExtension< item: { predicate: options.predicate, component: props => ( - - }> + + - + - + ), }, From e7c09c4f4b60d0107dcefe23a39ece2916b49ef3 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Sat, 14 Oct 2023 10:39:27 +0200 Subject: [PATCH 11/16] chore(search-react): add changeset file Signed-off-by: Camila Belo --- .changeset/young-days-talk.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/young-days-talk.md diff --git a/.changeset/young-days-talk.md b/.changeset/young-days-talk.md new file mode 100644 index 0000000000..b7420c7894 --- /dev/null +++ b/.changeset/young-days-talk.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-search-react': patch +--- + +Use default extensions boundary and suspense on the alpha declarative `createSearchResultListItem` extension factory. From 11555a0da0153d52fbcd57dd94b8ff632539dbf9 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 19 Oct 2023 11:32:39 +0200 Subject: [PATCH 12/16] refactor(catalog): forward plugin to extension boundary Signed-off-by: Camila Belo --- plugins/catalog/src/alpha.tsx | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/plugins/catalog/src/alpha.tsx b/plugins/catalog/src/alpha.tsx index 6eda8c3d26..27ec432d07 100644 --- a/plugins/catalog/src/alpha.tsx +++ b/plugins/catalog/src/alpha.tsx @@ -36,6 +36,7 @@ import { PortableSchema, ExtensionBoundary, createExtensionInput, + ExtensionSuspense, } from '@backstage/frontend-plugin-api'; import { AsyncEntityProvider, @@ -51,7 +52,6 @@ import { rootRouteRef, viewTechDocRouteRef, } from './routes'; -import { Progress } from '@backstage/core-components'; import { useEntityFromUrl } from './components/CatalogEntityPage/useEntityFromUrl'; /** @alpha */ @@ -97,16 +97,19 @@ export function createCatalogFilterExtension< configSchema?: PortableSchema; loader: (options: { config: TConfig }) => Promise; }) { + const id = `catalog.filter.${options.id}`; + const attachTo = { id: 'plugin.catalog.page.index', input: 'filters' }; + return createExtension({ - id: `catalog.filter.${options.id}`, - attachTo: { id: 'plugin.catalog.page.index', input: 'filters' }, + id, + attachTo, inputs: options.inputs ?? {}, configSchema: options.configSchema, output: { element: coreExtensionData.reactElement, }, factory({ bind, config, source }) { - const LazyComponent = React.lazy(() => + const ExtensionComponent = React.lazy(() => options .loader({ config }) .then(element => ({ default: () => element })), @@ -114,10 +117,10 @@ export function createCatalogFilterExtension< bind({ element: ( - - }> - - + + + + ), }); From e964c17db97ad0fdb27e9e2b2b6c19ad432e3e3a Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 19 Oct 2023 11:49:47 +0200 Subject: [PATCH 13/16] chore(catalog): add changeset file Signed-off-by: Camila Belo --- .changeset/fluffy-years-shake.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fluffy-years-shake.md diff --git a/.changeset/fluffy-years-shake.md b/.changeset/fluffy-years-shake.md new file mode 100644 index 0000000000..9612690d62 --- /dev/null +++ b/.changeset/fluffy-years-shake.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Use default extensions boundary and suspense on the alpha declarative `createCatalogFilterExtension` extension factory. From 1b8f005aab095f83eb3652b049b1ab9192adfb2f Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 19 Oct 2023 13:57:35 +0200 Subject: [PATCH 14/16] refactor(catalog): restore inline attachment Signed-off-by: Camila Belo --- plugins/catalog/src/alpha.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/plugins/catalog/src/alpha.tsx b/plugins/catalog/src/alpha.tsx index 27ec432d07..e9cb083978 100644 --- a/plugins/catalog/src/alpha.tsx +++ b/plugins/catalog/src/alpha.tsx @@ -98,11 +98,10 @@ export function createCatalogFilterExtension< loader: (options: { config: TConfig }) => Promise; }) { const id = `catalog.filter.${options.id}`; - const attachTo = { id: 'plugin.catalog.page.index', input: 'filters' }; return createExtension({ id, - attachTo, + attachTo: { id: 'plugin.catalog.page.index', input: 'filters' }, inputs: options.inputs ?? {}, configSchema: options.configSchema, output: { From cca1afc547100a9412a75c77e3ef15cf0f04d691 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 19 Oct 2023 13:57:54 +0200 Subject: [PATCH 15/16] refactor(search): restore inline attachment Signed-off-by: Camila Belo --- plugins/search-react/src/alpha.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/plugins/search-react/src/alpha.tsx b/plugins/search-react/src/alpha.tsx index f28bc44b0d..322bd3e67b 100644 --- a/plugins/search-react/src/alpha.tsx +++ b/plugins/search-react/src/alpha.tsx @@ -89,11 +89,6 @@ export function createSearchResultListItemExtension< >(options: SearchResultItemExtensionOptions) { const id = `plugin.search.result.item.${options.id}`; - const attachTo = options.attachTo ?? { - id: 'plugin.search.page', - input: 'items', - }; - const configSchema = 'configSchema' in options ? options.configSchema @@ -105,7 +100,10 @@ export function createSearchResultListItemExtension< return createExtension({ id, - attachTo, + attachTo: options.attachTo ?? { + id: 'plugin.search.page', + input: 'items', + }, configSchema, output: { item: searchResultItemExtensionData, From 66af240ea692929df76d8f817e96595d648c44d6 Mon Sep 17 00:00:00 2001 From: Camila Belo Date: Thu, 19 Oct 2023 14:54:35 +0200 Subject: [PATCH 16/16] refactor(frontend-plugin-api): merge boundary and suspense components Signed-off-by: Camila Belo --- packages/frontend-plugin-api/api-report.md | 13 +---- .../src/components/ErrorBoundary.tsx | 3 +- .../src/components/ExtensionBoundary.test.tsx | 6 +-- .../src/components/ExtensionBoundary.tsx | 47 ++++++++++++++----- .../src/components/index.ts | 5 -- .../src/extensions/createPageExtension.tsx | 41 ++++------------ plugins/catalog/src/alpha.tsx | 5 +- plugins/search-react/src/alpha.tsx | 17 +++---- 8 files changed, 57 insertions(+), 80 deletions(-) diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index b6f85e757b..ac8d164fae 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -340,7 +340,7 @@ export interface ExtensionBoundaryProps { // (undocumented) id: string; // (undocumented) - routeRef?: RouteRef; + routable?: boolean; // (undocumented) source?: BackstagePlugin; } @@ -416,17 +416,6 @@ export interface ExtensionOverridesOptions { extensions: Extension[]; } -// @public (undocumented) -export function ExtensionSuspense( - props: ExtensionSuspenseProps, -): React_2.JSX.Element; - -// @public (undocumented) -export interface ExtensionSuspenseProps { - // (undocumented) - children: ReactNode; -} - // @public export interface ExternalRouteRef< TParams extends AnyRouteRefParams = AnyRouteRefParams, diff --git a/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx b/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx index f9ce9c9809..1191b75a1d 100644 --- a/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx +++ b/packages/frontend-plugin-api/src/components/ErrorBoundary.tsx @@ -15,9 +15,10 @@ */ import React, { Component, PropsWithChildren } from 'react'; +// TODO: Dependency on MUI should be removed from core packages import { Button } from '@material-ui/core'; -import { BackstagePlugin } from '../wiring'; import { ErrorPanel } from '@backstage/core-components'; +import { BackstagePlugin } from '../wiring'; type DefaultErrorBoundaryFallbackProps = PropsWithChildren<{ plugin?: BackstagePlugin; diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx index 150ace717c..704cafda2d 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.test.tsx @@ -33,7 +33,6 @@ import { analyticsApiRef, useAnalytics } from '@backstage/core-plugin-api'; import { createApp } from '@backstage/frontend-app-api'; import { JsonObject } from '@backstage/types'; import { createRouteRef } from '../routing'; -import { toInternalRouteRef } from '../routing/RouteRef'; function renderExtensionInTestApp( extension: Extension, @@ -59,7 +58,6 @@ function renderExtensionInTestApp( const wrapInBoundaryExtension = (element: JSX.Element) => { const id = 'plugin.extension'; const routeRef = createRouteRef(); - toInternalRouteRef(routeRef).setId(id); return createExtension({ id, attachTo: { id: 'core.routes', input: 'routes' }, @@ -73,7 +71,7 @@ const wrapInBoundaryExtension = (element: JSX.Element) => { routeRef, path: '/', element: ( - + {element} ), @@ -128,7 +126,7 @@ describe('ExtensionBoundary', () => { subject, context: { extension: 'plugin.extension', - routeRef: 'plugin.extension', + routeRef: 'unknown', }, }), ); diff --git a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx index 796908c619..a9ea297216 100644 --- a/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx +++ b/packages/frontend-plugin-api/src/components/ExtensionBoundary.tsx @@ -14,36 +14,59 @@ * limitations under the License. */ -import React, { ReactNode } from 'react'; -import { AnalyticsContext } from '@backstage/core-plugin-api'; +import React, { PropsWithChildren, ReactNode, useEffect } from 'react'; +import { AnalyticsContext, useAnalytics } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '../wiring'; -import { RouteRef } from '../routing'; import { ErrorBoundary } from './ErrorBoundary'; -import { toInternalRouteRef } from '../routing/RouteRef'; +import { ExtensionSuspense } from './ExtensionSuspense'; +// eslint-disable-next-line @backstage/no-relative-monorepo-imports +import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/analytics/Tracker'; + +type RouteTrackerProps = PropsWithChildren<{ + disableTracking?: boolean; +}>; + +const RouteTracker = (props: RouteTrackerProps) => { + const { disableTracking, children } = props; + const analytics = useAnalytics(); + + // This event, never exposed to end-users of the analytics API, + // helps inform which extension metadata gets associated with a + // navigation event when the route navigated to is a gathered + // mountpoint. + useEffect(() => { + if (disableTracking) return; + analytics.captureEvent(routableExtensionRenderedEvent, ''); + }, [analytics, disableTracking]); + + return <>{children}; +}; /** @public */ export interface ExtensionBoundaryProps { id: string; source?: BackstagePlugin; - routeRef?: RouteRef; + routable?: boolean; children: ReactNode; } /** @public */ export function ExtensionBoundary(props: ExtensionBoundaryProps) { - const { id, source, routeRef, children } = props; + const { id, source, routable, children } = props; + // Skipping "routeRef" attribute in the new system, the extension "id" should provide more insight const attributes = { extension: id, pluginId: source?.id, - routeRef: routeRef - ? toInternalRouteRef(routeRef).getDescription() - : undefined, }; return ( - - {children} - + + + + {children} + + + ); } diff --git a/packages/frontend-plugin-api/src/components/index.ts b/packages/frontend-plugin-api/src/components/index.ts index fecd7f36cc..7056a59271 100644 --- a/packages/frontend-plugin-api/src/components/index.ts +++ b/packages/frontend-plugin-api/src/components/index.ts @@ -18,8 +18,3 @@ export { ExtensionBoundary, type ExtensionBoundaryProps, } from './ExtensionBoundary'; - -export { - ExtensionSuspense, - type ExtensionSuspenseProps, -} from './ExtensionSuspense'; diff --git a/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx b/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx index ad7db1d222..f05182149b 100644 --- a/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx +++ b/packages/frontend-plugin-api/src/extensions/createPageExtension.tsx @@ -14,11 +14,8 @@ * limitations under the License. */ -import React, { lazy, useEffect } from 'react'; -import { useAnalytics } from '@backstage/core-plugin-api'; -// eslint-disable-next-line @backstage/no-relative-monorepo-imports -import { routableExtensionRenderedEvent } from '../../../core-plugin-api/src/analytics/Tracker'; -import { ExtensionBoundary, ExtensionSuspense } from '../components'; +import React, { lazy } from 'react'; +import { ExtensionBoundary } from '../components'; import { createSchemaFromZod, PortableSchema } from '../schema'; import { coreExtensionData, @@ -58,9 +55,7 @@ export function createPageExtension< }) => Promise; }, ): Extension { - const { id, routeRef } = options; - - const attachTo = options.attachTo ?? { id: 'core.routes', input: 'routes' }; + const { id } = options; const configSchema = 'configSchema' in options @@ -71,7 +66,7 @@ export function createPageExtension< return createExtension({ id, - attachTo, + attachTo: options.attachTo ?? { id: 'core.routes', input: 'routes' }, configSchema, inputs: options.inputs, disabled: options.disabled, @@ -81,36 +76,18 @@ export function createPageExtension< routeRef: coreExtensionData.routeRef.optional(), }, factory({ bind, config, inputs, source }) { - const { path } = config; - - const PageComponent = lazy(() => + const ExtensionComponent = lazy(() => options .loader({ config, inputs }) .then(element => ({ default: () => element })), ); - const ExtensionComponent = () => { - const analytics = useAnalytics(); - - // This event, never exposed to end-users of the analytics API, - // helps inform which extension metadata gets associated with a - // navigation event when the route navigated to is a gathered - // mountpoint. - useEffect(() => { - analytics.captureEvent(routableExtensionRenderedEvent, ''); - }, [analytics]); - - return ; - }; - bind({ - path, - routeRef, + path: config.path, + routeRef: options.routeRef, element: ( - - - - + + ), }); diff --git a/plugins/catalog/src/alpha.tsx b/plugins/catalog/src/alpha.tsx index e9cb083978..31551c1930 100644 --- a/plugins/catalog/src/alpha.tsx +++ b/plugins/catalog/src/alpha.tsx @@ -36,7 +36,6 @@ import { PortableSchema, ExtensionBoundary, createExtensionInput, - ExtensionSuspense, } from '@backstage/frontend-plugin-api'; import { AsyncEntityProvider, @@ -117,9 +116,7 @@ export function createCatalogFilterExtension< bind({ element: ( - - - + ), }); diff --git a/plugins/search-react/src/alpha.tsx b/plugins/search-react/src/alpha.tsx index 322bd3e67b..f937ef99cc 100644 --- a/plugins/search-react/src/alpha.tsx +++ b/plugins/search-react/src/alpha.tsx @@ -20,7 +20,6 @@ import { ListItemProps } from '@material-ui/core'; import { ExtensionBoundary, - ExtensionSuspense, PortableSchema, createExtension, createExtensionDataRef, @@ -120,15 +119,13 @@ export function createSearchResultListItemExtension< predicate: options.predicate, component: props => ( - - - - - + + + ), },