Make use of proposed metadata

Signed-off-by: Eric Peterson <ericpeterson@spotify.com>
This commit is contained in:
Eric Peterson
2024-03-05 16:39:31 +01:00
parent 2e91e74e89
commit bcb26747f3
4 changed files with 210 additions and 1 deletions
@@ -0,0 +1,5 @@
---
'@backstage/plugin-catalog': patch
---
Added a "create something similar" button to the `<AboutCard>` that is visible and links to the scaffolder template corresponding to the entity's `backstage.io/source-template` annotation, if present.
@@ -31,7 +31,7 @@ import { AboutCard } from './AboutCard';
import { ConfigReader } from '@backstage/core-app-api';
import { RELATION_OWNED_BY } from '@backstage/catalog-model';
import React from 'react';
import { screen } from '@testing-library/react';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { permissionApiRef } from '@backstage/plugin-permission-react';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
@@ -44,6 +44,7 @@ describe('<AboutCard />', () => {
const catalogApi: jest.Mocked<CatalogApi> = {
getLocationById: jest.fn(),
getEntityByName: jest.fn(),
getEntityByRef: jest.fn(),
getEntities: jest.fn(),
addLocation: jest.fn(),
getLocationByRef: jest.fn(),
@@ -266,6 +267,138 @@ describe('<AboutCard />', () => {
expect(screen.getByText('View Source').closest('a')).toBeNull();
});
it('renders "create something similar" button', async () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
annotations: {
'backstage.io/source-template': 'template:default/foo-template',
},
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
// Return any valid value to indicate access to the template is okay.
catalogApi.getEntityByRef.mockImplementation(async ref => {
expect(ref).toBe('template:default/foo-template');
return entity;
});
await renderInTestApp(
<TestApiProvider
apis={[
[
scmIntegrationsApiRef,
ScmIntegrationsApi.fromConfig(
new ConfigReader({
integrations: {
github: [
{
host: 'github.com',
token: '...',
},
],
},
}),
),
],
[catalogApiRef, catalogApi],
[permissionApiRef, {}],
]}
>
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>
</TestApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
'/create/templates/:namespace/:templateName':
createFromTemplateRouteRef,
},
},
);
await waitFor(() => {
const editLink = screen
.getByTitle('Create something similar')
.closest('a');
expect(editLink).toHaveAttribute(
'href',
'/create/templates/default/foo-template',
);
});
});
it('should not render "create something similar" button if template does not exist', async () => {
const entity = {
apiVersion: 'v1',
kind: 'Component',
metadata: {
name: 'software',
annotations: {
'backstage.io/source-template': 'template:default/gone-template',
},
},
spec: {
owner: 'guest',
type: 'service',
lifecycle: 'production',
},
};
// Return any valid value to indicate access to the template is okay.
catalogApi.getEntityByRef.mockImplementation(async ref => {
expect(ref).toBe('template:default/gone-template');
return undefined;
});
await renderInTestApp(
<TestApiProvider
apis={[
[
scmIntegrationsApiRef,
ScmIntegrationsApi.fromConfig(
new ConfigReader({
integrations: {
github: [
{
host: 'github.com',
token: '...',
},
],
},
}),
),
],
[catalogApiRef, catalogApi],
[permissionApiRef, {}],
]}
>
<EntityProvider entity={entity}>
<AboutCard />
</EntityProvider>
</TestApiProvider>,
{
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
'/create/templates/:namespace/:templateName':
createFromTemplateRouteRef,
},
},
);
expect(
screen.queryByTitle('Create something similar'),
).not.toBeInTheDocument();
});
it.each([
'url:https://backstage.io/catalog-info.yaml',
'file:../../catalog-info.yaml',
@@ -54,12 +54,14 @@ import { createFromTemplateRouteRef, viewTechDocRouteRef } from '../../routes';
import { AboutContent } from './AboutContent';
import CachedIcon from '@material-ui/icons/Cached';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import CopyIcon from '@material-ui/icons/FileCopy';
import DocsIcon from '@material-ui/icons/Description';
import EditIcon from '@material-ui/icons/Edit';
import { isTemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { parseEntityRef } from '@backstage/catalog-model';
import { useEntityPermission } from '@backstage/plugin-catalog-react/alpha';
import { catalogEntityRefreshPermission } from '@backstage/plugin-catalog-common/alpha';
import { useSourceTemplateCompoundEntityRef } from './hooks';
const TECHDOCS_ANNOTATION = 'backstage.io/techdocs-ref';
@@ -108,6 +110,7 @@ export function AboutCard(props: AboutCardProps) {
const errorApi = useApi(errorApiRef);
const viewTechdocLink = useRouteRef(viewTechDocRouteRef);
const templateRoute = useRouteRef(createFromTemplateRouteRef);
const sourceTemplateRef = useSourceTemplateCompoundEntityRef(entity);
const { allowed: canRefresh } = useEntityPermission(
catalogEntityRefreshPermission,
);
@@ -236,6 +239,18 @@ export function AboutCard(props: AboutCardProps) {
>
<EditIcon />
</IconButton>
{sourceTemplateRef && templateRoute && (
<IconButton
component={Link}
title="Create something similar"
to={templateRoute({
namespace: sourceTemplateRef.namespace,
templateName: sourceTemplateRef.name,
})}
>
<CopyIcon />
</IconButton>
)}
</>
}
subheader={<HeaderIconLinkRow links={subHeaderLinks} />}
@@ -0,0 +1,56 @@
/*
* Copyright 2020 The Backstage Authors
*
* 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 {
CompoundEntityRef,
Entity,
parseEntityRef,
} from '@backstage/catalog-model';
import { useApi } from '@backstage/core-plugin-api';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import useAsync from 'react-use/lib/useAsync';
// todo: should this be a constant in a scaffolder package?
const SOURCE_TEMPLATE_ANNOTATION = 'backstage.io/source-template';
/**
* Returns the compound entity ref of the source template that was used to
* create this entity (assuming that it still exists and the user has access
* to it).
*/
export const useSourceTemplateCompoundEntityRef = (entity: Entity) => {
const catalogApi = useApi(catalogApiRef);
const { value: sourceTemplateRef } = useAsync(async () => {
const refCandidate =
entity.metadata.annotations?.[SOURCE_TEMPLATE_ANNOTATION];
let compoundRefCandidate: CompoundEntityRef | undefined;
if (!refCandidate) {
return undefined;
}
try {
// Check for access and that this template still exists.
const template = await catalogApi.getEntityByRef(refCandidate);
compoundRefCandidate = parseEntityRef(refCandidate);
return template !== undefined ? compoundRefCandidate : undefined;
} catch {
return undefined;
}
}, [catalogApi, entity]);
return sourceTemplateRef;
};