From d60e5ff9f227b52cfb30396a1656756944496fdd Mon Sep 17 00:00:00 2001 From: Tomas Coufal Date: Wed, 8 Nov 2023 13:08:31 +0100 Subject: [PATCH] feat(dynamic-plugins): replace static MUI Icons library import with dynamic appIcons Signed-off-by: Tomas Coufal --- .changeset/rotten-seas-fetch.md | 5 ++ app-config.dynamic-plugins.yaml | 10 ++- dynamic-plugins.default.yaml | 10 ++- dynamic-plugins/imports/package.json | 2 +- .../backstage-plugin-lighthouse/package.json | 3 +- .../backstage-plugin-lighthouse/src/index.ts | 1 + packages/app/config.d.ts | 15 ++-- .../components/DynamicRoot/DynamicRoot.tsx | 8 +- packages/app/src/components/Root/Root.tsx | 15 +++- .../utils/dynamicUI/extractDynamicConfig.ts | 78 ++++++++++++------- showcase-docs/dynamic-plugins.md | 25 +++++- 11 files changed, 124 insertions(+), 48 deletions(-) create mode 100644 .changeset/rotten-seas-fetch.md diff --git a/.changeset/rotten-seas-fetch.md b/.changeset/rotten-seas-fetch.md new file mode 100644 index 000000000..30bffeff9 --- /dev/null +++ b/.changeset/rotten-seas-fetch.md @@ -0,0 +1,5 @@ +--- +'app': minor +--- + +Instead of embedding all MUI4 icons into the bundle, we can depend in app.getSystemIcon() icon catalog. This PR allows users to extend it via dynamic plugins and therefore cleanup our bundle of unnecessary icons. diff --git a/app-config.dynamic-plugins.yaml b/app-config.dynamic-plugins.yaml index 1102c54d6..9fb25606c 100644 --- a/app-config.dynamic-plugins.yaml +++ b/app-config.dynamic-plugins.yaml @@ -71,11 +71,14 @@ dynamicPlugins: - hasAnnotation: backstage.io/kubernetes-id - hasAnnotation: backstage.io/kubernetes-namespace backstage.plugin-lighthouse: + appIcons: + - name: lighthouse + importName: LighthouseIcon dynamicRoutes: - path: /lighthouse importName: LighthousePage menuItem: - icon: Assessment + icon: lighthouse text: Lighthouse mountPoints: - mountPoint: entity.page.overview/cards @@ -173,11 +176,14 @@ dynamicPlugins: anyOf: - isNexusRepositoryManagerAvailable janus-idp.backstage-plugin-ocm: + appIcons: + - name: ocmIcon + importName: OcmIcon dynamicRoutes: - path: /ocm importName: OcmPage menuItem: - icon: Storage + icon: ocmIcon text: Clusters mountPoints: - mountPoint: entity.page.overview/context diff --git a/dynamic-plugins.default.yaml b/dynamic-plugins.default.yaml index c2626a448..6d1ac7db5 100644 --- a/dynamic-plugins.default.yaml +++ b/dynamic-plugins.default.yaml @@ -398,11 +398,14 @@ plugins: dynamicPlugins: frontend: janus-idp.backstage-plugin-ocm: + appIcons: + - name: ocmIcon + importName: OcmIcon dynamicRoutes: - path: /ocm importName: OcmPage menuItem: - icon: Storage + icon: ocmIcon text: Clusters mountPoints: - mountPoint: entity.page.overview/context @@ -629,11 +632,14 @@ plugins: dynamicPlugins: frontend: backstage.plugin-lighthouse: + appIcons: + - name: lighthouse + importName: LighthouseIcon dynamicRoutes: - path: /lighthouse importName: LighthousePage menuItem: - icon: Assessment + icon: lighthouse text: Lighthouse mountPoints: - mountPoint: entity.page.overview/cards diff --git a/dynamic-plugins/imports/package.json b/dynamic-plugins/imports/package.json index 615430b46..3c1515d4f 100644 --- a/dynamic-plugins/imports/package.json +++ b/dynamic-plugins/imports/package.json @@ -18,7 +18,7 @@ "@janus-idp/backstage-plugin-acr": "1.2.4", "@janus-idp/backstage-plugin-jfrog-artifactory": "1.2.4", "@janus-idp/backstage-plugin-nexus-repository-manager": "1.4.4", - "@janus-idp/backstage-plugin-ocm": "3.3.4", + "@janus-idp/backstage-plugin-ocm": "3.5.0", "@janus-idp/backstage-plugin-quay": "1.4.6", "@janus-idp/backstage-plugin-tekton": "3.1.3", "@janus-idp/backstage-plugin-topology": "1.16.4" diff --git a/dynamic-plugins/wrappers/backstage-plugin-lighthouse/package.json b/dynamic-plugins/wrappers/backstage-plugin-lighthouse/package.json index 684092a53..180e52884 100644 --- a/dynamic-plugins/wrappers/backstage-plugin-lighthouse/package.json +++ b/dynamic-plugins/wrappers/backstage-plugin-lighthouse/package.json @@ -20,7 +20,8 @@ "export-dynamic": "janus-cli package export-dynamic-plugin" }, "dependencies": { - "@backstage/plugin-lighthouse": "0.4.10" + "@backstage/plugin-lighthouse": "0.4.10", + "@mui/icons-material": "5.14.16" }, "devDependencies": { "@backstage/cli": "0.23.1", diff --git a/dynamic-plugins/wrappers/backstage-plugin-lighthouse/src/index.ts b/dynamic-plugins/wrappers/backstage-plugin-lighthouse/src/index.ts index 8507e8583..64e9d5ff0 100644 --- a/dynamic-plugins/wrappers/backstage-plugin-lighthouse/src/index.ts +++ b/dynamic-plugins/wrappers/backstage-plugin-lighthouse/src/index.ts @@ -1 +1,2 @@ export * from '@backstage/plugin-lighthouse'; +export { default as LighthouseIcon } from '@mui/icons-material/Assessment'; diff --git a/packages/app/config.d.ts b/packages/app/config.d.ts index 26f491a73..8eb27a1c0 100644 --- a/packages/app/config.d.ts +++ b/packages/app/config.d.ts @@ -54,24 +54,22 @@ export interface Config { /** @deepVisibility frontend */ frontend?: { [key: string]: { - dynamicRoutes: ({ - [key: string]: any; - } & { + dynamicRoutes?: { path: string; module?: string; importName?: string; - menuItem: { + menuItem?: { icon: string; text: string; }; - })[]; + }[]; routeBindings?: { bindTarget: string; bindMap: { [key: string]: string; }; }[]; - mountPoints: { + mountPoints?: { mountPoint: string; module?: string; importName?: string; @@ -104,6 +102,11 @@ export interface Config { }; }; }[]; + appIcons?: { + module?: string; + importName?: string; + name: string; + }[]; }; }; }; diff --git a/packages/app/src/components/DynamicRoot/DynamicRoot.tsx b/packages/app/src/components/DynamicRoot/DynamicRoot.tsx index 54720c089..7b1a84bc2 100644 --- a/packages/app/src/components/DynamicRoot/DynamicRoot.tsx +++ b/packages/app/src/components/DynamicRoot/DynamicRoot.tsx @@ -57,7 +57,7 @@ const DynamicRoot = ({ // Fills registry of remote components const initializeRemoteModules = useCallback(async () => { - const { dynamicRoutes, mountPoints, routeBindings } = + const { dynamicRoutes, mountPoints, routeBindings, appIcons } = await extractDynamicConfig(); const requiredModules = [ @@ -83,6 +83,12 @@ const DynamicRoot = ({ bindRoutes({ bind }) { bindAppRoutes(bind, remotePlugins, routeBindings); }, + icons: Object.fromEntries( + appIcons.map(({ scope, module, importName, name }) => [ + name, + remotePlugins[scope][module][importName], + ]), + ), themes: defaultThemes, components: defaultAppComponents, }); diff --git a/packages/app/src/components/Root/Root.tsx b/packages/app/src/components/Root/Root.tsx index ac4c35c7e..96d97a27c 100644 --- a/packages/app/src/components/Root/Root.tsx +++ b/packages/app/src/components/Root/Root.tsx @@ -17,7 +17,7 @@ import AppsIcon from '@mui/icons-material/Apps'; import ExtensionIcon from '@mui/icons-material/Extension'; import HomeIcon from '@mui/icons-material/Home'; import LibraryBooks from '@mui/icons-material/LibraryBooks'; -import MenuIcon from '@mui/icons-material/Menu'; +import MuiMenuIcon from '@mui/icons-material/Menu'; import MapIcon from '@mui/icons-material/MyLocation'; import SchoolIcon from '@mui/icons-material/School'; import SearchIcon from '@mui/icons-material/Search'; @@ -25,7 +25,7 @@ import { makeStyles } from 'tss-react/mui'; import React, { PropsWithChildren, useContext } from 'react'; import { SidebarLogo } from './SidebarLogo'; import DynamicRootContext from '../DynamicRoot/DynamicRootContext'; -import * as MuiIcons from '@mui/icons-material'; +import { useApp } from '@backstage/core-plugin-api'; const useStyles = makeStyles()({ sidebarItem: { @@ -51,6 +51,13 @@ const SideBarItemWrapper = (props: SidebarItemProps) => { ); }; +const MenuIcon = ({ icon }: { icon: string }) => { + const app = useApp(); + + const Icon = app.getSystemIcon(icon) || (() => null); + return ; +}; + export const Root = ({ children }: PropsWithChildren<{}>) => { const { dynamicRoutes } = useContext(DynamicRootContext); return ( @@ -61,7 +68,7 @@ export const Root = ({ children }: PropsWithChildren<{}>) => { - }> + }> {/* Global nav, not org-specific */} ) => { if (menuItem) { return ( } to={path} text={menuItem.text} /> diff --git a/packages/app/src/utils/dynamicUI/extractDynamicConfig.ts b/packages/app/src/utils/dynamicUI/extractDynamicConfig.ts index 37bcb659d..ca5e27626 100644 --- a/packages/app/src/utils/dynamicUI/extractDynamicConfig.ts +++ b/packages/app/src/utils/dynamicUI/extractDynamicConfig.ts @@ -4,6 +4,7 @@ import { isKind } from '@backstage/plugin-catalog'; import { hasAnnotation, isType } from '../../components/catalog/utils'; import { DynamicModuleEntry, + MenuItem, RouteBinding, ScalprumMountPointConfigRaw, ScalprumMountPointConfigRawIf, @@ -25,12 +26,22 @@ type DynamicRoute = { module: string; importName: string; path: string; + menuItem?: MenuItem; }; export type MountPoint = { + scope: string; mountPoint: string; - module?: string; - importName?: string; + module: string; + importName: string; + config?: ScalprumMountPointConfigRaw; +}; + +type AppIcon = { + scope: string; + name: string; + module: string; + importName: string; }; type CustomProperties = { @@ -41,6 +52,7 @@ type CustomProperties = { })[]; routeBindings?: RouteBinding[]; mountPoints?: MountPoint[]; + appIcons?: AppIcon[]; }; const conditionsArrayMapper = ( @@ -88,13 +100,8 @@ async function extractDynamicConfig() { const dynamicConfig = (appsConfig as AppConfig[]).reduce<{ routeBindings: RouteBinding[]; dynamicRoutes: DynamicRoute[]; - mountPoints: { - scope: string; - module: string; - importName: string; - mountPoint: string; - config?: ScalprumMountPointConfigRaw; - }[]; + appIcons: AppIcon[]; + mountPoints: MountPoint[]; }>( (acc, { data }) => { if (data?.dynamicPlugins?.frontend) { @@ -123,30 +130,43 @@ async function extractDynamicConfig() { ); acc.mountPoints.push( - ...Object.entries(data.dynamicPlugins.frontend).reduce< - { - scope: string; - module: string; - importName: string; - mountPoint: string; - }[] - >((accMountPoints, [scope, { mountPoints }]) => { - accMountPoints.push( - ...(mountPoints ?? []).map(point => ({ - ...point, - module: point.module ?? 'PluginRoot', - importName: point.importName ?? 'default', - scope, - })), - ); - return accMountPoints; - }, []), + ...Object.entries(data.dynamicPlugins.frontend).reduce( + (accMountPoints, [scope, { mountPoints }]) => { + accMountPoints.push( + ...(mountPoints ?? []).map(point => ({ + ...point, + module: point.module ?? 'PluginRoot', + importName: point.importName ?? 'default', + scope, + })), + ); + return accMountPoints; + }, + [], + ), + ); + + acc.appIcons.push( + ...Object.entries(data.dynamicPlugins.frontend).reduce( + (accAppIcons, [scope, { appIcons }]) => { + accAppIcons.push( + ...(appIcons ?? []).map(icon => ({ + ...icon, + module: icon.module ?? 'PluginRoot', + importName: icon.importName ?? 'default', + scope, + })), + ); + return accAppIcons; + }, + [], + ), ); } return acc; }, - { routeBindings: [], dynamicRoutes: [], mountPoints: [] }, - ) || { routeBindings: [], dynamicRoutes: [], mountPoints: [] }; // fallback to empty arrays + { routeBindings: [], dynamicRoutes: [], mountPoints: [], appIcons: [] }, + ) || { routeBindings: [], dynamicRoutes: [], mountPoints: [], appIcons: [] }; // fallback to empty arrays return dynamicConfig; } diff --git a/showcase-docs/dynamic-plugins.md b/showcase-docs/dynamic-plugins.md index 0c38c9833..dc04475d7 100644 --- a/showcase-docs/dynamic-plugins.md +++ b/showcase-docs/dynamic-plugins.md @@ -418,6 +418,7 @@ Similarly to traditional Backstage instances, there are 3 types of functionality - Full new page that declares a completely new route in the app - Extension to existing page via router `bind`ings - Use of mount points within the application +- Extend internal library of available icons The overall configuration is as follows: @@ -429,8 +430,28 @@ dynamicPlugins: dynamicRoutes: ... mountPoints: ... routeBindings: ... + appIcons: ... ``` +#### Extend internal library of available icons + +Backstage offers an internal catalog of system icons available across the application. This is traditionally used within Catalog items as icons for links for example. Dynamic plugins also use this catalog when fetching icons for [dynamically configured routes with sidebar navigation menu entry](#dynamic-routes). Therefore if a plugin requires a custom icon to be used for menu item, this icon must be added to the internal icon catalog. This is done via `appIcons` configuration: + +```yaml +# app-config.yaml +dynamicPlugins: + frontend: + : # same as `scalprum.name` key in plugin's `package.json` + appIcons: + - name: fooIcon # unique icon name + module: CustomModule # optional, same as key in `scalprum.exposedModules` key in plugin's `package.json` + importName: FooIcon # optional, actual component name that should be rendered +``` + +- `name` - Unique name in the app's internal icon catalog. +- `module` - Optional. Since dynamic plugins can expose multiple distinct modules, you may need to specify which set of assets you want to access within the plugin. If not provided, the default module named `PluginRoot` is used. This is the same as the key in `scalprum.exposedModules` key in plugin's `package.json`. +- `importName` - Optional. The actual component name that should be rendered as a standalone page. If not specified the `default` export is used. + #### Dynamic routes Traditionally, [Backstage full page extensions](https://backstage.io/docs/plugins/composability/#using-extensions-in-an-app) are done within the `packages/app/src/App.tsx` file. It may look like this: @@ -467,7 +488,7 @@ dynamicPlugins: module: CustomModule # optional, same as key in `scalprum.exposedModules` key in plugin's `package.json` importName: FooPluginPage # optional, actual component name that should be rendered menuItem: # optional, allows you to populate main sidebar navigation - icon: Storage # MUI4 icon to render in the sidebar + icon: fooIcon # Backstage system icon text: Foo Plugin Page # menu item text ``` @@ -476,7 +497,7 @@ Each plugin can expose multiple routes and each route is required to define its - `path` - Unique path in the app. Cannot override existing routes with the exception of the `/` home route: the main home page can be replaced via the dynamic plugins mechanism. - `module` - Optional. Since dynamic plugins can expose multiple distinct modules, you may need to specify which set of assets you want to access within the plugin. If not provided, the default module named `PluginRoot` is used. This is the same as the key in `scalprum.exposedModules` key in plugin's `package.json`. - `importName` - Optional. The actual component name that should be rendered as a standalone page. If not specified the `default` export is used. -- `menuItem` - This property allows users to extend the main sidebar navigation and point to their new route. It accepts `text` and `icon` properties. `icon` is a Material UI 4 icon name. +- `menuItem` - This property allows users to extend the main sidebar navigation and point to their new route. It accepts `text` and `icon` properties. `icon` refers to a Backstage system icon name. See [Backstage system icons](https://backstage.io/docs/getting-started/app-custom-theme/#icons) for the list of default icons and [Extending Icons Library](#extend-internal-library-of-available-icons) to extend this with dynamic plugins. #### Bind to existing plugins