diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index 9115d0732b..fe19b724ce 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -271,7 +271,7 @@ export const AppRootElementBlueprint: ExtensionBlueprint_2<{ dataRefs: never; }>; -// @public +// @public @deprecated export const AppRootWrapperBlueprint: ExtensionBlueprint_2<{ kind: 'app-root-wrapper'; params: { diff --git a/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.tsx b/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.tsx index 493fba05f6..62268bce77 100644 --- a/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.tsx +++ b/packages/frontend-plugin-api/src/blueprints/AppRootWrapperBlueprint.tsx @@ -27,6 +27,10 @@ const componentDataRef = createExtensionDataRef< * and similar. * * @public + * @deprecated Use {@link PluginWrapperBlueprint} instead if you want to wrap + * all plugin components in the same wrapper. If you want to wrap the entire + * app, use the `AppRootWrapperBlueprint` from `@backstage/plugin-app-react` + * instead, although note that only app modules are able to use that blueprint. */ export const AppRootWrapperBlueprint = createExtensionBlueprint({ kind: 'app-root-wrapper', diff --git a/plugins/app-react/report.api.md b/plugins/app-react/report.api.md index 48ec50749d..415ffe51bd 100644 --- a/plugins/app-react/report.api.md +++ b/plugins/app-react/report.api.md @@ -18,6 +18,33 @@ import { SwappableComponentRef } from '@backstage/frontend-plugin-api'; import { TranslationMessages } from '@backstage/frontend-plugin-api'; import { TranslationResource } from '@backstage/frontend-plugin-api'; +// @public +export const AppRootWrapperBlueprint: ExtensionBlueprint<{ + kind: 'app-root-wrapper'; + params: { + component: (props: { children: ReactNode }) => JSX.Element | null; + }; + output: ExtensionDataRef< + ComponentType<{ + children: ReactNode; + }>, + 'app.root-wrapper-component', + {} + >; + inputs: {}; + config: {}; + configInput: {}; + dataRefs: { + component: ConfigurableExtensionDataRef< + ComponentType<{ + children: ReactNode; + }>, + 'app.root-wrapper-component', + {} + >; + }; +}>; + // @public export const IconBundleBlueprint: ExtensionBlueprint<{ kind: 'icon-bundle'; diff --git a/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.test.tsx b/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.test.tsx new file mode 100644 index 0000000000..32aaeddeba --- /dev/null +++ b/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.test.tsx @@ -0,0 +1,138 @@ +/* + * Copyright 2024 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 { Fragment } from 'react'; +import { AppRootWrapperBlueprint } from './AppRootWrapperBlueprint'; +import { screen, waitFor } from '@testing-library/react'; +import { + coreExtensionData, + createExtension, + createExtensionInput, + createFrontendModule, +} from '@backstage/frontend-plugin-api'; +import { renderTestApp } from '@backstage/frontend-test-utils'; + +describe('AppRootWrapperBlueprint', () => { + it('should return an extension with sensible defaults', () => { + const extension = AppRootWrapperBlueprint.make({ + params: { + component: () =>
Hello
, + }, + }); + + expect(extension).toMatchInlineSnapshot(` + { + "$$type": "@backstage/ExtensionDefinition", + "T": undefined, + "attachTo": { + "id": "app/root", + "input": "wrappers", + }, + "configSchema": undefined, + "disabled": false, + "factory": [Function], + "inputs": {}, + "kind": "app-root-wrapper", + "name": undefined, + "output": [ + [Function], + ], + "override": [Function], + "toString": [Function], + "version": "v2", + } + `); + }); + + it('should render a simple component wrapper', async () => { + const extension = AppRootWrapperBlueprint.make({ + name: 'test', + params: { + component: () =>
Hello
, + }, + }); + + renderTestApp({ + features: [ + createFrontendModule({ + pluginId: 'app', + extensions: [extension], + }), + ], + }); + + await waitFor(() => expect(screen.getByText('Hello')).toBeInTheDocument()); + }); + + it('should render a complex component wrapper', async () => { + const extension = AppRootWrapperBlueprint.makeWithOverrides({ + config: { + schema: { + name: z => z.string(), + }, + }, + inputs: { + children: createExtensionInput([coreExtensionData.reactElement]), + }, + *factory(originalFactory, { inputs, config }) { + yield* originalFactory({ + component: ({ children }) => ( +
+ {inputs.children.flatMap((c, index) => ( + + {c.get(coreExtensionData.reactElement)} + + ))} + {children} +
+ ), + }); + }, + }); + + renderTestApp({ + features: [ + createFrontendModule({ + pluginId: 'app', + extensions: [ + extension, + createExtension({ + name: 'test-child', + attachTo: extension.inputs.children, + output: [coreExtensionData.reactElement], + factory: () => [ + coreExtensionData.reactElement(
Its Me
), + ], + }), + ], + }), + ], + config: { + app: { + extensions: [ + { + 'app-root-wrapper:app': { config: { name: 'Robin' } }, + }, + ], + }, + }, + }); + + await waitFor(() => { + expect(screen.getByTestId('Robin-1')).toBeInTheDocument(); + expect(screen.getByText('Its Me')).toBeInTheDocument(); + }); + }); +}); diff --git a/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.tsx b/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.tsx new file mode 100644 index 0000000000..79a35e2bc0 --- /dev/null +++ b/plugins/app-react/src/blueprints/AppRootWrapperBlueprint.tsx @@ -0,0 +1,51 @@ +/* + * Copyright 2024 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 { ComponentType, ReactNode } from 'react'; +import { + createExtensionBlueprint, + createExtensionDataRef, +} from '@backstage/frontend-plugin-api'; + +const componentDataRef = createExtensionDataRef< + ComponentType<{ children: ReactNode }> +>().with({ id: 'app.root-wrapper-component' }); + +/** + * Creates an extension that renders a React wrapper at the app root, enclosing + * the app layout. + * + * @remarks + * + * This is useful for example for adding global React contexts and similar. This + * extension is only available for use by app modules, plugins should use the + * {@link @backstage/frontend-plugin-api#PluginWrapperBlueprint} instead. + * + * @public + */ +export const AppRootWrapperBlueprint = createExtensionBlueprint({ + kind: 'app-root-wrapper', + attachTo: { id: 'app/root', input: 'wrappers' }, + output: [componentDataRef], + dataRefs: { + component: componentDataRef, + }, + *factory(params: { + component: (props: { children: ReactNode }) => JSX.Element | null; + }) { + yield componentDataRef(params.component); + }, +}); diff --git a/plugins/app-react/src/blueprints/index.ts b/plugins/app-react/src/blueprints/index.ts index 09d05b71ed..cf036aa060 100644 --- a/plugins/app-react/src/blueprints/index.ts +++ b/plugins/app-react/src/blueprints/index.ts @@ -27,6 +27,8 @@ import { TranslationBlueprint as _TranslationBlueprint, } from '@backstage/frontend-plugin-api'; +export { AppRootWrapperBlueprint } from './AppRootWrapperBlueprint'; + /** * Creates an extension that adds/replaces an app theme. This blueprint is limited to use by the app plugin. * diff --git a/plugins/app/report.api.md b/plugins/app/report.api.md index ea34a1731f..57103b8000 100644 --- a/plugins/app/report.api.md +++ b/plugins/app/report.api.md @@ -149,11 +149,22 @@ const appPlugin: OverridableFrontendPlugin< } >; wrappers: ExtensionInput< - ConfigurableExtensionDataRef< - (props: { children: ReactNode }) => JSX.Element | null, - 'app.root.wrapper', - {} - >, + | ConfigurableExtensionDataRef< + (props: { children: ReactNode }) => JSX.Element | null, + 'app.root.wrapper', + { + optional: true; + } + > + | ConfigurableExtensionDataRef< + ComponentType<{ + children: ReactNode; + }>, + 'app.root-wrapper-component', + { + optional: true; + } + >, { singleton: false; optional: false; diff --git a/plugins/app/src/extensions/AppRoot.tsx b/plugins/app/src/extensions/AppRoot.tsx index 47ea50a641..e51c63ca28 100644 --- a/plugins/app/src/extensions/AppRoot.tsx +++ b/plugins/app/src/extensions/AppRoot.tsx @@ -22,7 +22,7 @@ import { JSX, } from 'react'; import { - AppRootWrapperBlueprint, + AppRootWrapperBlueprint as OldAppRootWrapperBlueprint, RouterBlueprint, SignInPageBlueprint, coreExtensionData, @@ -44,6 +44,7 @@ import { identityApiRef, useApi, } from '@backstage/core-plugin-api'; +import { AppRootWrapperBlueprint } from '@backstage/plugin-app-react'; // eslint-disable-next-line @backstage/no-relative-monorepo-imports import { isProtectedApp } from '../../../../packages/core-app-api/src/app/isProtectedApp'; import { BrowserRouter } from 'react-router-dom'; @@ -69,7 +70,8 @@ export const AppRoot = createExtension({ }), elements: createExtensionInput([coreExtensionData.reactElement]), wrappers: createExtensionInput([ - AppRootWrapperBlueprint.dataRefs.component, + OldAppRootWrapperBlueprint.dataRefs.component.optional(), + AppRootWrapperBlueprint.dataRefs.component.optional(), ]), }, output: [coreExtensionData.reactElement], @@ -117,7 +119,28 @@ export const AppRoot = createExtension({ for (const wrapper of inputs.wrappers) { const Component = wrapper.get(AppRootWrapperBlueprint.dataRefs.component); - content = {content}; + const pluginId = wrapper.node.spec.plugin.id; + if (Component) { + if (pluginId === 'app') { + content = {content}; + } else { + // eslint-disable-next-line no-console + console.warn( + `Warning: The app root wrapper extension from the '${pluginId}' plugin was ignored, only the 'app' plugin is allowed to provide app root wrappers.`, + ); + } + } else { + const OldComponent = wrapper.get( + OldAppRootWrapperBlueprint.dataRefs.component, + ); + if (OldComponent) { + // eslint-disable-next-line no-console + console.warn( + `DEPRECATION WARNING: The ${pluginId} plugin provided a deprecated app root wrapper extension using the AppRootWrapperBlueprint from @backstage/frontend-plugin-api. This will break in a future release.`, + ); + content = {content}; + } + } } return [