Merge pull request #29274 from Vity01/scaffolding-detail-button

feat: Scaffolding - create - template - add link to template entity detail
This commit is contained in:
Fredrik Adelöw
2025-03-21 16:00:51 +01:00
committed by GitHub
8 changed files with 160 additions and 40 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-react': patch
---
Scaffolding - Template card - button to show template entity detail
@@ -347,6 +347,7 @@ export const scaffolderReactTranslationRef: TranslationRef<
readonly 'templateCategoryPicker.title': 'Categories';
readonly 'templateCard.noDescription': 'No description';
readonly 'templateCard.chooseButtonText': 'Choose';
readonly 'cardHeader.detailBtnTitle': 'Show template entity details';
readonly 'templateOutputs.title': 'Text Output';
}
>;
@@ -24,12 +24,21 @@ import {
renderInTestApp,
TestApiProvider,
} from '@backstage/test-utils';
import { starredEntitiesApiRef } from '@backstage/plugin-catalog-react';
import {
entityRouteRef,
starredEntitiesApiRef,
} from '@backstage/plugin-catalog-react';
import { DefaultStarredEntitiesApi } from '@backstage/plugin-catalog';
import Observable from 'zen-observable';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
const mountedRoutes = {
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
};
describe('CardHeader', () => {
it('should select the correct theme from the theme provider from the header', async () => {
// Can't really test what we want here.
@@ -64,6 +73,7 @@ describe('CardHeader', () => {
/>
</ThemeProvider>
</TestApiProvider>,
mountedRoutes,
);
expect(mockTheme.getPageTheme).toHaveBeenCalledWith({ themeId: 'service' });
@@ -93,6 +103,7 @@ describe('CardHeader', () => {
}}
/>
</TestApiProvider>,
mountedRoutes,
);
expect(getByText('service')).toBeInTheDocument();
@@ -118,6 +129,7 @@ describe('CardHeader', () => {
<TestApiProvider apis={[[starredEntitiesApiRef, starredEntitiesApi]]}>
<CardHeader template={mockTemplate} />
</TestApiProvider>,
mountedRoutes,
);
const favorite = getByRole('button', { name: 'Add to favorites' });
@@ -129,6 +141,38 @@ describe('CardHeader', () => {
);
});
it('renders TemplateDetailButton with link to entity page', async () => {
const { getByTitle } = await renderInTestApp(
<TestApiProvider
apis={[
[
starredEntitiesApiRef,
new DefaultStarredEntitiesApi({
storageApi: mockApis.storage(),
}),
],
]}
>
<CardHeader
template={{
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: 'test-template', namespace: 'default' },
spec: {
steps: [],
type: 'service',
},
}}
/>
</TestApiProvider>,
mountedRoutes,
);
const detailButton = getByTitle('Show template entity details');
const link = detailButton.querySelector('a');
expect(link).toBeInTheDocument();
});
it('should render the name of the entity', async () => {
const { getByText } = await renderInTestApp(
<TestApiProvider
@@ -153,6 +197,7 @@ describe('CardHeader', () => {
}}
/>
</TestApiProvider>,
mountedRoutes,
);
expect(getByText('bob')).toBeInTheDocument();
@@ -182,6 +227,7 @@ describe('CardHeader', () => {
}}
/>
</TestApiProvider>,
mountedRoutes,
);
expect(getByText('Iamtitle')).toBeInTheDocument();
@@ -15,10 +15,11 @@
*/
import React from 'react';
import { Theme, makeStyles, useTheme } from '@material-ui/core/styles';
import { makeStyles, Theme, useTheme } from '@material-ui/core/styles';
import { ItemCardHeader } from '@backstage/core-components';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { FavoriteEntity } from '@backstage/plugin-catalog-react';
import { TemplateDetailButton } from './TemplateDetailButton.tsx';
const useStyles = makeStyles<
Theme,
@@ -66,7 +67,11 @@ export const CardHeader = (props: CardHeaderProps) => {
<div className={styles.subtitleWrapper}>
<div>{type}</div>
<div>
<FavoriteEntity entity={props.template} style={{ padding: 0 }} />
<TemplateDetailButton template={props.template} />
<FavoriteEntity
entity={props.template}
style={{ padding: 0, marginLeft: 6 }}
/>
</div>
</div>
);
@@ -33,6 +33,12 @@ import { permissionApiRef } from '@backstage/plugin-permission-react';
import { AuthorizeResult } from '@backstage/plugin-permission-common';
import { SWRConfig } from 'swr';
const mountedRoutes = {
mountedRoutes: {
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
};
describe('TemplateCard', () => {
it('should render the card title', async () => {
const mockTemplate: TemplateEntityV1beta3 = {
@@ -59,6 +65,7 @@ describe('TemplateCard', () => {
>
<TemplateCard template={mockTemplate} />
</TestApiProvider>,
mountedRoutes,
);
expect(getByText('bob')).toBeInTheDocument();
@@ -89,6 +96,7 @@ describe('TemplateCard', () => {
>
<TemplateCard template={mockTemplate} />
</TestApiProvider>,
mountedRoutes,
);
const description = getByText('hello');
@@ -121,6 +129,7 @@ describe('TemplateCard', () => {
>
<TemplateCard template={mockTemplate} />
</TestApiProvider>,
mountedRoutes,
);
expect(getByText('No description')).toBeInTheDocument();
@@ -151,6 +160,7 @@ describe('TemplateCard', () => {
>
<TemplateCard template={mockTemplate} />
</TestApiProvider>,
mountedRoutes,
);
expect(queryByTestId('template-card-separator')).toBeInTheDocument();
@@ -187,6 +197,7 @@ describe('TemplateCard', () => {
>
<TemplateCard template={mockTemplate} />
</TestApiProvider>,
mountedRoutes,
);
for (const tag of mockTemplate.metadata.tags!) {
@@ -227,11 +238,7 @@ describe('TemplateCard', () => {
>
<TemplateCard template={mockTemplate} />
</TestApiProvider>,
{
mountedRoutes: {
'/catalog/:kind/:namespace/:name': entityRouteRef,
},
},
mountedRoutes,
);
expect(queryByTestId('template-card-separator')).toBeInTheDocument();
@@ -272,11 +279,7 @@ describe('TemplateCard', () => {
>
<TemplateCard template={mockTemplate} additionalLinks={[]} />
</TestApiProvider>,
{
mountedRoutes: {
'/catalog/:kind/:namespace/:name': entityRouteRef,
},
},
mountedRoutes,
);
expect(queryByTestId('template-card-separator')).toBeInTheDocument();
@@ -321,11 +324,7 @@ describe('TemplateCard', () => {
>
<TemplateCard template={mockTemplate} additionalLinks={[]} />
</TestApiProvider>,
{
mountedRoutes: {
'/catalog/:kind/:namespace/:name': entityRouteRef,
},
},
mountedRoutes,
);
expect(queryByTestId('template-card-separator')).not.toBeInTheDocument();
@@ -364,17 +363,13 @@ describe('TemplateCard', () => {
>
<TemplateCard template={mockTemplate} />
</TestApiProvider>,
{
mountedRoutes: {
'/catalog/:kind/:namespace/:name': entityRouteRef,
},
},
mountedRoutes,
);
expect(getByRole('link', { name: /.*my-test-user$/ })).toBeInTheDocument();
expect(getByRole('link', { name: /.*my-test-user$/ })).toHaveAttribute(
'href',
'/catalog/group/default/my-test-user',
'/catalog/default/group/my-test-user',
);
});
@@ -404,11 +399,7 @@ describe('TemplateCard', () => {
>
<TemplateCard template={mockTemplate} onSelected={mockOnSelected} />
</TestApiProvider>,
{
mountedRoutes: {
'/catalog/:kind/:namespace/:name': entityRouteRef,
},
},
mountedRoutes,
);
expect(getByRole('button', { name: 'Choose' })).toBeInTheDocument();
@@ -448,11 +439,7 @@ describe('TemplateCard', () => {
<TemplateCard template={mockTemplate} onSelected={mockOnSelected} />
</TestApiProvider>
</SWRConfig>,
{
mountedRoutes: {
'/catalog/:kind/:namespace/:name': entityRouteRef,
},
},
mountedRoutes,
);
expect(queryByText('Choose')).toBeNull();
@@ -0,0 +1,64 @@
/*
* Copyright 2025 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 React from 'react';
import Tooltip from '@material-ui/core/Tooltip';
import IconButton from '@material-ui/core/IconButton';
import Typography from '@material-ui/core/Typography';
import DescriptionIcon from '@material-ui/icons/Description';
import { Link } from '@backstage/core-components';
import {
entityRouteParams,
entityRouteRef,
} from '@backstage/plugin-catalog-react';
import { useApp, useRouteRef } from '@backstage/core-plugin-api';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { scaffolderReactTranslationRef } from '../../../translation';
import { useTranslationRef } from '@backstage/frontend-plugin-api';
export interface TemplateDetailButtonProps {
template: Entity;
}
export const TemplateDetailButton = ({
template,
}: TemplateDetailButtonProps) => {
const catalogEntityRoute = useRouteRef(entityRouteRef);
const { t } = useTranslationRef(scaffolderReactTranslationRef);
const entityRef = stringifyEntityRef(template);
const app = useApp();
const TemplateIcon = app.getSystemIcon('kind:template') || DescriptionIcon;
return (
<Tooltip id={`tooltip-${entityRef}`} title={t('cardHeader.detailBtnTitle')}>
<IconButton
aria-label={t('cardHeader.detailBtnTitle')}
id={`viewDetail-${entityRef}`}
style={{ padding: 0 }}
color="inherit"
>
<Typography component="span">
<Link
to={catalogEntityRoute(entityRouteParams(template))}
style={{ display: 'flex', alignItems: 'center' }}
>
<TemplateIcon />
</Link>
</Typography>
</IconButton>
</Tooltip>
);
};
@@ -44,6 +44,9 @@ export const scaffolderReactTranslationRef = createTranslationRef({
noDescription: 'No description',
chooseButtonText: 'Choose',
},
cardHeader: {
detailBtnTitle: 'Show template entity details',
},
templateOutputs: {
title: 'Text Output',
},
@@ -17,6 +17,7 @@
import { DefaultStarredEntitiesApi } from '@backstage/plugin-catalog';
import {
catalogApiRef,
entityRouteRef,
starredEntitiesApiRef,
} from '@backstage/plugin-catalog-react';
import { catalogApiMock } from '@backstage/plugin-catalog-react/testUtils';
@@ -30,6 +31,13 @@ import React from 'react';
import { rootRouteRef } from '../../../routes';
import { TemplateListPage } from './TemplateListPage';
const mountedRoutes = {
mountedRoutes: {
'/': rootRouteRef,
'/catalog/:namespace/:kind/:name': entityRouteRef,
},
};
describe('TemplateListPage', () => {
const mockCatalogApi = catalogApiMock({
entities: [
@@ -60,7 +68,7 @@ describe('TemplateListPage', () => {
>
<TemplateListPage />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
mountedRoutes,
);
expect(getByPlaceholderText('Search')).toBeInTheDocument();
@@ -82,7 +90,7 @@ describe('TemplateListPage', () => {
>
<TemplateListPage />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
mountedRoutes,
);
expect(getByRole('menuitem', { name: /All/ })).toBeInTheDocument();
@@ -105,7 +113,7 @@ describe('TemplateListPage', () => {
>
<TemplateListPage />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
mountedRoutes,
);
expect(getByText('Categories')).toBeInTheDocument();
@@ -127,7 +135,7 @@ describe('TemplateListPage', () => {
>
<TemplateListPage />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
mountedRoutes,
);
expect(getByText('Owner')).toBeInTheDocument();
@@ -150,6 +158,7 @@ describe('TemplateListPage', () => {
>
<TemplateListPage />
</TestApiProvider>,
mountedRoutes,
);
expect(getByText('Tags')).toBeInTheDocument();
@@ -172,7 +181,7 @@ describe('TemplateListPage', () => {
>
<TemplateListPage />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
mountedRoutes,
);
expect(queryByTestId('menu-button')).toBeInTheDocument();
});
@@ -199,7 +208,7 @@ describe('TemplateListPage', () => {
}}
/>
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
mountedRoutes,
);
expect(queryByTestId('menu-button')).not.toBeInTheDocument();
});