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
@@ -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.
@@ -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
```
+10
View File
@@ -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:
@@ -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.
+2
View File
@@ -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,
],
@@ -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;
}
@@ -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 (
<Card className={styles.templateCard}>
<CardHeader>
<Text
as="h3"
variant="body-medium"
weight="bold"
color="primary"
className={styles.templateName}
>
{title ?? name}
</Text>
</CardHeader>
<Box px="3" className={styles.templateBody}>
{description && (
<Text
as="p"
variant="body-small"
color="secondary"
className={styles.templateDescription}
>
{description}
</Text>
)}
{visibleTags.length > 0 && (
<TagGroup>
{visibleTags.map(t => (
<Tag key={t}>{t!}</Tag>
))}
</TagGroup>
)}
</Box>
<CardFooter>
<Flex justify="between" align="end">
<Button size="small" variant="secondary" onPress={handleRun}>
Run
</Button>
{owner && (
<Flex gap="0" direction="column" align="end">
<Text variant="body-x-small" color="secondary">
Created by
</Text>
<Text variant="body-x-small" color="primary">
<EntityRefLink entityRef={owner} hideIcon />
</Text>
</Flex>
)}
</Flex>
</CardFooter>
</Card>
);
}
@@ -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),
}),
}),
],
});
+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)),
},
];
};
+1
View File
@@ -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:^"