Move logic for generating URLs for the view, edit and source links of catalog entities from the catalog frontend into the backend
Signed-off-by: Oliver Sand <oliver.sand@sda-se.com>
This commit is contained in:
@@ -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
@@ -207,7 +207,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
|
||||
|
||||
+112
-5
@@ -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':
|
||||
'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': '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': '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',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
+40
-8
@@ -15,31 +15,63 @@
|
||||
*/
|
||||
|
||||
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);
|
||||
sourceLocation = scmIntegration?.resolveUrl({
|
||||
url: './',
|
||||
base: location.target,
|
||||
});
|
||||
}
|
||||
|
||||
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 }),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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':
|
||||
'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,29 +16,27 @@
|
||||
|
||||
import {
|
||||
Entity,
|
||||
LocationSpec,
|
||||
ENTITY_DEFAULT_NAMESPACE,
|
||||
SOURCE_LOCATION_ANNOTATION,
|
||||
RELATION_PROVIDES_API,
|
||||
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,
|
||||
CardHeader,
|
||||
Divider,
|
||||
IconButton,
|
||||
makeStyles,
|
||||
makeStyles
|
||||
} from '@material-ui/core';
|
||||
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 +50,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 +59,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 viewInSource: IconLinkVerticalProps = {
|
||||
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?.type} />,
|
||||
href: entitySourceLocation?.url,
|
||||
};
|
||||
const viewInTechDocs: IconLinkVerticalProps = {
|
||||
label: 'View TechDocs',
|
||||
@@ -133,9 +102,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');
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
+7
-23
@@ -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,44 @@
|
||||
/*
|
||||
* 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, SOURCE_LOCATION_ANNOTATION } from '@backstage/catalog-model';
|
||||
import { ConfigApi } from '@backstage/core';
|
||||
import { ScmIntegrations } from '@backstage/integration';
|
||||
|
||||
export type EntitySourceLocation = {
|
||||
url: string;
|
||||
type?: string;
|
||||
};
|
||||
|
||||
export function getEntitySourceLocation(
|
||||
entity: Entity,
|
||||
config: ConfigApi,
|
||||
): EntitySourceLocation | undefined {
|
||||
const sourceLocation =
|
||||
entity.metadata.annotations?.[SOURCE_LOCATION_ANNOTATION];
|
||||
|
||||
if (!sourceLocation) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const scmIntegrations = ScmIntegrations.fromConfig(config);
|
||||
const integration = scmIntegrations.byUrl(sourceLocation);
|
||||
|
||||
return {
|
||||
url: sourceLocation,
|
||||
type: integration?.type,
|
||||
};
|
||||
}
|
||||
@@ -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';
|
||||
Reference in New Issue
Block a user