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:
@@ -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
|
||||
```
|
||||
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"@backstage/core-components": "workspace:^",
|
||||
"@backstage/core-plugin-api": "workspace:^",
|
||||
"@backstage/errors": "workspace:^",
|
||||
"@backstage/filter-predicates": "workspace:^",
|
||||
"@backstage/frontend-plugin-api": "workspace:^",
|
||||
"@backstage/integration": "workspace:^",
|
||||
"@backstage/integration-react": "workspace:^",
|
||||
|
||||
@@ -553,11 +553,23 @@ const _default: OverridableFrontendPlugin<
|
||||
'sub-page:scaffolder/templates': OverridableExtensionDefinition<{
|
||||
config: {
|
||||
enableBackstageUi: boolean;
|
||||
groups:
|
||||
| {
|
||||
title: string;
|
||||
filter: FilterPredicate;
|
||||
}[]
|
||||
| undefined;
|
||||
path: string | undefined;
|
||||
title: string | undefined;
|
||||
};
|
||||
configInput: {
|
||||
enableBackstageUi?: boolean | undefined;
|
||||
groups?:
|
||||
| {
|
||||
title: string;
|
||||
filter: FilterPredicate;
|
||||
}[]
|
||||
| undefined;
|
||||
path?: string | undefined;
|
||||
title?: string | undefined;
|
||||
};
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ComponentType, useCallback } from 'react';
|
||||
import { ComponentType, useCallback, useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import { useApp, useRouteRef } from '@backstage/core-plugin-api';
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
TemplateCategoryPicker,
|
||||
TemplateGroups,
|
||||
} from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { createGroupsWithOther } from '../../lib/createGroupsWithOther';
|
||||
|
||||
import { RegisterExistingButton } from './RegisterExistingButton';
|
||||
import {
|
||||
@@ -54,10 +55,7 @@ import {
|
||||
} from '../../../routes';
|
||||
import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react';
|
||||
import {
|
||||
TranslationFunction,
|
||||
useTranslationRef,
|
||||
} from '@backstage/core-plugin-api/alpha';
|
||||
import { useTranslationRef } from '@backstage/core-plugin-api/alpha';
|
||||
import { scaffolderTranslationRef } from '../../../translation';
|
||||
import { buildTechDocsURL } from '@backstage/plugin-techdocs-react';
|
||||
import {
|
||||
@@ -87,17 +85,6 @@ export type TemplateListPageProps = {
|
||||
};
|
||||
};
|
||||
|
||||
const createGroupsWithOther = (
|
||||
groups: TemplateGroupFilter[],
|
||||
t: TranslationFunction<typeof scaffolderTranslationRef.T>,
|
||||
): TemplateGroupFilter[] => [
|
||||
...groups,
|
||||
{
|
||||
title: t('templateListPage.templateGroups.otherTitle'),
|
||||
filter: e => ![...groups].some(({ filter }) => filter(e)),
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
@@ -119,14 +106,21 @@ export const TemplateListPage = (props: TemplateListPageProps) => {
|
||||
const app = useApp();
|
||||
const { t } = useTranslationRef(scaffolderTranslationRef);
|
||||
|
||||
const groups = givenGroups.length
|
||||
? createGroupsWithOther(givenGroups, t)
|
||||
: [
|
||||
{
|
||||
title: t('templateListPage.templateGroups.defaultTitle'),
|
||||
filter: () => true,
|
||||
},
|
||||
];
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
givenGroups.length
|
||||
? createGroupsWithOther(
|
||||
givenGroups,
|
||||
t('templateListPage.templateGroups.otherTitle'),
|
||||
)
|
||||
: [
|
||||
{
|
||||
title: t('templateListPage.templateGroups.defaultTitle'),
|
||||
filter: () => true,
|
||||
},
|
||||
],
|
||||
[givenGroups, t],
|
||||
);
|
||||
|
||||
const scaffolderPageContextMenuProps = {
|
||||
onEditorClicked:
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { Routes, Route, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Content,
|
||||
@@ -37,10 +37,12 @@ import {
|
||||
TemplateCategoryPicker,
|
||||
TemplateGroups,
|
||||
} from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { createGroupsWithOther } from '../lib/createGroupsWithOther';
|
||||
import {
|
||||
FieldExtensionOptions,
|
||||
FormProps,
|
||||
SecretsContextProvider,
|
||||
TemplateGroupFilter,
|
||||
useCustomFieldExtensions,
|
||||
useCustomLayouts,
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
@@ -63,7 +65,11 @@ import {
|
||||
TECHDOCS_EXTERNAL_ANNOTATION,
|
||||
} from '@backstage/plugin-techdocs-common';
|
||||
|
||||
function TemplateListContent() {
|
||||
function TemplateListContent({
|
||||
groups: configuredGroups,
|
||||
}: {
|
||||
groups?: TemplateGroupFilter[];
|
||||
}) {
|
||||
const registerComponentLink = useRouteRef(registerComponentRouteRef);
|
||||
const viewTechDocsLink = useRouteRef(viewTechDocRouteRef);
|
||||
const templateRoute = useRouteRef(selectedTemplateRouteRef);
|
||||
@@ -71,12 +77,21 @@ function TemplateListContent() {
|
||||
const app = useApp();
|
||||
const { t } = useTranslationRef(scaffolderTranslationRef);
|
||||
|
||||
const groups = [
|
||||
{
|
||||
title: t('templateListPage.templateGroups.defaultTitle'),
|
||||
filter: () => true,
|
||||
},
|
||||
];
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
configuredGroups?.length
|
||||
? createGroupsWithOther(
|
||||
configuredGroups,
|
||||
t('templateListPage.templateGroups.otherTitle'),
|
||||
)
|
||||
: [
|
||||
{
|
||||
title: t('templateListPage.templateGroups.defaultTitle'),
|
||||
filter: () => true,
|
||||
},
|
||||
],
|
||||
[configuredGroups, t],
|
||||
);
|
||||
|
||||
const additionalLinksForEntity = useCallback(
|
||||
(template: TemplateEntityV1beta3) => {
|
||||
@@ -163,6 +178,7 @@ function TemplateListContent() {
|
||||
export function TemplatesSubPage(props: {
|
||||
formFields?: Array<FormField>;
|
||||
formProps?: FormProps;
|
||||
groups?: TemplateGroupFilter[];
|
||||
}) {
|
||||
const customFieldExtensions = useCustomFieldExtensions(undefined);
|
||||
const customLayouts = useCustomLayouts(undefined);
|
||||
@@ -181,7 +197,7 @@ export function TemplatesSubPage(props: {
|
||||
|
||||
return (
|
||||
<Routes>
|
||||
<Route index element={<TemplateListContent />} />
|
||||
<Route index element={<TemplateListContent groups={props.groups} />} />
|
||||
<Route
|
||||
path=":namespace/:templateName"
|
||||
element={
|
||||
|
||||
@@ -24,6 +24,11 @@ import {
|
||||
PageBlueprint,
|
||||
SubPageBlueprint,
|
||||
} from '@backstage/frontend-plugin-api';
|
||||
import { z } from 'zod/v4';
|
||||
import {
|
||||
createZodV4FilterPredicateSchema,
|
||||
filterPredicateToFilterFunction,
|
||||
} from '@backstage/filter-predicates';
|
||||
import { rootRouteRef } from '../routes';
|
||||
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
|
||||
import {
|
||||
@@ -31,7 +36,10 @@ import {
|
||||
formFieldsApiRef,
|
||||
} from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { scmIntegrationsApiRef } from '@backstage/integration-react';
|
||||
import { scaffolderApiRef } from '@backstage/plugin-scaffolder-react';
|
||||
import {
|
||||
scaffolderApiRef,
|
||||
TemplateGroupFilter,
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
import { ScaffolderClient } from '../api';
|
||||
|
||||
export const scaffolderPage = PageBlueprint.makeWithOverrides({
|
||||
@@ -51,14 +59,27 @@ export const scaffolderPage = PageBlueprint.makeWithOverrides({
|
||||
|
||||
export const scaffolderTemplatesSubPage = SubPageBlueprint.makeWithOverrides({
|
||||
name: 'templates',
|
||||
config: {
|
||||
schema: {
|
||||
enableBackstageUi: z => z.boolean().default(false),
|
||||
},
|
||||
configSchema: {
|
||||
enableBackstageUi: z.boolean().optional().default(false),
|
||||
groups: z
|
||||
.array(
|
||||
z.object({
|
||||
title: z.string(),
|
||||
filter: createZodV4FilterPredicateSchema(),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
},
|
||||
factory(originalFactory, { apis, config }) {
|
||||
const formFieldsApi = apis.get(formFieldsApiRef);
|
||||
|
||||
const groups: TemplateGroupFilter[] | undefined = config.groups?.map(
|
||||
group => ({
|
||||
title: group.title,
|
||||
filter: filterPredicateToFilterFunction(group.filter),
|
||||
}),
|
||||
);
|
||||
|
||||
return originalFactory({
|
||||
path: 'templates',
|
||||
title: 'Templates',
|
||||
@@ -68,6 +89,7 @@ export const scaffolderTemplatesSubPage = SubPageBlueprint.makeWithOverrides({
|
||||
return import('./components/TemplatesSubPage').then(m => (
|
||||
<m.TemplatesSubPage
|
||||
formFields={formFields}
|
||||
groups={groups}
|
||||
formProps={{
|
||||
EXPERIMENTAL_theme: config.enableBackstageUi ? 'bui' : 'mui',
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import { createGroupsWithOther } from './createGroupsWithOther';
|
||||
|
||||
const make = (type: string): TemplateEntityV1beta3 =>
|
||||
({
|
||||
apiVersion: 'scaffolder.backstage.io/v1beta3',
|
||||
kind: 'Template',
|
||||
metadata: { name: `n-${type}` },
|
||||
spec: { type, parameters: [], steps: [] },
|
||||
} as unknown as TemplateEntityV1beta3);
|
||||
|
||||
describe('createGroupsWithOther', () => {
|
||||
it('appends an Other group matching everything not matched by prior groups', () => {
|
||||
const groups = createGroupsWithOther(
|
||||
[{ title: 'Services', filter: e => e.spec?.type === 'service' }],
|
||||
'Other',
|
||||
);
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups[0].title).toBe('Services');
|
||||
expect(groups[0].filter(make('service'))).toBe(true);
|
||||
expect(groups[0].filter(make('library'))).toBe(false);
|
||||
|
||||
expect(groups[1].title).toBe('Other');
|
||||
expect(groups[1].filter(make('service'))).toBe(false);
|
||||
expect(groups[1].filter(make('library'))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns only the Other group when given no input groups', () => {
|
||||
const groups = createGroupsWithOther([], 'Other');
|
||||
expect(groups).toHaveLength(1);
|
||||
expect(groups[0].filter(make('anything'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2026 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react';
|
||||
|
||||
/**
|
||||
* Appends an "Other" group matching templates not matched by any of the
|
||||
* configured groups. The `otherTitle` should already be translated.
|
||||
*/
|
||||
export const createGroupsWithOther = (
|
||||
groups: TemplateGroupFilter[],
|
||||
otherTitle: string,
|
||||
): TemplateGroupFilter[] => {
|
||||
const baseGroups = [...groups];
|
||||
return [
|
||||
...baseGroups,
|
||||
{
|
||||
title: otherTitle,
|
||||
filter: e => !baseGroups.some(({ filter }) => filter(e)),
|
||||
},
|
||||
];
|
||||
};
|
||||
@@ -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:^"
|
||||
|
||||
Reference in New Issue
Block a user