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:
@@ -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:^",
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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)),
|
||||
},
|
||||
];
|
||||
};
|
||||
Reference in New Issue
Block a user