Merge pull request #4575 from tragiclifestories/link-annotation
Customise view, edit and source links through annotations
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/catalog-model': patch
|
||||
'@backstage/plugin-catalog': patch
|
||||
---
|
||||
|
||||
Implement annotations for customising Entity URLs in the Catalog pages.
|
||||
@@ -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';
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
+26
-1
@@ -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;
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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==
|
||||
|
||||
Reference in New Issue
Block a user