Merge pull request #31117 from Andy2003/feature/icons-for-tabs-on-entity-page
Add the ability to show icons for the tabs on the entity page (new frontend)
This commit is contained in:
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: <InfoIcon />,
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const notFoundErrorPageModule = createFrontendModule({
|
||||
pluginId: 'app',
|
||||
extensions: [notFoundErrorPage],
|
||||
@@ -131,6 +144,7 @@ const collectedLegacyPlugins = convertLegacyAppRoot(
|
||||
|
||||
const app = createApp({
|
||||
features: [
|
||||
customizedCatalog,
|
||||
pagesPlugin,
|
||||
convertedTechdocsPlugin,
|
||||
userSettingsPlugin,
|
||||
|
||||
@@ -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<string, 'core.routing.path', {}>
|
||||
@@ -373,6 +377,13 @@ const _default: OverridableFrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ExtensionDataRef<
|
||||
string | ReactElement<any, string | JSXElementConstructor<any>>,
|
||||
'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<JSX.Element>;
|
||||
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<string, 'core.routing.path', {}>
|
||||
@@ -433,6 +447,13 @@ const _default: OverridableFrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ExtensionDataRef<
|
||||
string | ReactElement<any, string | JSXElementConstructor<any>>,
|
||||
'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<JSX.Element>;
|
||||
routeRef?: RouteRef_2;
|
||||
filter?: string | EntityPredicate | ((entity: Entity) => boolean);
|
||||
|
||||
@@ -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<JSX.Element>;
|
||||
routeRef?: RouteRef;
|
||||
filter?: string | EntityPredicate | ((entity: Entity) => boolean);
|
||||
@@ -265,6 +287,13 @@ export const EntityContentBlueprint: ExtensionBlueprint<{
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ExtensionDataRef<
|
||||
string | ReactElement<any, string | JSXElementConstructor<any>>,
|
||||
'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<any, string | JSXElementConstructor<any>>,
|
||||
'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';
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<JSX.Element>;
|
||||
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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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<string>().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<keyof typeof defaultEntityContentGroupDefinitions, string>;
|
||||
|
||||
/** @internal */
|
||||
export const entityContentGroupDataRef = createExtensionDataRef<string>().with({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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<string, 'core.routing.path', {}>
|
||||
@@ -771,6 +775,13 @@ const _default: OverridableFrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ExtensionDataRef<
|
||||
string | ReactElement<any, string | JSXElementConstructor<any>>,
|
||||
'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<JSX.Element>;
|
||||
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<any, string | JSXElementConstructor<any>>,
|
||||
'catalog.entity-content-icon',
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>,
|
||||
{
|
||||
singleton: false;
|
||||
|
||||
@@ -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 && <Progress />}
|
||||
|
||||
{entity && <EntityTabs routes={routes} />}
|
||||
{entity && (
|
||||
<EntityTabs
|
||||
routes={routes}
|
||||
groupDefinitions={groupDefinitions}
|
||||
showIcons={showNavItemIcons}
|
||||
/>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Content>
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<EntityTabsList tabs={tabs} selectedIndex={index} />
|
||||
<EntityTabsList
|
||||
tabs={tabs}
|
||||
selectedIndex={index}
|
||||
showIcons={showIcons}
|
||||
groupDefinitions={groupDefinitions}
|
||||
/>
|
||||
<EntityTabsPanel>
|
||||
<Helmet title={route?.title} />
|
||||
{element}
|
||||
|
||||
@@ -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<ReturnType<typeof styles>>;
|
||||
indicator?: ReactNode;
|
||||
highlightedButton?: number;
|
||||
highlightedButton?: string;
|
||||
items: EntityTabsGroupItem[];
|
||||
onSelectTab: MouseEventHandler<HTMLAnchorElement>;
|
||||
onSelectTab?: MouseEventHandler<HTMLAnchorElement>;
|
||||
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 <Icon />;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
return icon;
|
||||
}
|
||||
|
||||
const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) {
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(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 (
|
||||
<ButtonBase
|
||||
<Button
|
||||
focusRipple={!disableFocusRipple}
|
||||
data-testid={testId}
|
||||
className={classnames(classArray)}
|
||||
className={classnames(
|
||||
classArray,
|
||||
classes && {
|
||||
[classes.labelIcon!]: label && (items[0].icon ?? groupIcon),
|
||||
},
|
||||
)}
|
||||
ref={ref}
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
@@ -223,18 +240,19 @@ const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) {
|
||||
component={Link}
|
||||
onClick={onSelectTab}
|
||||
to={items[0]?.path}
|
||||
startIcon={resolveIcon(items[0].icon, iconsApi, showIcons)}
|
||||
>
|
||||
<Typography className={classes?.wrapper} variant="button">
|
||||
{icon}
|
||||
{items[0].label}
|
||||
</Typography>
|
||||
{indicator}
|
||||
</ButtonBase>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
const hasIcons = showIcons && items.some(i => i.icon);
|
||||
return (
|
||||
<>
|
||||
<ButtonBase
|
||||
<Button
|
||||
data-testid={testId}
|
||||
focusRipple={!disableFocusRipple}
|
||||
className={classnames(classArray)}
|
||||
@@ -243,12 +261,13 @@ const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) {
|
||||
aria-selected={selected}
|
||||
disabled={disabled}
|
||||
onClick={handleMenuClick}
|
||||
startIcon={groupIcon}
|
||||
>
|
||||
<Typography className={classes?.wrapper} variant="button">
|
||||
{label}
|
||||
</Typography>
|
||||
<ExpandMoreIcon />
|
||||
</ButtonBase>
|
||||
</Button>
|
||||
<Popover
|
||||
id={submenuId}
|
||||
open={open}
|
||||
@@ -263,35 +282,44 @@ const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) {
|
||||
horizontal: 'center',
|
||||
}}
|
||||
>
|
||||
{items.map((i, idx) => (
|
||||
<div key={`popover_item_${idx}`}>
|
||||
<ButtonBase
|
||||
focusRipple={!disableFocusRipple}
|
||||
className={classnames(
|
||||
innerButtonClasses,
|
||||
classes?.popInButton,
|
||||
highlightedButton === i.index
|
||||
? classes?.selectedButton
|
||||
: classes?.unselectedButton,
|
||||
)}
|
||||
ref={ref}
|
||||
aria-selected={selected}
|
||||
disabled={disabled}
|
||||
component={Link}
|
||||
onClick={e => {
|
||||
handleMenuClose();
|
||||
onSelectTab(e);
|
||||
}}
|
||||
to={i.path}
|
||||
>
|
||||
<Typography className={classes?.wrapper} variant="button">
|
||||
{icon}
|
||||
{i.label}
|
||||
</Typography>
|
||||
{indicator}
|
||||
</ButtonBase>
|
||||
</div>
|
||||
))}
|
||||
<List component="nav">
|
||||
{items.map(i => {
|
||||
const itemIcon = resolveIcon(i.icon, iconsApi, showIcons);
|
||||
return (
|
||||
<ListItem
|
||||
key={`popover_item_${i.id}`}
|
||||
button
|
||||
focusRipple={!disableFocusRipple}
|
||||
classes={{
|
||||
selected: classnames(classes?.selectedButton),
|
||||
default: classnames(classes?.unselectedButton),
|
||||
disabled: classnames(classes?.disabled),
|
||||
}}
|
||||
ref={ref}
|
||||
aria-selected={selected}
|
||||
disabled={disabled}
|
||||
selected={highlightedButton === i.id}
|
||||
component={Link}
|
||||
onClick={e => {
|
||||
handleMenuClose();
|
||||
onSelectTab?.(e);
|
||||
}}
|
||||
to={i.path}
|
||||
>
|
||||
{itemIcon && <ListItemIcon>{itemIcon}</ListItemIcon>}
|
||||
<ListItemText
|
||||
inset={!itemIcon && hasIcons}
|
||||
primary={
|
||||
<>
|
||||
<Typography variant="button">{i.label}</Typography>
|
||||
{indicator}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Popover>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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<Omit<Tab, 'group'>>;
|
||||
};
|
||||
|
||||
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<string, TabGroup>),
|
||||
[items, groupDefinitions],
|
||||
);
|
||||
|
||||
const [selectedGroup, setSelectedGroup] = useState<number>(
|
||||
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 (
|
||||
<Box className={styles.tabsWrapper}>
|
||||
<Tabs
|
||||
@@ -113,32 +109,22 @@ export function EntityTabsList(props: EntityTabsListProps) {
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
aria-label="tabs"
|
||||
value={selectedGroup}
|
||||
value={selectedItem?.group ?? selectedItem?.id}
|
||||
>
|
||||
{groups.map((group, groupIndex) => {
|
||||
const groupItems: TabItem[] = [];
|
||||
items.forEach((item, itemIndex) => {
|
||||
if (item.group === group) {
|
||||
groupItems.push({
|
||||
...item,
|
||||
index: itemIndex,
|
||||
});
|
||||
}
|
||||
});
|
||||
return (
|
||||
<EntityTabsGroup
|
||||
data-testid={`header-tab-${groupIndex}`}
|
||||
className={styles.defaultTab}
|
||||
classes={{ selected: styles.selected, root: styles.tabRoot }}
|
||||
key={group}
|
||||
label={group}
|
||||
value={groupIndex}
|
||||
items={groupItems}
|
||||
highlightedButton={selectedItem}
|
||||
onSelectTab={() => handleChange(groupIndex)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{Object.entries(groups).map(([id, tabGroup]) => (
|
||||
<EntityTabsGroup
|
||||
data-testid={`header-tab-${id}`}
|
||||
className={styles.defaultTab}
|
||||
classes={{ selected: styles.selected, root: styles.tabRoot }}
|
||||
key={id}
|
||||
label={tabGroup.group?.title}
|
||||
icon={tabGroup.group?.icon}
|
||||
value={id}
|
||||
items={tabGroup.items}
|
||||
highlightedButton={selectedItem?.id}
|
||||
showIcons={showIcons}
|
||||
/>
|
||||
))}
|
||||
</Tabs>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -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<Groups>(
|
||||
(rest, group) => {
|
||||
const [groupId, groupValue] = group;
|
||||
return {
|
||||
...rest,
|
||||
[groupId]: { title: groupValue, items: [] },
|
||||
};
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
// config groups override default groups
|
||||
if (config.groups) {
|
||||
groups = config.groups.reduce<Groups>((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({
|
||||
<EntityLayout
|
||||
header={header}
|
||||
contextMenuItems={filteredMenuItems}
|
||||
groupDefinitions={groupDefinitions}
|
||||
showNavItemIcons={config.showNavItemIcons}
|
||||
>
|
||||
{Object.values(groups).flatMap(({ title, items }) =>
|
||||
items.map(output => (
|
||||
<EntityLayout.Route
|
||||
group={title}
|
||||
key={output.get(coreExtensionData.routePath)}
|
||||
path={output.get(coreExtensionData.routePath)}
|
||||
title={output.get(EntityContentBlueprint.dataRefs.title)}
|
||||
if={buildFilterFn(
|
||||
output.get(
|
||||
EntityContentBlueprint.dataRefs.filterFunction,
|
||||
),
|
||||
output.get(
|
||||
EntityContentBlueprint.dataRefs.filterExpression,
|
||||
),
|
||||
)}
|
||||
>
|
||||
{output.get(coreExtensionData.reactElement)}
|
||||
</EntityLayout.Route>
|
||||
)),
|
||||
)}
|
||||
{inputs.contents.map(output => (
|
||||
<EntityLayout.Route
|
||||
group={output.get(EntityContentBlueprint.dataRefs.group)}
|
||||
key={output.get(coreExtensionData.routePath)}
|
||||
path={output.get(coreExtensionData.routePath)}
|
||||
title={output.get(EntityContentBlueprint.dataRefs.title)}
|
||||
icon={output.get(EntityContentBlueprint.dataRefs.icon)}
|
||||
if={buildFilterFn(
|
||||
output.get(
|
||||
EntityContentBlueprint.dataRefs.filterFunction,
|
||||
),
|
||||
output.get(
|
||||
EntityContentBlueprint.dataRefs.filterExpression,
|
||||
),
|
||||
)}
|
||||
>
|
||||
{output.get(coreExtensionData.reactElement)}
|
||||
</EntityLayout.Route>
|
||||
))}
|
||||
</EntityLayout>
|
||||
</AsyncEntityProvider>
|
||||
);
|
||||
|
||||
@@ -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<string, 'core.routing.path', {}>
|
||||
@@ -131,6 +135,13 @@ const _default: OverridableFrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ExtensionDataRef<
|
||||
string | ReactElement<any, string | JSXElementConstructor<any>>,
|
||||
'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<JSX.Element>;
|
||||
routeRef?: RouteRef_2;
|
||||
filter?: string | EntityPredicate | ((entity: Entity) => boolean);
|
||||
|
||||
@@ -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<string, 'core.routing.path', {}>
|
||||
@@ -164,6 +168,13 @@ const _default: OverridableFrontendPlugin<
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ExtensionDataRef<
|
||||
string | ReactElement<any, string | JSXElementConstructor<any>>,
|
||||
'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<JSX.Element>;
|
||||
routeRef?: RouteRef_2;
|
||||
filter?: string | EntityPredicate | ((entity: Entity) => boolean);
|
||||
|
||||
Reference in New Issue
Block a user