diff --git a/.changeset/large-baboons-prove.md b/.changeset/large-baboons-prove.md
new file mode 100644
index 0000000000..072d57de4c
--- /dev/null
+++ b/.changeset/large-baboons-prove.md
@@ -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`).
diff --git a/.changeset/loose-ants-learn.md b/.changeset/loose-ants-learn.md
new file mode 100644
index 0000000000..29f357966c
--- /dev/null
+++ b/.changeset/loose-ants-learn.md
@@ -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.
diff --git a/.changeset/open-eyes-learn.md b/.changeset/open-eyes-learn.md
new file mode 100644
index 0000000000..3a0299aafb
--- /dev/null
+++ b/.changeset/open-eyes-learn.md
@@ -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: ,
+ 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.
diff --git a/.changeset/rare-rules-move.md b/.changeset/rare-rules-move.md
new file mode 100644
index 0000000000..6abc778941
--- /dev/null
+++ b/.changeset/rare-rules-move.md
@@ -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.
diff --git a/docs/features/search/declarative-integration.md b/docs/features/search/declarative-integration.md
index c331d810ef..b8f1c6ce5c 100644
--- a/docs/features/search/declarative-integration.md
+++ b/docs/features/search/declarative-integration.md
@@ -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
diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md
index 46d3030140..36a93a8a5e 100644
--- a/plugins/catalog-react/report-alpha.api.md
+++ b/plugins/catalog-react/report-alpha.api.md
@@ -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;
+ 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
diff --git a/plugins/catalog-react/src/alpha/blueprints/EntityIconLinkBlueprint.tsx b/plugins/catalog-react/src/alpha/blueprints/EntityIconLinkBlueprint.tsx
new file mode 100644
index 0000000000..11c9a2b805
--- /dev/null
+++ b/plugins/catalog-react/src/alpha/blueprints/EntityIconLinkBlueprint.tsx
@@ -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;
+ 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 }));
+ },
+});
diff --git a/plugins/catalog-react/src/alpha/blueprints/index.ts b/plugins/catalog-react/src/alpha/blueprints/index.ts
index b21522331d..640ebb881c 100644
--- a/plugins/catalog-react/src/alpha/blueprints/index.ts
+++ b/plugins/catalog-react/src/alpha/blueprints/index.ts
@@ -28,3 +28,4 @@ export {
type EntityContextMenuItemParams,
type UseProps,
} from './EntityContextMenuItemBlueprint';
+export { EntityIconLinkBlueprint } from './EntityIconLinkBlueprint';
diff --git a/plugins/catalog/report-alpha.api.md b/plugins/catalog/report-alpha.api.md
index 7ef20d8228..4be914a69d 100644
--- a/plugins/catalog/report-alpha.api.md
+++ b/plugins/catalog/report-alpha.api.md
@@ -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;
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;
+ filter?: EntityPredicate | ((entity: Entity) => boolean);
+ };
+ }>;
'nav-item:catalog': ExtensionDefinition<{
kind: 'nav-item';
name: undefined;
diff --git a/plugins/catalog/report.api.md b/plugins/catalog/report.api.md
index 10e120d208..52b13e1c43 100644
--- a/plugins/catalog/report.api.md
+++ b/plugins/catalog/report.api.md
@@ -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;
diff --git a/plugins/catalog/src/alpha/entityCards.tsx b/plugins/catalog/src/alpha/entityCards.tsx
index 8662518d30..3913f8b2b7 100644
--- a/plugins/catalog/src/alpha/entityCards.tsx
+++ b/plugins/catalog/src/alpha/entityCards.tsx
@@ -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(),
- ),
+ 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());
+ return links.length ? : null;
+ }
+ return originalFactory({
+ type: 'info',
+ async loader() {
+ const { InternalAboutCard } = await import(
+ '../components/AboutCard/AboutCard'
+ );
+ return compatWrapper(
+ } />,
+ );
+ },
+ });
},
});
diff --git a/plugins/catalog/src/alpha/entityIconLinks.tsx b/plugins/catalog/src/alpha/entityIconLinks.tsx
new file mode 100644
index 0000000000..b13b13477c
--- /dev/null
+++ b/plugins/catalog/src/alpha/entityIconLinks.tsx
@@ -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];
diff --git a/plugins/catalog/src/alpha/plugin.tsx b/plugins/catalog/src/alpha/plugin.tsx
index c5dad31cf0..ff26d20003 100644
--- a/plugins/catalog/src/alpha/plugin.tsx
+++ b/plugins/catalog/src/alpha/plugin.tsx
@@ -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,
],
diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx
index 88933b2a66..fd1c05ba5f 100644
--- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx
+++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx
@@ -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: ,
+ 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: ,
+ 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: ,
+ 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 ;
+}
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: ,
- href: entitySourceLocation?.locationTargetUrl,
- };
- const viewInTechDocs: IconLinkVerticalProps = {
- label: t('aboutCard.viewTechdocs'),
- disabled:
- !(
- entity.metadata.annotations?.[TECHDOCS_ANNOTATION] ||
- entity.metadata.annotations?.[TECHDOCS_EXTERNAL_ANNOTATION]
- ) || !viewTechdocLink,
- icon: ,
- 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: ,
- 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={}
+ subheader={subheader ?? }
/>
@@ -249,3 +280,14 @@ export function AboutCard(props: AboutCardProps) {
);
}
+
+/**
+ * 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 ;
+}
diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md
index 69fcec11e6..54acadd561 100644
--- a/plugins/scaffolder/report-alpha.api.md
+++ b/plugins/scaffolder/report-alpha.api.md
@@ -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;
+ 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';
diff --git a/plugins/scaffolder/src/alpha/hooks/useScaffolderTemplateIconLinkProps.tsx b/plugins/scaffolder/src/alpha/hooks/useScaffolderTemplateIconLinkProps.tsx
new file mode 100644
index 0000000000..87754468fa
--- /dev/null
+++ b/plugins/scaffolder/src/alpha/hooks/useScaffolderTemplateIconLinkProps.tsx
@@ -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: ,
+ disabled: !templateRoute || !canCreateTemplateTask,
+ href:
+ templateRoute &&
+ templateRoute({
+ templateName: entity.metadata.name,
+ namespace: entity.metadata.namespace || DEFAULT_NAMESPACE,
+ }),
+ };
+}
diff --git a/plugins/scaffolder/src/alpha/plugin.tsx b/plugins/scaffolder/src/alpha/plugin.tsx
index ebad851901..a414c61da5 100644
--- a/plugins/scaffolder/src/alpha/plugin.tsx
+++ b/plugins/scaffolder/src/alpha/plugin.tsx
@@ -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,
diff --git a/plugins/scaffolder/src/translation.ts b/plugins/scaffolder/src/translation.ts
index 2354410924..fc0f0a6889 100644
--- a/plugins/scaffolder/src/translation.ts
+++ b/plugins/scaffolder/src/translation.ts
@@ -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',
diff --git a/plugins/techdocs/package.json b/plugins/techdocs/package.json
index f688fb9503..c79dadca05 100644
--- a/plugins/techdocs/package.json
+++ b/plugins/techdocs/package.json
@@ -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"
diff --git a/plugins/techdocs/report-alpha.api.md b/plugins/techdocs/report-alpha.api.md
index e34046ad2e..ce1d10a00c 100644
--- a/plugins/techdocs/report-alpha.api.md
+++ b/plugins/techdocs/report-alpha.api.md
@@ -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;
+ filter?: EntityPredicate | ((entity: Entity) => boolean);
+ };
+ }>;
'nav-item:techdocs': ExtensionDefinition<{
kind: 'nav-item';
name: undefined;
diff --git a/plugins/techdocs/src/alpha/hooks/useTechdocsReaderIconLinkProps.tsx b/plugins/techdocs/src/alpha/hooks/useTechdocsReaderIconLinkProps.tsx
new file mode 100644
index 0000000000..0cd2cf9ca1
--- /dev/null
+++ b/plugins/techdocs/src/alpha/hooks/useTechdocsReaderIconLinkProps.tsx
@@ -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: ,
+ href: buildTechDocsURL(entity, viewTechdocLink),
+ };
+}
diff --git a/plugins/techdocs/src/alpha.tsx b/plugins/techdocs/src/alpha/index.tsx
similarity index 88%
rename from plugins/techdocs/src/alpha.tsx
rename to plugins/techdocs/src/alpha/index.tsx
index 21b594c82d..44205a480a 100644
--- a/plugins/techdocs/src/alpha.tsx
+++ b/plugins/techdocs/src/alpha/index.tsx
@@ -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(),
),
},
@@ -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(
@@ -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,
diff --git a/plugins/techdocs/src/translation.ts b/plugins/techdocs/src/translation.ts
new file mode 100644
index 0000000000..aa62246788
--- /dev/null
+++ b/plugins/techdocs/src/translation.ts
@@ -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',
+ },
+ },
+});