Merge pull request #30051 from backstage/camilaibs/about-card-icon-link-extensions

[NFS] Create entity icon link extensions
This commit is contained in:
Camila Belo
2025-06-04 12:40:23 +02:00
committed by GitHub
23 changed files with 746 additions and 113 deletions
+9
View File
@@ -0,0 +1,9 @@
---
'@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.
**BREAKING ALPHA**
The `Scaffolder` launch template and `TechDocs` read documentation icons have been extracted from the default `Catalog` about card links and are now provided respectively by the `Scaffolder` and `TechDocs` plugins in the new frontend system. It means that they will not be available unless you install the `TechDocs` and `Scaffolder` plugins. Also If you are using translation for these icon link titles other than the default, you should now translate them using the scaffolder translation reference or the TechDocs translation reference (the translation keys are still the same, `aboutCard.viewTechdocs` and `aboutCard.launchTemplate`).
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-techdocs': minor
---
**New Frontend System Only:**
The `TechDocs` plugin is now responsible for providing an entity icon link extension to read documentation from the catalog entity page.
+49
View File
@@ -0,0 +1,49 @@
---
'@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 a `useProps` hook as `param` and this function returns the following props that will be passed to the icon link component:
| 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 |
| `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 |
Here is an usage example:
```tsx
import { EntityIconLinkBlueprint } from '@backstage/plugin-catalog-react/alpha';
//...
EntityIconLinkBlueprint.make({
name: 'my-icon-link',
params: {
useProps() {
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` and `title` 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'
```
Finally, you can disable all links if you want to hide the About card header completely (useful, for example, when links are displayed on separate cards). The header is hidden when no icon links extensions are enabled.
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-scaffolder': minor
---
**New Frontend System Only:**
The `Scaffolder` plugin is now responsible for providing an entity icon link extension to launch templates from the catalog entity page.
@@ -182,7 +182,7 @@ export default createPlugin({
})
```
Here is the `plugins/techdocs/alpha.tsx` final version, and you can also take a look at the [actual implementation](https://github.com/backstage/backstage/blob/master/plugins/techdocs/src/alpha.tsx) of a custom `TechDocs` search result item:
Here is the `plugins/techdocs/alpha/index.tsx` final version, and you can also take a look at the [actual implementation](https://github.com/backstage/backstage/blob/master/plugins/techdocs/src/alpha/index.tsx) of a custom `TechDocs` search result item:
```tsx
// plugins/techdocs/alpha.tsx
+59
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,64 @@ export const EntityHeaderBlueprint: ExtensionBlueprint<{
};
}>;
// @alpha (undocumented)
export const EntityIconLinkBlueprint: ExtensionBlueprint<{
kind: 'entity-icon-link';
name: undefined;
params: {
useProps: () => Omit<IconLinkVerticalProps, 'color'>;
filter?: EntityPredicate | ((entity: Entity) => boolean);
};
output:
| ConfigurableExtensionDataRef<
(entity: Entity) => boolean,
'catalog.entity-filter-function',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'catalog.entity-filter-expression',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
() => IconLinkVerticalProps,
'entity-icon-link-props',
{}
>;
inputs: {};
config: {
label: string | undefined;
title: string | undefined;
filter: EntityPredicate | undefined;
};
configInput: {
filter?: EntityPredicate | undefined;
label?: string | undefined;
title?: string | undefined;
};
dataRefs: {
useProps: ConfigurableExtensionDataRef<
() => IconLinkVerticalProps,
'entity-icon-link-props',
{}
>;
filterFunction: ConfigurableExtensionDataRef<
(entity: Entity) => boolean,
'catalog.entity-filter-function',
{}
>;
filterExpression: ConfigurableExtensionDataRef<
string,
'catalog.entity-filter-expression',
{}
>;
};
}>;
// @alpha (undocumented)
export type EntityPredicate =
| EntityPredicateExpression
@@ -0,0 +1,83 @@
/*
* 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';
import { EntityPredicate } from '../predicates';
import { createEntityPredicateSchema } from '../predicates/createEntityPredicateSchema';
import {
entityFilterExpressionDataRef,
entityFilterFunctionDataRef,
} from './extensionData';
import { Entity } from '@backstage/catalog-model';
import { resolveEntityFilterData } from './resolveEntityFilterData';
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: [
entityFilterFunctionDataRef.optional(),
entityFilterExpressionDataRef.optional(),
entityIconLinkPropsDataRef,
],
dataRefs: {
useProps: entityIconLinkPropsDataRef,
filterFunction: entityFilterFunctionDataRef,
filterExpression: entityFilterExpressionDataRef,
},
config: {
schema: {
label: z => z.string().optional(),
title: z => z.string().optional(),
filter: z => createEntityPredicateSchema(z).optional(),
},
},
*factory(
params: {
useProps: () => Omit<IconLinkVerticalProps, 'color'>;
filter?: EntityPredicate | ((entity: Entity) => boolean);
},
{ config, node },
) {
const { filter, useProps } = params;
yield* resolveEntityFilterData(filter, config, node);
// Only include properties that are defined in the config
// to avoid overriding defaults with undefined values
const configProps = Object.entries(config).reduce(
(rest, [key, value]) =>
value !== undefined
? {
...rest,
[key]: value,
}
: rest,
{},
);
yield entityIconLinkPropsDataRef(() => ({ ...useProps(), ...configProps }));
},
});
@@ -28,3 +28,4 @@ export {
type EntityContextMenuItemParams,
type UseProps,
} from './EntityContextMenuItemBlueprint';
export { EntityIconLinkBlueprint } from './EntityIconLinkBlueprint';
+69 -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,35 @@ const _default: FrontendPlugin<
optional: true;
}
>;
inputs: {};
inputs: {
iconLinks: ExtensionInput<
| ConfigurableExtensionDataRef<
(entity: Entity) => boolean,
'catalog.entity-filter-function',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'catalog.entity-filter-expression',
{
optional: true;
}
>
| 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 +950,45 @@ const _default: FrontendPlugin<
inputs: {};
params: EntityContextMenuItemParams;
}>;
'entity-icon-link:catalog/view-source': ExtensionDefinition<{
kind: 'entity-icon-link';
name: 'view-source';
config: {
label: string | undefined;
title: string | undefined;
filter: EntityPredicate | undefined;
};
configInput: {
filter?: EntityPredicate | undefined;
label?: string | undefined;
title?: string | undefined;
};
output:
| ConfigurableExtensionDataRef<
(entity: Entity) => boolean,
'catalog.entity-filter-function',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'catalog.entity-filter-expression',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
() => IconLinkVerticalProps,
'entity-icon-link-props',
{}
>;
inputs: {};
params: {
useProps: () => Omit<IconLinkVerticalProps, 'color'>;
filter?: EntityPredicate | ((entity: Entity) => boolean);
};
}>;
'nav-item:catalog': ExtensionDefinition<{
kind: 'nav-item';
name: undefined;
+2 -3
View File
@@ -41,10 +41,9 @@ import { TabProps } from '@material-ui/core/Tab';
import { UserListFilterKind } from '@backstage/plugin-catalog-react';
// @public
export interface AboutCardProps {
// (undocumented)
export type AboutCardProps = {
variant?: InfoCardVariants;
}
};
// @public (undocumented)
export function AboutContent(props: AboutContentProps): JSX_2.Element;
+48 -8
View File
@@ -14,17 +14,57 @@
* 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,
IconLinkVerticalProps,
} from '@backstage/core-components';
import { useEntity } from '@backstage/plugin-catalog-react';
import { buildFilterFn } from './filter/FilterWrapper';
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.filterFunction.optional(),
EntityIconLinkBlueprint.dataRefs.filterExpression.optional(),
EntityIconLinkBlueprint.dataRefs.useProps,
]),
},
factory(originalFactory, { inputs }) {
function Subheader() {
const { entity } = useEntity();
// The "useProps" 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.reduce((rest, iconLink) => {
const props = iconLink.get(EntityIconLinkBlueprint.dataRefs.useProps)();
const filter = buildFilterFn(
iconLink.get(EntityIconLinkBlueprint.dataRefs.filterFunction),
iconLink.get(EntityIconLinkBlueprint.dataRefs.filterExpression),
);
if (filter(entity)) {
return [...rest, props];
}
return rest;
}, new Array<IconLinkVerticalProps>());
return links.length ? <HeaderIconLinkRow links={links} /> : null;
}
return originalFactory({
type: 'info',
async loader() {
const { InternalAboutCard } = await import(
'../components/AboutCard/AboutCard'
);
return compatWrapper(
<InternalAboutCard variant="gridItem" subheader={<Subheader />} />,
);
},
});
},
});
@@ -0,0 +1,27 @@
/*
* 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 { useCatalogSourceIconLinkProps } from '../components/AboutCard/AboutCard';
const catalogViewSourceEntityIconLink = EntityIconLinkBlueprint.make({
name: 'view-source',
params: {
useProps: useCatalogSourceIconLinkProps,
},
});
export default [catalogViewSourceEntityIconLink];
+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,
],
@@ -13,18 +13,20 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
ANNOTATION_EDIT_URL,
ANNOTATION_LOCATION,
DEFAULT_NAMESPACE,
stringifyEntityRef,
} from '@backstage/catalog-model';
import { useCallback } from 'react';
import { makeStyles } from '@material-ui/core/styles';
import Card from '@material-ui/core/Card';
import CardContent from '@material-ui/core/CardContent';
import CardHeader from '@material-ui/core/CardHeader';
import Divider from '@material-ui/core/Divider';
import IconButton from '@material-ui/core/IconButton';
import { makeStyles } from '@material-ui/core/styles';
import CachedIcon from '@material-ui/icons/Cached';
import EditIcon from '@material-ui/icons/Edit';
import DocsIcon from '@material-ui/icons/Description';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import {
AppIcon,
HeaderIconLinkRow,
@@ -32,43 +34,123 @@ import {
InfoCardVariants,
Link,
} from '@backstage/core-components';
import { useCallback } from 'react';
import {
alertApiRef,
errorApiRef,
useApp,
useApi,
useRouteRef,
} from '@backstage/core-plugin-api';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import {
ScmIntegrationIcon,
scmIntegrationsApiRef,
} from '@backstage/integration-react';
import {
alertApiRef,
errorApiRef,
useApi,
useApp,
useRouteRef,
} from '@backstage/core-plugin-api';
DEFAULT_NAMESPACE,
ANNOTATION_EDIT_URL,
ANNOTATION_LOCATION,
stringifyEntityRef,
} from '@backstage/catalog-model';
import {
catalogApiRef,
getEntitySourceLocation,
useEntity,
} from '@backstage/plugin-catalog-react';
import { createFromTemplateRouteRef, viewTechDocRouteRef } from '../../routes';
import { AboutContent } from './AboutContent';
import CachedIcon from '@material-ui/icons/Cached';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import DocsIcon from '@material-ui/icons/Description';
import EditIcon from '@material-ui/icons/Edit';
import { isTemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { useEntityPermission } from '@backstage/plugin-catalog-react/alpha';
import { catalogEntityRefreshPermission } from '@backstage/plugin-catalog-common/alpha';
import { useSourceTemplateCompoundEntityRef } from './hooks';
import { taskCreatePermission } from '@backstage/plugin-scaffolder-common/alpha';
import { usePermission } from '@backstage/plugin-permission-react';
import { catalogTranslationRef } from '../../alpha/translation';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { buildTechDocsURL } from '@backstage/plugin-techdocs-react';
import {
TECHDOCS_ANNOTATION,
TECHDOCS_EXTERNAL_ANNOTATION,
} from '@backstage/plugin-techdocs-common';
import { buildTechDocsURL } from '@backstage/plugin-techdocs-react';
import { isTemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { taskCreatePermission } from '@backstage/plugin-scaffolder-common/alpha';
import { usePermission } from '@backstage/plugin-permission-react';
import { createFromTemplateRouteRef, viewTechDocRouteRef } from '../../routes';
import { catalogTranslationRef } from '../../alpha/translation';
import { useSourceTemplateCompoundEntityRef } from './hooks';
import { AboutContent } from './AboutContent';
export function useCatalogSourceIconLinkProps() {
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,
};
}
// TODO: This hook is duplicated from the TechDocs plugin for backwards compatibility
// Remove it when the the legacy frontend system support is dropped.
function useTechdocsReaderIconLinkProps(): IconLinkVerticalProps {
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),
};
}
// TODO: This hook is duplicated from the Scaffolder plugin for backwards compatibility
// Remove it when the the legacy frontend system support is dropped.
function useScaffolderTemplateIconLinkProps(): IconLinkVerticalProps {
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 />,
disabled: !templateRoute || !canCreateTemplateTask,
href:
templateRoute &&
templateRoute({
templateName: entity.metadata.name,
namespace: entity.metadata.namespace || DEFAULT_NAMESPACE,
}),
};
}
function DefaultAboutCardSubheader() {
const { entity } = useEntity();
const catalogSourceIconLink = useCatalogSourceIconLinkProps();
const techdocsreaderIconLink = useTechdocsReaderIconLinkProps();
const scaffolderTemplateIconLink = useScaffolderTemplateIconLinkProps();
const links = [catalogSourceIconLink, techdocsreaderIconLink];
if (isTemplateEntityV1beta3(entity)) {
links.push(scaffolderTemplateIconLink);
}
return <HeaderIconLinkRow links={links} />;
}
const useStyles = makeStyles({
gridItemCard: {
@@ -95,27 +177,21 @@ const useStyles = makeStyles({
*
* @public
*/
export interface AboutCardProps {
export type AboutCardProps = {
variant?: InfoCardVariants;
};
export interface InternalAboutCardProps extends AboutCardProps {
subheader?: JSX.Element;
}
/**
* Exported publicly via the EntityAboutCard
*
* NOTE: We generally do not accept pull requests to extend this class with more
* props and customizability. If you need to tweak it, consider making a bespoke
* card in your own repository instead, that is perfect for your own needs.
*/
export function AboutCard(props: AboutCardProps) {
const { variant } = props;
const app = useApp();
export function InternalAboutCard(props: InternalAboutCardProps) {
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,54 +199,9 @@ 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);
}
let cardClass = '';
if (variant === 'gridItem') {
cardClass = classes.gridItemCard;
@@ -240,7 +271,7 @@ export function AboutCard(props: AboutCardProps) {
)}
</>
}
subheader={<HeaderIconLinkRow links={subHeaderLinks} />}
subheader={subheader ?? <DefaultAboutCardSubheader />}
/>
<Divider />
<CardContent className={cardContentClass}>
@@ -249,3 +280,14 @@ export function AboutCard(props: AboutCardProps) {
</Card>
);
}
/**
* Exported publicly via the EntityAboutCard
*
* NOTE: We generally do not accept pull requests to extend this class with more
* props and customizability. If you need to tweak it, consider making a bespoke
* card in your own repository instead, that is perfect for your own needs.
*/
export function AboutCard(props: AboutCardProps) {
return <InternalAboutCard {...props} />;
}
+43
View File
@@ -8,6 +8,8 @@ import { AnyRouteRefParams } from '@backstage/frontend-plugin-api';
import { ApiRef } from '@backstage/frontend-plugin-api';
import { ComponentType } from 'react';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { Entity } from '@backstage/catalog-model';
import { EntityPredicate } from '@backstage/plugin-catalog-react/alpha';
import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
@@ -18,6 +20,7 @@ import type { FormProps as FormProps_2 } from '@rjsf/core';
import { FormProps as FormProps_3 } from '@backstage/plugin-scaffolder-react';
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 { LayoutOptions } from '@backstage/plugin-scaffolder-react';
import { PathParams } from '@backstage/core-plugin-api';
@@ -121,6 +124,45 @@ const _default: FrontendPlugin<
factory: AnyApiFactory;
};
}>;
'entity-icon-link:scaffolder/launch-template': ExtensionDefinition<{
kind: 'entity-icon-link';
name: 'launch-template';
config: {
label: string | undefined;
title: string | undefined;
filter: EntityPredicate | undefined;
};
configInput: {
filter?: EntityPredicate | undefined;
label?: string | undefined;
title?: string | undefined;
};
output:
| ConfigurableExtensionDataRef<
(entity: Entity) => boolean,
'catalog.entity-filter-function',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'catalog.entity-filter-expression',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
() => IconLinkVerticalProps,
'entity-icon-link-props',
{}
>;
inputs: {};
params: {
useProps: () => Omit<IconLinkVerticalProps, 'color'>;
filter?: EntityPredicate | ((entity: Entity) => boolean);
};
}>;
'nav-item:scaffolder': ExtensionDefinition<{
kind: 'nav-item';
name: undefined;
@@ -325,6 +367,7 @@ export const scaffolderTranslationRef: TranslationRef<
readonly 'fields.repoUrlPicker.repository.title': 'Repositories Available';
readonly 'fields.repoUrlPicker.repository.description': 'The name of the repository';
readonly 'fields.repoUrlPicker.repository.inputTitle': 'Repository';
readonly 'aboutCard.launchTemplate': 'Launch Template';
readonly 'actionsPage.content.emptyState.title': 'No information to display';
readonly 'actionsPage.content.emptyState.description': 'There are no actions installed or there was an issue communicating with backend.';
readonly 'actionsPage.content.searchFieldPlaceholder': 'Search for an action';
@@ -0,0 +1,53 @@
/*
* 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 { DEFAULT_NAMESPACE } from '@backstage/catalog-model';
import { useApp, useRouteRef } from '@backstage/core-plugin-api';
import { useEntity } from '@backstage/plugin-catalog-react';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import { taskCreatePermission } from '@backstage/plugin-scaffolder-common/alpha';
import { usePermission } from '@backstage/plugin-permission-react';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../translation';
import { selectedTemplateRouteRef } from '../../routes';
// Note: If you update this hook, please also update the "useScaffolderTemplateIconLinkProps" hook
// in the "plugins/catalog/src/components/AboutCard/AboutCard.tsx" file
/** @alpha */
export function useScaffolderTemplateIconLinkProps() {
const app = useApp();
const { entity } = useEntity();
const templateRoute = useRouteRef(selectedTemplateRouteRef);
const { t } = useTranslationRef(scaffolderTranslationRef);
const Icon = app.getSystemIcon('scaffolder') ?? CreateComponentIcon;
const { allowed: canCreateTemplateTask } = usePermission({
permission: taskCreatePermission,
});
return {
label: t('aboutCard.launchTemplate'),
icon: <Icon />,
disabled: !templateRoute || !canCreateTemplateTask,
href:
templateRoute &&
templateRoute({
templateName: entity.metadata.name,
namespace: entity.metadata.namespace || DEFAULT_NAMESPACE,
}),
};
}
+13
View File
@@ -33,8 +33,20 @@ import {
scaffolderPage,
scaffolderApi,
} from './extensions';
import { isTemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { formFieldsApi } from '@backstage/plugin-scaffolder-react/alpha';
import { formDecoratorsApi } from './api';
import { EntityIconLinkBlueprint } from '@backstage/plugin-catalog-react/alpha';
import { useScaffolderTemplateIconLinkProps } from './hooks/useScaffolderTemplateIconLinkProps';
/** @alpha */
const scaffolderEntityIconLink = EntityIconLinkBlueprint.make({
name: 'launch-template',
params: {
filter: isTemplateEntityV1beta3,
useProps: useScaffolderTemplateIconLinkProps,
},
});
/** @alpha */
export default createFrontendPlugin({
@@ -57,6 +69,7 @@ export default createFrontendPlugin({
scaffolderApi,
scaffolderPage,
scaffolderNavItem,
scaffolderEntityIconLink,
formDecoratorsApi,
formFieldsApi,
repoUrlPickerFormField,
+3
View File
@@ -19,6 +19,9 @@ import { createTranslationRef } from '@backstage/core-plugin-api/alpha';
export const scaffolderTranslationRef = createTranslationRef({
id: 'scaffolder',
messages: {
aboutCard: {
launchTemplate: 'Launch Template',
},
actionsPage: {
title: 'Installed actions',
pageTitle: 'Create a New Component',
+2 -2
View File
@@ -30,7 +30,7 @@
"sideEffects": false,
"exports": {
".": "./src/index.ts",
"./alpha": "./src/alpha.tsx",
"./alpha": "./src/alpha/index.tsx",
"./package.json": "./package.json"
},
"main": "src/index.ts",
@@ -38,7 +38,7 @@
"typesVersions": {
"*": {
"alpha": [
"src/alpha.tsx"
"src/alpha/index.tsx"
],
"package.json": [
"package.json"
+40
View File
@@ -14,6 +14,7 @@ import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
import { ExtensionInput } 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';
@@ -173,6 +174,45 @@ const _default: FrontendPlugin<
filter?: string | EntityPredicate | ((entity: Entity) => boolean);
};
}>;
'entity-icon-link:techdocs/read-docs': ExtensionDefinition<{
kind: 'entity-icon-link';
name: 'read-docs';
config: {
label: string | undefined;
title: string | undefined;
filter: EntityPredicate | undefined;
};
configInput: {
filter?: EntityPredicate | undefined;
label?: string | undefined;
title?: string | undefined;
};
output:
| ConfigurableExtensionDataRef<
(entity: Entity) => boolean,
'catalog.entity-filter-function',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
string,
'catalog.entity-filter-expression',
{
optional: true;
}
>
| ConfigurableExtensionDataRef<
() => IconLinkVerticalProps,
'entity-icon-link-props',
{}
>;
inputs: {};
params: {
useProps: () => Omit<IconLinkVerticalProps, 'color'>;
filter?: EntityPredicate | ((entity: Entity) => boolean);
};
}>;
'nav-item:techdocs': ExtensionDefinition<{
kind: 'nav-item';
name: undefined;
@@ -0,0 +1,51 @@
/*
* 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 DocsIcon from '@material-ui/icons/Description';
import { useRouteRef } from '@backstage/core-plugin-api';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import {
TECHDOCS_ANNOTATION,
TECHDOCS_EXTERNAL_ANNOTATION,
} from '@backstage/plugin-techdocs-common';
import { buildTechDocsURL } from '@backstage/plugin-techdocs-react';
import { useEntity } from '@backstage/plugin-catalog-react';
import { techdocsTranslationRef } from '../../translation';
import { rootDocsRouteRef } from '../../routes';
// Note: If you update this hook, please also update the "useTechdocsReaderIconLinkProps" hook
// in the "plugins/catalog/src/components/AboutCard/AboutCard.tsx" file
/** @alpha */
export function useTechdocsReaderIconLinkProps() {
const { entity } = useEntity();
const viewTechdocLink = useRouteRef(rootDocsRouteRef);
const { t } = useTranslationRef(techdocsTranslationRef);
return {
label: t('aboutCard.viewTechdocs'),
disabled:
!(
entity.metadata.annotations?.[TECHDOCS_ANNOTATION] ||
entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]
) || !viewTechdocLink,
icon: <DocsIcon />,
href: buildTechDocsURL(entity, viewTechdocLink),
};
}
@@ -35,16 +35,19 @@ import {
convertLegacyRouteRef,
convertLegacyRouteRefs,
} from '@backstage/core-compat-api';
import { EntityContentBlueprint } from '@backstage/plugin-catalog-react/alpha';
import {
EntityContentBlueprint,
EntityIconLinkBlueprint,
} from '@backstage/plugin-catalog-react/alpha';
import { SearchResultListItemBlueprint } from '@backstage/plugin-search-react/alpha';
import { AddonBlueprint } from '@backstage/plugin-techdocs-react/alpha';
import { TechDocsClient, TechDocsStorageClient } from './client';
import { TechDocsClient, TechDocsStorageClient } from '../client';
import {
rootCatalogDocsRouteRef,
rootDocsRouteRef,
rootRouteRef,
} from './routes';
import { TechDocsReaderLayout } from './reader';
} from '../routes';
import { TechDocsReaderLayout } from '../reader';
import { attachTechDocsAddonComponentData } from '@backstage/plugin-techdocs-react/alpha';
import {
TechDocsAddons,
@@ -52,6 +55,16 @@ import {
techdocsStorageApiRef,
} from '@backstage/plugin-techdocs-react';
import { useTechdocsReaderIconLinkProps } from './hooks/useTechdocsReaderIconLinkProps';
/** @alpha */
const techdocsEntityIconLink = EntityIconLinkBlueprint.make({
name: 'read-docs',
params: {
useProps: useTechdocsReaderIconLinkProps,
},
});
/** @alpha */
const techDocsStorageApi = ApiBlueprint.make({
name: 'storage',
@@ -109,7 +122,7 @@ export const techDocsSearchResultListItemExtension =
predicate: result => result.type === 'techdocs',
component: async () => {
const { TechDocsSearchResultListItem } = await import(
'./search/components/TechDocsSearchResultListItem'
'../search/components/TechDocsSearchResultListItem'
);
return props =>
compatWrapper(
@@ -130,7 +143,7 @@ const techDocsPage = PageBlueprint.make({
defaultPath: '/docs',
routeRef: convertLegacyRouteRef(rootRouteRef),
loader: () =>
import('./home/components/TechDocsIndexPage').then(m =>
import('../home/components/TechDocsIndexPage').then(m =>
compatWrapper(<m.TechDocsIndexPage />),
),
},
@@ -158,7 +171,7 @@ const techDocsReaderPage = PageBlueprint.makeWithOverrides({
defaultPath: '/docs/:namespace/:kind/:name',
routeRef: convertLegacyRouteRef(rootDocsRouteRef),
loader: async () =>
await import('./Router').then(({ TechDocsReaderRouter }) => {
await import('../Router').then(({ TechDocsReaderRouter }) => {
return compatWrapper(
<TechDocsReaderRouter>
<TechDocsReaderLayout />
@@ -193,7 +206,7 @@ const techDocsEntityContent = EntityContentBlueprint.makeWithOverrides({
defaultTitle: 'TechDocs',
routeRef: convertLegacyRouteRef(rootCatalogDocsRouteRef),
loader: () =>
import('./Router').then(({ EmbeddedDocsRouter }) => {
import('../Router').then(({ EmbeddedDocsRouter }) => {
const addons = context.inputs.addons.map(output => {
const options = output.get(AddonBlueprint.dataRefs.addon);
const Addon = options.component;
@@ -236,13 +249,14 @@ const techDocsNavItem = NavItemBlueprint.make({
/** @alpha */
export default createFrontendPlugin({
pluginId: 'techdocs',
info: { packageJson: () => import('../package.json') },
info: { packageJson: () => import('../../package.json') },
extensions: [
techDocsClientApi,
techDocsStorageApi,
techDocsNavItem,
techDocsPage,
techDocsReaderPage,
techdocsEntityIconLink,
techDocsEntityContent,
techDocsEntityContentEmptyState,
techDocsSearchResultListItemExtension,
+27
View File
@@ -0,0 +1,27 @@
/*
* 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 { createTranslationRef } from '@backstage/core-plugin-api/alpha';
/** @alpha */
export const techdocsTranslationRef = createTranslationRef({
id: 'techdocs',
messages: {
aboutCard: {
viewTechdocs: 'View TechDocs',
},
},
});