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
+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)),
},
];
};