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}
-
+
>
);
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);