Merge pull request #4575 from tragiclifestories/link-annotation

Customise view, edit and source links through annotations
This commit is contained in:
Fredrik Adelöw
2021-02-20 14:17:56 +01:00
committed by GitHub
14 changed files with 231 additions and 25 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/catalog-model': patch
'@backstage/plugin-catalog': patch
---
Implement annotations for customising Entity URLs in the Catalog pages.
+1
View File
@@ -166,6 +166,7 @@ interop
jq
js
json
jsonnet
jsx
kubectl
kubernetes
@@ -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
@@ -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';
@@ -17,6 +17,8 @@
export {
ENTITY_DEFAULT_NAMESPACE,
ENTITY_META_GENERATED_FIELDS,
VIEW_URL_ANNOTATION,
EDIT_URL_ANNOTATION,
} from './constants';
export type {
Entity,
@@ -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';
+5 -1
View File
@@ -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';
@@ -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('<AboutCard /> BitBucket', () => {
);
});
});
describe('<AboutCard /> 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(
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>,
);
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',
);
});
});
@@ -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 = {
@@ -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(
<CatalogTable
titlePreamble="Owned"
entities={[entity]}
loading={false}
/>,
),
);
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(
<CatalogTable
titlePreamble="Owned"
entities={[entity]}
loading={false}
/>,
),
);
const viewButton = getByTitle('View');
await act(async () => {
fireEvent.click(viewButton);
});
expect(window.open).toHaveBeenCalledWith('https://other.place', '_blank');
});
});
@@ -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<EntityRow>['actions'] = [
({ entity }) => {
const location = findLocationForEntityMeta(entity.metadata);
const url = findViewUrl(entity);
return {
icon: () => <OpenInNew fontSize="small" />,
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: () => <Edit fontSize="small" />,
tooltip: 'Edit',
onClick: () => {
if (!location) return;
window.open(createEditLink(location), '_blank');
if (!url) return;
window.open(url, '_blank');
},
};
},
@@ -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;
};
+7 -3
View File
@@ -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),
};
}
+1 -2
View File
@@ -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==