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