diff --git a/.changeset/scaffolder-react-template-card-swappable.md b/.changeset/scaffolder-react-template-card-swappable.md
new file mode 100644
index 0000000000..d444ed3e63
--- /dev/null
+++ b/.changeset/scaffolder-react-template-card-swappable.md
@@ -0,0 +1,10 @@
+---
+'@backstage/plugin-scaffolder-react': minor
+---
+
+The `TemplateCard` component is now a swappable component. Apps using the new
+frontend system can replace it by registering a `SwappableComponentBlueprint`
+that targets `TemplateCard`. Components used as the swappable implementation
+receive `TemplateCardComponentProps`, where `onSelected` is a zero-argument
+callback bound to the rendered template. Existing usage continues to work
+unchanged.
diff --git a/.changeset/scaffolder-template-groups-config.md b/.changeset/scaffolder-template-groups-config.md
new file mode 100644
index 0000000000..51690f4f3f
--- /dev/null
+++ b/.changeset/scaffolder-template-groups-config.md
@@ -0,0 +1,26 @@
+---
+'@backstage/plugin-scaffolder': minor
+---
+
+The `sub-page:scaffolder/templates` extension now accepts a `groups` config
+field that lets you define template groups on the template list page. Each group
+has a `title` and a `filter` predicate. Templates not matched by any
+configured group fall into an automatically appended "Other Templates" group.
+With no groups configured, the page renders a single "Templates" group as
+before.
+
+Example:
+
+```yaml
+app:
+ extensions:
+ - sub-page:scaffolder/templates:
+ config:
+ groups:
+ - title: Recommended Services
+ filter:
+ spec.type: service
+ - title: Documentation
+ filter:
+ spec.type: documentation
+```
diff --git a/app-config.yaml b/app-config.yaml
index e321615ccf..46774f3550 100644
--- a/app-config.yaml
+++ b/app-config.yaml
@@ -100,6 +100,16 @@ app:
- custom:
title: Custom
+ - sub-page:scaffolder/templates:
+ config:
+ groups:
+ - title: Recommended Services
+ filter:
+ spec.type: service
+ - title: Documentation
+ filter:
+ spec.type: documentation
+
# Entity page cards
- entity-card:catalog/about:
config:
diff --git a/docs/features/software-templates/configuration.md b/docs/features/software-templates/configuration.md
index c266fa1217..25b1973e61 100644
--- a/docs/features/software-templates/configuration.md
+++ b/docs/features/software-templates/configuration.md
@@ -109,6 +109,10 @@ Default secrets are resolved from environment variables and accessible via `${{
## Customizing the ScaffolderPage with Grouping and Filtering
+The sections below cover the legacy (JSX) frontend system. For the new
+frontend system, see [Customizing the templates page in the new frontend system](#customizing-the-templates-page-in-the-new-frontend-system)
+below.
+
Once you have more than a few software templates you may want to customize your
`ScaffolderPage` by grouping and surfacing certain templates together. You can
accomplish this by creating `groups` and passing them to your `ScaffolderPage`
@@ -149,3 +153,74 @@ You can have several use cases for that:
}
/>
```
+
+## Customizing the templates page in the new frontend system
+
+In the new frontend system the templates page is built from extensions, so
+customisations are configured rather than passed as JSX props.
+
+### Defining template groups in `app-config.yaml`
+
+The `sub-page:scaffolder/templates` extension accepts a `groups` config field.
+Each group has a `title` and a `filter` predicate (using
+[entity predicate queries](https://backstage.io/docs/features/software-catalog/catalog-customization#entity-predicate-queries)).
+Templates not matched by any group fall into an automatically appended
+"Other Templates" group. With no groups configured the page renders a single
+"Templates" group.
+
+```yaml
+app:
+ extensions:
+ - sub-page:scaffolder/templates:
+ config:
+ groups:
+ - title: Recommended Services
+ filter:
+ spec.type: service
+ - title: Documentation
+ filter:
+ spec.type: documentation
+```
+
+Predicate values are matched case-insensitively. The matchers `$exists`,
+`$in`, `$contains`, `$hasPrefix` and the logical operators `$all`, `$any`, `$not`
+are also supported — see the
+[entity predicate queries reference](https://backstage.io/docs/features/software-catalog/catalog-customization#entity-predicate-queries)
+for the full grammar.
+
+### Replacing the default `TemplateCard`
+
+The `TemplateCard` exported from `@backstage/plugin-scaffolder-react/alpha`
+is a swappable component. Apps can replace it by registering a
+`SwappableComponentBlueprint` extension that targets `TemplateCard`:
+
+```tsx
+// packages/app/src/modules/appModuleScaffolder.tsx
+import { createFrontendModule } from '@backstage/frontend-plugin-api';
+import { SwappableComponentBlueprint } from '@backstage/plugin-app-react';
+import { TemplateCard } from '@backstage/plugin-scaffolder-react/alpha';
+
+export const appModuleScaffolder = createFrontendModule({
+ pluginId: 'app',
+ extensions: [
+ SwappableComponentBlueprint.make({
+ name: 'scaffolder-template-card',
+ params: defineParams =>
+ defineParams({
+ component: TemplateCard,
+ loader: () => import('./MyTemplateCard').then(m => m.MyTemplateCard),
+ }),
+ }),
+ ],
+});
+```
+
+Wire the module into your app by adding `appModuleScaffolder` to the
+`features` array of `createApp` in `packages/app/src/App.tsx`.
+
+`MyTemplateCard` receives `TemplateCardComponentProps`
+(`{ template, additionalLinks?, onSelected? }`). The list takes care of
+binding the template to `onSelected`, so the card just calls
+`props.onSelected?.()` to choose itself. The example app under
+`packages/app/src/modules/BuiTemplateCard.tsx` shows a Backstage UI (BUI)
+implementation you can use as a starting point.
diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx
index 6a6814ae4a..1bd2db5b99 100644
--- a/packages/app/src/App.tsx
+++ b/packages/app/src/App.tsx
@@ -50,6 +50,7 @@ import { convertLegacyPageExtension } from '@backstage/core-compat-api';
import { convertLegacyEntityContentExtension } from '@backstage/plugin-catalog-react/alpha';
import { pluginInfoResolver } from './pluginInfoResolver';
import { appModuleNav } from './modules/appModuleNav';
+import { appModuleScaffolder } from './modules/appModuleScaffolder';
import catalogPlugin from '@backstage/plugin-catalog/alpha';
import InfoIcon from '@material-ui/icons/Info';
@@ -140,6 +141,7 @@ const app = createApp({
kubernetesPlugin,
notFoundErrorPageModule,
appModuleNav,
+ appModuleScaffolder,
customHomePageModule,
...collectedLegacyPlugins,
],
diff --git a/packages/app/src/modules/BuiTemplateCard.module.css b/packages/app/src/modules/BuiTemplateCard.module.css
new file mode 100644
index 0000000000..e938bacfc0
--- /dev/null
+++ b/packages/app/src/modules/BuiTemplateCard.module.css
@@ -0,0 +1,28 @@
+.templateCard {
+ height: 212px;
+ display: flex;
+}
+
+.templateName {
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 1;
+ line-clamp: 1;
+}
+
+.templateDescription {
+ overflow: hidden;
+ display: -webkit-box;
+ -webkit-box-orient: vertical;
+ -webkit-line-clamp: 3;
+ line-clamp: 3;
+}
+
+.templateBody {
+ display: flex;
+ flex: 1;
+ gap: var(--bui-space-3);
+ flex-direction: column;
+ justify-content: space-between;
+}
diff --git a/packages/app/src/modules/BuiTemplateCard.tsx b/packages/app/src/modules/BuiTemplateCard.tsx
new file mode 100644
index 0000000000..d2d765abd7
--- /dev/null
+++ b/packages/app/src/modules/BuiTemplateCard.tsx
@@ -0,0 +1,116 @@
+/*
+ * 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 { useMemo } from 'react';
+import { RELATION_OWNED_BY } from '@backstage/catalog-model';
+import { useAnalytics } from '@backstage/frontend-plugin-api';
+import {
+ EntityRefLink,
+ getEntityRelations,
+} from '@backstage/plugin-catalog-react';
+import {
+ Box,
+ Button,
+ Card,
+ CardFooter,
+ CardHeader,
+ Flex,
+ Tag,
+ TagGroup,
+ Text,
+} from '@backstage/ui';
+import type { TemplateCardComponentProps } from '@backstage/plugin-scaffolder-react/alpha';
+import styles from './BuiTemplateCard.module.css';
+
+const MAX_TAGS = 4;
+
+export function BuiTemplateCard(props: TemplateCardComponentProps) {
+ const { template, onSelected } = props;
+ const analytics = useAnalytics();
+
+ const {
+ spec: { type },
+ metadata: { tags, description, name, title },
+ } = template;
+
+ const visibleTags = useMemo(
+ () =>
+ Array.from(new Set([type, ...(tags ?? [])].filter(Boolean))).slice(
+ 0,
+ MAX_TAGS,
+ ),
+ [type, tags],
+ );
+
+ const owner = getEntityRelations(template, RELATION_OWNED_BY)[0];
+
+ const handleRun = () => {
+ analytics.captureEvent('click', 'Template has been opened');
+ onSelected?.();
+ };
+
+ return (
+
+
+
+ {title ?? name}
+
+
+
+ {description && (
+
+ {description}
+
+ )}
+ {visibleTags.length > 0 && (
+
+ {visibleTags.map(t => (
+ {t!}
+ ))}
+
+ )}
+
+
+
+
+ {owner && (
+
+
+ Created by
+
+
+
+
+
+ )}
+
+
+
+ );
+}
diff --git a/packages/app/src/modules/appModuleScaffolder.tsx b/packages/app/src/modules/appModuleScaffolder.tsx
new file mode 100644
index 0000000000..ae9c63cba8
--- /dev/null
+++ b/packages/app/src/modules/appModuleScaffolder.tsx
@@ -0,0 +1,34 @@
+/*
+ * 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 { createFrontendModule } from '@backstage/frontend-plugin-api';
+import { SwappableComponentBlueprint } from '@backstage/plugin-app-react';
+import { TemplateCard } from '@backstage/plugin-scaffolder-react/alpha';
+
+export const appModuleScaffolder = createFrontendModule({
+ pluginId: 'app',
+ extensions: [
+ SwappableComponentBlueprint.make({
+ name: 'scaffolder-template-card',
+ params: defineParams =>
+ defineParams({
+ component: TemplateCard,
+ loader: () =>
+ import('./BuiTemplateCard').then(m => m.BuiTemplateCard),
+ }),
+ }),
+ ],
+});
diff --git a/plugins/scaffolder-react/report-alpha.api.md b/plugins/scaffolder-react/report-alpha.api.md
index 11c1a2a0e6..068bc0b9cc 100644
--- a/plugins/scaffolder-react/report-alpha.api.md
+++ b/plugins/scaffolder-react/report-alpha.api.md
@@ -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 {
diff --git a/plugins/scaffolder-react/src/next/components/TemplateCard/TemplateCard.test.tsx b/plugins/scaffolder-react/src/next/components/TemplateCard/TemplateCard.test.tsx
index af513ac52a..2a8a01edad 100644
--- a/plugins/scaffolder-react/src/next/components/TemplateCard/TemplateCard.test.tsx
+++ b/plugins/scaffolder-react/src/next/components/TemplateCard/TemplateCard.test.tsx
@@ -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 = {
diff --git a/plugins/scaffolder-react/src/next/components/TemplateCard/TemplateCard.tsx b/plugins/scaffolder-react/src/next/components/TemplateCard/TemplateCard.tsx
index 377ee0fa50..958d4cd480 100644
--- a/plugins/scaffolder-react/src/next/components/TemplateCard/TemplateCard.tsx
+++ b/plugins/scaffolder-react/src/next/components/TemplateCard/TemplateCard.tsx
@@ -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(() => ({
- 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({
+ 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 (
-
-
-
-
-
- {displayDefaultDivider && (
-
-
-
- )}
- {hasTags && }
- {hasLinks && (
-
- )}
-
-
-
-
-
-
- );
-};
diff --git a/plugins/scaffolder-react/src/next/components/TemplateCard/TemplateCardImpl.tsx b/plugins/scaffolder-react/src/next/components/TemplateCard/TemplateCardImpl.tsx
new file mode 100644
index 0000000000..f4e1b3444e
--- /dev/null
+++ b/plugins/scaffolder-react/src/next/components/TemplateCard/TemplateCardImpl.tsx
@@ -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(() => ({
+ 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 (
+
+
+
+
+
+ {displayDefaultDivider && (
+
+
+
+ )}
+ {hasTags && }
+ {hasLinks && (
+
+ )}
+
+
+
+
+
+
+ );
+};
diff --git a/plugins/scaffolder-react/src/next/components/TemplateCard/index.ts b/plugins/scaffolder-react/src/next/components/TemplateCard/index.ts
index 529aa938e4..0c1fd77291 100644
--- a/plugins/scaffolder-react/src/next/components/TemplateCard/index.ts
+++ b/plugins/scaffolder-react/src/next/components/TemplateCard/index.ts
@@ -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';
diff --git a/plugins/scaffolder-react/src/next/components/TemplateGroup/TemplateGroup.test.tsx b/plugins/scaffolder-react/src/next/components/TemplateGroup/TemplateGroup.test.tsx
index 0d1f2df18d..2d57b5f736 100644
--- a/plugins/scaffolder-react/src/next/components/TemplateGroup/TemplateGroup.test.tsx
+++ b/plugins/scaffolder-react/src/next/components/TemplateGroup/TemplateGroup.test.tsx
@@ -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', () => {
diff --git a/plugins/scaffolder-react/src/next/components/TemplateGroup/TemplateGroup.tsx b/plugins/scaffolder-react/src/next/components/TemplateGroup/TemplateGroup.tsx
index 74ade33344..47bb3bdf97 100644
--- a/plugins/scaffolder-react/src/next/components/TemplateGroup/TemplateGroup.tsx
+++ b/plugins/scaffolder-react/src/next/components/TemplateGroup/TemplateGroup.tsx
@@ -62,8 +62,6 @@ export const TemplateGroup = (props: TemplateGroupProps) => {
return null;
}
- const Card = CardComponent || TemplateCard;
-
return (
{titleComponent}
@@ -75,11 +73,19 @@ export const TemplateGroup = (props: TemplateGroupProps) => {
}}
key={stringifyEntityRef(template)}
>
-
+ {CardComponent ? (
+
+ ) : (
+ onSelected(template)}
+ />
+ )}
))}
diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json
index 8000789054..91960324d4 100644
--- a/plugins/scaffolder/package.json
+++ b/plugins/scaffolder/package.json
@@ -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:^",
diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md
index 616823f896..471f460241 100644
--- a/plugins/scaffolder/report-alpha.api.md
+++ b/plugins/scaffolder/report-alpha.api.md
@@ -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;
};
diff --git a/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.tsx b/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.tsx
index 37a50ed904..4ac7ece764 100644
--- a/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.tsx
+++ b/plugins/scaffolder/src/alpha/components/TemplateListPage/TemplateListPage.tsx
@@ -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,
-): 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:
diff --git a/plugins/scaffolder/src/alpha/components/TemplatesSubPage.tsx b/plugins/scaffolder/src/alpha/components/TemplatesSubPage.tsx
index f761516bed..2bda206e1e 100644
--- a/plugins/scaffolder/src/alpha/components/TemplatesSubPage.tsx
+++ b/plugins/scaffolder/src/alpha/components/TemplatesSubPage.tsx
@@ -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;
formProps?: FormProps;
+ groups?: TemplateGroupFilter[];
}) {
const customFieldExtensions = useCustomFieldExtensions(undefined);
const customLayouts = useCustomLayouts(undefined);
@@ -181,7 +197,7 @@ export function TemplatesSubPage(props: {
return (
- } />
+ } />
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 => (
+ ({
+ 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);
+ });
+});
diff --git a/plugins/scaffolder/src/alpha/lib/createGroupsWithOther.ts b/plugins/scaffolder/src/alpha/lib/createGroupsWithOther.ts
new file mode 100644
index 0000000000..07272f4b38
--- /dev/null
+++ b/plugins/scaffolder/src/alpha/lib/createGroupsWithOther.ts
@@ -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)),
+ },
+ ];
+};
diff --git a/yarn.lock b/yarn.lock
index 6d10400044..042e889794 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -7060,6 +7060,7 @@ __metadata:
"@backstage/core-plugin-api": "workspace:^"
"@backstage/dev-utils": "workspace:^"
"@backstage/errors": "workspace:^"
+ "@backstage/filter-predicates": "workspace:^"
"@backstage/frontend-plugin-api": "workspace:^"
"@backstage/frontend-test-utils": "workspace:^"
"@backstage/integration": "workspace:^"