diff --git a/.changeset/remove-multiple-attachment-points-frontend-app-api.md b/.changeset/remove-multiple-attachment-points-frontend-app-api.md new file mode 100644 index 0000000000..4d54ef599f --- /dev/null +++ b/.changeset/remove-multiple-attachment-points-frontend-app-api.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +**DEPRECATED**: Deprecated support for multiple attachment points. diff --git a/.changeset/remove-multiple-attachment-points-frontend-plugin-api.md b/.changeset/remove-multiple-attachment-points-frontend-plugin-api.md new file mode 100644 index 0000000000..b0d5739ea3 --- /dev/null +++ b/.changeset/remove-multiple-attachment-points-frontend-plugin-api.md @@ -0,0 +1,7 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +**DEPRECATED**: Multiple attachment points for extensions have been deprecated. The functionality continues to work for backward compatibility, but will log a deprecation warning and be removed in a future release. + +Extensions using array attachment points should migrate to using Utility APIs instead. See the [Sharing Extensions Across Multiple Locations](https://backstage.io/docs/frontend-system/architecture/27-sharing-extensions) guide for the recommended pattern. diff --git a/.changeset/scaffolder-form-fields-api-migration-react.md b/.changeset/scaffolder-form-fields-api-migration-react.md new file mode 100644 index 0000000000..4d640c58fc --- /dev/null +++ b/.changeset/scaffolder-form-fields-api-migration-react.md @@ -0,0 +1,11 @@ +--- +'@backstage/plugin-scaffolder-react': patch +--- + +Scaffolder form fields in the new frontend system now use a Utility API pattern instead of multiple attachment points. The `FormFieldBlueprint` now uses this new approach, and while form fields created with older versions still work, they will produce a deprecation warning and will stop working in a future release. + +As part of this change, the following alpha exports were removed: + +- `formFieldsApi` +- `formFieldsApiRef` +- `ScaffolderFormFieldsApi` diff --git a/.changeset/scaffolder-form-fields-api-migration.md b/.changeset/scaffolder-form-fields-api-migration.md new file mode 100644 index 0000000000..cfbed3b749 --- /dev/null +++ b/.changeset/scaffolder-form-fields-api-migration.md @@ -0,0 +1,10 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +Scaffolder form fields in the new frontend system now use a Utility API pattern instead of multiple attachment points. The `FormFieldBlueprint` now uses this new approach, and while form fields created with older versions still work, they will produce a deprecation warning and will stop working in a future release. + +As part of this change, the following alpha exports were removed: + +- `formFieldsApiRef` +- `ScaffolderFormFieldsApi` diff --git a/.changeset/techdocs-addons-api-migration-react.md b/.changeset/techdocs-addons-api-migration-react.md new file mode 100644 index 0000000000..9a2c5f1da9 --- /dev/null +++ b/.changeset/techdocs-addons-api-migration-react.md @@ -0,0 +1,7 @@ +--- +'@backstage/plugin-techdocs-react': patch +--- + +TechDocs addons in the new frontend system now use a Utility API pattern instead of multiple attachment points. The `AddonBlueprint` now uses this new approach, and while addons created with older versions still work, they will produce a deprecation warning and will stop working in a future release. + +As part of this change, the `techDocsAddonDataRef` alpha export was removed. diff --git a/.changeset/techdocs-addons-api-migration.md b/.changeset/techdocs-addons-api-migration.md new file mode 100644 index 0000000000..f2e481de7e --- /dev/null +++ b/.changeset/techdocs-addons-api-migration.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-techdocs': patch +--- + +TechDocs addons in the new frontend system now use a Utility API pattern instead of multiple attachment points. The `AddonBlueprint` now uses this new approach, and while addons created with older versions still work, they will produce a deprecation warning and will stop working in a future release. diff --git a/docs/frontend-system/architecture/20-extensions.md b/docs/frontend-system/architecture/20-extensions.md index c13a54427a..99b77f5448 100644 --- a/docs/frontend-system/architecture/20-extensions.md +++ b/docs/frontend-system/architecture/20-extensions.md @@ -337,23 +337,11 @@ const routableExtension = createExtension({ }); ``` -## Multiple attachment points +## Sharing extensions across multiple locations -For some cases it can be useful to attach extensions to multiple parents. An example of this are Scaffolder field extensions or TechDocs addons that are consumed by multiple extensions. Specifying multiple attachments is done by providing an array of attachment points to the `attachTo` property of the extension. Keep in mind that this increases the complexity of your extension tree and should only be done when necessary. The following example shows how to attach our example extension to multiple parents: +If you need to make extensions available in multiple locations throughout your app, use a Utility API that collects the extensions and allows multiple parent extensions to consume them. This pattern provides better separation of concerns and makes data flow more explicit. -```tsx -const extension = createExtension({ - name: 'my-extension', - attachTo: [ - { id: 'my-first-parent', input: 'content' }, - { id: 'my-second-parent', input: 'children' }, // The input names do not need to match - ], - output: [coreExtensionData.reactElement], - factory() { - return [coreExtensionData.reactElement(
Hello World
)]; - }, -}); -``` +See the [Sharing Extensions Across Multiple Locations](./27-sharing-extensions.md) guide for a complete explanation of this pattern with detailed examples. ## Relative attachment points @@ -363,7 +351,7 @@ When creating an extension or an [extension blueprint](./23-extension-blueprints // Parent extension with a fixed attachment point const parentExtension = createExtension({ kind: 'section', - attachTo: [{ id: 'app/some-fixed-extension', input: 'children' }], + attachTo: { id: 'app/some-fixed-extension', input: 'children' }, inputs: { content: createExtensionInput([coreExtensionData.reactElement], { singleton: true, @@ -385,7 +373,7 @@ const parentExtension = createExtension({ // Child extension with a relative attachment point const childExtension = createExtension({ kind: 'section-content', - attachTo: [{ relative: { kind: 'section' }, input: 'content' }], + attachTo: { relative: { kind: 'section' }, input: 'content' }, output: [coreExtensionData.reactElement], factory() { return [coreExtensionData.reactElement(

Section Content

)]; diff --git a/docs/frontend-system/architecture/27-sharing-extensions.md b/docs/frontend-system/architecture/27-sharing-extensions.md new file mode 100644 index 0000000000..a08fd7d1db --- /dev/null +++ b/docs/frontend-system/architecture/27-sharing-extensions.md @@ -0,0 +1,173 @@ +--- +id: sharing-extensions +title: Sharing Extensions Across Multiple Locations +sidebar_label: Sharing Extensions +description: Using Utility APIs to share extensions across multiple locations in your app +--- + +Some plugins may need to provide extensibility that can be reused in multiple locations throughout the app. For example, in the pattern demonstrated on this page, a plugin can be made extensible by allowing widgets to be contributed that are then rendered on multiple pages. To achieve this, the recommended pattern is to use a Utility API that collects the extensions and makes them available throughout the plugin or the app. + +## Overview + +This pattern combines a Utility API with an extension blueprint to: + +1. Define the extension data types and API interface +2. Provide a blueprint for creating extensions +3. Create a Utility API extension that collects extensions as input +4. Consume the extensions via the API + +This approach provides a native integration with the frontend system, allowing to further rely on features like making the extensions configurable or have further extension points. + +## Basic Pattern + +The following example demonstrates this pattern using widgets that can be displayed on multiple pages. However, this pattern is flexible and can be adapted for many different scenarios where you need to: + +- Share the same type of extension across different pages or views +- Allow third-party plugins to contribute extensions in a decoupled way +- Aggregate similar functionality from multiple sources in a consistent way + +The core concepts remain the same regardless of what type of functionality you're sharing. + +### 1. Define the Extension Data Types and API Interface + +First, in your plugin's `-react` package (e.g., `backstage-plugin-foo-react`), define the widget types and API interface: + +```tsx title="in backstage-plugin-foo-react" +import { createApiRef } from '@backstage/frontend-plugin-api'; +import { ComponentType } from 'react'; + +export interface FooWidgetProps { + title: string; +} + +// Define what data each widget provides, prefer using lazy loading for large pieces of functionality like components +export interface FooWidget { + title: string; + size: 'small' | 'medium' | 'large'; + loader: () => Promise>; +} + +// Define the API interface +export interface FooWidgetsApi { + getWidgets(): FooWidget[]; +} + +// Create the API reference +export const fooWidgetsApiRef = createApiRef({ + id: 'plugin.foo.widgets', +}); +``` + +### 2. Provide a Blueprint for Creating Extensions + +Next, also in your `-react` package (e.g., `backstage-plugin-foo-react`), create a blueprint that creates extensions. The blueprint creates an internal data reference and exposes it via the `dataRefs` property. This blueprint will be exported for other plugins to use: + +```tsx title="in backstage-plugin-foo-react" +import { + createExtensionBlueprint, + createExtensionDataRef, + ExtensionBoundary, +} from '@backstage/frontend-plugin-api'; + +const fooWidgetDataRef = createExtensionDataRef().with({ + id: 'foo.widget', +}); + +export const FooWidgetBlueprint = createExtensionBlueprint({ + kind: 'foo-widget', + // Attach extensions created with this blueprint to the API extension that will be created in the next step + attachTo: { id: 'api:foo/widgets', input: 'widgets' }, + output: [fooWidgetDataRef], + *factory(params: FooWidget, { node }) { + yield fooWidgetDataRef({ + title: params.title, + size: params.size, + loader: ExtensionBoundary.lazyComponent(node, params.loader), + }); + }, + dataRefs: { + widget: fooWidgetDataRef, + }, +}); +``` + +### 3. Create a Utility API Extension that Collects Extensions + +In your main plugin package (e.g., `backstage-plugin-foo`), create a Utility API extension that collects widgets as input. Note that this imports the blueprint's data reference via `FooWidgetBlueprint.dataRefs.widget`: + +```tsx title="in backstage-plugin-foo" +import { + ApiBlueprint, + createExtensionInput, +} from '@backstage/frontend-plugin-api'; +import { + FooWidgetBlueprint, + fooWidgetsApiRef, +} from 'backstage-plugin-foo-react'; + +export const FooWidgetsApiExtension = ApiBlueprint.makeWithOverrides({ + name: 'widgets', + inputs: { + widgets: createExtensionInput([FooWidgetBlueprint.dataRefs.widget]), + }, + factory(originalFactory, { inputs }) { + // Collect all widgets from the inputs and forward them to the API implementation + const widgets = inputs.widgets.map(w => + w.get(FooWidgetBlueprint.dataRefs.widget), + ); + + return originalFactory(defineParams => + defineParams({ + api: fooWidgetsApiRef, + deps: {}, + factory: () => ({ + getWidgets: () => widgets, + }), + }), + ); + }, +}); +``` + +Other plugins can now import the blueprint from your `-react` package and create widget extensions that will be collected by the API: + +```tsx title="in a consuming plugin" +import { FooWidgetBlueprint } from 'backstage-plugin-foo-react'; + +const barWidgetExtension = FooWidgetBlueprint.make({ + name: 'bar', + params: { + title: 'Bar Widget', + size: 'small', + loader: () => import('./components/BarWidget').then(m => m.BarWidget), + }, +}); + +const bazWidgetExtension = FooWidgetBlueprint.make({ + name: 'baz', + params: { + title: 'Baz Widget', + size: 'medium', + loader: () => import('./components/BazWidget').then(m => m.BazWidget), + }, +}); +``` + +### 4. Consume the Extensions via the API + +You can now consume the widgets using any of the available methods for consuming Utility APIs. For example, this is how you would access the widgets in a component: + +```tsx title="in backstage-plugin-foo" +import { useApi } from '@backstage/frontend-plugin-api'; +import { fooWidgetsApiRef } from 'backstage-plugin-foo-react'; +import { Suspense, lazy } from 'react'; + +export function FooPageContent() { + const widgetsApi = useApi(fooWidgetsApiRef); + const widgets = widgetsApi.getWidgets(); + + return; // load and render widgets ... +} +``` + +For more information on consuming Utility APIs, see the [Consuming Utility APIs](../utility-apis/03-consuming.md) page. diff --git a/packages/frontend-app-api/src/tree/resolveAppTree.ts b/packages/frontend-app-api/src/tree/resolveAppTree.ts index f2f31f8950..c2054c4bac 100644 --- a/packages/frontend-app-api/src/tree/resolveAppTree.ts +++ b/packages/frontend-app-api/src/tree/resolveAppTree.ts @@ -165,6 +165,12 @@ export function resolveAppTree( if (spec.id === rootNodeId) { rootNode = node; } else if (Array.isArray(spec.attachTo)) { + // eslint-disable-next-line no-console + console.warn( + `Extension '${spec.id}' is using multiple attachment points which is deprecated and will be removed in a future release. ` + + `Use a Utility API instead to share functionality across multiple locations. ` + + `See https://backstage.io/docs/frontend-system/architecture/27-sharing-extensions for migration guidance.`, + ); let foundFirstParent = false; for (const origAttachTo of spec.attachTo) { let attachTo = origAttachTo; diff --git a/packages/frontend-plugin-api/report.api.md b/packages/frontend-plugin-api/report.api.md index c4c8ef96e7..80e2ff8df4 100644 --- a/packages/frontend-plugin-api/report.api.md +++ b/packages/frontend-plugin-api/report.api.md @@ -1206,6 +1206,9 @@ export type ExtensionDefinitionAttachTo< id?: never; } | ExtensionInput + /** + * @deprecated Multiple attachment points are deprecated and will be removed in a future release. Use a Utility API instead to share functionality across multiple locations. See https://backstage.io/docs/frontend-system/architecture/27-sharing-extensions for migration guidance. + */ | Array< | { id: string; diff --git a/packages/frontend-plugin-api/src/wiring/createExtension.ts b/packages/frontend-plugin-api/src/wiring/createExtension.ts index 6e4aba2910..c1f704f61a 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtension.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtension.ts @@ -152,7 +152,7 @@ export type VerifyExtensionAttachTo< * const page = ParentBlueprint.make({ ... }); * const child = ChildBlueprint.make({ attachTo: page.inputs.children }); * - * // Attach to multiple parents at once + * // Attach to multiple parents at once (deprecated - use Utility APIs instead) * [ * { id: 'page/home', input: 'widgets' }, * { relative: { kind: 'page' }, input: 'widgets' }, @@ -167,6 +167,9 @@ export type ExtensionDefinitionAttachTo< | { id: string; input: string; relative?: never } | { relative: { kind?: string; name?: string }; input: string; id?: never } | ExtensionInput + /** + * @deprecated Multiple attachment points are deprecated and will be removed in a future release. Use a Utility API instead to share functionality across multiple locations. See https://backstage.io/docs/frontend-system/architecture/27-sharing-extensions for migration guidance. + */ | Array< | { id: string; input: string; relative?: never } | { diff --git a/plugins/scaffolder-react/report-alpha.api.md b/plugins/scaffolder-react/report-alpha.api.md index 1befe9ca46..5074e7b1e9 100644 --- a/plugins/scaffolder-react/report-alpha.api.md +++ b/plugins/scaffolder-react/report-alpha.api.md @@ -3,19 +3,14 @@ > Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). ```ts -import { AnyApiFactory } from '@backstage/frontend-plugin-api'; import { AnyApiRef } from '@backstage/core-plugin-api'; -import { ApiFactory } from '@backstage/frontend-plugin-api'; import { ApiHolder } from '@backstage/core-plugin-api'; -import { ApiRef } from '@backstage/frontend-plugin-api'; import { ComponentType } from 'react'; import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api'; import { CustomFieldValidator } from '@backstage/plugin-scaffolder-react'; import { Dispatch } from 'react'; import { ExtensionBlueprint } from '@backstage/frontend-plugin-api'; -import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api'; import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; -import { ExtensionInput } from '@backstage/frontend-plugin-api'; import { FieldExtensionComponentProps } from '@backstage/plugin-scaffolder-react'; import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react'; import { FieldSchema } from '@backstage/plugin-scaffolder-react'; @@ -26,7 +21,6 @@ import { JsonObject } from '@backstage/types'; import { JsonValue } from '@backstage/types'; import { JSX as JSX_2 } from 'react/jsx-runtime'; import { LayoutOptions } from '@backstage/plugin-scaffolder-react'; -import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api'; import { Overrides } from '@material-ui/core/styles/overrides'; import { PropsWithChildren } from 'react'; import { ReactElement } from 'react'; @@ -204,39 +198,6 @@ export type FormFieldExtensionData< schema?: FieldSchema, z.output>; }; -// @alpha (undocumented) -export const formFieldsApi: OverridableExtensionDefinition<{ - config: {}; - configInput: {}; - output: ExtensionDataRef; - inputs: { - formFields: ExtensionInput< - ConfigurableExtensionDataRef< - () => Promise, - 'scaffolder.form-field-loader', - {} - >, - { - singleton: false; - optional: false; - internal: false; - } - >; - }; - kind: 'api'; - name: 'form-fields'; - params: < - TApi, - TImpl extends TApi, - TDeps extends { [name in string]: unknown }, - >( - params: ApiFactory, - ) => ExtensionBlueprintParams; -}>; - -// @alpha @deprecated (undocumented) -export const formFieldsApiRef: ApiRef; - // @alpha (undocumented) export type FormValidation = { [name: string]: FieldValidation | FormValidation; @@ -311,12 +272,6 @@ export type ScaffolderFormDecoratorContext< ) => void; }; -// @alpha @deprecated (undocumented) -export interface ScaffolderFormFieldsApi { - // (undocumented) - getFormFields(): Promise; -} - // @alpha (undocumented) export function ScaffolderPageContextMenu( props: ScaffolderPageContextMenuProps, @@ -345,6 +300,11 @@ export const scaffolderReactTranslationRef: TranslationRef< 'scaffolder-react', { readonly 'workflow.noDescription': 'No description'; + readonly 'stepper.backButtonText': 'Back'; + readonly 'stepper.createButtonText': 'Create'; + readonly 'stepper.reviewButtonText': 'Review'; + readonly 'stepper.nextButtonText': 'Next'; + readonly 'stepper.stepIndexLabel': 'Step {{index, number}}'; readonly 'passwordWidget.content': 'This widget is insecure. Please use [`ui:field: Secret`](https://backstage.io/docs/features/software-templates/writing-templates/#using-secrets) instead of `ui:widget: password`'; readonly 'scaffolderPageContextMenu.createLabel': 'Create'; readonly 'scaffolderPageContextMenu.moreLabel': 'more'; @@ -352,11 +312,6 @@ export const scaffolderReactTranslationRef: TranslationRef< readonly 'scaffolderPageContextMenu.actionsLabel': 'Installed Actions'; readonly 'scaffolderPageContextMenu.tasksLabel': 'Task List'; readonly 'scaffolderPageContextMenu.templatingExtensionsLabel': 'Templating Extensions'; - readonly 'stepper.backButtonText': 'Back'; - readonly 'stepper.createButtonText': 'Create'; - readonly 'stepper.reviewButtonText': 'Review'; - readonly 'stepper.nextButtonText': 'Next'; - readonly 'stepper.stepIndexLabel': 'Step {{index, number}}'; readonly 'templateCategoryPicker.title': 'Categories'; readonly 'templateCard.noDescription': 'No description'; readonly 'templateCard.chooseButtonText': 'Choose'; diff --git a/plugins/scaffolder-react/src/hooks/useCustomFieldExtensions.test.tsx b/plugins/scaffolder-react/src/hooks/useCustomFieldExtensions.test.tsx index 3be482d684..8b8251d4eb 100644 --- a/plugins/scaffolder-react/src/hooks/useCustomFieldExtensions.test.tsx +++ b/plugins/scaffolder-react/src/hooks/useCustomFieldExtensions.test.tsx @@ -16,9 +16,8 @@ import { PropsWithChildren } from 'react'; import { createPlugin } from '@backstage/core-plugin-api'; -import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils'; -import { renderHook, waitFor } from '@testing-library/react'; -import { ScaffolderFormFieldsApi, formFieldsApiRef } from '../alpha'; +import { wrapInTestApp } from '@backstage/test-utils'; +import { renderHook } from '@testing-library/react'; import { useCustomFieldExtensions } from './useCustomFieldExtensions'; import { ScaffolderFieldExtensions, @@ -33,22 +32,10 @@ const plugin = createPlugin({ }); describe('useCustomFieldExtensions', () => { - const mockFormFieldsApi: jest.Mocked = { - getFormFields: jest.fn(), - }; const wrapper = ({ children }: PropsWithChildren<{}>) => - wrapInTestApp( - - {children} - , - ); - - beforeEach(() => { - jest.resetAllMocks(); - }); + wrapInTestApp(<>{children}); it('should return field extensions from the React tree', async () => { - mockFormFieldsApi.getFormFields.mockResolvedValue([]); const CustomFieldExtension = plugin.provide( createScaffolderFieldExtension({ name: 'test', @@ -70,60 +57,4 @@ describe('useCustomFieldExtensions', () => { expect(result.current).toEqual([expect.objectContaining({ name: 'test' })]); }); - - it('should return field extensions from formFieldsApi', async () => { - mockFormFieldsApi.getFormFields.mockResolvedValue([ - { - name: 'blueprint', - component: () =>
Test
, - }, - ]); - - const { result } = renderHook(() => useCustomFieldExtensions(
), { - wrapper, - }); - - await waitFor(() => { - expect(result.current.length).toBeGreaterThan(0); - }); - - expect(result.current).toEqual([ - expect.objectContaining({ name: 'blueprint' }), - ]); - }); - - it('should return field extensions from both sources', async () => { - mockFormFieldsApi.getFormFields.mockResolvedValue([ - { - name: 'blueprint', - component: () =>
Test
, - }, - ]); - - const CustomFieldExtension = plugin.provide( - createScaffolderFieldExtension({ - name: 'test', - component: () =>
Test
, - }), - ); - - const { result } = renderHook( - () => - useCustomFieldExtensions( - - - , - ), - { - wrapper, - }, - ); - - await waitFor(() => { - expect(result.current).toHaveLength(2); - }); - - const fieldNames = result.current.map(field => field.name); - expect(fieldNames).toEqual(expect.arrayContaining(['test', 'blueprint'])); - }); }); diff --git a/plugins/scaffolder-react/src/hooks/useCustomFieldExtensions.ts b/plugins/scaffolder-react/src/hooks/useCustomFieldExtensions.ts index be24e4186d..47a91e5d63 100644 --- a/plugins/scaffolder-react/src/hooks/useCustomFieldExtensions.ts +++ b/plugins/scaffolder-react/src/hooks/useCustomFieldExtensions.ts @@ -13,9 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useAsync, useMountEffect } from '@react-hookz/web'; -import { useApi, useElementFilter } from '@backstage/core-plugin-api'; -import { formFieldsApiRef } from '../next'; +import { useElementFilter } from '@backstage/core-plugin-api'; import { FieldExtensionOptions } from '../extensions'; import { FIELD_EXTENSION_KEY, @@ -32,14 +30,6 @@ export const useCustomFieldExtensions = < >( outlet: React.ReactNode, ) => { - // Get custom fields created with FormFieldBlueprint - const formFieldsApi = useApi(formFieldsApiRef); - const [{ result: blueprintFields }, { execute }] = useAsync( - () => formFieldsApi.getFormFields(), - [], - ); - useMountEffect(execute); - // Get custom fields created with ScaffolderFieldExtensions const outletFields = useElementFilter(outlet, elements => elements @@ -51,17 +41,5 @@ export const useCustomFieldExtensions = < }), ); - // This should really be a different type moving forward, but we do this to keep type compatibility. - // should probably also move the defaults into the API eventually too, but that will come with the move - // to the new frontend system. - const blueprintsToLegacy: FieldExtensionOptions[] = blueprintFields?.map( - field => ({ - component: field.component, - name: field.name, - validation: field.validation, - schema: field.schema?.schema, - }), - ); - - return [...blueprintsToLegacy, ...outletFields] as TComponentDataType[]; + return outletFields as TComponentDataType[]; }; diff --git a/plugins/scaffolder-react/src/next/api/index.ts b/plugins/scaffolder-react/src/next/api/index.ts index 52c40ad99a..9988f4dd82 100644 --- a/plugins/scaffolder-react/src/next/api/index.ts +++ b/plugins/scaffolder-react/src/next/api/index.ts @@ -14,6 +14,4 @@ * limitations under the License. */ -export { formFieldsApi } from './FormFieldsApi'; -export { formFieldsApiRef } from './ref'; -export type { ScaffolderFormFieldsApi, FormField } from './types'; +export { type FormField } from './types'; diff --git a/plugins/scaffolder-react/src/next/api/ref.ts b/plugins/scaffolder-react/src/next/api/ref.ts deleted file mode 100644 index 6832b91a3d..0000000000 --- a/plugins/scaffolder-react/src/next/api/ref.ts +++ /dev/null @@ -1,26 +0,0 @@ -/* - * 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 { createApiRef } from '@backstage/frontend-plugin-api'; -import { ScaffolderFormFieldsApi } from './types'; - -/** - * @alpha - * @deprecated This API is no longer necessary and will be removed - */ -export const formFieldsApiRef = createApiRef({ - id: 'plugin.scaffolder.form-fields', -}); diff --git a/plugins/scaffolder-react/src/next/api/types.ts b/plugins/scaffolder-react/src/next/api/types.ts index 9e028567f6..38929ee189 100644 --- a/plugins/scaffolder-react/src/next/api/types.ts +++ b/plugins/scaffolder-react/src/next/api/types.ts @@ -14,16 +14,6 @@ * limitations under the License. */ -import { FormFieldExtensionData } from '../blueprints'; - -/** - * @alpha - * @deprecated This API is no longer necessary and will be removed - */ -export interface ScaffolderFormFieldsApi { - getFormFields(): Promise; -} - /** @alpha */ export interface FormField { readonly $$type: '@backstage/scaffolder/FormField'; diff --git a/plugins/scaffolder-react/src/next/blueprints/FormFieldBlueprint.tsx b/plugins/scaffolder-react/src/next/blueprints/FormFieldBlueprint.tsx index afbf2b501d..8b5a88f7df 100644 --- a/plugins/scaffolder-react/src/next/blueprints/FormFieldBlueprint.tsx +++ b/plugins/scaffolder-react/src/next/blueprints/FormFieldBlueprint.tsx @@ -35,10 +35,7 @@ const formFieldExtensionDataRef = createExtensionDataRef< * */ export const FormFieldBlueprint = createExtensionBlueprint({ kind: 'scaffolder-form-field', - attachTo: [ - { id: 'page:scaffolder', input: 'formFields' }, - { id: 'api:scaffolder/form-fields', input: 'formFields' }, - ], + attachTo: { id: 'api:scaffolder/form-fields', input: 'formFields' }, dataRefs: { formFieldLoader: formFieldExtensionDataRef, }, diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md index b9aa82782f..b90bc4c693 100644 --- a/plugins/scaffolder/report-alpha.api.md +++ b/plugins/scaffolder/report-alpha.api.md @@ -17,7 +17,6 @@ import { ExtensionInput } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react'; import { FormField } from '@backstage/plugin-scaffolder-react/alpha'; -import { formFieldsApiRef } from '@backstage/plugin-scaffolder-react/alpha'; import type { FormProps as FormProps_2 } from '@rjsf/core'; import { FormProps as FormProps_3 } from '@backstage/plugin-scaffolder-react'; import { IconComponent } from '@backstage/frontend-plugin-api'; @@ -31,7 +30,6 @@ import { ReviewStepProps } from '@backstage/plugin-scaffolder-react'; import { RouteRef } from '@backstage/core-plugin-api'; import { RouteRef as RouteRef_2 } from '@backstage/frontend-plugin-api'; import { ScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha'; -import { ScaffolderFormFieldsApi } from '@backstage/plugin-scaffolder-react/alpha'; import { SubRouteRef } from '@backstage/core-plugin-api'; import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; import { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react'; @@ -432,8 +430,6 @@ export const formDecoratorsApi: OverridableExtensionDefinition<{ // @alpha (undocumented) export const formDecoratorsApiRef: ApiRef; -export { formFieldsApiRef }; - // @alpha @deprecated export type FormProps = Pick< FormProps_2, @@ -453,8 +449,6 @@ export interface ScaffolderFormDecoratorsApi { getFormDecorators(): Promise; } -export { ScaffolderFormFieldsApi }; - // @public (undocumented) export type ScaffolderTemplateEditorClassKey = | 'root' diff --git a/plugins/scaffolder/src/alpha/extensions.tsx b/plugins/scaffolder/src/alpha/extensions.tsx index 5215fc0968..8c17f8cc1c 100644 --- a/plugins/scaffolder/src/alpha/extensions.tsx +++ b/plugins/scaffolder/src/alpha/extensions.tsx @@ -29,6 +29,7 @@ import { FormFieldBlueprint } from '@backstage/plugin-scaffolder-react/alpha'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react'; import { ScaffolderClient } from '../api'; +import { formFieldsApiRef } from './formFieldsApi'; export const scaffolderPage = PageBlueprint.makeWithOverrides({ inputs: { @@ -36,17 +37,29 @@ export const scaffolderPage = PageBlueprint.makeWithOverrides({ FormFieldBlueprint.dataRefs.formFieldLoader, ]), }, - factory(originalFactory, { inputs }) { - const formFieldLoaders = inputs.formFields.map(i => - i.get(FormFieldBlueprint.dataRefs.formFieldLoader), - ); + factory(originalFactory, { apis, inputs }) { + const formFieldsApi = apis.get(formFieldsApiRef); + return originalFactory({ routeRef: rootRouteRef, path: '/create', - loader: () => - import('../components/Router/Router').then(m => ( - - )), + loader: async () => { + // Merge form fields from the API with old-style direct attachments + const apiFormFields = (await formFieldsApi?.loadFormFields()) ?? []; + const formFieldLoaders = inputs.formFields.map(output => + output.get(FormFieldBlueprint.dataRefs.formFieldLoader), + ); + + // Resolve direct attachments and combine with API form fields + const loadedFormFields = await Promise.all( + formFieldLoaders.map(loader => loader()), + ); + const formFields = [...apiFormFields, ...loadedFormFields]; + + return import('../components/Router/Router').then(m => ( + + )); + }, }); }, }); diff --git a/plugins/scaffolder-react/src/next/api/FormFieldsApi.ts b/plugins/scaffolder/src/alpha/formFieldsApi.ts similarity index 62% rename from plugins/scaffolder-react/src/next/api/FormFieldsApi.ts rename to plugins/scaffolder/src/alpha/formFieldsApi.ts index 08ccc662d4..df88645eb4 100644 --- a/plugins/scaffolder-react/src/next/api/FormFieldsApi.ts +++ b/plugins/scaffolder/src/alpha/formFieldsApi.ts @@ -16,32 +16,24 @@ import { ApiBlueprint, + createApiRef, createExtensionInput, } from '@backstage/frontend-plugin-api'; -import { formFieldsApiRef } from './ref'; -import { FormField, ScaffolderFormFieldsApi } from './types'; -import { FormFieldBlueprint } from '../blueprints'; +import { FormFieldBlueprint } from '@backstage/plugin-scaffolder-react/alpha'; import { OpaqueFormField } from '@internal/scaffolder'; -class DefaultScaffolderFormFieldsApi implements ScaffolderFormFieldsApi { - private readonly formFieldLoaders: Array<() => Promise>; - - constructor(formFieldLoaders: Array<() => Promise> = []) { - this.formFieldLoaders = formFieldLoaders; - } - - async getFormFields() { - const formFields = await Promise.all( - this.formFieldLoaders.map(loader => loader()), - ); - - const internalFormFields = formFields.map(OpaqueFormField.toInternal); - - return internalFormFields; - } +interface FormField { + readonly $$type: '@backstage/scaffolder/FormField'; } -/** @alpha */ +interface ScaffolderFormFieldsApi { + loadFormFields(): Promise; +} + +const formFieldsApiRef = createApiRef({ + id: 'plugin.scaffolder.form-fields-loader', +}); + export const formFieldsApi = ApiBlueprint.makeWithOverrides({ name: 'form-fields', inputs: { @@ -58,8 +50,22 @@ export const formFieldsApi = ApiBlueprint.makeWithOverrides({ defineParams({ api: formFieldsApiRef, deps: {}, - factory: () => new DefaultScaffolderFormFieldsApi(formFieldLoaders), + factory: () => ({ + async loadFormFields() { + const formFields = await Promise.all( + formFieldLoaders.map(loader => loader()), + ); + + const internalFormFields = formFields.map( + OpaqueFormField.toInternal, + ); + + return internalFormFields; + }, + }), }), ); }, }); + +export { formFieldsApiRef }; diff --git a/plugins/scaffolder/src/alpha/index.ts b/plugins/scaffolder/src/alpha/index.ts index d239f44b4f..53e2b8f0bf 100644 --- a/plugins/scaffolder/src/alpha/index.ts +++ b/plugins/scaffolder/src/alpha/index.ts @@ -25,9 +25,4 @@ export { export { scaffolderTranslationRef } from '../translation'; export * from './api'; -export { - formFieldsApiRef, - type ScaffolderFormFieldsApi, -} from '@backstage/plugin-scaffolder-react/alpha'; - export { default } from './plugin'; diff --git a/plugins/scaffolder/src/alpha/plugin.tsx b/plugins/scaffolder/src/alpha/plugin.tsx index 502aab0bbc..9b7698250a 100644 --- a/plugins/scaffolder/src/alpha/plugin.tsx +++ b/plugins/scaffolder/src/alpha/plugin.tsx @@ -42,7 +42,7 @@ import { scaffolderPage, } from './extensions'; import { isTemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common'; -import { formFieldsApi } from '@backstage/plugin-scaffolder-react/alpha'; +import { formFieldsApi } from './formFieldsApi'; import { formDecoratorsApi } from './api'; import { EntityIconLinkBlueprint } from '@backstage/plugin-catalog-react/alpha'; import { useScaffolderTemplateIconLinkProps } from './hooks/useScaffolderTemplateIconLinkProps'; diff --git a/plugins/scaffolder/src/components/Router/Router.test.tsx b/plugins/scaffolder/src/components/Router/Router.test.tsx index 20b21fabc7..04f6cac690 100644 --- a/plugins/scaffolder/src/components/Router/Router.test.tsx +++ b/plugins/scaffolder/src/components/Router/Router.test.tsx @@ -13,13 +13,8 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { ReactElement } from 'react'; import { Router } from './Router'; -import { - renderInTestApp, - TestApiProvider, - TestAppOptions, -} from '@backstage/test-utils'; +import { renderInTestApp } from '@backstage/test-utils'; import { createScaffolderFieldExtension, ScaffolderFieldExtensions, @@ -30,23 +25,12 @@ import { ScaffolderLayouts, } from '@backstage/plugin-scaffolder-react'; import { TemplateListPage, TemplateWizardPage } from '../../alpha/components'; -import { formFieldsApiRef } from '@backstage/plugin-scaffolder-react/alpha'; jest.mock('../../alpha/components', () => ({ TemplateWizardPage: jest.fn(() => null), TemplateListPage: jest.fn(() => null), })); -const wrapInApisAndRender = (element: ReactElement, opts?: TestAppOptions) => - renderInTestApp( - [] }]]} - > - {element} - , - opts, - ); - describe('Router', () => { beforeEach(() => { (TemplateWizardPage as jest.Mock).mockClear(); @@ -54,13 +38,13 @@ describe('Router', () => { }); describe('/', () => { it('should render the TemplateListPage', async () => { - await wrapInApisAndRender(); + await renderInTestApp(); expect(TemplateListPage).toHaveBeenCalled(); }); it('should render user-provided TemplateListPage', async () => { - const { getByText } = await wrapInApisAndRender( + const { getByText } = await renderInTestApp( <>foobar, @@ -76,7 +60,7 @@ describe('Router', () => { it('should render not found error page', async () => { await expect( - wrapInApisAndRender(, { + renderInTestApp(, { routeEntries: ['/foonotfounderror'], }), ).rejects.toThrow('Reached NotFound Page'); @@ -85,7 +69,7 @@ describe('Router', () => { describe('/templates/:templateName', () => { it('should render the TemplateWizard page', async () => { - await wrapInApisAndRender(, { + await renderInTestApp(, { routeEntries: ['/templates/default/foo'], }); @@ -93,7 +77,7 @@ describe('Router', () => { }); it('should render user-provided TemplateWizardPage', async () => { - const { getByText } = await wrapInApisAndRender( + const { getByText } = await renderInTestApp( <>foobar, @@ -110,7 +94,7 @@ describe('Router', () => { it('should pass through the FormProps property', async () => { const transformErrorsMock = jest.fn(); - await wrapInApisAndRender( + await renderInTestApp( { }), ); - await wrapInApisAndRender( + await renderInTestApp( @@ -170,7 +154,7 @@ describe('Router', () => { }), ); - await wrapInApisAndRender( + await renderInTestApp( diff --git a/plugins/scaffolder/src/components/Router/Router.tsx b/plugins/scaffolder/src/components/Router/Router.tsx index 7a2496c046..0fa43901bf 100644 --- a/plugins/scaffolder/src/components/Router/Router.tsx +++ b/plugins/scaffolder/src/components/Router/Router.tsx @@ -63,7 +63,6 @@ import { RequirePermission } from '@backstage/plugin-permission-react'; import { templateManagementPermission } from '@backstage/plugin-scaffolder-common/alpha'; import { useApp } from '@backstage/core-plugin-api'; import { OpaqueFormField } from '@internal/scaffolder'; -import { useAsync, useMountEffect } from '@react-hookz/web'; import { TemplatingExtensionsPage } from '../TemplatingExtensionsPage'; import { FormField } from '@backstage/plugin-scaffolder-react/alpha'; @@ -116,7 +115,9 @@ export type RouterProps = { */ export const InternalRouter = ( props: PropsWithChildren< - RouterProps & { formFieldLoaders?: Array<() => Promise> } + RouterProps & { + formFields?: Array; + } >, ) => { const { @@ -133,14 +134,13 @@ export const InternalRouter = ( } = props; const outlet = useOutlet() || props.children; const customFieldExtensions = useCustomFieldExtensions(outlet); - const loadedFieldExtensions = useFormFieldLoaders(props.formFieldLoaders); const app = useApp(); const { NotFoundErrorPage } = app.getComponents(); const fieldExtensions = [ ...customFieldExtensions, - ...loadedFieldExtensions, + ...(props.formFields?.map(OpaqueFormField.toInternal) ?? []), ...DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS.filter( ({ name }) => !customFieldExtensions.some( @@ -261,17 +261,3 @@ export const InternalRouter = ( export const Router = (props: PropsWithChildren) => { return ; }; - -function useFormFieldLoaders( - formFieldLoaders?: Array<() => Promise>, -) { - const [{ result: loadedFieldExtensions }, { execute }] = - useAsync(async () => { - const loaded = await Promise.all( - (formFieldLoaders ?? []).map(loader => loader()), - ); - return loaded.map(f => OpaqueFormField.toInternal(f)); - }, []); - useMountEffect(execute); - return loadedFieldExtensions; -} diff --git a/plugins/scaffolder/src/plugin.tsx b/plugins/scaffolder/src/plugin.tsx index dabcb683c7..50a5d29280 100644 --- a/plugins/scaffolder/src/plugin.tsx +++ b/plugins/scaffolder/src/plugin.tsx @@ -81,7 +81,6 @@ import { RepoBranchPicker } from './components/fields/RepoBranchPicker/RepoBranc import { RepoBranchPickerSchema } from './components/fields/RepoBranchPicker/schema'; import { formDecoratorsApiRef } from './alpha/api/ref'; import { DefaultScaffolderFormDecoratorsApi } from './alpha/api/FormDecoratorsApi'; -import { formFieldsApiRef } from '@backstage/plugin-scaffolder-react/alpha'; import { RepoOwnerPicker, RepoOwnerPickerSchema, @@ -115,11 +114,6 @@ export const scaffolderPlugin = createPlugin({ deps: {}, factory: () => DefaultScaffolderFormDecoratorsApi.create(), }), - createApiFactory({ - api: formFieldsApiRef, - deps: {}, - factory: () => ({ getFormFields: async () => [] }), - }), ], routes: { root: rootRouteRef, diff --git a/plugins/techdocs-react/report-alpha.api.md b/plugins/techdocs-react/report-alpha.api.md index 1fed274f3d..cb2a86a037 100644 --- a/plugins/techdocs-react/report-alpha.api.md +++ b/plugins/techdocs-react/report-alpha.api.md @@ -31,13 +31,6 @@ export const attachTechDocsAddonComponentData:

( data: TechDocsAddonOptions, ) => void; -// @alpha (undocumented) -export const techDocsAddonDataRef: ConfigurableExtensionDataRef< - TechDocsAddonOptions, - 'techdocs.addon', - {} ->; - // @public export const TechDocsAddonLocations: Readonly<{ readonly Header: 'Header'; diff --git a/plugins/techdocs-react/src/alpha.ts b/plugins/techdocs-react/src/alpha.ts index 83c0444753..031ec6711f 100644 --- a/plugins/techdocs-react/src/alpha.ts +++ b/plugins/techdocs-react/src/alpha.ts @@ -29,8 +29,7 @@ import { /** @alpha */ export type { TechDocsAddonOptions, TechDocsAddonLocations } from './types'; -/** @alpha */ -export const techDocsAddonDataRef = +const techDocsAddonDataRef = createExtensionDataRef().with({ id: 'techdocs.addon', }); @@ -41,10 +40,7 @@ export const techDocsAddonDataRef = */ export const AddonBlueprint = createExtensionBlueprint({ kind: 'addon', - attachTo: [ - { id: 'page:techdocs/reader', input: 'addons' }, - { id: 'entity-content:techdocs', input: 'addons' }, - ], + attachTo: { id: 'api:techdocs/addons', input: 'addons' }, output: [techDocsAddonDataRef], factory: (params: TechDocsAddonOptions) => [techDocsAddonDataRef(params)], dataRefs: { diff --git a/plugins/techdocs/report-alpha.api.md b/plugins/techdocs/report-alpha.api.md index cbdb912938..493d1500da 100644 --- a/plugins/techdocs/report-alpha.api.md +++ b/plugins/techdocs/report-alpha.api.md @@ -53,6 +53,34 @@ const _default: OverridableFrontendPlugin< params: ApiFactory, ) => ExtensionBlueprintParams; }>; + 'api:techdocs/addons': OverridableExtensionDefinition<{ + config: {}; + configInput: {}; + output: ExtensionDataRef; + inputs: { + addons: ExtensionInput< + ConfigurableExtensionDataRef< + TechDocsAddonOptions, + 'techdocs.addon', + {} + >, + { + singleton: false; + optional: false; + internal: false; + } + >; + }; + kind: 'api'; + name: 'addons'; + params: < + TApi, + TImpl extends TApi, + TDeps extends { [name in string]: unknown }, + >( + params: ApiFactory, + ) => ExtensionBlueprintParams; + }>; 'api:techdocs/storage': OverridableExtensionDefinition<{ kind: 'api'; name: 'storage'; diff --git a/plugins/techdocs/src/alpha/addonsApi.ts b/plugins/techdocs/src/alpha/addonsApi.ts new file mode 100644 index 0000000000..86d4739f46 --- /dev/null +++ b/plugins/techdocs/src/alpha/addonsApi.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2025 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 { + ApiBlueprint, + createApiRef, + createExtensionInput, +} from '@backstage/frontend-plugin-api'; +import { AddonBlueprint } from '@backstage/plugin-techdocs-react/alpha'; +import { TechDocsAddonOptions } from '@backstage/plugin-techdocs-react'; + +interface TechDocsAddonsApi { + getAddons(): TechDocsAddonOptions[]; +} + +export const techdocsAddonsApiRef = createApiRef({ + id: 'plugin.techdocs.addons', +}); + +export const TechDocsAddonsApiExtension = ApiBlueprint.makeWithOverrides({ + name: 'addons', + inputs: { + addons: createExtensionInput([AddonBlueprint.dataRefs.addon]), + }, + factory(originalFactory, { inputs }) { + const addons = inputs.addons.map(output => + output.get(AddonBlueprint.dataRefs.addon), + ); + return originalFactory(defineParams => + defineParams({ + api: techdocsAddonsApiRef, + deps: {}, + factory: () => ({ + getAddons: () => addons, + }), + }), + ); + }, +}); diff --git a/plugins/techdocs/src/alpha/index.tsx b/plugins/techdocs/src/alpha/index.tsx index d3fc94c7a8..b93d5dfb0d 100644 --- a/plugins/techdocs/src/alpha/index.tsx +++ b/plugins/techdocs/src/alpha/index.tsx @@ -14,6 +14,7 @@ * limitations under the License. */ +import { Suspense } from 'react'; import LibraryBooks from '@material-ui/icons/LibraryBooks'; import { createFrontendPlugin, @@ -34,7 +35,11 @@ import { EntityIconLinkBlueprint, } from '@backstage/plugin-catalog-react/alpha'; import { SearchResultListItemBlueprint } from '@backstage/plugin-search-react/alpha'; -import { AddonBlueprint } from '@backstage/plugin-techdocs-react/alpha'; +import { + AddonBlueprint, + attachTechDocsAddonComponentData, +} from '@backstage/plugin-techdocs-react/alpha'; +import { TechDocsAddonsApiExtension, techdocsAddonsApiRef } from './addonsApi'; import { TechDocsClient, TechDocsStorageClient } from '../client'; import { rootCatalogDocsRouteRef, @@ -42,7 +47,6 @@ import { rootRouteRef, } from '../routes'; import { TechDocsReaderLayout } from '../reader'; -import { attachTechDocsAddonComponentData } from '@backstage/plugin-techdocs-react/alpha'; import { TechDocsAddons, techdocsApiRef, @@ -152,24 +156,37 @@ const techDocsReaderPage = PageBlueprint.makeWithOverrides({ inputs: { addons: createExtensionInput([AddonBlueprint.dataRefs.addon]), }, - factory(originalFactory, { inputs }) { - const addons = inputs.addons.map(output => { - const options = output.get(AddonBlueprint.dataRefs.addon); - const Addon = options.component; - attachTechDocsAddonComponentData(Addon, options); - return ; - }); + factory(originalFactory, { apis, inputs }) { + const addonsApi = apis.get(techdocsAddonsApiRef); return originalFactory({ path: '/docs/:namespace/:kind/:name', routeRef: rootDocsRouteRef, - loader: async () => - await import('../Router').then(({ TechDocsReaderRouter }) => ( + loader: async () => { + // Merge addons from the API with old-style direct attachments + const apiAddons = addonsApi?.getAddons() ?? []; + const directAddons = inputs.addons.map(output => + output.get(AddonBlueprint.dataRefs.addon), + ); + const addonOptions = [...apiAddons, ...directAddons]; + + const addons = addonOptions.map(options => { + const Addon = options.component; + attachTechDocsAddonComponentData(Addon, options); + return ( + + + + ); + }); + + return import('../Router').then(({ TechDocsReaderRouter }) => ( {addons} - )), + )); + }, }); }, }); @@ -191,29 +208,41 @@ const techDocsEntityContent = EntityContentBlueprint.makeWithOverrides({ ), }, factory(originalFactory, context) { + const addonsApi = context.apis.get(techdocsAddonsApiRef); + return originalFactory( { path: 'docs', title: 'TechDocs', routeRef: rootCatalogDocsRouteRef, - loader: () => - import('../Router').then(({ EmbeddedDocsRouter }) => { - const addons = context.inputs.addons.map(output => { - const options = output.get(AddonBlueprint.dataRefs.addon); - const Addon = options.component; - attachTechDocsAddonComponentData(Addon, options); - return ; - }); + loader: () => { + // Merge addons from the API with old-style direct attachments + const apiAddons = addonsApi?.getAddons() ?? []; + const directAddons = context.inputs.addons.map(output => + output.get(AddonBlueprint.dataRefs.addon), + ); + const addonOptions = [...apiAddons, ...directAddons]; + + const addons = addonOptions.map(options => { + const Addon = options.component; + attachTechDocsAddonComponentData(Addon, options); return ( - - {addons} - + + + ); - }), + }); + + return import('../Router').then(({ EmbeddedDocsRouter }) => ( + + {addons} + + )); + }, }, context, ); @@ -244,6 +273,7 @@ export default createFrontendPlugin({ extensions: [ techDocsClientApi, techDocsStorageApi, + TechDocsAddonsApiExtension, techDocsNavItem, techDocsPage, techDocsReaderPage,