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==