feat: create entity icon link extensions

Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
Camila Belo
2025-05-26 09:09:02 +02:00
parent 79e446d190
commit 406acb6d61
14 changed files with 460 additions and 62 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': minor
---
The `IconLinkVertical` component now includes an optional hide property. When set to `true`, this property completely hides the icon element.
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': minor
---
Add support to customize the about card icon links via `EntityIconLinkBlueprint` and provide a default catalog view catalog source, launch scaffolder template and read techdocs docs icon links extensions.
+71
View File
@@ -0,0 +1,71 @@
---
'@backstage/plugin-catalog-react': minor
---
Introduces a new `EntityIconLinkBlueprint` that customizes the `About` card icon links on the `Catalog` entity page.
The blueprint currently accepts the following `params`:
| Name | Description | Type | Default Value |
| ---------- | --------------------------------------------------- | -------------------------- | ------------- |
| `icon` | The icon to display. | `JSX.Element` | N/A |
| `label` | The label for the element. | `string` | N/A |
| `title` | The title for the element. | `string` | N/A |
| `color` | The color of the element. | `'primary' \| 'secondary'` | `primary` |
| `disabled` | Whether the element is disabled. | `boolean` | `false` |
| `href` | The URL to navigate to when the element is clicked. | `string` | N/A |
| `onClick` | A function to call when the element is clicked. | `() => void` | N/A |
| `hidden` | Whether the element is hidden. | `boolean` | `false` |
Here is an usage example:
```tsx
import { EntityIconLinkBlueprint } from '@backstage/plugin-catalog-react/alpha';
//...
// Defining the icon link properties using an object
EntityIconLinkBlueprint.make({
name: 'my-icon-link',
params: {
props: {
label: 'My Icon Link Label',
icon: <MyIconLinkIcon />,
href: '/my-plugin',
},
},
});
```
or
```tsx
import { EntityIconLinkBlueprint } from '@backstage/plugin-catalog-react/alpha';
//...
// Defining the icon link properties using a function
EntityIconLinkBlueprint.make({
name: 'my-icon-link',
params: {
props: function useMyIconLinkProps() {
// Use a function when you would like use a hook for defining the
// icon link props on runtime
const { t } = useTranslationRef(myIconLinkTranslationRef);
return {
label: t('myIconLink.label'),
icon: <MyIconLinkIcon />,
href: '/my-plugin',
};
},
},
});
```
Additionally, the `app-config.yaml` file allows you to override some of the default icon link parameters, including `label`, `title`, `color`, `href`, `disabled`, and `hidden` values. Here's how to set them:
```yaml
app:
extensions:
- entity-icon-link:my-plugin/my-icon-link:
config:
label: 'My Custom Icon Link label'
```
+3 -1
View File
@@ -582,7 +582,8 @@ export function IconLinkVertical({
label,
onClick,
title,
}: IconLinkVerticalProps): JSX_2.Element;
hidden,
}: IconLinkVerticalProps): JSX_2.Element | null;
// @public (undocumented)
export type IconLinkVerticalClassKey =
@@ -603,6 +604,7 @@ export type IconLinkVerticalProps = {
label: string;
onClick?: MouseEventHandler<HTMLAnchorElement>;
title?: string;
hidden?: boolean;
};
// @public (undocumented)
@@ -29,6 +29,7 @@ export type IconLinkVerticalProps = {
label: string;
onClick?: MouseEventHandler<HTMLAnchorElement>;
title?: string;
hidden?: boolean;
};
/** @public */
@@ -75,9 +76,14 @@ export function IconLinkVertical({
label,
onClick,
title,
hidden,
}: IconLinkVerticalProps) {
const classes = useIconStyles();
if (hidden) {
return null;
}
if (disabled) {
return (
<Box title={title} className={classnames(classes.link, classes.disabled)}>
+39
View File
@@ -9,6 +9,7 @@ import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { ExtensionBlueprint } from '@backstage/frontend-plugin-api';
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 { ReactNode } from 'react';
@@ -390,6 +391,44 @@ export const EntityHeaderBlueprint: ExtensionBlueprint<{
};
}>;
// @alpha (undocumented)
export const EntityIconLinkBlueprint: ExtensionBlueprint<{
kind: 'entity-icon-link';
name: undefined;
params: {
props: IconLinkVerticalProps | (() => IconLinkVerticalProps);
};
output: ConfigurableExtensionDataRef<
() => IconLinkVerticalProps,
'entity-icon-link-props',
{}
>;
inputs: {};
config: {
label: string | undefined;
title: string | undefined;
color: 'primary' | 'secondary' | undefined;
href: string | undefined;
hidden: boolean | undefined;
disabled: boolean | undefined;
};
configInput: {
color?: 'primary' | 'secondary' | undefined;
hidden?: boolean | undefined;
label?: string | undefined;
title?: string | undefined;
disabled?: boolean | undefined;
href?: string | undefined;
};
dataRefs: {
props: ConfigurableExtensionDataRef<
() => IconLinkVerticalProps,
'entity-icon-link-props',
{}
>;
};
}>;
// @alpha (undocumented)
export type EntityPredicate =
| EntityPredicateExpression
@@ -0,0 +1,71 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { IconLinkVerticalProps } from '@backstage/core-components';
import {
createExtensionBlueprint,
createExtensionDataRef,
} from '@backstage/frontend-plugin-api';
const entityIconLinkPropsDataRef = createExtensionDataRef<
() => IconLinkVerticalProps
>().with({
id: 'entity-icon-link-props',
});
/** @alpha */
export const EntityIconLinkBlueprint = createExtensionBlueprint({
kind: 'entity-icon-link',
attachTo: { id: 'entity-card:catalog/about', input: 'iconLinks' },
output: [entityIconLinkPropsDataRef],
dataRefs: {
props: entityIconLinkPropsDataRef,
},
config: {
schema: {
label: z => z.string().optional(),
title: z => z.string().optional(),
color: z => z.enum(['primary', 'secondary']).optional(),
href: z => z.string().optional(),
hidden: z => z.boolean().optional(),
disabled: z => z.boolean().optional(),
},
},
*factory(
params: {
props: IconLinkVerticalProps | (() => IconLinkVerticalProps);
},
{ config },
) {
yield entityIconLinkPropsDataRef(() => ({
...(typeof params.props === 'function' ? params.props() : params.props),
...Object.entries(config).reduce((rest, [key, value]) => {
// Only include properties that are defined in the config
// to avoid overriding defaults with undefined values
if (value !== undefined) {
return {
...rest,
[key]: value,
// Removing the "onClick" handler if href is provided prevents the handler
// from redirecting to a different page
...(key === 'href' ? { onClick: undefined } : {}),
};
}
return rest;
}, {}),
}));
},
});
@@ -28,3 +28,4 @@ export {
type EntityContextMenuItemParams,
type UseProps,
} from './EntityContextMenuItemBlueprint';
export { EntityIconLinkBlueprint } from './EntityIconLinkBlueprint';
+103 -3
View File
@@ -18,6 +18,7 @@ import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
import { FrontendPlugin } from '@backstage/frontend-plugin-api';
import { IconComponent } from '@backstage/core-plugin-api';
import { IconLinkVerticalProps } from '@backstage/core-components';
import { JSX as JSX_2 } from 'react';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { SearchResultItemExtensionComponent } from '@backstage/plugin-search-react/alpha';
@@ -336,8 +337,6 @@ const _default: FrontendPlugin<
};
}>;
'entity-card:catalog/about': ExtensionDefinition<{
kind: 'entity-card';
name: 'about';
config: {
filter: EntityPredicate | undefined;
type: 'content' | 'summary' | 'info' | undefined;
@@ -369,7 +368,21 @@ const _default: FrontendPlugin<
optional: true;
}
>;
inputs: {};
inputs: {
iconLinks: ExtensionInput<
ConfigurableExtensionDataRef<
() => IconLinkVerticalProps,
'entity-icon-link-props',
{}
>,
{
singleton: false;
optional: false;
}
>;
};
kind: 'entity-card';
name: 'about';
params: {
loader: () => Promise<JSX.Element>;
filter?: string | EntityPredicate | ((entity: Entity) => boolean);
@@ -923,6 +936,93 @@ const _default: FrontendPlugin<
inputs: {};
params: EntityContextMenuItemParams;
}>;
'entity-icon-link:catalog/catalog-view-source': ExtensionDefinition<{
kind: 'entity-icon-link';
name: 'catalog-view-source';
config: {
label: string | undefined;
title: string | undefined;
color: 'primary' | 'secondary' | undefined;
href: string | undefined;
hidden: boolean | undefined;
disabled: boolean | undefined;
};
configInput: {
color?: 'primary' | 'secondary' | undefined;
hidden?: boolean | undefined;
label?: string | undefined;
title?: string | undefined;
disabled?: boolean | undefined;
href?: string | undefined;
};
output: ConfigurableExtensionDataRef<
() => IconLinkVerticalProps,
'entity-icon-link-props',
{}
>;
inputs: {};
params: {
props: IconLinkVerticalProps | (() => IconLinkVerticalProps);
};
}>;
'entity-icon-link:catalog/scaffolder-launch-template': ExtensionDefinition<{
kind: 'entity-icon-link';
name: 'scaffolder-launch-template';
config: {
label: string | undefined;
title: string | undefined;
color: 'primary' | 'secondary' | undefined;
href: string | undefined;
hidden: boolean | undefined;
disabled: boolean | undefined;
};
configInput: {
color?: 'primary' | 'secondary' | undefined;
hidden?: boolean | undefined;
label?: string | undefined;
title?: string | undefined;
disabled?: boolean | undefined;
href?: string | undefined;
};
output: ConfigurableExtensionDataRef<
() => IconLinkVerticalProps,
'entity-icon-link-props',
{}
>;
inputs: {};
params: {
props: IconLinkVerticalProps | (() => IconLinkVerticalProps);
};
}>;
'entity-icon-link:catalog/techdocs-view-documentation': ExtensionDefinition<{
kind: 'entity-icon-link';
name: 'techdocs-view-documentation';
config: {
label: string | undefined;
title: string | undefined;
color: 'primary' | 'secondary' | undefined;
href: string | undefined;
hidden: boolean | undefined;
disabled: boolean | undefined;
};
configInput: {
color?: 'primary' | 'secondary' | undefined;
hidden?: boolean | undefined;
label?: string | undefined;
title?: string | undefined;
disabled?: boolean | undefined;
href?: string | undefined;
};
output: ConfigurableExtensionDataRef<
() => IconLinkVerticalProps,
'entity-icon-link-props',
{}
>;
inputs: {};
params: {
props: IconLinkVerticalProps | (() => IconLinkVerticalProps);
};
}>;
'nav-item:catalog': ExtensionDefinition<{
kind: 'nav-item';
name: undefined;
+2
View File
@@ -42,6 +42,8 @@ import { UserListFilterKind } from '@backstage/plugin-catalog-react';
// @public
export interface AboutCardProps {
// (undocumented)
subheader?: JSX.Element;
// (undocumented)
variant?: InfoCardVariants;
}
+28 -8
View File
@@ -14,17 +14,37 @@
* limitations under the License.
*/
import { EntityCardBlueprint } from '@backstage/plugin-catalog-react/alpha';
import {
EntityIconLinkBlueprint,
EntityCardBlueprint,
} from '@backstage/plugin-catalog-react/alpha';
import { compatWrapper } from '@backstage/core-compat-api';
import { createExtensionInput } from '@backstage/frontend-plugin-api';
import { HeaderIconLinkRow } from '@backstage/core-components';
export const catalogAboutEntityCard = EntityCardBlueprint.make({
export const catalogAboutEntityCard = EntityCardBlueprint.makeWithOverrides({
name: 'about',
params: {
type: 'info',
loader: async () =>
import('../components/AboutCard').then(m =>
compatWrapper(<m.AboutCard variant="gridItem" />),
),
inputs: {
iconLinks: createExtensionInput([EntityIconLinkBlueprint.dataRefs.props]),
},
factory(originalFactory, { inputs }) {
function Subheader() {
// The props input functions may be calling other hooks, so we need to
// call them in a component function to avoid breaking the rules of hooks.
const links = inputs.iconLinks.map(iconLink =>
iconLink.get(EntityIconLinkBlueprint.dataRefs.props)(),
);
return <HeaderIconLinkRow links={links} />;
}
return originalFactory({
type: 'info',
async loader() {
const { AboutCard } = await import('../components/AboutCard');
return compatWrapper(
<AboutCard variant="gridItem" subheader={<Subheader />} />,
);
},
});
},
});
@@ -0,0 +1,49 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EntityIconLinkBlueprint } from '@backstage/plugin-catalog-react/alpha';
import {
useCatalogViewSourceEntityIconLinkProps,
useScaffolderLaunchTemplateEntityIconLinkProps,
useTechdocsViewDocumentionIconLinkProps,
} from '../components/AboutCard/AboutCard';
const catalogViewSourceEntityIconLink = EntityIconLinkBlueprint.make({
name: 'catalog-view-source',
params: {
props: useCatalogViewSourceEntityIconLinkProps,
},
});
const techdocsViewDocumentationAboutEntityLink = EntityIconLinkBlueprint.make({
name: 'techdocs-view-documentation',
params: {
props: useTechdocsViewDocumentionIconLinkProps,
},
});
const scaffolderLaunchTemplateEntityIconLink = EntityIconLinkBlueprint.make({
name: 'scaffolder-launch-template',
params: {
props: useScaffolderLaunchTemplateEntityIconLinkProps,
},
});
export default [
catalogViewSourceEntityIconLink,
techdocsViewDocumentationAboutEntityLink,
scaffolderLaunchTemplateEntityIconLink,
];
+2
View File
@@ -33,6 +33,7 @@ import filters from './filters';
import navItems from './navItems';
import entityCards from './entityCards';
import entityContents from './entityContents';
import entityIconLinks from './entityIconLinks';
import searchResultItems from './searchResultItems';
import contextMenuItems from './contextMenuItems';
@@ -57,6 +58,7 @@ export default createFrontendPlugin({
...navItems,
...entityCards,
...entityContents,
...entityIconLinks,
...contextMenuItems,
...searchResultItems,
],
@@ -28,7 +28,6 @@ import { makeStyles } from '@material-ui/core/styles';
import {
AppIcon,
HeaderIconLinkRow,
IconLinkVerticalProps,
InfoCardVariants,
Link,
} from '@backstage/core-components';
@@ -70,6 +69,63 @@ import {
TECHDOCS_EXTERNAL_ANNOTATION,
} from '@backstage/plugin-techdocs-common';
export function useCatalogViewSourceEntityIconLinkProps() {
const { entity } = useEntity();
const scmIntegrationsApi = useApi(scmIntegrationsApiRef);
const { t } = useTranslationRef(catalogTranslationRef);
const entitySourceLocation = getEntitySourceLocation(
entity,
scmIntegrationsApi,
);
return {
label: t('aboutCard.viewSource'),
disabled: !entitySourceLocation,
icon: <ScmIntegrationIcon type={entitySourceLocation?.integrationType} />,
href: entitySourceLocation?.locationTargetUrl,
};
}
export function useTechdocsViewDocumentionIconLinkProps() {
const { entity } = useEntity();
const viewTechdocLink = useRouteRef(viewTechDocRouteRef);
const { t } = useTranslationRef(catalogTranslationRef);
return {
label: t('aboutCard.viewTechdocs'),
disabled:
!(
entity.metadata.annotations?.[TECHDOCS_ANNOTATION] ||
entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]
) || !viewTechdocLink,
icon: <DocsIcon />,
href: buildTechDocsURL(entity, viewTechdocLink),
};
}
export function useScaffolderLaunchTemplateEntityIconLinkProps() {
const app = useApp();
const { entity } = useEntity();
const templateRoute = useRouteRef(createFromTemplateRouteRef);
const { t } = useTranslationRef(catalogTranslationRef);
const Icon = app.getSystemIcon('scaffolder') ?? CreateComponentIcon;
const { allowed: canCreateTemplateTask } = usePermission({
permission: taskCreatePermission,
});
return {
label: t('aboutCard.launchTemplate'),
icon: <Icon />,
hidden: !isTemplateEntityV1beta3(entity),
disabled: !templateRoute || !canCreateTemplateTask,
href:
templateRoute &&
templateRoute({
templateName: entity.metadata.name,
namespace: entity.metadata.namespace || DEFAULT_NAMESPACE,
}),
};
}
const useStyles = makeStyles({
gridItemCard: {
display: 'flex',
@@ -97,6 +153,7 @@ const useStyles = makeStyles({
*/
export interface AboutCardProps {
variant?: InfoCardVariants;
subheader?: JSX.Element;
}
/**
@@ -107,15 +164,12 @@ export interface AboutCardProps {
* card in your own repository instead, that is perfect for your own needs.
*/
export function AboutCard(props: AboutCardProps) {
const { variant } = props;
const app = useApp();
const { variant, subheader } = props;
const classes = useStyles();
const { entity } = useEntity();
const scmIntegrationsApi = useApi(scmIntegrationsApiRef);
const catalogApi = useApi(catalogApiRef);
const alertApi = useApi(alertApiRef);
const errorApi = useApi(errorApiRef);
const viewTechdocLink = useRouteRef(viewTechDocRouteRef);
const templateRoute = useRouteRef(createFromTemplateRouteRef);
const sourceTemplateRef = useSourceTemplateCompoundEntityRef(entity);
const { allowed: canRefresh } = useEntityPermission(
@@ -123,53 +177,14 @@ export function AboutCard(props: AboutCardProps) {
);
const { t } = useTranslationRef(catalogTranslationRef);
const { allowed: canCreateTemplateTask } = usePermission({
permission: taskCreatePermission,
});
const entitySourceLocation = getEntitySourceLocation(
entity,
scmIntegrationsApi,
);
const entityMetadataEditUrl =
entity.metadata.annotations?.[ANNOTATION_EDIT_URL];
const viewInSource: IconLinkVerticalProps = {
label: t('aboutCard.viewSource'),
disabled: !entitySourceLocation,
icon: <ScmIntegrationIcon type={entitySourceLocation?.integrationType} />,
href: entitySourceLocation?.locationTargetUrl,
};
const viewInTechDocs: IconLinkVerticalProps = {
label: t('aboutCard.viewTechdocs'),
disabled:
!(
entity.metadata.annotations?.[TECHDOCS_ANNOTATION] ||
entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]
) || !viewTechdocLink,
icon: <DocsIcon />,
href: buildTechDocsURL(entity, viewTechdocLink),
};
const subHeaderLinks = [viewInSource, viewInTechDocs];
if (isTemplateEntityV1beta3(entity)) {
const Icon = app.getSystemIcon('scaffolder') ?? CreateComponentIcon;
const launchTemplate: IconLinkVerticalProps = {
label: t('aboutCard.launchTemplate'),
icon: <Icon />,
disabled: !templateRoute || !canCreateTemplateTask,
href:
templateRoute &&
templateRoute({
templateName: entity.metadata.name,
namespace: entity.metadata.namespace || DEFAULT_NAMESPACE,
}),
};
subHeaderLinks.push(launchTemplate);
}
const viewCatalogSourceIconLink = useCatalogViewSourceEntityIconLinkProps();
const viewTechdocsDocumentationIconLink =
useTechdocsViewDocumentionIconLinkProps();
const launchScaffolderTemplateIconLink =
useScaffolderLaunchTemplateEntityIconLinkProps();
let cardClass = '';
if (variant === 'gridItem') {
@@ -240,7 +255,17 @@ export function AboutCard(props: AboutCardProps) {
)}
</>
}
subheader={<HeaderIconLinkRow links={subHeaderLinks} />}
subheader={
subheader ?? (
<HeaderIconLinkRow
links={[
viewCatalogSourceIconLink,
viewTechdocsDocumentationIconLink,
launchScaffolderTemplateIconLink,
]}
/>
)
}
/>
<Divider />
<CardContent className={cardContentClass}>