diff --git a/.changeset/fruity-peaches-march.md b/.changeset/fruity-peaches-march.md new file mode 100644 index 0000000000..34303d17e8 --- /dev/null +++ b/.changeset/fruity-peaches-march.md @@ -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. diff --git a/.changeset/large-baboons-prove.md b/.changeset/large-baboons-prove.md new file mode 100644 index 0000000000..d9b964ef43 --- /dev/null +++ b/.changeset/large-baboons-prove.md @@ -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. diff --git a/.changeset/open-eyes-learn.md b/.changeset/open-eyes-learn.md new file mode 100644 index 0000000000..60cc016bc7 --- /dev/null +++ b/.changeset/open-eyes-learn.md @@ -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: , + 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: , + 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' +``` diff --git a/packages/core-components/report.api.md b/packages/core-components/report.api.md index 90fa9ced22..56d2662041 100644 --- a/packages/core-components/report.api.md +++ b/packages/core-components/report.api.md @@ -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; title?: string; + hidden?: boolean; }; // @public (undocumented) diff --git a/packages/core-components/src/components/HeaderIconLinkRow/IconLinkVertical.tsx b/packages/core-components/src/components/HeaderIconLinkRow/IconLinkVertical.tsx index a15e600e8c..817b7ad5c4 100644 --- a/packages/core-components/src/components/HeaderIconLinkRow/IconLinkVertical.tsx +++ b/packages/core-components/src/components/HeaderIconLinkRow/IconLinkVertical.tsx @@ -29,6 +29,7 @@ export type IconLinkVerticalProps = { label: string; onClick?: MouseEventHandler; 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 ( diff --git a/plugins/catalog-react/report-alpha.api.md b/plugins/catalog-react/report-alpha.api.md index 46d3030140..cdcfe99c77 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,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 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..01e49d5d16 --- /dev/null +++ b/plugins/catalog-react/src/alpha/blueprints/EntityIconLinkBlueprint.tsx @@ -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; + }, {}), + })); + }, +}); 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..ca501cb107 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,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; 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; diff --git a/plugins/catalog/report.api.md b/plugins/catalog/report.api.md index 10e120d208..556b540b92 100644 --- a/plugins/catalog/report.api.md +++ b/plugins/catalog/report.api.md @@ -42,6 +42,8 @@ import { UserListFilterKind } from '@backstage/plugin-catalog-react'; // @public export interface AboutCardProps { + // (undocumented) + subheader?: JSX.Element; // (undocumented) variant?: InfoCardVariants; } diff --git a/plugins/catalog/src/alpha/entityCards.tsx b/plugins/catalog/src/alpha/entityCards.tsx index 8662518d30..775959bef0 100644 --- a/plugins/catalog/src/alpha/entityCards.tsx +++ b/plugins/catalog/src/alpha/entityCards.tsx @@ -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(), - ), + 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 ; + } + return originalFactory({ + type: 'info', + async loader() { + const { AboutCard } = await import('../components/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..21595ad258 --- /dev/null +++ b/plugins/catalog/src/alpha/entityIconLinks.tsx @@ -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, +]; 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..1c093a049c 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -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: , + 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: , + 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: , + 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: , - 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); - } + const viewCatalogSourceIconLink = useCatalogViewSourceEntityIconLinkProps(); + const viewTechdocsDocumentationIconLink = + useTechdocsViewDocumentionIconLinkProps(); + const launchScaffolderTemplateIconLink = + useScaffolderLaunchTemplateEntityIconLinkProps(); let cardClass = ''; if (variant === 'gridItem') { @@ -240,7 +255,17 @@ export function AboutCard(props: AboutCardProps) { )} } - subheader={} + subheader={ + subheader ?? ( + + ) + } />