diff --git a/.changeset/catalog-create-something-similar.md b/.changeset/catalog-create-something-similar.md new file mode 100644 index 0000000000..cbd1632d42 --- /dev/null +++ b/.changeset/catalog-create-something-similar.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-catalog': patch +--- + +Added a "create something similar" button to the `` that is visible and links to the scaffolder template corresponding to the entity's `backstage.io/source-template` annotation, if present. diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx index e27de38913..481d5b4f64 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.test.tsx @@ -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('', () => { const catalogApi: jest.Mocked = { getLocationById: jest.fn(), getEntityByName: jest.fn(), + getEntityByRef: jest.fn(), getEntities: jest.fn(), addLocation: jest.fn(), getLocationByRef: jest.fn(), @@ -266,6 +267,138 @@ describe('', () => { 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( + + + + + , + { + 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( + + + + + , + { + 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', diff --git a/plugins/catalog/src/components/AboutCard/AboutCard.tsx b/plugins/catalog/src/components/AboutCard/AboutCard.tsx index 09e1790f3b..7c26a567a1 100644 --- a/plugins/catalog/src/components/AboutCard/AboutCard.tsx +++ b/plugins/catalog/src/components/AboutCard/AboutCard.tsx @@ -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) { > + {sourceTemplateRef && templateRoute && ( + + + + )} } subheader={} diff --git a/plugins/catalog/src/components/AboutCard/hooks.ts b/plugins/catalog/src/components/AboutCard/hooks.ts new file mode 100644 index 0000000000..412175ff87 --- /dev/null +++ b/plugins/catalog/src/components/AboutCard/hooks.ts @@ -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; +};