feat(scaffolder): config-driven template groups and swappable TemplateCard (#34147)

* feat(scaffolder): config-driven template groups and swappable TemplateCard

Signed-off-by: benjdlambert <ben@blam.sh>

* refactor(scaffolder): keep createGroupsWithOther internal

Signed-off-by: benjdlambert <ben@blam.sh>

* docs(scaffolder): fix sub-page extension ID in changeset

Signed-off-by: benjdlambert <ben@blam.sh>

* address PR review feedback

Signed-off-by: benjdlambert <ben@blam.sh>

* split TemplateCard swappable contract from legacy props

Signed-off-by: benjdlambert <ben@blam.sh>

* address review feedback: dedupe tags, defensive groups copy, doc clarifications

Signed-off-by: benjdlambert <ben@blam.sh>

* regenerate api reports

Signed-off-by: benjdlambert <ben@blam.sh>

* align docs and changeset with actual default group titles

Signed-off-by: benjdlambert <ben@blam.sh>

* regen api reports after rebase

Signed-off-by: benjdlambert <ben@blam.sh>

---------

Signed-off-by: benjdlambert <ben@blam.sh>
This commit is contained in:
Ben Lambert
2026-05-12 12:29:44 +02:00
committed by GitHub
parent 92dfe61e79
commit d09c21cb84
23 changed files with 656 additions and 135 deletions
@@ -405,7 +405,7 @@ describe('TemplateCard', () => {
fireEvent.click(getByRole('button', { name: 'Choose' }));
expect(mockOnSelected).toHaveBeenCalledWith(mockTemplate);
expect(mockOnSelected).toHaveBeenCalledWith();
});
it('should not render the choose button when user has insufficient permissions', async () => {
const mockTemplate: TemplateEntityV1beta3 = {
@@ -14,95 +14,23 @@
* limitations under the License.
*/
import { RELATION_OWNED_BY } from '@backstage/catalog-model';
import { IconComponent, useAnalytics } from '@backstage/core-plugin-api';
import { getEntityRelations } from '@backstage/plugin-catalog-react';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import Card from '@material-ui/core/Card';
import CardActions from '@material-ui/core/CardActions';
import CardContent from '@material-ui/core/CardContent';
import Divider from '@material-ui/core/Divider';
import Grid from '@material-ui/core/Grid';
import { makeStyles, Theme } from '@material-ui/core/styles';
import { useCallback } from 'react';
import { CardHeader } from './CardHeader';
import { usePermission } from '@backstage/plugin-permission-react';
import { taskCreatePermission } from '@backstage/plugin-scaffolder-common/alpha';
import { TemplateCardContent } from './TemplateCardContent';
import { TemplateCardTags } from './TemplateCardTags';
import { TemplateCardLinks } from './TemplateCardLinks';
import { TemplateCardActions } from './TemplateCardActions';
import { createSwappableComponent } from '@backstage/frontend-plugin-api';
import type { TemplateCardComponentProps } from './TemplateCardImpl';
const useStyles = makeStyles<Theme>(() => ({
actionContainer: { padding: '16px', flex: 1, alignItems: 'flex-end' },
}));
export type {
TemplateCardProps,
TemplateCardComponentProps,
} from './TemplateCardImpl';
/**
* The Props for the {@link TemplateCard} component
* The `TemplateCard` component that is rendered in a list for each template.
* Apps using the new frontend system can replace it by registering a
* `SwappableComponentBlueprint` that targets `TemplateCard`.
*
* @alpha
*/
export interface TemplateCardProps {
template: TemplateEntityV1beta3;
additionalLinks?: {
icon: IconComponent;
text: string;
url: string;
}[];
onSelected?: (template: TemplateEntityV1beta3) => void;
}
/**
* The `TemplateCard` component that is rendered in a list for each template
* @alpha
*/
export const TemplateCard = (props: TemplateCardProps) => {
const { additionalLinks, onSelected, template } = props;
const styles = useStyles();
const analytics = useAnalytics();
const ownedByRelations = getEntityRelations(template, RELATION_OWNED_BY);
const hasTags = !!template.metadata.tags?.length;
const hasLinks =
!!additionalLinks?.length || !!template.metadata.links?.length;
const displayDefaultDivider = !hasTags && !hasLinks;
const { allowed: canCreateTask } = usePermission({
permission: taskCreatePermission,
export const TemplateCard =
createSwappableComponent<TemplateCardComponentProps>({
id: 'scaffolder.templateCard',
loader: () => import('./TemplateCardImpl').then(m => m.TemplateCardImpl),
});
const handleChoose = useCallback(() => {
analytics.captureEvent('click', `Template has been opened`);
onSelected?.(template);
}, [analytics, onSelected, template]);
return (
<Card>
<CardHeader template={template} data-testid="template-card-header" />
<CardContent>
<Grid container spacing={2} data-testid="template-card-content">
<TemplateCardContent template={template} />
{displayDefaultDivider && (
<Grid item xs={12}>
<Divider data-testid="template-card-separator" />
</Grid>
)}
{hasTags && <TemplateCardTags template={template} />}
{hasLinks && (
<TemplateCardLinks
template={template}
additionalLinks={additionalLinks}
/>
)}
</Grid>
</CardContent>
<CardActions
className={styles.actionContainer}
data-testid="template-card-actions"
>
<TemplateCardActions
canCreateTask={canCreateTask}
handleChoose={handleChoose}
ownedByRelations={ownedByRelations}
/>
</CardActions>
</Card>
);
};
@@ -0,0 +1,124 @@
/*
* Copyright 2022 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 { RELATION_OWNED_BY } from '@backstage/catalog-model';
import { IconComponent, useAnalytics } from '@backstage/core-plugin-api';
import { getEntityRelations } from '@backstage/plugin-catalog-react';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import Card from '@material-ui/core/Card';
import CardActions from '@material-ui/core/CardActions';
import CardContent from '@material-ui/core/CardContent';
import Divider from '@material-ui/core/Divider';
import Grid from '@material-ui/core/Grid';
import { makeStyles, Theme } from '@material-ui/core/styles';
import { useCallback } from 'react';
import { CardHeader } from './CardHeader';
import { usePermission } from '@backstage/plugin-permission-react';
import { taskCreatePermission } from '@backstage/plugin-scaffolder-common/alpha';
import { TemplateCardContent } from './TemplateCardContent';
import { TemplateCardTags } from './TemplateCardTags';
import { TemplateCardLinks } from './TemplateCardLinks';
import { TemplateCardActions } from './TemplateCardActions';
const useStyles = makeStyles<Theme>(() => ({
actionContainer: { padding: '16px', flex: 1, alignItems: 'flex-end' },
}));
/**
* The legacy Props for the `CardComponent` slot in {@link TemplateGroupsProps}.
* @alpha
*/
export interface TemplateCardProps {
template: TemplateEntityV1beta3;
additionalLinks?: {
icon: IconComponent;
text: string;
url: string;
}[];
onSelected?: (template: TemplateEntityV1beta3) => void;
}
/**
* The Props for components used as the swappable {@link TemplateCard}. The
* surrounding list takes care of binding the template to `onSelected`, so
* implementations only need to invoke it without arguments.
* @alpha
*/
export interface TemplateCardComponentProps {
template: TemplateEntityV1beta3;
additionalLinks?: {
icon: IconComponent;
text: string;
url: string;
}[];
onSelected?: () => void;
}
/**
* Default implementation of the `TemplateCard`. The exported `TemplateCard`
* is a swappable wrapper around this component.
*/
export const TemplateCardImpl = (props: TemplateCardComponentProps) => {
const { additionalLinks, onSelected, template } = props;
const styles = useStyles();
const analytics = useAnalytics();
const ownedByRelations = getEntityRelations(template, RELATION_OWNED_BY);
const hasTags = !!template.metadata.tags?.length;
const hasLinks =
!!additionalLinks?.length || !!template.metadata.links?.length;
const displayDefaultDivider = !hasTags && !hasLinks;
const { allowed: canCreateTask } = usePermission({
permission: taskCreatePermission,
});
const handleChoose = useCallback(() => {
analytics.captureEvent('click', 'Template has been opened');
onSelected?.();
}, [analytics, onSelected]);
return (
<Card>
<CardHeader template={template} data-testid="template-card-header" />
<CardContent>
<Grid container spacing={2} data-testid="template-card-content">
<TemplateCardContent template={template} />
{displayDefaultDivider && (
<Grid item xs={12}>
<Divider data-testid="template-card-separator" />
</Grid>
)}
{hasTags && <TemplateCardTags template={template} />}
{hasLinks && (
<TemplateCardLinks
template={template}
additionalLinks={additionalLinks}
/>
)}
</Grid>
</CardContent>
<CardActions
className={styles.actionContainer}
data-testid="template-card-actions"
>
<TemplateCardActions
canCreateTask={canCreateTask}
handleChoose={handleChoose}
ownedByRelations={ownedByRelations}
/>
</CardActions>
</Card>
);
};
@@ -13,4 +13,8 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { TemplateCard, type TemplateCardProps } from './TemplateCard';
export {
TemplateCard,
type TemplateCardProps,
type TemplateCardComponentProps,
} from './TemplateCard';
@@ -62,10 +62,16 @@ describe('TemplateGroup', () => {
for (const { template } of mockTemplates) {
expect(TemplateCard).toHaveBeenCalledWith(
expect.objectContaining({ template, onSelected: mockOnSelected }),
expect.objectContaining({ template, onSelected: expect.any(Function) }),
{},
);
}
const lastCall = jest.mocked(TemplateCard).mock.calls.at(-1)![0];
lastCall.onSelected!();
expect(mockOnSelected).toHaveBeenCalledWith(
mockTemplates[mockTemplates.length - 1].template,
);
});
it('should use the passed in TemplateCard prop to render the template card', () => {
@@ -62,8 +62,6 @@ export const TemplateGroup = (props: TemplateGroupProps) => {
return null;
}
const Card = CardComponent || TemplateCard;
return (
<Content>
{titleComponent}
@@ -75,11 +73,19 @@ export const TemplateGroup = (props: TemplateGroupProps) => {
}}
key={stringifyEntityRef(template)}
>
<Card
additionalLinks={additionalLinks}
template={template}
onSelected={onSelected}
/>
{CardComponent ? (
<CardComponent
additionalLinks={additionalLinks}
template={template}
onSelected={onSelected}
/>
) : (
<TemplateCard
additionalLinks={additionalLinks}
template={template}
onSelected={() => onSelected(template)}
/>
)}
</AnalyticsContext>
))}
</ItemCardGrid>