diff --git a/.changeset/light-meals-cover.md b/.changeset/light-meals-cover.md new file mode 100644 index 0000000000..b0cc56d36c --- /dev/null +++ b/.changeset/light-meals-cover.md @@ -0,0 +1,9 @@ +--- +'@backstage/plugin-catalog-react': minor +'@backstage/plugin-kubernetes': patch +'@backstage/plugin-api-docs': patch +'@backstage/plugin-techdocs': patch +'@backstage/plugin-catalog': minor +--- + +Add the ability to show icons for the tabs on the entity page (new frontend) diff --git a/docs/features/software-catalog/catalog-customization.md b/docs/features/software-catalog/catalog-customization.md index a828e6591a..d62bb34505 100644 --- a/docs/features/software-catalog/catalog-customization.md +++ b/docs/features/software-catalog/catalog-customization.md @@ -696,3 +696,63 @@ filter: targetRef: $in: [group:default/admins, group:default/viewers] ``` + +### Configure groups, titles, and icons + +You can define and customize the tab groups that appear on the entity page, as well as enable icons for both groups and individual tabs. + +```yaml +app: + extensions: + # Entity page (new frontend system) + - page:catalog/entity: + config: + # Show icons next to group and tab titles + showNavItemIcons: true + + # Optionally override default groups and their icons + groups: + - overview: + title: Overview + icon: dashboard + - quality: + title: Quality + icon: verified + - documentation: + title: Docs + icon: description +``` + +Notes: + +- Icons for groups and tabs are resolved via the app's IconsApi. When using a string icon id (for example `"dashboard"`), ensure that the corresponding icon bundles are enabled/installed in your app (see the [IconBundleBlueprint documentation](https://backstage.io/api/stable/variables/_backstage_plugin-app-react.IconBundleBlueprint.html)). +- Group icons are only rendered if `showNavItemIcons` is set to `true`. + +### Overriding or disabling a tab's group (per extension) + +Each entity content extension (tabs on the entity page) can declare a default `group` in code. You can override or disable this per installation in `app-config.yaml` using the extension's config: + +```yaml +app: + extensions: + # ... + # Example entity content extension instance id + - entity-content:example/my-content: + config: + # Move this tab to a custom group you defined above + group: custom + # Show an icon for this entity content page but only if `showNavItemIcons` is enabled for the `page:catalog/entity` extension + icon: my-icon + + # Disassociate from any group and show as a standalone tab + - entity-content:example/another-content: + config: + group: false +``` + +### Tab icons for entity content + +Entity content extensions can also declare an `icon` parameter. When provided as a string, the icon id is looked up via the IconsApi. For the icon to render: + +- The entity page must have `showNavItemIcons: true` (see configuration above). +- The icon id must be available in the app's enabled icon bundles. diff --git a/docs/frontend-system/building-plugins/03-common-extension-blueprints.md b/docs/frontend-system/building-plugins/03-common-extension-blueprints.md index aff66f3cc6..3bb613848f 100644 --- a/docs/frontend-system/building-plugins/03-common-extension-blueprints.md +++ b/docs/frontend-system/building-plugins/03-common-extension-blueprints.md @@ -73,6 +73,31 @@ Avoid using `convertLegacyEntityCardExtension` from `@backstage/core-compat-api` Creates entity content to be displayed on the entity pages of the catalog plugin. Exported as `EntityContentBlueprint`. +Supports optional params such as `group` and `icon` to: + +- group: string | false — associates the content with a tab group on the entity page (for example "overview", "quality", "deployment", or any custom id). You can override or disable this per-installation via app-config using `app.extensions[...].config.group`, where `false` removes the grouping. +- icon: string — sets the tab icon. Note: when providing a string, the icon is looked up via the app's IconsApi; make sure icon bundles are enabled/installed in your app (see the Icons blueprint reference above) so that the icon id you use is available. + +To render icons in the entity page tabs, the page must also have icons enabled via app configuration. Set `showNavItemIcons: true` on the catalog entity page config (created via `page:catalog/entity`). Example: + +```yaml +app: + extensions: + # Entity page + - page:catalog/entity: + config: + # Enable tab- and group-icons + showNavItemIcons: true + # Optionally override default groups and their icons + groups: + - overview: + title: Overview + icon: dashboard + - documentation: + title: Docs + icon: description +``` + Avoid using `convertLegacyEntityContentExtension` from `@backstage/core-compat-api` to convert legacy entity content extensions to the new system. Instead, use the `EntityContentBlueprint` directly. The legacy converter is only intended to help adapt 3rd party plugins that you don't control, and doesn't produce as good results as using the blueprint directly. ## Extension blueprints in `@backstage/plugin-search-react/alpha` diff --git a/packages/app-next/app-config.yaml b/packages/app-next/app-config.yaml index d21e19f0de..3579a18348 100644 --- a/packages/app-next/app-config.yaml +++ b/packages/app-next/app-config.yaml @@ -47,6 +47,7 @@ app: # Pages - page:catalog/entity: config: + showNavItemIcons: true groups: # placing a tab at the beginning - overview: @@ -56,6 +57,7 @@ app: # example overriding a default group title - documentation: title: Docs + icon: docs - deployment: title: Deployments # example adding a new group @@ -103,9 +105,11 @@ app: config: # example associating with a default group group: documentation + icon: kind:api - entity-content:techdocs: config: group: documentation + icon: techdocs - entity-content:kubernetes/kubernetes: config: # example disassociating with a default group diff --git a/packages/app-next/src/App.tsx b/packages/app-next/src/App.tsx index 47f717c7ee..3503805394 100644 --- a/packages/app-next/src/App.tsx +++ b/packages/app-next/src/App.tsx @@ -47,6 +47,8 @@ import { pluginInfoResolver } from './pluginInfoResolver'; import { appModuleNav } from './modules/appModuleNav'; import devtoolsPlugin from '@backstage/plugin-devtools/alpha'; import { unprocessedEntitiesDevToolsContent } from '@backstage/plugin-catalog-unprocessed-entities/alpha'; +import catalogPlugin from '@backstage/plugin-catalog/alpha'; +import InfoIcon from '@material-ui/icons/Info'; /* @@ -113,6 +115,17 @@ const customHomePageModule = createFrontendModule({ ], }); +// customize catalog example +const customizedCatalog = catalogPlugin.withOverrides({ + extensions: [ + catalogPlugin.getExtension('entity-content:catalog/overview').override({ + params: { + icon: , + }, + }), + ], +}); + const notFoundErrorPageModule = createFrontendModule({ pluginId: 'app', extensions: [notFoundErrorPage], @@ -131,6 +144,7 @@ const collectedLegacyPlugins = convertLegacyAppRoot( const app = createApp({ features: [ + customizedCatalog, pagesPlugin, convertedTechdocsPlugin, userSettingsPlugin, diff --git a/plugins/api-docs/report-alpha.api.md b/plugins/api-docs/report-alpha.api.md index 2b42b528aa..33cb3e6f76 100644 --- a/plugins/api-docs/report-alpha.api.md +++ b/plugins/api-docs/report-alpha.api.md @@ -15,8 +15,10 @@ import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { IconComponent } from '@backstage/frontend-plugin-api'; import { JSX as JSX_2 } from 'react'; +import { JSXElementConstructor } from 'react'; import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api'; import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api'; +import { ReactElement } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; import { RouteRef as RouteRef_2 } from '@backstage/frontend-plugin-api'; import { TranslationRef } from '@backstage/frontend-plugin-api'; @@ -335,12 +337,14 @@ const _default: OverridableFrontendPlugin< title: string | undefined; filter: EntityPredicate | undefined; group: string | false | undefined; + icon: string | undefined; }; configInput: { filter?: EntityPredicate | undefined; title?: string | undefined; path?: string | undefined; group?: string | false | undefined; + icon?: string | undefined; }; output: | ExtensionDataRef @@ -373,6 +377,13 @@ const _default: OverridableFrontendPlugin< { optional: true; } + > + | ExtensionDataRef< + string | ReactElement>, + 'catalog.entity-content-icon', + { + optional: true; + } >; inputs: {}; params: { @@ -382,6 +393,7 @@ const _default: OverridableFrontendPlugin< title: string; defaultGroup?: [Error: `Use the 'group' param instead`]; group?: keyof defaultEntityContentGroups | (string & {}); + icon?: string | ReactElement; loader: () => Promise; routeRef?: RouteRef_2; filter?: string | EntityPredicate | ((entity: Entity) => boolean); @@ -395,12 +407,14 @@ const _default: OverridableFrontendPlugin< title: string | undefined; filter: EntityPredicate | undefined; group: string | false | undefined; + icon: string | undefined; }; configInput: { filter?: EntityPredicate | undefined; title?: string | undefined; path?: string | undefined; group?: string | false | undefined; + icon?: string | undefined; }; output: | ExtensionDataRef @@ -433,6 +447,13 @@ const _default: OverridableFrontendPlugin< { optional: true; } + > + | ExtensionDataRef< + string | ReactElement>, + 'catalog.entity-content-icon', + { + optional: true; + } >; inputs: {}; params: { @@ -442,6 +463,7 @@ const _default: OverridableFrontendPlugin< title: string; defaultGroup?: [Error: `Use the 'group' param instead`]; group?: keyof defaultEntityContentGroups | (string & {}); + icon?: string | ReactElement; loader: () => Promise; routeRef?: RouteRef_2; filter?: string | EntityPredicate | ((entity: Entity) => boolean); diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index d4cdf44742..3ad5833a9f 100644 --- a/plugins/catalog-react/report-alpha.api.md +++ b/plugins/catalog-react/report-alpha.api.md @@ -13,6 +13,8 @@ import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import { IconLinkVerticalProps } from '@backstage/core-components'; import { JsonValue } from '@backstage/types'; import { JSX as JSX_2 } from 'react'; +import { JSXElementConstructor } from 'react'; +import { ReactElement } from 'react'; import { ReactNode } from 'react'; import { ResourcePermission } from '@backstage/plugin-permission-common'; import { RouteRef } from '@backstage/frontend-plugin-api'; @@ -143,21 +145,40 @@ export function convertLegacyEntityContentExtension( filter?: string | EntityPredicate | ((entity: Entity) => boolean); path?: string; title?: string; + icon?: string | ReactElement; defaultPath?: [Error: `Use the 'path' override instead`]; defaultTitle?: [Error: `Use the 'title' override instead`]; }, ): ExtensionDefinition; // @alpha -export const defaultEntityContentGroups: { - overview: string; - documentation: string; - development: string; - deployment: string; - operation: string; - observability: string; +export const defaultEntityContentGroupDefinitions: { + overview: { + title: string; + }; + documentation: { + title: string; + }; + development: { + title: string; + }; + deployment: { + title: string; + }; + operation: { + title: string; + }; + observability: { + title: string; + }; }; +// @alpha @deprecated +export const defaultEntityContentGroups: Record< + keyof typeof defaultEntityContentGroupDefinitions, + string +>; + // @alpha export const EntityCardBlueprint: ExtensionBlueprint<{ kind: 'entity-card'; @@ -230,6 +251,7 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ title: string; defaultGroup?: [Error: `Use the 'group' param instead`]; group?: keyof typeof defaultEntityContentGroups | (string & {}); + icon?: string | ReactElement; loader: () => Promise; routeRef?: RouteRef; filter?: string | EntityPredicate | ((entity: Entity) => boolean); @@ -265,6 +287,13 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ { optional: true; } + > + | ExtensionDataRef< + string | ReactElement>, + 'catalog.entity-content-icon', + { + optional: true; + } >; inputs: {}; config: { @@ -272,12 +301,14 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ title: string | undefined; filter: EntityPredicate | undefined; group: string | false | undefined; + icon: string | undefined; }; configInput: { filter?: EntityPredicate | undefined; title?: string | undefined; path?: string | undefined; group?: string | false | undefined; + icon?: string | undefined; }; dataRefs: { title: ConfigurableExtensionDataRef< @@ -300,9 +331,23 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ 'catalog.entity-content-group', {} >; + icon: ConfigurableExtensionDataRef< + string | ReactElement>, + 'catalog.entity-content-icon', + {} + >; }; }>; +// @alpha (undocumented) +export type EntityContentGroupDefinitions = Record< + string, + { + title: string; + icon?: string | ReactElement; + } +>; + // @alpha (undocumented) export const EntityContentLayoutBlueprint: ExtensionBlueprint<{ kind: 'entity-content-layout'; diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx index e80834be92..cd1053db0b 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.test.tsx @@ -188,6 +188,9 @@ describe('EntityContentBlueprint', () => { }, ], }, + "icon": { + "type": "string", + }, "path": { "type": "string", }, @@ -243,6 +246,15 @@ describe('EntityContentBlueprint', () => { "optional": [Function], "toString": [Function], }, + { + "$$type": "@backstage/ExtensionDataRef", + "config": { + "optional": true, + }, + "id": "catalog.entity-content-icon", + "optional": [Function], + "toString": [Function], + }, ], "override": [Function], "toString": [Function], diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts index 40fbbdf0f8..dde2517095 100644 --- a/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts +++ b/plugins/catalog-react/src/alpha/blueprints/EntityContentBlueprint.ts @@ -26,11 +26,13 @@ import { entityFilterExpressionDataRef, entityContentGroupDataRef, defaultEntityContentGroups, + entityContentIconDataRef, } from './extensionData'; import { EntityPredicate } from '../predicates/types'; import { resolveEntityFilterData } from './resolveEntityFilterData'; import { createEntityPredicateSchema } from '../predicates/createEntityPredicateSchema'; import { Entity } from '@backstage/catalog-model'; +import { ReactElement } from 'react'; /** * @alpha @@ -47,12 +49,14 @@ export const EntityContentBlueprint = createExtensionBlueprint({ entityFilterFunctionDataRef.optional(), entityFilterExpressionDataRef.optional(), entityContentGroupDataRef.optional(), + entityContentIconDataRef.optional(), ], dataRefs: { title: entityContentTitleDataRef, filterFunction: entityFilterFunctionDataRef, filterExpression: entityFilterExpressionDataRef, group: entityContentGroupDataRef, + icon: entityContentIconDataRef, }, config: { schema: { @@ -61,6 +65,7 @@ export const EntityContentBlueprint = createExtensionBlueprint({ filter: z => z.union([z.string(), createEntityPredicateSchema(z)]).optional(), group: z => z.literal(false).or(z.string()).optional(), + icon: z => z.string().optional(), }, }, *factory( @@ -80,6 +85,7 @@ export const EntityContentBlueprint = createExtensionBlueprint({ */ defaultGroup?: [Error: `Use the 'group' param instead`]; group?: keyof typeof defaultEntityContentGroups | (string & {}); + icon?: string | ReactElement; loader: () => Promise; routeRef?: RouteRef; filter?: string | EntityPredicate | ((entity: Entity) => boolean); @@ -91,6 +97,7 @@ export const EntityContentBlueprint = createExtensionBlueprint({ // up by packages that depend on `catalog-react`. const path = config.path ?? params.path ?? params.defaultPath; const title = config.title ?? params.title ?? params.defaultTitle; + const icon = config.icon ?? params.icon; const group = config.group ?? params.group ?? params.defaultGroup; yield coreExtensionData.reactElement( @@ -110,5 +117,8 @@ export const EntityContentBlueprint = createExtensionBlueprint({ if (group && typeof group === 'string') { yield entityContentGroupDataRef(group); } + if (icon) { + yield entityContentIconDataRef(icon); + } }, }); diff --git a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx index c304ccf7eb..83b9aa983b 100644 --- a/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx +++ b/plugins/catalog-react/src/alpha/blueprints/extensionData.tsx @@ -16,12 +16,20 @@ import { Entity } from '@backstage/catalog-model'; import { createExtensionDataRef } from '@backstage/frontend-plugin-api'; +import { ReactElement } from 'react'; /** @internal */ export const entityContentTitleDataRef = createExtensionDataRef().with({ id: 'catalog.entity-content-title', }); +/** @internal */ +export const entityContentIconDataRef = createExtensionDataRef< + string | ReactElement +>().with({ + id: 'catalog.entity-content-icon', +}); + /** @internal */ export const entityFilterFunctionDataRef = createExtensionDataRef< (entity: Entity) => boolean @@ -33,18 +41,50 @@ export const entityFilterExpressionDataRef = id: 'catalog.entity-filter-expression', }); +/** @alpha */ +export type EntityContentGroupDefinitions = Record< + string, + { + title: string; + icon?: string | ReactElement; + } +>; + /** * @alpha * Default entity content groups. */ -export const defaultEntityContentGroups = { - overview: 'Overview', - documentation: 'Documentation', - development: 'Development', - deployment: 'Deployment', - operation: 'Operation', - observability: 'Observability', -}; +export const defaultEntityContentGroupDefinitions = { + overview: { + title: 'Overview', + }, + documentation: { + title: 'Documentation', + }, + development: { + title: 'Development', + }, + deployment: { + title: 'Deployment', + }, + operation: { + title: 'Operation', + }, + observability: { + title: 'Observability', + }, +} satisfies EntityContentGroupDefinitions; + +/** + * @alpha + * Default entity content groups. + * @deprecated use defaultEntityContentGroupDefinitions + */ +export const defaultEntityContentGroups = Object.fromEntries( + Object.entries(defaultEntityContentGroupDefinitions).map( + ([key, { title }]) => [key, title], + ), +) as Record; /** @internal */ export const entityContentGroupDataRef = createExtensionDataRef().with({ diff --git a/plugins/catalog-react/src/alpha/blueprints/index.ts b/plugins/catalog-react/src/alpha/blueprints/index.ts index 640ebb881c..07401018cb 100644 --- a/plugins/catalog-react/src/alpha/blueprints/index.ts +++ b/plugins/catalog-react/src/alpha/blueprints/index.ts @@ -21,7 +21,11 @@ export { type EntityContentLayoutProps, } from './EntityContentLayoutBlueprint'; export { EntityHeaderBlueprint } from './EntityHeaderBlueprint'; -export { defaultEntityContentGroups } from './extensionData'; +export { + defaultEntityContentGroups, + defaultEntityContentGroupDefinitions, + type EntityContentGroupDefinitions, +} from './extensionData'; export type { EntityCardType } from './extensionData'; export { EntityContextMenuItemBlueprint, diff --git a/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.tsx b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.tsx index 605ee96d71..e929845a2c 100644 --- a/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.tsx +++ b/plugins/catalog-react/src/alpha/converters/convertLegacyEntityContentExtension.tsx @@ -26,7 +26,7 @@ import { import { ExtensionDefinition } from '@backstage/frontend-plugin-api'; import kebabCase from 'lodash/kebabCase'; import startCase from 'lodash/startCase'; -import { ComponentType } from 'react'; +import { ComponentType, ReactElement } from 'react'; import { EntityContentBlueprint } from '../blueprints/EntityContentBlueprint'; import { EntityPredicate } from '../predicates/types'; import { Entity } from '@backstage/catalog-model'; @@ -39,6 +39,7 @@ export function convertLegacyEntityContentExtension( filter?: string | EntityPredicate | ((entity: Entity) => boolean); path?: string; title?: string; + icon?: string | ReactElement; /** * @deprecated Use the `path` param instead. @@ -95,6 +96,7 @@ export function convertLegacyEntityContentExtension( title: (overrides?.title ?? overrides?.defaultTitle ?? startCase(infix)) as string, + icon: overrides?.icon, routeRef: mountPoint && convertLegacyRouteRef(mountPoint), loader: async () => compatWrapper(element), }, diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md index 7f68ba4c72..4ad9f94206 100644 --- a/plugins/catalog/report-alpha.api.md +++ b/plugins/catalog/report-alpha.api.md @@ -20,8 +20,10 @@ import { ExternalRouteRef } from '@backstage/core-plugin-api'; import { IconComponent } from '@backstage/frontend-plugin-api'; import { IconLinkVerticalProps } from '@backstage/core-components'; import { JSX as JSX_2 } from 'react'; +import { JSXElementConstructor } from 'react'; import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api'; import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api'; +import { ReactElement } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; import { RouteRef as RouteRef_2 } from '@backstage/frontend-plugin-api'; import { SearchResultItemExtensionComponent } from '@backstage/plugin-search-react/alpha'; @@ -733,12 +735,14 @@ const _default: OverridableFrontendPlugin< title: string | undefined; filter: EntityPredicate | undefined; group: string | false | undefined; + icon: string | undefined; }; configInput: { filter?: EntityPredicate | undefined; title?: string | undefined; path?: string | undefined; group?: string | false | undefined; + icon?: string | undefined; }; output: | ExtensionDataRef @@ -771,6 +775,13 @@ const _default: OverridableFrontendPlugin< { optional: true; } + > + | ExtensionDataRef< + string | ReactElement>, + 'catalog.entity-content-icon', + { + optional: true; + } >; inputs: { layouts: ExtensionInput< @@ -838,6 +849,7 @@ const _default: OverridableFrontendPlugin< title: string; defaultGroup?: [Error: `Use the 'group' param instead`]; group?: keyof defaultEntityContentGroups | (string & {}); + icon?: string | ReactElement; loader: () => Promise; routeRef?: RouteRef_2; filter?: string | EntityPredicate | ((entity: Entity) => boolean); @@ -1024,9 +1036,11 @@ const _default: OverridableFrontendPlugin< string, { title: string; + icon?: string | undefined; } >[] | undefined; + showNavItemIcons: boolean; path: string | undefined; }; configInput: { @@ -1035,9 +1049,11 @@ const _default: OverridableFrontendPlugin< string, { title: string; + icon?: string | undefined; } >[] | undefined; + showNavItemIcons?: boolean | undefined; path?: string | undefined; }; output: @@ -1107,6 +1123,13 @@ const _default: OverridableFrontendPlugin< { optional: true; } + > + | ConfigurableExtensionDataRef< + string | ReactElement>, + 'catalog.entity-content-icon', + { + optional: true; + } >, { singleton: false; diff --git a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx index 750c8567ee..dbe25e9401 100644 --- a/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx +++ b/plugins/catalog/src/alpha/components/EntityLayout/EntityLayout.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { ComponentProps, ReactNode } from 'react'; +import { ComponentProps, ReactNode, ReactElement } from 'react'; import Alert from '@material-ui/lab/Alert'; @@ -40,11 +40,13 @@ import { import { catalogTranslationRef } from '../../translation'; import { EntityHeader } from '../EntityHeader'; import { EntityTabs } from '../EntityTabs'; +import { EntityContentGroupDefinitions } from '@backstage/plugin-catalog-react/alpha'; export type EntityLayoutRouteProps = { path: string; title: string; - group: string; + group?: string; + icon?: string | ReactElement; children: JSX.Element; if?: (entity: Entity) => boolean; }; @@ -77,6 +79,8 @@ export interface EntityLayoutProps { * It adds breadcrumbs in the Entity page to enhance user navigation and context awareness. */ parentEntityRelations?: string[]; + groupDefinitions: EntityContentGroupDefinitions; + showNavItemIcons?: boolean; } /** @@ -105,6 +109,8 @@ export const EntityLayout = (props: EntityLayoutProps) => { header, NotFoundComponent, parentEntityRelations, + groupDefinitions, + showNavItemIcons, } = props; const { kind } = useRouteRefParams(entityRouteRef); const { entity, loading, error } = useAsyncEntity(); @@ -132,6 +138,7 @@ export const EntityLayout = (props: EntityLayoutProps) => { title: elementProps.title, group: elementProps.group, children: elementProps.children, + icon: elementProps.icon, }, ]; }), @@ -153,7 +160,13 @@ export const EntityLayout = (props: EntityLayoutProps) => { {loading && } - {entity && } + {entity && ( + + )} {error && ( diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx index 87e38ad8ee..f0088f688f 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx @@ -13,16 +13,18 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { useMemo } from 'react'; +import { ReactElement, useMemo } from 'react'; import { Helmet } from 'react-helmet'; import { matchRoutes, useParams, useRoutes, Outlet } from 'react-router-dom'; import { EntityTabsPanel } from './EntityTabsPanel'; import { EntityTabsList } from './EntityTabsList'; +import { EntityContentGroupDefinitions } from '@backstage/plugin-catalog-react/alpha'; type SubRoute = { - group: string; + group?: string; path: string; title: string; + icon?: string | ReactElement; children: JSX.Element; }; @@ -77,17 +79,19 @@ export function useSelectedSubRoute(subRoutes: SubRoute[]): { type EntityTabsProps = { routes: SubRoute[]; + groupDefinitions: EntityContentGroupDefinitions; + showIcons?: boolean; }; export function EntityTabs(props: EntityTabsProps) { - const { routes } = props; + const { routes, groupDefinitions, showIcons } = props; const { index, route, element } = useSelectedSubRoute(routes); const tabs = useMemo( () => routes.map(t => { - const { path, title, group } = t; + const { path, title, group, icon } = t; let to = path; // Remove trailing /* to = to.replace(/\/\*$/, ''); @@ -98,6 +102,7 @@ export function EntityTabs(props: EntityTabsProps) { id: path, path: to, label: title, + icon, }; }), [routes], @@ -105,7 +110,12 @@ export function EntityTabs(props: EntityTabsProps) { return ( <> - + {element} diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsGroup.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsGroup.tsx index 564b83bd25..c21b3ef242 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsGroup.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsGroup.tsx @@ -19,17 +19,24 @@ import { useState, MouseEvent, MouseEventHandler, + ReactElement, } from 'react'; import { Link } from 'react-router-dom'; import classnames from 'classnames'; import Typography from '@material-ui/core/Typography'; -import ButtonBase from '@material-ui/core/ButtonBase'; import Popover from '@material-ui/core/Popover'; import { TabProps, TabClassKey } from '@material-ui/core/Tab'; import { capitalize } from '@material-ui/core/utils'; import { createStyles, Theme, withStyles } from '@material-ui/core/styles'; import ExpandMoreIcon from '@material-ui/icons/ExpandMore'; +import Button from '@material-ui/core/Button'; +import ListItem from '@material-ui/core/ListItem'; +import ListItemIcon from '@material-ui/core/ListItemIcon'; +import ListItemText from '@material-ui/core/ListItemText'; +import List from '@material-ui/core/List'; +import { useApi } from '@backstage/core-plugin-api'; +import { IconsApi, iconsApiRef } from '@backstage/frontend-plugin-api'; const styles = (theme: Theme) => createStyles({ @@ -53,9 +60,6 @@ const styles = (theme: Theme) => minWidth: 160, }, }, - popInButton: { - width: '100%', - }, defaultTab: { ...theme.typography.caption, padding: theme.spacing(3, 3), @@ -138,22 +142,41 @@ const styles = (theme: Theme) => type EntityTabsGroupItem = { id: string; - index: number; label: string; path: string; - group: string; + icon?: string | ReactElement; }; type EntityTabsGroupProps = TabProps & { classes?: Partial>; indicator?: ReactNode; - highlightedButton?: number; + highlightedButton?: string; items: EntityTabsGroupItem[]; - onSelectTab: MouseEventHandler; + onSelectTab?: MouseEventHandler; + showIcons?: boolean; }; +function resolveIcon( + icon: string | ReactElement | undefined, + iconsApi: IconsApi, + showIcons: boolean, +) { + if (!showIcons) { + return undefined; + } + if (typeof icon === 'string') { + const Icon = iconsApi.getIcon(icon); + if (Icon) { + return ; + } + return undefined; + } + return icon; +} + const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) { const [anchorEl, setAnchorEl] = useState(null); + const iconsApi = useApi(iconsApiRef); const open = Boolean(anchorEl); const submenuId = open ? 'tabbed-submenu' : undefined; @@ -165,7 +188,6 @@ const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) { disableFocusRipple = false, items, fullWidth, - icon, indicator, label, onSelectTab, @@ -173,8 +195,10 @@ const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) { textColor = 'inherit', wrapped = false, highlightedButton, + showIcons = false, } = props; + const groupIcon = resolveIcon(props.icon, iconsApi, showIcons); const testId = 'data-testid' in props && props['data-testid']; const handleMenuClose = () => { @@ -191,31 +215,24 @@ const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) { classes && { [classes.disabled!]: disabled, [classes.selected!]: selected, - [classes.labelIcon!]: label && icon, + [classes.labelIcon!]: label && groupIcon, [classes.fullWidth!]: fullWidth, [classes.wrapped!]: wrapped, }, className, ]; - const innerButtonClasses = [ - classes?.root, - classes?.[`textColor${capitalize(textColor)}` as TabClassKey], - classes?.defaultTab, - classes && { - [classes.disabled!]: disabled, - [classes.labelIcon!]: label && icon, - [classes.fullWidth!]: fullWidth, - [classes.wrapped!]: wrapped, - }, - ]; - if (items.length === 1) { return ( - - {icon} {items[0].label} {indicator} - + ); } + const hasIcons = showIcons && items.some(i => i.icon); return ( <> - {label} - + - {items.map((i, idx) => ( -
- { - handleMenuClose(); - onSelectTab(e); - }} - to={i.path} - > - - {icon} - {i.label} - - {indicator} - -
- ))} + + {items.map(i => { + const itemIcon = resolveIcon(i.icon, iconsApi, showIcons); + return ( + { + handleMenuClose(); + onSelectTab?.(e); + }} + to={i.path} + > + {itemIcon && {itemIcon}} + + {i.label} + {indicator} + + } + /> + + ); + })} +
); diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx index c5475ed710..dae400ce5d 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx @@ -14,11 +14,12 @@ * limitations under the License. */ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ReactElement, useMemo } from 'react'; import Box from '@material-ui/core/Box'; import Tabs from '@material-ui/core/Tabs'; import { makeStyles } from '@material-ui/core/styles'; import { EntityTabsGroup } from './EntityTabsGroup'; +import { EntityContentGroupDefinitions } from '@backstage/plugin-catalog-react/alpha'; /** @public */ export type HeaderTabsClassKey = @@ -59,51 +60,46 @@ type Tab = { id: string; label: string; path: string; - group: string; + group?: string; + icon?: string | ReactElement; }; -type TabItem = { - group: string; - id: string; - index: number; - label: string; - path: string; +type TabGroup = { + group?: { + title: string; + icon?: string | ReactElement; + }; + items: Array>; }; type EntityTabsListProps = { tabs: Tab[]; + groupDefinitions: EntityContentGroupDefinitions; + showIcons?: boolean; selectedIndex?: number; - onChange?: (index: number) => void; }; export function EntityTabsList(props: EntityTabsListProps) { const styles = useStyles(); - const { tabs: items, onChange, selectedIndex: selectedItem = 0 } = props; + const { tabs: items, selectedIndex = 0, showIcons, groupDefinitions } = props; const groups = useMemo( - () => [...new Set(items.map(item => item.group))], - [items], + () => + items.reduce((result, tab) => { + const group = tab.group ? groupDefinitions[tab.group] : undefined; + const groupOrId = group && tab.group ? tab.group : tab.id; + result[groupOrId] = result[groupOrId] ?? { + group, + items: [], + }; + result[groupOrId].items.push(tab); + return result; + }, {} as Record), + [items, groupDefinitions], ); - const [selectedGroup, setSelectedGroup] = useState( - selectedItem && items[selectedItem] - ? groups.indexOf(items[selectedItem].group) - : 0, - ); - - const handleChange = useCallback( - (index: number) => { - if (selectedItem !== index) onChange?.(index); - }, - [selectedItem, onChange], - ); - - useEffect(() => { - if (selectedItem === undefined || !items[selectedItem]) return; - setSelectedGroup(groups.indexOf(items[selectedItem].group)); - }, [items, selectedItem, groups, setSelectedGroup]); - + const selectedItem = items[selectedIndex]; return ( - {groups.map((group, groupIndex) => { - const groupItems: TabItem[] = []; - items.forEach((item, itemIndex) => { - if (item.group === group) { - groupItems.push({ - ...item, - index: itemIndex, - }); - } - }); - return ( - handleChange(groupIndex)} - /> - ); - })} + {Object.entries(groups).map(([id, tabGroup]) => ( + + ))} ); diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx index b9b8f9ba1f..aafbbd2178 100644 --- a/plugins/catalog/src/alpha/pages.tsx +++ b/plugins/catalog/src/alpha/pages.tsx @@ -25,10 +25,11 @@ import { entityRouteRef, } from '@backstage/plugin-catalog-react'; import { - EntityHeaderBlueprint, + defaultEntityContentGroupDefinitions, EntityContentBlueprint, - defaultEntityContentGroups, EntityContextMenuItemBlueprint, + EntityHeaderBlueprint, + EntityContentGroupDefinitions, } from '@backstage/plugin-catalog-react/alpha'; import { rootRouteRef } from '../routes'; import { useEntityFromUrl } from '../components/CatalogEntityPage/useEntityFromUrl'; @@ -88,6 +89,7 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ EntityContentBlueprint.dataRefs.filterFunction.optional(), EntityContentBlueprint.dataRefs.filterExpression.optional(), EntityContentBlueprint.dataRefs.group.optional(), + EntityContentBlueprint.dataRefs.icon.optional(), ]), contextMenuItems: createExtensionInput([ coreExtensionData.reactElement, @@ -98,8 +100,17 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ schema: { groups: z => z - .array(z.record(z.string(), z.object({ title: z.string() }))) + .array( + z.record( + z.string(), + z.object({ + title: z.string(), + icon: z.string().optional(), + }), + ), + ) .optional(), + showNavItemIcons: z => z.boolean().optional().default(false), }, }, factory(originalFactory, { config, inputs }) { @@ -122,11 +133,6 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ (() => true), })); - type Groups = Record< - string, - { title: string; items: Array<(typeof inputs.contents)[0]> } - >; - // Get available headers, sorted by if they have a filter function or not. // TODO(blam): we should really have priority or some specificity here which can be used to sort the headers. // That can be done with embedding the priority in the dataRef alongside the filter function. @@ -141,39 +147,11 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ return 0; }); - let groups = Object.entries(defaultEntityContentGroups).reduce( - (rest, group) => { - const [groupId, groupValue] = group; - return { - ...rest, - [groupId]: { title: groupValue, items: [] }, - }; - }, - {}, - ); - - // config groups override default groups - if (config.groups) { - groups = config.groups.reduce((rest, group) => { - const [groupId, groupValue] = Object.entries(group)[0]; - return { - ...rest, - [groupId]: { title: groupValue.title, items: [] }, - }; - }, {}); - } - - for (const output of inputs.contents) { - const itemId = output.node.spec.id; - const itemTitle = output.get(EntityContentBlueprint.dataRefs.title); - const itemGroup = output.get(EntityContentBlueprint.dataRefs.group); - const group = itemGroup && groups[itemGroup]; - if (!group) { - groups[itemId] = { title: itemTitle, items: [output] }; - continue; - } - group.items.push(output); - } + const groupDefinitions = + config.groups?.reduce( + (rest, group) => ({ ...rest, ...group }), + {} as EntityContentGroupDefinitions, + ) ?? defaultEntityContentGroupDefinitions; const Component = () => { const entityFromUrl = useEntityFromUrl(); @@ -191,27 +169,28 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ - {Object.values(groups).flatMap(({ title, items }) => - items.map(output => ( - - {output.get(coreExtensionData.reactElement)} - - )), - )} + {inputs.contents.map(output => ( + + {output.get(coreExtensionData.reactElement)} + + ))} ); diff --git a/plugins/kubernetes/report-alpha.api.md b/plugins/kubernetes/report-alpha.api.md index 9b847b862a..020c9580d2 100644 --- a/plugins/kubernetes/report-alpha.api.md +++ b/plugins/kubernetes/report-alpha.api.md @@ -12,8 +12,10 @@ import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha'; import { ExtensionBlueprintParams } from '@backstage/frontend-plugin-api'; import { ExtensionDataRef } from '@backstage/frontend-plugin-api'; import { JSX as JSX_2 } from 'react'; +import { JSXElementConstructor } from 'react'; import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api'; import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api'; +import { ReactElement } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; import { RouteRef as RouteRef_2 } from '@backstage/frontend-plugin-api'; import { TranslationRef } from '@backstage/frontend-plugin-api'; @@ -93,12 +95,14 @@ const _default: OverridableFrontendPlugin< title: string | undefined; filter: EntityPredicate | undefined; group: string | false | undefined; + icon: string | undefined; }; configInput: { filter?: EntityPredicate | undefined; title?: string | undefined; path?: string | undefined; group?: string | false | undefined; + icon?: string | undefined; }; output: | ExtensionDataRef @@ -131,6 +135,13 @@ const _default: OverridableFrontendPlugin< { optional: true; } + > + | ExtensionDataRef< + string | ReactElement>, + 'catalog.entity-content-icon', + { + optional: true; + } >; inputs: {}; params: { @@ -140,6 +151,7 @@ const _default: OverridableFrontendPlugin< title: string; defaultGroup?: [Error: `Use the 'group' param instead`]; group?: keyof defaultEntityContentGroups | (string & {}); + icon?: string | ReactElement; loader: () => Promise; routeRef?: RouteRef_2; filter?: string | EntityPredicate | ((entity: Entity) => boolean); diff --git a/plugins/techdocs/report-alpha.api.md b/plugins/techdocs/report-alpha.api.md index 493d1500da..db712d02b5 100644 --- a/plugins/techdocs/report-alpha.api.md +++ b/plugins/techdocs/report-alpha.api.md @@ -16,8 +16,10 @@ import { ExtensionInput } from '@backstage/frontend-plugin-api'; import { IconComponent } from '@backstage/frontend-plugin-api'; import { IconLinkVerticalProps } from '@backstage/core-components'; import { JSX as JSX_2 } from 'react'; +import { JSXElementConstructor } from 'react'; import { OverridableExtensionDefinition } from '@backstage/frontend-plugin-api'; import { OverridableFrontendPlugin } from '@backstage/frontend-plugin-api'; +import { ReactElement } from 'react'; import { RouteRef } from '@backstage/core-plugin-api'; import { RouteRef as RouteRef_2 } from '@backstage/frontend-plugin-api'; import { SearchResultItemExtensionComponent } from '@backstage/plugin-search-react/alpha'; @@ -126,12 +128,14 @@ const _default: OverridableFrontendPlugin< title: string | undefined; filter: EntityPredicate | undefined; group: string | false | undefined; + icon: string | undefined; }; configInput: { filter?: EntityPredicate | undefined; title?: string | undefined; path?: string | undefined; group?: string | false | undefined; + icon?: string | undefined; }; output: | ExtensionDataRef @@ -164,6 +168,13 @@ const _default: OverridableFrontendPlugin< { optional: true; } + > + | ExtensionDataRef< + string | ReactElement>, + 'catalog.entity-content-icon', + { + optional: true; + } >; inputs: { addons: ExtensionInput< @@ -202,6 +213,7 @@ const _default: OverridableFrontendPlugin< title: string; defaultGroup?: [Error: `Use the 'group' param instead`]; group?: keyof defaultEntityContentGroups | (string & {}); + icon?: string | ReactElement; loader: () => Promise; routeRef?: RouteRef_2; filter?: string | EntityPredicate | ((entity: Entity) => boolean);