diff --git a/.changeset/light-meals-cover.md b/.changeset/light-meals-cover.md new file mode 100644 index 0000000000..80293d1941 --- /dev/null +++ b/.changeset/light-meals-cover.md @@ -0,0 +1,10 @@ +--- +'@backstage/plugin-catalog-react': minor +'@backstage/plugin-kubernetes': minor +'example-app-next': minor +'@backstage/plugin-api-docs': minor +'@backstage/plugin-techdocs': minor +'@backstage/plugin-catalog': minor +--- + +Add the ability to show icons for the tabs on the entity page (new frontend) diff --git a/docs/frontend-system/building-plugins/01-index.md b/docs/frontend-system/building-plugins/01-index.md index d9acf76e15..735e37c89b 100644 --- a/docs/frontend-system/building-plugins/01-index.md +++ b/docs/frontend-system/building-plugins/01-index.md @@ -198,6 +198,15 @@ const exampleEntityContent = EntityContentBlueprint.make({ params: { path: 'example', title: 'Example', + // Optional: associate this content with a group on the entity page tabs + // Use a known default group id like "overview", "quality", "documentation", + // or provide a custom string. You can also override or disable this via app-config (see note below). + // group: 'overview', + // Optional: set a tab icon. When using a string, the icon is resolved via the IconsApi. + // Ensure your app has icon bundles enabled/installed so the icon id is available. + // Note: icons are shown in the entity page tab groups only if `showIcons` is enabled in the + // catalog entity page config (page:catalog/entity) via app-config. + // icon: 'dashboard', loader: () => import('./components/ExampleEntityContent').then(m => ( 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..3b8ad29da0 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` in addition to `path`, `title`, `loader`, `filter`, and `routeRef`: + +- 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 | ReactElement — 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 `showIcons: 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 + showIcons: 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..3fdcab269d 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: + showIcons: 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..bc8017de5b 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 { default as 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..eff709a7a7 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,6 +145,7 @@ 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`]; }, @@ -230,6 +233,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 +269,13 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ { optional: true; } + > + | ExtensionDataRef< + string | ReactElement>, + 'catalog.entity-content-icon', + { + optional: true; + } >; inputs: {}; config: { @@ -272,12 +283,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,6 +313,11 @@ export const EntityContentBlueprint: ExtensionBlueprint<{ 'catalog.entity-content-group', {} >; + icon: ConfigurableExtensionDataRef< + string | ReactElement>, + 'catalog.entity-content-icon', + {} + >; }; }>; 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..bf85810ff5 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 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..f19dcd5f3c 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,12 @@ const _default: OverridableFrontendPlugin< string, { title: string; + icon?: string | undefined; } >[] | undefined; + showIcons: boolean; + } & { path: string | undefined; }; configInput: { @@ -1035,9 +1050,12 @@ const _default: OverridableFrontendPlugin< string, { title: string; + icon?: string | undefined; } >[] | undefined; + showIcons?: boolean | undefined; + } & { path?: string | undefined; }; output: @@ -1107,6 +1125,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..d02a0b1193 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'; @@ -44,7 +44,8 @@ import { EntityTabs } from '../EntityTabs'; export type EntityLayoutRouteProps = { path: string; title: string; - group: string; + group: string | { title: string; icon?: string }; + icon?: string | ReactElement; children: JSX.Element; if?: (entity: Entity) => boolean; }; @@ -132,6 +133,7 @@ export const EntityLayout = (props: EntityLayoutProps) => { title: elementProps.title, group: elementProps.group, children: elementProps.children, + icon: elementProps.icon, }, ]; }), diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx index 87e38ad8ee..cb4c64300a 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabs.tsx @@ -13,16 +13,17 @@ * 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'; type SubRoute = { - group: string; + group: string | { title: string; icon?: string }; path: string; title: string; + icon?: string | ReactElement; children: JSX.Element; }; @@ -87,17 +88,18 @@ export function EntityTabs(props: EntityTabsProps) { 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(/\/\*$/, ''); // And remove leading / for relative navigation to = to.replace(/^\//, ''); return { - group, + group: typeof group === 'string' ? { title: group } : group, id: path, path: to, label: title, + icon: icon, }; }), [routes], diff --git a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsGroup.tsx b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsGroup.tsx index 564b83bd25..bc2e4f92b9 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), @@ -141,7 +145,7 @@ type EntityTabsGroupItem = { index: number; label: string; path: string; - group: string; + icon?: string | ReactElement; }; type EntityTabsGroupProps = TabProps & { @@ -152,8 +156,23 @@ type EntityTabsGroupProps = TabProps & { onSelectTab: MouseEventHandler; }; +function resolveIcon( + icon: string | ReactElement | undefined, + iconsApi: IconsApi, +) { + const itemIcon = icon; + if (typeof itemIcon === 'string') { + const Icon = iconsApi.getIcon(itemIcon); + if (Icon) { + return ; + } + } + return itemIcon; +} + 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 +184,6 @@ const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) { disableFocusRipple = false, items, fullWidth, - icon, indicator, label, onSelectTab, @@ -175,6 +193,7 @@ const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) { highlightedButton, } = props; + const groupIcon = resolveIcon(props.icon, iconsApi); const testId = 'data-testid' in props && props['data-testid']; const handleMenuClose = () => { @@ -191,31 +210,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 = items.some(i => i.icon); return ( <> - {label} - + - {items.map((i, idx) => ( -
- { - handleMenuClose(); - onSelectTab(e); - }} - to={i.path} - > - - {icon} - {i.label} - - {indicator} - -
- ))} + + {items.map((i, idx) => { + const itemIcon = resolveIcon(i.icon, iconsApi); + 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..1228d539ed 100644 --- a/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx +++ b/plugins/catalog/src/alpha/components/EntityTabs/EntityTabsList.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import { useCallback, useEffect, useMemo, useState } from 'react'; +import { ReactElement, useCallback, useEffect, useMemo, useState } from 'react'; import Box from '@material-ui/core/Box'; import Tabs from '@material-ui/core/Tabs'; import { makeStyles } from '@material-ui/core/styles'; @@ -59,15 +59,12 @@ type Tab = { id: string; label: string; path: string; - group: string; + group: { title: string; icon?: string }; + icon?: string | ReactElement; }; -type TabItem = { - group: string; - id: string; +type TabItem = Tab & { index: number; - label: string; - path: string; }; type EntityTabsListProps = { @@ -82,13 +79,21 @@ export function EntityTabsList(props: EntityTabsListProps) { const { tabs: items, onChange, selectedIndex: selectedItem = 0 } = props; const groups = useMemo( - () => [...new Set(items.map(item => item.group))], + () => + Object.values( + items.reduce((result, i) => { + result[i.group.title] = i.group; + return result; + }, {} as Record), + ), [items], ); const [selectedGroup, setSelectedGroup] = useState( selectedItem && items[selectedItem] - ? groups.indexOf(items[selectedItem].group) + ? groups.findIndex( + ({ title }) => title === items[selectedItem].group.title, + ) : 0, ); @@ -101,7 +106,11 @@ export function EntityTabsList(props: EntityTabsListProps) { useEffect(() => { if (selectedItem === undefined || !items[selectedItem]) return; - setSelectedGroup(groups.indexOf(items[selectedItem].group)); + setSelectedGroup( + groups.findIndex( + ({ title }) => title === items[selectedItem].group.title, + ), + ); }, [items, selectedItem, groups, setSelectedGroup]); return ( @@ -118,7 +127,7 @@ export function EntityTabsList(props: EntityTabsListProps) { {groups.map((group, groupIndex) => { const groupItems: TabItem[] = []; items.forEach((item, itemIndex) => { - if (item.group === group) { + if (item.group.title === group.title) { groupItems.push({ ...item, index: itemIndex, @@ -130,8 +139,9 @@ export function EntityTabsList(props: EntityTabsListProps) { data-testid={`header-tab-${groupIndex}`} className={styles.defaultTab} classes={{ selected: styles.selected, root: styles.tabRoot }} - key={group} - label={group} + key={group.title} + label={group.title} + icon={group.icon} value={groupIndex} items={groupItems} highlightedButton={selectedItem} diff --git a/plugins/catalog/src/alpha/pages.tsx b/plugins/catalog/src/alpha/pages.tsx index b9b8f9ba1f..c3fd9aa773 100644 --- a/plugins/catalog/src/alpha/pages.tsx +++ b/plugins/catalog/src/alpha/pages.tsx @@ -88,6 +88,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 +99,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(), + showIcons: z => z.boolean().optional().default(false), }, }, factory(originalFactory, { config, inputs }) { @@ -124,7 +134,11 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ type Groups = Record< string, - { title: string; items: Array<(typeof inputs.contents)[0]> } + { + title: string; + icon?: string; + items: Array<(typeof inputs.contents)[0]>; + } >; // Get available headers, sorted by if they have a filter function or not. @@ -158,7 +172,11 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ const [groupId, groupValue] = Object.entries(group)[0]; return { ...rest, - [groupId]: { title: groupValue.title, items: [] }, + [groupId]: { + title: groupValue.title, + icon: config.showIcons ? groupValue.icon : undefined, + items: [], + }, }; }, {}); } @@ -192,13 +210,18 @@ export const catalogEntityPage = PageBlueprint.makeWithOverrides({ header={header} contextMenuItems={filteredMenuItems} > - {Object.values(groups).flatMap(({ title, items }) => + {Object.values(groups).flatMap(({ title, icon, items }) => items.map(output => ( @@ -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);