diff --git a/.changeset/forty-ties-agree.md b/.changeset/forty-ties-agree.md new file mode 100644 index 0000000000..caa45841a2 --- /dev/null +++ b/.changeset/forty-ties-agree.md @@ -0,0 +1,18 @@ +--- +'@backstage/frontend-plugin-api': patch +--- + +Added a new `IconBundleBlueprint` that lets you create icon bundle extensions that can be installed in an App in order to override or add new app icons. + +```tsx +import { IconBundleBlueprint } from '@backstage/frontend-plugin-api'; + +const exampleIconBundle = IconBundleBlueprint.make({ + name: 'example-bundle', + params: { + icons: { + user: MyOwnUserIcon, + }, + }, +}); +``` diff --git a/.changeset/forty-ties-disagree.md b/.changeset/forty-ties-disagree.md new file mode 100644 index 0000000000..214097b35d --- /dev/null +++ b/.changeset/forty-ties-disagree.md @@ -0,0 +1,5 @@ +--- +'@backstage/frontend-app-api': patch +--- + +Support icon overriding with the new `IconBundleBlueprint` API. diff --git a/docs/frontend-system/building-apps/03-built-in-extensions.md b/docs/frontend-system/building-apps/03-built-in-extensions.md index 4f8417f74f..a5e6476eb0 100644 --- a/docs/frontend-system/building-apps/03-built-in-extensions.md +++ b/docs/frontend-system/building-apps/03-built-in-extensions.md @@ -45,6 +45,7 @@ This extension is the first extension attached to the extension tree. It is resp | themes | The app themes list. | [createThemeExtension.themeDataRef](https://backstage.io/docs/reference/frontend-plugin-api.createthemeextension.themedataref) | false | See [default themes](#default-theme-extensions). | [createThemeExtension](https://backstage.io/docs/reference/frontend-plugin-api.createthemeextension) | | components | The app components list. | [createComponentExtension.componentDataRef](https://backstage.io/docs/reference/frontend-plugin-api.createcomponentextension.componentdataref) | false | See [default components](#default-components-extensions). | [createComponentExtension](https://backstage.io/docs/reference/frontend-plugin-api.createcomponentextension) | | translations | The app translations list. | [createTranslationExtension.translationDataRef](https://backstage.io/docs/reference/frontend-plugin-api.createtranslationextension.translationdataref) | false | - | [createTranslationExtension](https://backstage.io/docs/reference/frontend-plugin-api.createtranslationextension) | +| icons | The app icons list. | [IconBundleBlueprint.dataRefs.icons](https://backstage.io/docs/reference/frontend-plugin-api.iconbundleblueprint.dataRefs.icons) | true | - | [IconBundleBlueprint](https://backstage.io/docs/reference/frontend-plugin-api.iconbundleblueprint) | #### Default theme extensions diff --git a/docs/frontend-system/building-apps/08-migrating.md b/docs/frontend-system/building-apps/08-migrating.md index 0abdd2aac3..716e0da772 100644 --- a/docs/frontend-system/building-apps/08-migrating.md +++ b/docs/frontend-system/building-apps/08-migrating.md @@ -314,6 +314,31 @@ const app = createApp({ }); ``` +### `icons` + +Icons are now installed as extensions, using the `IconBundleBlueprint` to make new instances which can be added to the app. + +```ts +import { IconBundleBlueprint } from '@backstage/frontend-plugin-api'; + +const exampleIconBundle = IconBundleBlueprint.make({ + name: 'example-bundle', + params: { + icons: { + user: MyOwnUserIcon, + }, + }, +}); + +const app = createApp({ + features: [ + createExtensionOverrides({ + extensions: [exampleIconBundle], + }), + ], +}); +``` + ### `bindRoutes` Route bindings can still be done using this option, but you now also have the ability to bind routes using static configuration instead. See the section on [binding routes](../architecture/07-routes.md#binding-external-route-references) for more information. diff --git a/docs/frontend-system/building-plugins/03-extension-types.md b/docs/frontend-system/building-plugins/03-extension-types.md index 2bea073ed6..1f3baaa710 100644 --- a/docs/frontend-system/building-plugins/03-extension-types.md +++ b/docs/frontend-system/building-plugins/03-extension-types.md @@ -38,6 +38,10 @@ Sign-in page extension have a single purpose - to implement a custom sign-in pag Theme extensions provide custom themes for the app. They are always attached to the app extension and you can have any number of themes extensions installed in an app at once, letting the user choose which theme to use. +### Icons - [Reference](../../reference/frontend-plugin-api.iconbundleblueprint.md) + +Icon bundle extensions provide the ability to replace or provide new icons to the app. You can use the above blueprint to make new extension instances which can be installed into the app. + ### Translation - [Reference](../../reference/frontend-plugin-api.createtranslationextension.md) Translation extension provide custom translation messages for the app. They can be used both to override the default english messages to custom ones, as well as provide translations for additional languages. diff --git a/packages/frontend-app-api/src/extensions/App.tsx b/packages/frontend-app-api/src/extensions/App.tsx index 82e7600178..7794aceb79 100644 --- a/packages/frontend-app-api/src/extensions/App.tsx +++ b/packages/frontend-app-api/src/extensions/App.tsx @@ -24,6 +24,7 @@ import { createExtensionInput, createThemeExtension, createTranslationExtension, + IconBundleBlueprint, } from '@backstage/frontend-plugin-api'; export const App = createExtension({ @@ -42,6 +43,9 @@ export const App = createExtension({ translations: createExtensionInput({ translation: createTranslationExtension.translationDataRef, }), + icons: createExtensionInput({ + icon: IconBundleBlueprint.dataRefs.icons, + }), root: createExtensionInput( { element: coreExtensionData.reactElement, diff --git a/packages/frontend-app-api/src/wiring/createApp.tsx b/packages/frontend-app-api/src/wiring/createApp.tsx index cf6ea6dab4..d82d0ca782 100644 --- a/packages/frontend-app-api/src/wiring/createApp.tsx +++ b/packages/frontend-app-api/src/wiring/createApp.tsx @@ -25,6 +25,7 @@ import { createThemeExtension, createTranslationExtension, FrontendFeature, + IconBundleBlueprint, iconsApiRef, RouteResolutionApi, routeResolutionApiRef, @@ -171,6 +172,7 @@ export interface CreateAppFeatureLoader { /** @public */ export function createApp(options?: { + /** @deprecated - Please use {@link @backstage/frontend-plugin-api#IconBundleBlueprint} to make new icon bundles which can be installed in the app seperately */ icons?: { [key in string]: IconComponent }; features?: (FrontendFeature | CreateAppFeatureLoader)[]; configLoader?: () => Promise<{ config: ConfigApi }>; @@ -246,6 +248,7 @@ export function createApp(options?: { * @public */ export function createSpecializedApp(options?: { + /** @deprecated - Please use {@link @backstage/frontend-plugin-api#IconBundleBlueprint} to make new icon bundles which can be installed in the app seperately */ icons?: { [key in string]: IconComponent }; features?: FrontendFeature[]; config?: ConfigApi; @@ -373,6 +376,11 @@ function createApiHolder( (x): x is typeof createTranslationExtension.translationDataRef.T => !!x, ) ?? []; + const extensionIcons = tree.root.edges.attachments + .get('icons') + ?.map(e => e.instance?.getData(IconBundleBlueprint.dataRefs.icons)) + .reduce((acc, bundle) => ({ ...acc, ...bundle }), {}); + for (const factory of pluginApis) { factoryRegistry.register('default', factory); } @@ -413,7 +421,8 @@ function createApiHolder( factoryRegistry.register('static', { api: iconsApiRef, deps: {}, - factory: () => new DefaultIconsApi({ ...defaultIcons, ...icons }), + factory: () => + new DefaultIconsApi({ ...defaultIcons, ...extensionIcons, ...icons }), }); factoryRegistry.register('static', { diff --git a/packages/frontend-plugin-api/api-report.md b/packages/frontend-plugin-api/api-report.md index 65445b5c4d..3c082a7694 100644 --- a/packages/frontend-plugin-api/api-report.md +++ b/packages/frontend-plugin-api/api-report.md @@ -522,9 +522,16 @@ export function createExtensionBlueprint< TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, + TDataRefs extends AnyExtensionDataMap = never, >( - options: CreateExtensionBlueprintOptions, -): ExtensionBlueprint; + options: CreateExtensionBlueprintOptions< + TParams, + TInputs, + TOutput, + TConfig, + TDataRefs + >, +): ExtensionBlueprint; // @public (undocumented) export interface CreateExtensionBlueprintOptions< @@ -532,6 +539,7 @@ export interface CreateExtensionBlueprintOptions< TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, + TDataRefs extends AnyExtensionDataMap, > { // (undocumented) attachTo: { @@ -541,6 +549,8 @@ export interface CreateExtensionBlueprintOptions< // (undocumented) configSchema?: PortableSchema; // (undocumented) + dataRefs?: TDataRefs; + // (undocumented) disabled?: boolean; // (undocumented) factory( @@ -917,7 +927,10 @@ export interface ExtensionBlueprint< TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, + TDataRefs extends AnyExtensionDataMap, > { + // (undocumented) + dataRefs: TDataRefs; // (undocumented) make(args: { namespace?: string; @@ -1085,6 +1098,35 @@ export { gitlabAuthApiRef }; export { googleAuthApiRef }; +// @public (undocumented) +export const IconBundleBlueprint: ExtensionBlueprint< + { + icons: { + [x: string]: IconComponent; + }; + }, + AnyExtensionInputMap, + { + icons: ConfigurableExtensionDataRef< + 'core.icons', + { + [x: string]: IconComponent; + }, + {} + >; + }, + unknown, + { + icons: ConfigurableExtensionDataRef< + 'core.icons', + { + [x: string]: IconComponent; + }, + {} + >; + } +>; + // @public export type IconComponent = ComponentType< | { diff --git a/packages/frontend-plugin-api/src/extensions/IconBundleBlueprint.ts b/packages/frontend-plugin-api/src/extensions/IconBundleBlueprint.ts new file mode 100644 index 0000000000..2ad1404102 --- /dev/null +++ b/packages/frontend-plugin-api/src/extensions/IconBundleBlueprint.ts @@ -0,0 +1,35 @@ +/* + * 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 { IconComponent } from '../icons'; +import { createExtensionBlueprint, createExtensionDataRef } from '../wiring'; + +const iconsDataRef = createExtensionDataRef<{ + [key in string]: IconComponent; +}>().with({ id: 'core.icons' }); + +/** @public */ +export const IconBundleBlueprint = createExtensionBlueprint({ + kind: 'icon-bundle', + namespace: 'app', + attachTo: { id: 'app', input: 'icons' }, + output: { + icons: iconsDataRef, + }, + factory: (params: { icons: { [key in string]: IconComponent } }) => params, + dataRefs: { + icons: iconsDataRef, + }, +}); diff --git a/packages/frontend-plugin-api/src/extensions/index.ts b/packages/frontend-plugin-api/src/extensions/index.ts index 562cb728cb..267201db81 100644 --- a/packages/frontend-plugin-api/src/extensions/index.ts +++ b/packages/frontend-plugin-api/src/extensions/index.ts @@ -25,3 +25,4 @@ export { createSignInPageExtension } from './createSignInPageExtension'; export { createThemeExtension } from './createThemeExtension'; export { createComponentExtension } from './createComponentExtension'; export { createTranslationExtension } from './createTranslationExtension'; +export { IconBundleBlueprint } from './IconBundleBlueprint'; diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx index c7b077bdbd..87b3cec152 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.test.tsx @@ -18,6 +18,7 @@ import React from 'react'; import { coreExtensionData } from './coreExtensionData'; import { createExtensionBlueprint } from './createExtensionBlueprint'; import { createExtensionTester } from '@backstage/frontend-test-utils'; +import { createExtensionDataRef } from './createExtensionDataRef'; describe('createExtensionBlueprint', () => { it('should allow creation of extension blueprints', () => { @@ -102,4 +103,28 @@ describe('createExtensionBlueprint', () => { const { container } = createExtensionTester(extension).render(); expect(container.querySelector('h2')).toHaveTextContent('Hello, world!'); }); + + it('should allow exporting the dataRefs from the extension blueprint', () => { + const dataRef = createExtensionDataRef().with({ id: 'test.data' }); + + const TestExtensionBlueprint = createExtensionBlueprint({ + kind: 'test-extension', + attachTo: { id: 'test', input: 'default' }, + output: { + element: coreExtensionData.reactElement, + }, + dataRefs: { + data: dataRef, + }, + factory(params: { text: string }) { + return { + element:

{params.text}

, + }; + }, + }); + + expect(TestExtensionBlueprint.dataRefs).toEqual({ + data: dataRef, + }); + }); }); diff --git a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts index 6c79017a0a..b504db5f03 100644 --- a/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts +++ b/packages/frontend-plugin-api/src/wiring/createExtensionBlueprint.ts @@ -34,6 +34,7 @@ export interface CreateExtensionBlueprintOptions< TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, + TDataRefs extends AnyExtensionDataMap, > { kind: string; namespace?: string; @@ -50,6 +51,8 @@ export interface CreateExtensionBlueprintOptions< inputs: Expand>; }, ): Expand>; + + dataRefs?: TDataRefs; } /** @@ -60,7 +63,10 @@ export interface ExtensionBlueprint< TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, + TDataRefs extends AnyExtensionDataMap, > { + dataRefs: TDataRefs; + make(args: { namespace?: string; name?: string; @@ -97,15 +103,21 @@ class ExtensionBlueprintImpl< TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, + TDataRefs extends AnyExtensionDataMap, > { constructor( private readonly options: CreateExtensionBlueprintOptions< TParams, TInputs, TOutput, - TConfig + TConfig, + TDataRefs >, - ) {} + ) { + this.dataRefs = options.dataRefs!; + } + + dataRefs: TDataRefs; public make(args: { namespace?: string; @@ -184,8 +196,15 @@ export function createExtensionBlueprint< TInputs extends AnyExtensionInputMap, TOutput extends AnyExtensionDataMap, TConfig, + TDataRefs extends AnyExtensionDataMap = never, >( - options: CreateExtensionBlueprintOptions, -): ExtensionBlueprint { + options: CreateExtensionBlueprintOptions< + TParams, + TInputs, + TOutput, + TConfig, + TDataRefs + >, +): ExtensionBlueprint { return new ExtensionBlueprintImpl(options); }