Merge pull request #4702 from SDA-SE/feat/annotations-view-edit-source

Move logic for generating URLs for the view, edit and source links of catalog entities from the catalog frontend into the backend
This commit is contained in:
Oliver Sand
2021-03-08 12:40:19 +01:00
committed by GitHub
28 changed files with 674 additions and 331 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/integration': patch
---
Add `resolveEditUrl` to integrations to resolve a URL that can be used to edit
a file in the web interfaces of an SCM.
+11
View File
@@ -0,0 +1,11 @@
---
'@backstage/plugin-catalog': patch
'@backstage/plugin-catalog-backend': patch
---
Move logic for generating URLs for the view, edit and source links of catalog
entities from the catalog frontend into the backend. This is done using the
existing support for the `backstage.io/view-url`, `backstage.io/edit-url` and
`backstage.io/source-location` annotations that are now filled by the
`AnnotateLocationEntityProcessor`. If these annotations are missing or empty,
the UI disables the related controls.
+1 -1
View File
@@ -208,7 +208,7 @@ catalog:
# groupFilter: securityEnabled eq false and mailEnabled eq true and groupTypes/any(c:c+eq+'Unified')
locations:
# Add a location here to ingest it, for example from an URL:
# Add a location here to ingest it, for example from a URL:
#
# - type: url
# target: https://github.com/backstage/backstage/blob/master/packages/catalog-model/examples/all-components.yaml
@@ -88,4 +88,10 @@ describe('ScmIntegrations', () => {
}),
).toBe('https://absolute.com/path');
});
it('can resolveEditUrl using fallback', () => {
expect(i.resolveEditUrl('http://example.com/x/a.yaml')).toBe(
'http://example.com/x/a.yaml',
);
});
});
@@ -91,4 +91,13 @@ export class ScmIntegrations implements ScmIntegrationRegistry {
return integration.resolveUrl(options);
}
resolveEditUrl(url: string): string {
const integration = this.byUrl(url);
if (!integration) {
return url;
}
return integration.resolveEditUrl(url);
}
}
@@ -99,4 +99,18 @@ describe('AzureIntegration', () => {
).toBe('https://dev.azure.com/organization/project/test');
});
});
it('resolve edit URL', () => {
const integration = new AzureIntegration({ host: 'h.com' } as any);
// TODO: The Azure integration doesn't support resolving an edit URL yet,
// instead we keep the input URL.
expect(
integration.resolveEditUrl(
'https://dev.azure.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml',
),
).toBe(
'https://dev.azure.com/organization/project/_git/repository?path=%2Fcatalog-info.yaml',
);
});
});
@@ -15,7 +15,7 @@
*/
import parseGitUrl from 'git-url-parse';
import { basicIntegrations } from '../helpers';
import { basicIntegrations, isValidUrl } from '../helpers';
import { ScmIntegration, ScmIntegrationsFactory } from '../types';
import { AzureIntegrationConfig, readAzureIntegrationConfigs } from './config';
@@ -53,12 +53,8 @@ export class AzureIntegration implements ScmIntegration {
const { url, base } = options;
// If we can parse the url, it is absolute - then return it verbatim
try {
// eslint-disable-next-line no-new
new URL(url);
if (isValidUrl(url)) {
return url;
} catch {
// Ignore intentionally - looks like a relative path
}
const parsed = parseGitUrl(base);
@@ -78,4 +74,10 @@ export class AzureIntegration implements ScmIntegration {
return newUrl.toString();
}
resolveEditUrl(url: string): string {
// TODO: Implement edit URL for Azure, fallback to view url as I don't know
// how azure works.
return url;
}
}
@@ -44,4 +44,16 @@ describe('BitbucketIntegration', () => {
expect(integration.type).toBe('bitbucket');
expect(integration.title).toBe('h.com');
});
it('resolve edit URL', () => {
const integration = new BitbucketIntegration({ host: 'h.com' } as any);
expect(
integration.resolveEditUrl(
'https://bitbucket.org/my-owner/my-project/src/master/README.md',
),
).toBe(
'https://bitbucket.org/my-owner/my-project/src/master/README.md?mode=edit&spa=0&at=master',
);
});
});
@@ -14,6 +14,7 @@
* limitations under the License.
*/
import parseGitUrl from 'git-url-parse';
import { basicIntegrations, defaultScmResolveUrl } from '../helpers';
import { ScmIntegration, ScmIntegrationsFactory } from '../types';
import {
@@ -51,4 +52,16 @@ export class BitbucketIntegration implements ScmIntegration {
resolveUrl(options: { url: string; base: string }): string {
return defaultScmResolveUrl(options);
}
resolveEditUrl(url: string): string {
const urlData = parseGitUrl(url);
const editUrl = new URL(url);
editUrl.searchParams.set('mode', 'edit');
// TODO: Not sure what spa=0 does, at least bitbucket.org doesn't support it
// but this is taken over from the initial implementation.
editUrl.searchParams.set('spa', '0');
editUrl.searchParams.set('at', urlData.ref);
return editUrl.toString();
}
}
@@ -15,7 +15,7 @@
*/
import { ConfigReader } from '@backstage/config';
import { GitHubIntegration } from './GitHubIntegration';
import { GitHubIntegration, replaceUrlType } from './GitHubIntegration';
describe('GitHubIntegration', () => {
it('has a working factory', () => {
@@ -49,4 +49,60 @@ describe('GitHubIntegration', () => {
expect(integration.title).toBe('h.com');
expect(integration.config.host).toBe('h.com');
});
it('resolveUrl', () => {
const integration = new GitHubIntegration({ host: 'h.com' });
expect(
integration.resolveUrl({
url: '../a.yaml',
base:
'https://github.com/backstage/backstage/blob/master/test/README.md',
}),
).toBe('https://github.com/backstage/backstage/tree/master/a.yaml');
expect(
integration.resolveUrl({
url: './',
base:
'https://github.com/backstage/backstage/blob/master/test/README.md',
}),
).toBe('https://github.com/backstage/backstage/tree/master/test/');
});
it('resolve edit URL', () => {
const integration = new GitHubIntegration({ host: 'h.com' });
expect(
integration.resolveEditUrl(
'https://github.com/backstage/backstage/blob/master/README.md',
),
).toBe('https://github.com/backstage/backstage/edit/master/README.md');
});
});
describe('replaceUrlType', () => {
it('should replace with expected type', () => {
expect(
replaceUrlType(
'https://github.com/backstage/backstage/blob/master/README.md',
'edit',
),
).toBe('https://github.com/backstage/backstage/edit/master/README.md');
expect(
replaceUrlType(
'https://github.com/webmodules/blob/blob/master/test',
'tree',
),
).toBe('https://github.com/webmodules/blob/tree/master/test');
expect(
replaceUrlType('https://github.com/blob/blob/blob/master/test', 'tree'),
).toBe('https://github.com/blob/blob/tree/master/test');
expect(
replaceUrlType(
'https://github.com/backstage/backstage/edit/tree/README.md',
'blob',
),
).toBe('https://github.com/backstage/backstage/blob/tree/README.md');
});
});
@@ -47,6 +47,25 @@ export class GitHubIntegration implements ScmIntegration {
}
resolveUrl(options: { url: string; base: string }): string {
return defaultScmResolveUrl(options);
// GitHub uses blob URLs for files and tree urls for directory listings. But
// there is a redirect from tree to blob for files, so we can always return
// tree urls here.
return replaceUrlType(defaultScmResolveUrl(options), 'tree');
}
resolveEditUrl(url: string): string {
return replaceUrlType(url, 'edit');
}
}
export function replaceUrlType(
url: string,
type: 'blob' | 'tree' | 'edit',
): string {
return url.replace(
/\/\/([^/]+)\/([^/]+)\/([^/]+)\/(blob|tree|edit)\//,
(_, host, owner, repo) => {
return `//${host}/${owner}/${repo}/${type}/`;
},
);
}
@@ -15,7 +15,7 @@
*/
import { ConfigReader } from '@backstage/config';
import { GitLabIntegration } from './GitLabIntegration';
import { GitLabIntegration, replaceUrlType } from './GitLabIntegration';
describe('GitLabIntegration', () => {
it('has a working factory', () => {
@@ -43,4 +43,43 @@ describe('GitLabIntegration', () => {
expect(integration.type).toBe('gitlab');
expect(integration.title).toBe('h.com');
});
it('resolve edit URL', () => {
const integration = new GitLabIntegration({ host: 'h.com' } as any);
expect(
integration.resolveEditUrl(
'https://gitlab.com/my-org/my-project/-/blob/develop/README.md',
),
).toBe('https://gitlab.com/my-org/my-project/-/edit/develop/README.md');
});
});
describe('replaceUrlType', () => {
it('should replace with expected type', () => {
expect(
replaceUrlType(
'https://gitlab.com/my-org/my-project/-/blob/develop/README.md',
'edit',
),
).toBe('https://gitlab.com/my-org/my-project/-/edit/develop/README.md');
expect(
replaceUrlType(
'https://gitlab.com/webmodules/blob/-/blob/develop/test',
'tree',
),
).toBe('https://gitlab.com/webmodules/blob/-/tree/develop/test');
expect(
replaceUrlType(
'https://gitlab.com/blob/blob/-/blob/develop/test',
'tree',
),
).toBe('https://gitlab.com/blob/blob/-/tree/develop/test');
expect(
replaceUrlType(
'https://gitlab.com/blob/blob/-/edit/develop/README.md',
'tree',
),
).toBe('https://gitlab.com/blob/blob/-/tree/develop/README.md');
});
});
@@ -49,4 +49,15 @@ export class GitLabIntegration implements ScmIntegration {
resolveUrl(options: { url: string; base: string }): string {
return defaultScmResolveUrl(options);
}
resolveEditUrl(url: string): string {
return replaceUrlType(url, 'edit');
}
}
export function replaceUrlType(
url: string,
type: 'blob' | 'tree' | 'edit',
): string {
return url.replace(/\/\-\/(blob|tree|edit)\//, `/-/${type}/`);
}
+26
View File
@@ -51,6 +51,19 @@ export interface ScmIntegration {
* @param options.base The base URL onto which this resolution happens
*/
resolveUrl(options: { url: string; base: string }): string;
/**
* Resolves the edit URL for a file within the SCM system.
*
* Most SCM systems have a web interface that allows viewing and editing files
* in the repository. The returned URL directly jumps into the edit mode for
* the file.
* If this is not possible, the integration can fall back to a URL to view
* the file in the web interface.
*
* @param url The absolute URL to the file that should be edited.
*/
resolveEditUrl(url: string): string;
}
/**
@@ -103,6 +116,19 @@ export interface ScmIntegrationRegistry
* @param options.base The base URL onto which this resolution happens
*/
resolveUrl(options: { url: string; base: string }): string;
/**
* Resolves the edit URL for a file within the SCM system.
*
* Most SCM systems have a web interface that allows viewing and editing files
* in the repository. The returned URL directly jumps into the edit mode for
* the file.
* If this is not possible, the integration can fall back to a URL to view
* the file in the web interface.
*
* @param url The absolute URL to the file that should be edited.
*/
resolveEditUrl(url: string): string;
}
export type ScmIntegrationsFactory<T extends ScmIntegration> = (options: {
@@ -15,6 +15,8 @@
*/
import { Entity, LocationSpec } from '@backstage/catalog-model';
import { ConfigReader } from '@backstage/config';
import { ScmIntegrations } from '@backstage/integration';
import { AnnotateLocationEntityProcessor } from './AnnotateLocationEntityProcessor';
describe('AnnotateLocationEntityProcessor', () => {
@@ -30,14 +32,17 @@ describe('AnnotateLocationEntityProcessor', () => {
const location: LocationSpec = {
type: 'url',
target: 'my-location',
target:
'https://github.com/backstage/backstage/blob/master/packages/app/catalog-info.yaml',
};
const originLocation: LocationSpec = {
type: 'url',
target: 'my-origin-location',
target:
'https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
};
const processor = new AnnotateLocationEntityProcessor();
const integrations = ScmIntegrations.fromConfig(new ConfigReader({}));
const processor = new AnnotateLocationEntityProcessor({ integrations });
expect(
await processor.preProcessEntity(
@@ -52,8 +57,110 @@ describe('AnnotateLocationEntityProcessor', () => {
metadata: {
name: 'my-component',
annotations: {
'backstage.io/managed-by-location': 'url:my-location',
'backstage.io/managed-by-origin-location': 'url:my-origin-location',
'backstage.io/managed-by-location':
'url:https://github.com/backstage/backstage/blob/master/packages/app/catalog-info.yaml',
'backstage.io/managed-by-origin-location':
'url:https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
'backstage.io/view-url':
'https://github.com/backstage/backstage/blob/master/packages/app/catalog-info.yaml',
'backstage.io/edit-url':
'https://github.com/backstage/backstage/edit/master/packages/app/catalog-info.yaml',
'backstage.io/source-location':
'url:https://github.com/backstage/backstage/tree/master/packages/app/',
},
},
});
});
it('does not override existing annotations', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
annotations: {
'backstage.io/view-url': 'https://example.com/view',
'backstage.io/edit-url': 'https://example.com/edit',
'backstage.io/source-location': 'url:https://example.com/source',
},
},
};
const location: LocationSpec = {
type: 'url',
target:
'https://github.com/backstage/backstage/blob/master/packages/app/catalog-info.yaml',
};
const originLocation: LocationSpec = {
type: 'url',
target:
'https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
};
const integrations = ScmIntegrations.fromConfig(new ConfigReader({}));
const processor = new AnnotateLocationEntityProcessor({ integrations });
expect(
await processor.preProcessEntity(
entity,
location,
() => {},
originLocation,
),
).toEqual({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
annotations: {
'backstage.io/managed-by-location':
'url:https://github.com/backstage/backstage/blob/master/packages/app/catalog-info.yaml',
'backstage.io/managed-by-origin-location':
'url:https://github.com/backstage/backstage/blob/master/catalog-info.yaml',
'backstage.io/view-url': 'https://example.com/view',
'backstage.io/edit-url': 'https://example.com/edit',
'backstage.io/source-location': 'url:https://example.com/source',
},
},
});
});
it('does not output view, edit or source location annotations for non url type locations', async () => {
const entity: Entity = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
},
};
const location: LocationSpec = {
type: 'file',
target: './test.yaml',
};
const originLocation: LocationSpec = {
type: 'file',
target: './test.yaml',
};
const integrations = ScmIntegrations.fromConfig(new ConfigReader({}));
const processor = new AnnotateLocationEntityProcessor({ integrations });
expect(
await processor.preProcessEntity(
entity,
location,
() => {},
originLocation,
),
).toEqual({
apiVersion: 'backstage.io/v1alpha1',
kind: 'Component',
metadata: {
name: 'my-component',
annotations: {
'backstage.io/managed-by-location': 'file:./test.yaml',
'backstage.io/managed-by-origin-location': 'file:./test.yaml',
},
},
});
@@ -15,31 +15,71 @@
*/
import {
EDIT_URL_ANNOTATION,
Entity,
LocationSpec,
LOCATION_ANNOTATION,
ORIGIN_LOCATION_ANNOTATION,
SOURCE_LOCATION_ANNOTATION,
stringifyLocationReference,
VIEW_URL_ANNOTATION,
} from '@backstage/catalog-model';
import lodash from 'lodash';
import { ScmIntegrationRegistry } from '@backstage/integration';
import { identity, merge, pickBy } from 'lodash';
import { CatalogProcessor, CatalogProcessorEmit } from './types';
type Options = {
integrations: ScmIntegrationRegistry;
};
export class AnnotateLocationEntityProcessor implements CatalogProcessor {
constructor(private readonly options: Options) {}
async preProcessEntity(
entity: Entity,
location: LocationSpec,
_: CatalogProcessorEmit,
originLocation: LocationSpec,
): Promise<Entity> {
return lodash.merge(
const { integrations } = this.options;
let viewUrl;
let editUrl;
let sourceLocation;
if (location.type === 'url') {
const scmIntegration = integrations.byUrl(location.target);
viewUrl = location.target;
editUrl = scmIntegration?.resolveEditUrl(location.target);
const sourceUrl = scmIntegration?.resolveUrl({
url: './',
base: location.target,
});
if (sourceUrl) {
sourceLocation = stringifyLocationReference({
type: 'url',
target: sourceUrl,
});
}
}
return merge(
{
metadata: {
annotations: {
[LOCATION_ANNOTATION]: stringifyLocationReference(location),
[ORIGIN_LOCATION_ANNOTATION]: stringifyLocationReference(
originLocation,
),
},
annotations: pickBy(
{
[LOCATION_ANNOTATION]: stringifyLocationReference(location),
[ORIGIN_LOCATION_ANNOTATION]: stringifyLocationReference(
originLocation,
),
[VIEW_URL_ANNOTATION]: viewUrl,
[EDIT_URL_ANNOTATION]: editUrl,
[SOURCE_LOCATION_ANNOTATION]: sourceLocation,
},
identity,
),
},
},
entity,
@@ -331,7 +331,7 @@ describe('PlaceholderProcessor', () => {
},
),
).rejects.toThrow(
'Placeholder $text could not form an URL out of ./a/b/catalog-info.yaml and ../c/catalog-info.yaml',
'Placeholder $text could not form a URL out of ./a/b/catalog-info.yaml and ../c/catalog-info.yaml',
);
expect(read).not.toBeCalled();
@@ -203,7 +203,7 @@ function relativeUrl({ key, value, baseUrl }: ResolverParams): string {
// path traversal attacks and access to any file on the host system. Implementing this
// would require additional security measures.
throw new Error(
`Placeholder \$${key} could not form an URL out of ${baseUrl} and ${value}`,
`Placeholder \$${key} could not form a URL out of ${baseUrl} and ${value}`,
);
}
}
@@ -311,7 +311,7 @@ export class CatalogBuilder {
new UrlReaderProcessor({ reader, logger }),
new CodeOwnersProcessor({ reader, logger }),
new LocationEntityProcessor({ integrations }),
new AnnotateLocationEntityProcessor(),
new AnnotateLocationEntityProcessor({ integrations }),
);
}
+1
View File
@@ -33,6 +33,7 @@
"@backstage/catalog-client": "^0.3.6",
"@backstage/catalog-model": "^0.7.3",
"@backstage/core": "^0.7.0",
"@backstage/integration": "^0.5.0",
"@backstage/plugin-catalog-react": "^0.1.1",
"@backstage/theme": "^0.2.3",
"@material-ui/core": "^4.11.0",
@@ -14,25 +14,74 @@
* limitations under the License.
*/
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { RELATION_OWNED_BY } from '@backstage/catalog-model';
import {
SOURCE_LOCATION_ANNOTATION,
EDIT_URL_ANNOTATION,
} from '@backstage/catalog-model';
import { render, act, fireEvent } from '@testing-library/react';
ApiProvider,
ApiRegistry,
configApiRef,
ConfigReader,
} from '@backstage/core';
import { EntityProvider } from '@backstage/plugin-catalog-react';
import { renderInTestApp } from '@backstage/test-utils';
import { act, fireEvent } from '@testing-library/react';
import React from 'react';
import { AboutCard } from './AboutCard';
describe('<AboutCard /> GitHub', () => {
it('renders info and "view source" link', async () => {
describe('<AboutCard />', () => {
it('renders info', async () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
description: 'This is the decription',
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
relations: [
{
type: RELATION_OWNED_BY,
target: {
kind: 'user',
name: 'guest',
namespace: 'default',
},
},
],
};
const apis = ApiRegistry.with(
configApiRef,
new ConfigReader({
integrations: {},
}),
);
const { getByText } = await renderInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>
</ApiProvider>,
);
expect(getByText('service')).toBeInTheDocument();
expect(getByText('user:guest')).toBeInTheDocument();
expect(getByText('production')).toBeInTheDocument();
expect(getByText('This is the decription')).toBeInTheDocument();
});
it('renders "view source" link', async () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
annotations: {
'backstage.io/managed-by-location':
'github:https://github.com/backstage/backstage/blob/master/software.yaml',
'backstage.io/source-location':
'url:https://github.com/backstage/backstage/blob/master/software.yaml',
},
},
spec: {
@@ -41,16 +90,71 @@ describe('<AboutCard /> GitHub', () => {
lifecycle: 'production',
},
};
const { getByText, getByTitle } = render(
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>,
const apis = ApiRegistry.with(
configApiRef,
new ConfigReader({
integrations: {
github: [
{
host: 'github.com',
token: '...',
},
],
},
}),
);
const { getByText } = await renderInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>
</ApiProvider>,
);
expect(getByText('service')).toBeInTheDocument();
expect(getByText('View Source').closest('a')).toHaveAttribute(
'href',
'https://github.com/backstage/backstage/blob/master/software.yaml',
);
});
it('renders "edit metadata" button', async () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
annotations: {
'backstage.io/edit-url':
'https://github.com/backstage/backstage/edit/master/software.yaml',
},
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
const apis = ApiRegistry.with(
configApiRef,
new ConfigReader({
integrations: {
github: [
{
host: 'github.com',
token: '...',
},
],
},
}),
);
const { getByTitle } = await renderInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>
</ApiProvider>,
);
const editButton = getByTitle('Edit Metadata');
window.open = jest.fn();
@@ -62,19 +166,13 @@ describe('<AboutCard /> GitHub', () => {
'_blank',
);
});
});
describe('<AboutCard /> GitLab', () => {
it('renders info and "view source" link', async () => {
it('renders without "view source" link', async () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
annotations: {
'backstage.io/managed-by-location':
'gitlab:https://gitlab.com/backstage/backstage/-/blob/master/software.yaml',
},
},
spec: {
owner: 'guest',
@@ -82,108 +180,15 @@ describe('<AboutCard /> GitLab', () => {
lifecycle: 'production',
},
};
const { getByText, getByTitle } = render(
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>,
);
const apis = ApiRegistry.with(configApiRef, new ConfigReader({}));
expect(getByText('service')).toBeInTheDocument();
expect(getByText('View Source').closest('a')).toHaveAttribute(
'href',
'https://gitlab.com/backstage/backstage/-/blob/master/software.yaml',
);
const editButton = getByTitle('Edit Metadata');
window.open = jest.fn();
await act(async () => {
fireEvent.click(editButton);
});
expect(window.open).toHaveBeenCalledWith(
`https://gitlab.com/backstage/backstage/-/edit/master/software.yaml`,
'_blank',
const { getByText } = await renderInTestApp(
<ApiProvider apis={apis}>
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>
</ApiProvider>,
);
});
});
describe('<AboutCard /> BitBucket', () => {
it('renders info and "view source" link', async () => {
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',
},
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
const { getByText, getByTitle } = render(
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>,
);
expect(getByText('service')).toBeInTheDocument();
expect(getByText('View Source').closest('a')).toHaveAttribute(
'href',
'https://bitbucket.org/backstage/backstage/src/master/software.yaml',
);
const editButton = getByTitle('Edit Metadata');
window.open = jest.fn();
await act(async () => {
fireEvent.click(editButton);
});
expect(window.open).toHaveBeenCalledWith(
`https://bitbucket.org/backstage/backstage/src/master/software.yaml?mode=edit&spa=0&at=master`,
'_blank',
);
});
});
describe('<AboutCard /> custom links', () => {
it('renders info and "view source" link', async () => {
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, getByTitle } = render(
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>,
);
expect(getByText('service')).toBeInTheDocument();
expect(getByText('View Source').closest('a')).toHaveAttribute(
'href',
'https://another.place/backstage.git',
);
const editButton = getByTitle('Edit Metadata');
window.open = jest.fn();
await act(async () => {
fireEvent.click(editButton);
});
expect(window.open).toHaveBeenCalledWith(`https://another.place`, '_blank');
expect(getByText('View Source').closest('a')).not.toHaveAttribute('href');
});
});
@@ -16,13 +16,17 @@
import {
Entity,
LocationSpec,
ENTITY_DEFAULT_NAMESPACE,
SOURCE_LOCATION_ANNOTATION,
RELATION_CONSUMES_API,
RELATION_PROVIDES_API,
} from '@backstage/catalog-model';
import { HeaderIconLinkRow, IconLinkVerticalProps } from '@backstage/core';
import { useEntity } from '@backstage/plugin-catalog-react';
import {
configApiRef,
HeaderIconLinkRow,
IconLinkVerticalProps,
useApi,
} from '@backstage/core';
import { getEntityRelations, useEntity } from '@backstage/plugin-catalog-react';
import {
Card,
CardContent,
@@ -34,11 +38,10 @@ import {
import DocsIcon from '@material-ui/icons/Description';
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, parseLocation } from '../../data/utils';
import { findEditUrl, determineUrlType } from '../actions';
import { getEntityMetadataEditUrl, getEntitySourceLocation } from '../../utils';
import { AboutContent } from './AboutContent';
import { ScmIntegrationIcon } from './ScmIntegrationIcon';
const useStyles = makeStyles({
gridItemCard: {
@@ -52,47 +55,6 @@ const useStyles = makeStyles({
},
});
const iconMap: Record<string, React.ReactNode> = {
github: <GitHubIcon />,
};
type CodeLinkInfo = {
icon?: React.ReactNode;
edithref?: string;
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 =
sourceLocation.type === 'url'
? determineUrlType(sourceLocation.target)
: sourceLocation.type;
return {
edithref: editUrl,
icon: iconMap[type],
href: sourceLocation.target,
};
}
return { edithref: editUrl, href: sourceLocation?.target };
}
type AboutCardProps = {
/** @deprecated The entity is now grabbed from context instead */
entity?: Entity;
@@ -102,13 +64,25 @@ type AboutCardProps = {
export function AboutCard({ variant }: AboutCardProps) {
const classes = useStyles();
const { entity } = useEntity();
const codeLink = getCodeLinkInfo(entity);
// TODO: Also support RELATION_CONSUMES_API here
const hasApis = entity.relations?.some(r => r.type === RELATION_PROVIDES_API);
const configApi = useApi(configApiRef);
const entitySourceLocation = getEntitySourceLocation(entity, configApi);
const entityMetadataEditUrl = getEntityMetadataEditUrl(entity);
const providesApiRelations = getEntityRelations(
entity,
RELATION_PROVIDES_API,
);
const consumesApiRelations = getEntityRelations(
entity,
RELATION_CONSUMES_API,
);
const hasApis =
providesApiRelations.length > 0 || consumesApiRelations.length > 0;
const viewInSource: IconLinkVerticalProps = {
label: 'View Source',
href: codeLink.href,
icon: codeLink.icon,
disabled: !entitySourceLocation,
icon: <ScmIntegrationIcon type={entitySourceLocation?.integrationType} />,
href: entitySourceLocation?.locationTargetUrl,
};
const viewInTechDocs: IconLinkVerticalProps = {
label: 'View TechDocs',
@@ -133,9 +107,10 @@ export function AboutCard({ variant }: AboutCardProps) {
action={
<IconButton
aria-label="Edit"
disabled={!entityMetadataEditUrl}
title="Edit Metadata"
onClick={() => {
window.open(codeLink.edithref || '#', '_blank');
window.open(entityMetadataEditUrl ?? '#', '_blank');
}}
>
<EditIcon />
@@ -0,0 +1,31 @@
/*
* Copyright 2020 Spotify AB
*
* 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 CodeIcon from '@material-ui/icons/Code';
import GitHubIcon from '@material-ui/icons/GitHub';
import React from 'react';
export const ScmIntegrationIcon = ({ type }: { type?: string }) => {
// TODO: In the future we might want to support more types here as a GitLab or
// Bitbucket icons were requested here in the past, or even use the icon
// customization feature of the app. But material UI react doesn't provide more.
switch (type) {
case 'github':
return <GitHubIcon />;
default:
return <CodeIcon />;
}
};
@@ -21,11 +21,11 @@ import {
} from '@backstage/catalog-model';
import {
CodeSnippet,
OverflowTooltip,
Table,
TableColumn,
TableProps,
WarningPanel,
OverflowTooltip,
} from '@backstage/core';
import {
EntityRefLink,
@@ -38,7 +38,10 @@ 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 { findViewUrl, findEditUrl } from '../actions';
import {
getEntityMetadataEditUrl,
getEntityMetadataViewUrl,
} from '../../utils';
import {
favouriteEntityIcon,
favouriteEntityTooltip,
@@ -152,10 +155,11 @@ export const CatalogTable = ({
const actions: TableProps<EntityRow>['actions'] = [
({ entity }) => {
const url = findViewUrl(entity);
const url = getEntityMetadataViewUrl(entity);
return {
icon: () => <OpenInNew fontSize="small" />,
tooltip: 'View',
disabled: !url,
onClick: () => {
if (!url) return;
window.open(url, '_blank');
@@ -163,10 +167,11 @@ export const CatalogTable = ({
};
},
({ entity }) => {
const url = findEditUrl(entity);
const url = getEntityMetadataEditUrl(entity);
return {
icon: () => <Edit fontSize="small" />,
tooltip: 'Edit',
disabled: !url,
onClick: () => {
if (!url) return;
window.open(url, '_blank');
-102
View File
@@ -1,102 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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 {
LocationSpec,
Entity,
EDIT_URL_ANNOTATION,
VIEW_URL_ANNOTATION,
} from '@backstage/catalog-model';
import { findLocationForEntityMeta } from '../data/utils';
import parseGitUrl from 'git-url-parse';
/**
* Creates the edit link for components yaml file
* @see LocationSpec
* @param location The LocationSpec being used to determine entity SCM location
* @returns string representing the edit location based on SCM path
*/
export const createEditLink = (location: LocationSpec): string | undefined => {
try {
const urlData = parseGitUrl(location.target);
const url = new URL(location.target);
switch (location.type) {
case 'github':
case 'gitlab':
return location.target.replace('/blob/', '/edit/');
case 'bitbucket':
url.searchParams.set('mode', 'edit');
url.searchParams.set('spa', '0');
url.searchParams.set('at', urlData.ref);
return url.toString();
case 'url':
if (
urlData.source === 'github.com' ||
urlData.source === 'gitlab.com/'
) {
return location.target.replace('/blob/', '/edit/');
} else if (urlData.source === 'bitbucket.org') {
url.searchParams.set('mode', 'edit');
url.searchParams.set('spa', '0');
url.searchParams.set('at', urlData.ref);
return url.toString();
}
return location.target;
default:
return location.target;
}
} catch {
return undefined;
}
};
/**
* Determines type based on passed in url. This is used to set the icon associated with the type of entity
* @param url
* @returns string representing type of icon to be used
*/
export const determineUrlType = (url: string): string => {
const urlData = parseGitUrl(url);
if (urlData.source === 'github.com') {
return 'github';
} else if (urlData.source === 'bitbucket.org') {
return 'bitbucket';
} else if (urlData.source === 'gitlab.com') {
return 'gitlab';
}
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;
};
@@ -15,31 +15,15 @@
*/
import {
EntityMeta,
LocationSpec,
LOCATION_ANNOTATION,
parseLocationReference,
EDIT_URL_ANNOTATION,
Entity,
VIEW_URL_ANNOTATION,
} from '@backstage/catalog-model';
export function findLocationForEntityMeta(
meta: EntityMeta,
): LocationSpec | undefined {
if (!meta) {
return undefined;
}
const annotation = meta.annotations?.[LOCATION_ANNOTATION];
if (!annotation) {
return undefined;
}
return parseLocation(annotation);
export function getEntityMetadataViewUrl(entity: Entity): string | undefined {
return entity.metadata.annotations?.[VIEW_URL_ANNOTATION];
}
export function parseLocation(reference: string): LocationSpec | undefined {
try {
return parseLocationReference(reference);
} catch {
return undefined;
}
export function getEntityMetadataEditUrl(entity: Entity): string | undefined {
return entity.metadata.annotations?.[EDIT_URL_ANNOTATION];
}
@@ -0,0 +1,53 @@
/*
* Copyright 2020 Spotify AB
*
* 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 {
Entity,
parseLocationReference,
SOURCE_LOCATION_ANNOTATION,
} from '@backstage/catalog-model';
import { ConfigApi } from '@backstage/core';
import { ScmIntegrations } from '@backstage/integration';
export type EntitySourceLocation = {
locationTargetUrl: string;
integrationType?: string;
};
export function getEntitySourceLocation(
entity: Entity,
config: ConfigApi,
): EntitySourceLocation | undefined {
const sourceLocation =
entity.metadata.annotations?.[SOURCE_LOCATION_ANNOTATION];
if (!sourceLocation) {
return undefined;
}
try {
const sourceLocationRef = parseLocationReference(sourceLocation);
const scmIntegrations = ScmIntegrations.fromConfig(config);
const integration = scmIntegrations.byUrl(sourceLocationRef.target);
return {
locationTargetUrl: sourceLocationRef.target,
integrationType: integration?.type,
};
} catch {
return undefined;
}
}
+20
View File
@@ -0,0 +1,20 @@
/*
* Copyright 2020 Spotify AB
*
* 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.
*/
export {
getEntityMetadataEditUrl,
getEntityMetadataViewUrl,
} from './getEntityMetadataUrl';
export { getEntitySourceLocation } from './getEntitySourceLocation';