Add the ability to show icons for the tabs on the entity page (new frontend)
Signed-off-by: Andreas Berger <andreas@berger-ecommerce.com>
This commit is contained in:
@@ -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)
|
||||
@@ -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 => (
|
||||
<m.ExampleEntityContent />
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: <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,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<JSX.Element>;
|
||||
routeRef?: RouteRef;
|
||||
filter?: string | EntityPredicate | ((entity: Entity) => boolean);
|
||||
@@ -265,6 +269,13 @@ export const EntityContentBlueprint: ExtensionBlueprint<{
|
||||
{
|
||||
optional: true;
|
||||
}
|
||||
>
|
||||
| ExtensionDataRef<
|
||||
string | ReactElement<any, string | JSXElementConstructor<any>>,
|
||||
'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<any, string | JSXElementConstructor<any>>,
|
||||
'catalog.entity-content-icon',
|
||||
{}
|
||||
>;
|
||||
};
|
||||
}>;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,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<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';
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
}),
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<HTMLAnchorElement>;
|
||||
};
|
||||
|
||||
function resolveIcon(
|
||||
icon: string | ReactElement | undefined,
|
||||
iconsApi: IconsApi,
|
||||
) {
|
||||
const itemIcon = icon;
|
||||
if (typeof itemIcon === 'string') {
|
||||
const Icon = iconsApi.getIcon(itemIcon);
|
||||
if (Icon) {
|
||||
return <Icon />;
|
||||
}
|
||||
}
|
||||
return itemIcon;
|
||||
}
|
||||
|
||||
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 +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 (
|
||||
<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 +235,19 @@ const Tab = forwardRef(function Tab(props: EntityTabsGroupProps, ref: any) {
|
||||
component={Link}
|
||||
onClick={onSelectTab}
|
||||
to={items[0]?.path}
|
||||
startIcon={resolveIcon(items[0].icon, iconsApi) ?? groupIcon}
|
||||
>
|
||||
<Typography className={classes?.wrapper} variant="button">
|
||||
{icon}
|
||||
{items[0].label}
|
||||
</Typography>
|
||||
{indicator}
|
||||
</ButtonBase>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
const hasIcons = items.some(i => i.icon);
|
||||
return (
|
||||
<>
|
||||
<ButtonBase
|
||||
<Button
|
||||
data-testid={testId}
|
||||
focusRipple={!disableFocusRipple}
|
||||
className={classnames(classArray)}
|
||||
@@ -243,12 +256,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 +277,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, idx) => {
|
||||
const itemIcon = resolveIcon(i.icon, iconsApi);
|
||||
return (
|
||||
<ListItem
|
||||
key={`popover_item_${idx}`}
|
||||
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.index}
|
||||
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,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<string, Tab['group']>),
|
||||
),
|
||||
[items],
|
||||
);
|
||||
|
||||
const [selectedGroup, setSelectedGroup] = useState<number>(
|
||||
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}
|
||||
|
||||
@@ -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 => (
|
||||
<EntityLayout.Route
|
||||
group={title}
|
||||
group={{ title, icon }}
|
||||
key={output.get(coreExtensionData.routePath)}
|
||||
path={output.get(coreExtensionData.routePath)}
|
||||
title={output.get(EntityContentBlueprint.dataRefs.title)}
|
||||
icon={
|
||||
config.showIcons
|
||||
? output.get(EntityContentBlueprint.dataRefs.icon)
|
||||
: undefined
|
||||
}
|
||||
if={buildFilterFn(
|
||||
output.get(
|
||||
EntityContentBlueprint.dataRefs.filterFunction,
|
||||
|
||||
@@ -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