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:^"