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
+22 -1
View File
@@ -32,6 +32,7 @@ import { ScaffolderStep } from '@backstage/plugin-scaffolder-react';
import { ScaffolderTaskOutput } from '@backstage/plugin-scaffolder-react';
import { SetStateAction } from 'react';
import { StyleRules } from '@material-ui/core/styles/withStyles';
import { SwappableComponentRef } from '@backstage/frontend-plugin-api';
import { TaskStep } from '@backstage/plugin-scaffolder-common';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react';
@@ -389,7 +390,27 @@ export interface TaskStepsProps {
}
// @alpha
export const TemplateCard: (props: TemplateCardProps) => JSX_2.Element;
export const TemplateCard: {
(props: TemplateCardComponentProps): JSX.Element | null;
ref: SwappableComponentRef<
TemplateCardComponentProps,
TemplateCardComponentProps
>;
};
// @alpha
export interface TemplateCardComponentProps {
// (undocumented)
additionalLinks?: {
icon: IconComponent;
text: string;
url: string;
}[];
// (undocumented)
onSelected?: () => void;
// (undocumented)
template: TemplateEntityV1beta3;
}
// @alpha
export interface TemplateCardProps {
@@ -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>
+1
View File
@@ -63,6 +63,7 @@
"@backstage/core-components": "workspace:^",
"@backstage/core-plugin-api": "workspace:^",
"@backstage/errors": "workspace:^",
"@backstage/filter-predicates": "workspace:^",
"@backstage/frontend-plugin-api": "workspace:^",
"@backstage/integration": "workspace:^",
"@backstage/integration-react": "workspace:^",
+12
View File
@@ -553,11 +553,23 @@ const _default: OverridableFrontendPlugin<
'sub-page:scaffolder/templates': OverridableExtensionDefinition<{
config: {
enableBackstageUi: boolean;
groups:
| {
title: string;
filter: FilterPredicate;
}[]
| undefined;
path: string | undefined;
title: string | undefined;
};
configInput: {
enableBackstageUi?: boolean | undefined;
groups?:
| {
title: string;
filter: FilterPredicate;
}[]
| undefined;
path?: string | undefined;
title?: string | undefined;
};
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { ComponentType, useCallback } from 'react';
import { ComponentType, useCallback, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { useApp, useRouteRef } from '@backstage/core-plugin-api';
@@ -41,6 +41,7 @@ import {
TemplateCategoryPicker,
TemplateGroups,
} from '@backstage/plugin-scaffolder-react/alpha';
import { createGroupsWithOther } from '../../lib/createGroupsWithOther';
import { RegisterExistingButton } from './RegisterExistingButton';
import {
@@ -54,10 +55,7 @@ import {
} from '../../../routes';
import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model';
import { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react';
import {
TranslationFunction,
useTranslationRef,
} from '@backstage/core-plugin-api/alpha';
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
import { scaffolderTranslationRef } from '../../../translation';
import { buildTechDocsURL } from '@backstage/plugin-techdocs-react';
import {
@@ -87,17 +85,6 @@ export type TemplateListPageProps = {
};
};
const createGroupsWithOther = (
groups: TemplateGroupFilter[],
t: TranslationFunction<typeof scaffolderTranslationRef.T>,
): TemplateGroupFilter[] => [
...groups,
{
title: t('templateListPage.templateGroups.otherTitle'),
filter: e => ![...groups].some(({ filter }) => filter(e)),
},
];
/**
* @alpha
*/
@@ -119,14 +106,21 @@ export const TemplateListPage = (props: TemplateListPageProps) => {
const app = useApp();
const { t } = useTranslationRef(scaffolderTranslationRef);
const groups = givenGroups.length
? createGroupsWithOther(givenGroups, t)
: [
{
title: t('templateListPage.templateGroups.defaultTitle'),
filter: () => true,
},
];
const groups = useMemo(
() =>
givenGroups.length
? createGroupsWithOther(
givenGroups,
t('templateListPage.templateGroups.otherTitle'),
)
: [
{
title: t('templateListPage.templateGroups.defaultTitle'),
filter: () => true,
},
],
[givenGroups, t],
);
const scaffolderPageContextMenuProps = {
onEditorClicked:
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { useCallback } from 'react';
import { useCallback, useMemo } from 'react';
import { Routes, Route, useNavigate } from 'react-router-dom';
import {
Content,
@@ -37,10 +37,12 @@ import {
TemplateCategoryPicker,
TemplateGroups,
} from '@backstage/plugin-scaffolder-react/alpha';
import { createGroupsWithOther } from '../lib/createGroupsWithOther';
import {
FieldExtensionOptions,
FormProps,
SecretsContextProvider,
TemplateGroupFilter,
useCustomFieldExtensions,
useCustomLayouts,
} from '@backstage/plugin-scaffolder-react';
@@ -63,7 +65,11 @@ import {
TECHDOCS_EXTERNAL_ANNOTATION,
} from '@backstage/plugin-techdocs-common';
function TemplateListContent() {
function TemplateListContent({
groups: configuredGroups,
}: {
groups?: TemplateGroupFilter[];
}) {
const registerComponentLink = useRouteRef(registerComponentRouteRef);
const viewTechDocsLink = useRouteRef(viewTechDocRouteRef);
const templateRoute = useRouteRef(selectedTemplateRouteRef);
@@ -71,12 +77,21 @@ function TemplateListContent() {
const app = useApp();
const { t } = useTranslationRef(scaffolderTranslationRef);
const groups = [
{
title: t('templateListPage.templateGroups.defaultTitle'),
filter: () => true,
},
];
const groups = useMemo(
() =>
configuredGroups?.length
? createGroupsWithOther(
configuredGroups,
t('templateListPage.templateGroups.otherTitle'),
)
: [
{
title: t('templateListPage.templateGroups.defaultTitle'),
filter: () => true,
},
],
[configuredGroups, t],
);
const additionalLinksForEntity = useCallback(
(template: TemplateEntityV1beta3) => {
@@ -163,6 +178,7 @@ function TemplateListContent() {
export function TemplatesSubPage(props: {
formFields?: Array<FormField>;
formProps?: FormProps;
groups?: TemplateGroupFilter[];
}) {
const customFieldExtensions = useCustomFieldExtensions(undefined);
const customLayouts = useCustomLayouts(undefined);
@@ -181,7 +197,7 @@ export function TemplatesSubPage(props: {
return (
<Routes>
<Route index element={<TemplateListContent />} />
<Route index element={<TemplateListContent groups={props.groups} />} />
<Route
path=":namespace/:templateName"
element={
+27 -5
View File
@@ -24,6 +24,11 @@ import {
PageBlueprint,
SubPageBlueprint,
} from '@backstage/frontend-plugin-api';
import { z } from 'zod/v4';
import {
createZodV4FilterPredicateSchema,
filterPredicateToFilterFunction,
} from '@backstage/filter-predicates';
import { rootRouteRef } from '../routes';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import {
@@ -31,7 +36,10 @@ import {
formFieldsApiRef,
} from '@backstage/plugin-scaffolder-react/alpha';
import { scmIntegrationsApiRef } from '@backstage/integration-react';
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
import {
scaffolderApiRef,
TemplateGroupFilter,
} from '@backstage/plugin-scaffolder-react';
import { ScaffolderClient } from '../api';
export const scaffolderPage = PageBlueprint.makeWithOverrides({
@@ -51,14 +59,27 @@ export const scaffolderPage = PageBlueprint.makeWithOverrides({
export const scaffolderTemplatesSubPage = SubPageBlueprint.makeWithOverrides({
name: 'templates',
config: {
schema: {
enableBackstageUi: z => z.boolean().default(false),
},
configSchema: {
enableBackstageUi: z.boolean().optional().default(false),
groups: z
.array(
z.object({
title: z.string(),
filter: createZodV4FilterPredicateSchema(),
}),
)
.optional(),
},
factory(originalFactory, { apis, config }) {
const formFieldsApi = apis.get(formFieldsApiRef);
const groups: TemplateGroupFilter[] | undefined = config.groups?.map(
group => ({
title: group.title,
filter: filterPredicateToFilterFunction(group.filter),
}),
);
return originalFactory({
path: 'templates',
title: 'Templates',
@@ -68,6 +89,7 @@ export const scaffolderTemplatesSubPage = SubPageBlueprint.makeWithOverrides({
return import('./components/TemplatesSubPage').then(m => (
<m.TemplatesSubPage
formFields={formFields}
groups={groups}
formProps={{
EXPERIMENTAL_theme: config.enableBackstageUi ? 'bui' : 'mui',
}}
@@ -0,0 +1,50 @@
/*
* Copyright 2026 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 { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { createGroupsWithOther } from './createGroupsWithOther';
const make = (type: string): TemplateEntityV1beta3 =>
({
apiVersion: 'scaffolder.backstage.io/v1beta3',
kind: 'Template',
metadata: { name: `n-${type}` },
spec: { type, parameters: [], steps: [] },
} as unknown as TemplateEntityV1beta3);
describe('createGroupsWithOther', () => {
it('appends an Other group matching everything not matched by prior groups', () => {
const groups = createGroupsWithOther(
[{ title: 'Services', filter: e => e.spec?.type === 'service' }],
'Other',
);
expect(groups).toHaveLength(2);
expect(groups[0].title).toBe('Services');
expect(groups[0].filter(make('service'))).toBe(true);
expect(groups[0].filter(make('library'))).toBe(false);
expect(groups[1].title).toBe('Other');
expect(groups[1].filter(make('service'))).toBe(false);
expect(groups[1].filter(make('library'))).toBe(true);
});
it('returns only the Other group when given no input groups', () => {
const groups = createGroupsWithOther([], 'Other');
expect(groups).toHaveLength(1);
expect(groups[0].filter(make('anything'))).toBe(true);
});
});
@@ -0,0 +1,35 @@
/*
* Copyright 2026 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 { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react';
/**
* Appends an "Other" group matching templates not matched by any of the
* configured groups. The `otherTitle` should already be translated.
*/
export const createGroupsWithOther = (
groups: TemplateGroupFilter[],
otherTitle: string,
): TemplateGroupFilter[] => {
const baseGroups = [...groups];
return [
...baseGroups,
{
title: otherTitle,
filter: e => !baseGroups.some(({ filter }) => filter(e)),
},
];
};