diff --git a/.changeset/honest-hounds-exist.md b/.changeset/honest-hounds-exist.md new file mode 100644 index 0000000000..8aadd84a95 --- /dev/null +++ b/.changeset/honest-hounds-exist.md @@ -0,0 +1,6 @@ +--- +'@backstage/catalog-model': patch +'@backstage/plugin-catalog': patch +--- + +Implement annotations for customising Entity URLs in the Catalog pages. diff --git a/.github/styles/vocab.txt b/.github/styles/vocab.txt index 41ec95e9aa..2a8854b8a2 100644 --- a/.github/styles/vocab.txt +++ b/.github/styles/vocab.txt @@ -166,6 +166,7 @@ interop jq js json +jsonnet jsx kubectl kubernetes diff --git a/docs/features/software-catalog/well-known-annotations.md b/docs/features/software-catalog/well-known-annotations.md index 2cbb829554..8ec4813799 100644 --- a/docs/features/software-catalog/well-known-annotations.md +++ b/docs/features/software-catalog/well-known-annotations.md @@ -70,6 +70,35 @@ The value of this annotation is a location reference string (see above). If this annotation is specified, it is expected to point to a repository that the TechDocs system can read and generate docs from. +### backstage.io/view-url, backstage.io/edit-url + +```yaml +# Example: +metadata: + annotations: + backstage.io/view-url: https://some.website/catalog-info.yaml + backstage.io/edit-url: https://github.com/my-org/catalog/edit/master/my-service.jsonnet +``` + +These annotations allow customising links from the catalog pages. The view URL +should point to the canonical metadata YAML that governs this entity. The edit +URL should point to the source file for the metadata. In the example above, +`my-org` generates its catalog data from Jsonnet files in a monorepo, so the +view and edit links need changing. + +### backstage.io/source-location + +```yaml +# Example: +metadata: + annotations: + backstage.io/source-location: github:https://github.com/my-org/my-service +``` + +A `Location` reference that points to the source code of the entity (typically a +`Component`). Useful when catalog files do not get ingested from the source code +repository itself. + ### jenkins.io/github-folder ```yaml diff --git a/packages/catalog-model/src/entity/constants.ts b/packages/catalog-model/src/entity/constants.ts index 42ea2ae8ba..c8f88e3b0c 100644 --- a/packages/catalog-model/src/entity/constants.ts +++ b/packages/catalog-model/src/entity/constants.ts @@ -27,3 +27,9 @@ export const ENTITY_META_GENERATED_FIELDS = [ 'etag', 'generation', ] as const; + +/** + * Annotations for linking to entity from catalog pages. + */ +export const VIEW_URL_ANNOTATION = 'backstage.io/view-url'; +export const EDIT_URL_ANNOTATION = 'backstage.io/edit-url'; diff --git a/packages/catalog-model/src/entity/index.ts b/packages/catalog-model/src/entity/index.ts index e80e14f7a4..e267c607e1 100644 --- a/packages/catalog-model/src/entity/index.ts +++ b/packages/catalog-model/src/entity/index.ts @@ -17,6 +17,8 @@ export { ENTITY_DEFAULT_NAMESPACE, ENTITY_META_GENERATED_FIELDS, + VIEW_URL_ANNOTATION, + EDIT_URL_ANNOTATION, } from './constants'; export type { Entity, diff --git a/packages/catalog-model/src/location/annotation.ts b/packages/catalog-model/src/location/annotation.ts index 93f2fabea4..ba875c3edf 100644 --- a/packages/catalog-model/src/location/annotation.ts +++ b/packages/catalog-model/src/location/annotation.ts @@ -17,3 +17,5 @@ export const LOCATION_ANNOTATION = 'backstage.io/managed-by-location'; export const ORIGIN_LOCATION_ANNOTATION = 'backstage.io/managed-by-origin-location'; + +export const SOURCE_LOCATION_ANNOTATION = 'backstage.io/source-location'; diff --git a/packages/catalog-model/src/location/index.ts b/packages/catalog-model/src/location/index.ts index 8fd516120a..6cfb074613 100644 --- a/packages/catalog-model/src/location/index.ts +++ b/packages/catalog-model/src/location/index.ts @@ -20,4 +20,8 @@ export { locationSpecSchema, analyzeLocationSchema, } from './validation'; -export { LOCATION_ANNOTATION, ORIGIN_LOCATION_ANNOTATION } from './annotation'; +export { + LOCATION_ANNOTATION, + ORIGIN_LOCATION_ANNOTATION, + SOURCE_LOCATION_ANNOTATION, +} from './annotation'; diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx index ada827115d..449d3c1fc9 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx @@ -15,6 +15,10 @@ */ import { EntityProvider } from '@backstage/plugin-catalog-react'; +import { + SOURCE_LOCATION_ANNOTATION, + EDIT_URL_ANNOTATION, +} from '@backstage/catalog-model'; import { render } from '@testing-library/react'; import React from 'react'; import { AboutCard } from './AboutCard'; @@ -123,3 +127,41 @@ describe(' BitBucket', () => { ); }); }); + +describe(' custom links', () => { + it('renders info and "view source" link', () => { + const entity = { + apiVersion: 'v1', + kind: 'Component', + metadata: { + name: 'software', + annotations: { + 'backstage.io/managed-by-location': + 'bitbucket:https://bitbucket.org/backstage/backstage/src/master/software.yaml', + [EDIT_URL_ANNOTATION]: 'https://another.place', + [SOURCE_LOCATION_ANNOTATION]: + 'url:https://another.place/backstage.git', + }, + }, + spec: { + owner: 'guest', + type: 'service', + lifecycle: 'production', + }, + }; + const { getByText } = render( + + + , + ); + expect(getByText('service')).toBeInTheDocument(); + expect(getByText('View Source').closest('a')).toHaveAttribute( + 'href', + 'https://another.place/backstage.git', + ); + expect(getByText('View Source').closest('a')).toHaveAttribute( + 'edithref', + 'https://another.place', + ); + }); +}); diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx index 2d6cd6bca8..64564f5ec4 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -16,7 +16,9 @@ import { Entity, + LocationSpec, ENTITY_DEFAULT_NAMESPACE, + SOURCE_LOCATION_ANNOTATION, RELATION_PROVIDES_API, } from '@backstage/catalog-model'; import { HeaderIconLinkRow } from '@backstage/core'; @@ -34,8 +36,8 @@ import EditIcon from '@material-ui/icons/Edit'; import ExtensionIcon from '@material-ui/icons/Extension'; import GitHubIcon from '@material-ui/icons/GitHub'; import React from 'react'; -import { findLocationForEntityMeta } from '../../data/utils'; -import { createEditLink, determineUrlType } from '../createEditLink'; +import { findLocationForEntityMeta, parseLocation } from '../../data/utils'; +import { findEditUrl, determineUrlType } from '../actions'; import { AboutContent } from './AboutContent'; const useStyles = makeStyles({ @@ -60,20 +62,35 @@ type CodeLinkInfo = { href?: string; }; +function getSourceLocationForEntity( + entity: Entity, + location?: LocationSpec, +): LocationSpec | undefined { + const annotation = entity.metadata?.annotations?.[SOURCE_LOCATION_ANNOTATION]; + const parsed = annotation && parseLocation(annotation); + + return parsed || location; +} + function getCodeLinkInfo(entity: Entity): CodeLinkInfo { const location = findLocationForEntityMeta(entity?.metadata); + const editUrl = findEditUrl(entity); + let sourceLocation = getSourceLocationForEntity(entity, location); + if (location) { + sourceLocation = sourceLocation || location; const type = - location.type === 'url' - ? determineUrlType(location.target) - : location.type; + sourceLocation.type === 'url' + ? determineUrlType(sourceLocation.target) + : sourceLocation.type; return { + edithref: editUrl, icon: iconMap[type], - edithref: createEditLink(location), - href: location.target, + href: sourceLocation.target, }; } - return {}; + + return { edithref: editUrl, href: sourceLocation?.target }; } type AboutCardProps = { diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx index 16c91c0d6c..ffd5f3d190 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.test.tsx @@ -14,7 +14,12 @@ * limitations under the License. */ -import { Entity } from '@backstage/catalog-model'; +import { + Entity, + VIEW_URL_ANNOTATION, + EDIT_URL_ANNOTATION, +} from '@backstage/catalog-model'; +import { act, fireEvent } from '@testing-library/react'; import { renderWithEffects, wrapInTestApp } from '@backstage/test-utils'; import * as React from 'react'; import { CatalogTable } from './CatalogTable'; @@ -38,6 +43,14 @@ const entities: Entity[] = [ ]; describe('CatalogTable component', () => { + beforeEach(() => { + window.open = jest.fn(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + it('should render error message when error is passed in props', async () => { const rendered = await renderWithEffects( wrapInTestApp( @@ -70,4 +83,62 @@ describe('CatalogTable component', () => { expect(rendered.getByText(/component2/)).toBeInTheDocument(); expect(rendered.getByText(/component3/)).toBeInTheDocument(); }); + + it('should use specified edit URL if in annotation', async () => { + const entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'component1', + annotations: { [EDIT_URL_ANNOTATION]: 'https://other.place' }, + }, + }; + + const { getByTitle } = await renderWithEffects( + wrapInTestApp( + , + ), + ); + + const editButton = getByTitle('Edit'); + + await act(async () => { + fireEvent.click(editButton); + }); + + expect(window.open).toHaveBeenCalledWith('https://other.place', '_blank'); + }); + + it('should use specified view URL if in annotation', async () => { + const entity = { + apiVersion: 'backstage.io/v1alpha1', + kind: 'Component', + metadata: { + name: 'component1', + annotations: { [VIEW_URL_ANNOTATION]: 'https://other.place' }, + }, + }; + + const { getByTitle } = await renderWithEffects( + wrapInTestApp( + , + ), + ); + + const viewButton = getByTitle('View'); + + await act(async () => { + fireEvent.click(viewButton); + }); + + expect(window.open).toHaveBeenCalledWith('https://other.place', '_blank'); + }); }); diff --git a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx index 12d97e91a6..7d693d1bc8 100644 --- a/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx +++ b/plugins/catalog/src/components/CatalogTable/CatalogTable.tsx @@ -34,13 +34,11 @@ import { getEntityRelations, useStarredEntities, } from '@backstage/plugin-catalog-react'; - import { Chip } from '@material-ui/core'; import Edit from '@material-ui/icons/Edit'; import OpenInNew from '@material-ui/icons/OpenInNew'; import React from 'react'; -import { findLocationForEntityMeta } from '../../data/utils'; -import { createEditLink } from '../createEditLink'; +import { findViewUrl, findEditUrl } from '../actions'; import { favouriteEntityIcon, favouriteEntityTooltip, @@ -153,24 +151,24 @@ export const CatalogTable = ({ const actions: TableProps['actions'] = [ ({ entity }) => { - const location = findLocationForEntityMeta(entity.metadata); + const url = findViewUrl(entity); return { icon: () => , tooltip: 'View', onClick: () => { - if (!location) return; - window.open(location.target, '_blank'); + if (!url) return; + window.open(url, '_blank'); }, }; }, ({ entity }) => { - const location = findLocationForEntityMeta(entity.metadata); + const url = findEditUrl(entity); return { icon: () => , tooltip: 'Edit', onClick: () => { - if (!location) return; - window.open(createEditLink(location), '_blank'); + if (!url) return; + window.open(url, '_blank'); }, }; }, diff --git a/plugins/catalog/src/components/createEditLink.ts b/plugins/catalog/src/components/actions.ts similarity index 76% rename from plugins/catalog/src/components/createEditLink.ts rename to plugins/catalog/src/components/actions.ts index 57fc876704..72e1bf67ec 100644 --- a/plugins/catalog/src/components/createEditLink.ts +++ b/plugins/catalog/src/components/actions.ts @@ -14,7 +14,13 @@ * limitations under the License. */ -import { LocationSpec } from '@backstage/catalog-model'; +import { + LocationSpec, + Entity, + EDIT_URL_ANNOTATION, + VIEW_URL_ANNOTATION, +} from '@backstage/catalog-model'; +import { findLocationForEntityMeta } from '../data/utils'; import parseGitUrl from 'git-url-parse'; /** @@ -75,3 +81,22 @@ export const determineUrlType = (url: string): string => { } return 'url'; }; + +export const findEditUrl = ({ metadata }: Entity): string | undefined => { + const annotations = metadata.annotations || {}; + + const editUrl = annotations[EDIT_URL_ANNOTATION]; + + if (editUrl) return editUrl; + + const location = findLocationForEntityMeta(metadata); + + return location && createEditLink(location); +}; + +export const findViewUrl = ({ metadata }: Entity): string | undefined => { + const annotations = metadata.annotations || {}; + const location = findLocationForEntityMeta(metadata); + + return annotations[VIEW_URL_ANNOTATION] || location?.target; +}; diff --git a/plugins/catalog/src/data/utils.ts b/plugins/catalog/src/data/utils.ts index df14875092..ec63d94754 100644 --- a/plugins/catalog/src/data/utils.ts +++ b/plugins/catalog/src/data/utils.ts @@ -32,13 +32,17 @@ export function findLocationForEntityMeta( return undefined; } - const separatorIndex = annotation.indexOf(':'); + return parseLocation(annotation); +} + +export function parseLocation(reference: string): LocationSpec | undefined { + const separatorIndex = reference.indexOf(':'); if (separatorIndex === -1) { return undefined; } return { - type: annotation.substring(0, separatorIndex), - target: annotation.substring(separatorIndex + 1), + type: reference.substring(0, separatorIndex), + target: reference.substring(separatorIndex + 1), }; } diff --git a/yarn.lock b/yarn.lock index c32766faa3..90e7e8bb14 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1874,7 +1874,6 @@ "@backstage/catalog-model" "^0.7.1" "@backstage/core" "^0.6.2" "@backstage/plugin-catalog-react" "^0.0.4" - "@backstage/plugin-scaffolder" "^0.5.1" "@backstage/theme" "^0.2.3" "@material-ui/core" "^4.11.0" "@material-ui/icons" "^4.9.1" @@ -18238,7 +18237,7 @@ modify-values@^1.0.0: resolved "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022" integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw== -moment@^2.19.3, moment@^2.25.3, moment@^2.26.0, moment@^2.27.0: +moment@^2.19.3, moment@^2.25.3, moment@^2.27.0: version "2.29.1" resolved "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz#b2be769fa31940be9eeea6469c075e35006fa3d3" integrity sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ==