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:
@@ -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)),
|
||||
},
|
||||
];
|
||||
};
|
||||
Reference in New Issue
Block a user