Merge pull request #16795 from acierto/filter-out-templates
Filter out templates
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': minor
|
||||
---
|
||||
|
||||
Added a `templateFilter` prop to the `<Router/>` component to allow for filtering of templates through a function.
|
||||
@@ -93,3 +93,18 @@ You can also further customize groups by passing in a `titleComponent` instead
|
||||
of a `title` which will be a component to use as the header instead of just the
|
||||
default `ContentHeader` with the `title` set as it's value.
|
||||

|
||||
|
||||
There is also an option to hide some templates.
|
||||
You can have several use cases for that:
|
||||
|
||||
- it's still in an experimental phase, so you can combine it with feature flagging for example
|
||||
- you don't want to make them accessible from template list, but only open it on some action with pre-filled data.
|
||||
- show different set of templates depends on target environment
|
||||
|
||||
```typescript jsx
|
||||
<ScaffolderPage
|
||||
templateFilter={entity =>
|
||||
entity?.metadata?.tags?.includes('experimental') ?? false
|
||||
}
|
||||
/>
|
||||
```
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
```ts
|
||||
/// <reference types="react" />
|
||||
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { FormProps as FormProps_2 } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import type { FormProps as FormProps_3 } from '@rjsf/core-v5';
|
||||
import { PropsWithChildren } from 'react';
|
||||
@@ -31,6 +30,7 @@ export type NextRouterProps = {
|
||||
}>;
|
||||
};
|
||||
groups?: TemplateGroupFilter[];
|
||||
templateFilter?: (entity: TemplateEntityV1beta3) => boolean;
|
||||
FormProps?: FormProps_2;
|
||||
contextMenu?: {
|
||||
editor?: boolean;
|
||||
@@ -47,7 +47,7 @@ export const NextScaffolderPage: (
|
||||
// @alpha (undocumented)
|
||||
export type TemplateGroupFilter = {
|
||||
title?: React_2.ReactNode;
|
||||
filter: (entity: Entity) => boolean;
|
||||
filter: (entity: TemplateEntityV1beta3) => boolean;
|
||||
};
|
||||
|
||||
// (No @packageDocumentation comment for this package)
|
||||
|
||||
@@ -388,6 +388,7 @@ export type RouterProps = {
|
||||
title?: React_2.ReactNode;
|
||||
filter: (entity: Entity) => boolean;
|
||||
}>;
|
||||
templateFilter?: (entity: TemplateEntityV1beta3) => boolean;
|
||||
defaultPreviewTemplate?: string;
|
||||
headerOptions?: {
|
||||
pageTitleOverride?: string;
|
||||
|
||||
@@ -58,6 +58,7 @@ export type RouterProps = {
|
||||
title?: React.ReactNode;
|
||||
filter: (entity: Entity) => boolean;
|
||||
}>;
|
||||
templateFilter?: (entity: TemplateEntityV1beta3) => boolean;
|
||||
defaultPreviewTemplate?: string;
|
||||
headerOptions?: {
|
||||
pageTitleOverride?: string;
|
||||
@@ -81,7 +82,12 @@ export type RouterProps = {
|
||||
* @public
|
||||
*/
|
||||
export const Router = (props: RouterProps) => {
|
||||
const { groups, components = {}, defaultPreviewTemplate } = props;
|
||||
const {
|
||||
groups,
|
||||
templateFilter,
|
||||
components = {},
|
||||
defaultPreviewTemplate,
|
||||
} = props;
|
||||
|
||||
const { ReviewStepComponent, TemplateCardComponent, TaskPageComponent } =
|
||||
components;
|
||||
@@ -126,6 +132,7 @@ export const Router = (props: RouterProps) => {
|
||||
element={
|
||||
<ScaffolderPage
|
||||
groups={groups}
|
||||
templateFilter={templateFilter}
|
||||
TemplateCardComponent={TemplateCardComponent}
|
||||
contextMenu={props.contextMenu}
|
||||
headerOptions={props.headerOptions}
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
Page,
|
||||
SupportButton,
|
||||
} from '@backstage/core-components';
|
||||
import { Entity } from '@backstage/catalog-model';
|
||||
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import { useRouteRef } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
@@ -47,8 +46,9 @@ export type ScaffolderPageProps = {
|
||||
| undefined;
|
||||
groups?: Array<{
|
||||
title?: React.ReactNode;
|
||||
filter: (entity: Entity) => boolean;
|
||||
filter: (entity: TemplateEntityV1beta3) => boolean;
|
||||
}>;
|
||||
templateFilter?: (entity: TemplateEntityV1beta3) => boolean;
|
||||
contextMenu?: {
|
||||
editor?: boolean;
|
||||
actions?: boolean;
|
||||
@@ -64,13 +64,14 @@ export type ScaffolderPageProps = {
|
||||
export const ScaffolderPageContents = ({
|
||||
TemplateCardComponent,
|
||||
groups,
|
||||
templateFilter,
|
||||
contextMenu,
|
||||
headerOptions,
|
||||
}: ScaffolderPageProps) => {
|
||||
const registerComponentLink = useRouteRef(registerComponentRouteRef);
|
||||
const otherTemplatesGroup = {
|
||||
title: groups ? 'Other Templates' : 'Templates',
|
||||
filter: (entity: Entity) => {
|
||||
filter: (entity: TemplateEntityV1beta3) => {
|
||||
const filtered = (groups ?? []).map(group => group.filter(entity));
|
||||
return !filtered.some(result => result === true);
|
||||
},
|
||||
@@ -123,11 +124,13 @@ export const ScaffolderPageContents = ({
|
||||
key={index}
|
||||
TemplateCardComponent={TemplateCardComponent}
|
||||
group={group}
|
||||
templateFilter={templateFilter}
|
||||
/>
|
||||
))}
|
||||
<TemplateList
|
||||
key="other"
|
||||
TemplateCardComponent={TemplateCardComponent}
|
||||
templateFilter={templateFilter}
|
||||
group={otherTemplatesGroup}
|
||||
/>
|
||||
</CatalogFilterLayout.Content>
|
||||
@@ -140,6 +143,7 @@ export const ScaffolderPageContents = ({
|
||||
export const ScaffolderPage = ({
|
||||
TemplateCardComponent,
|
||||
groups,
|
||||
templateFilter,
|
||||
contextMenu,
|
||||
headerOptions,
|
||||
}: ScaffolderPageProps) => (
|
||||
@@ -147,6 +151,7 @@ export const ScaffolderPage = ({
|
||||
<ScaffolderPageContents
|
||||
TemplateCardComponent={TemplateCardComponent}
|
||||
groups={groups}
|
||||
templateFilter={templateFilter}
|
||||
contextMenu={contextMenu}
|
||||
headerOptions={headerOptions}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright 2020 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 { screen } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
|
||||
import { TemplateList } from './TemplateList';
|
||||
import { rootRouteRef } from '../../routes';
|
||||
import {
|
||||
ScmIntegrationsApi,
|
||||
scmIntegrationsApiRef,
|
||||
} from '@backstage/integration-react';
|
||||
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
|
||||
jest.mock('@backstage/plugin-catalog-react', () => ({
|
||||
useEntityList: jest.fn().mockReturnValue({
|
||||
loading: false,
|
||||
entities: [
|
||||
{
|
||||
apiVersion: 'scaffolder.backstage.io/v1beta3',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
name: 't1',
|
||||
},
|
||||
spec: {},
|
||||
},
|
||||
{
|
||||
apiVersion: 'scaffolder.backstage.io/v1beta3',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
name: 't2',
|
||||
},
|
||||
spec: {},
|
||||
},
|
||||
],
|
||||
}),
|
||||
getEntityRelations: jest.fn().mockImplementation(() => []),
|
||||
getEntitySourceLocation: jest.fn().mockImplementation(() => ({})),
|
||||
}));
|
||||
|
||||
describe('TemplateList', () => {
|
||||
const mockIntegrationsApi: Partial<ScmIntegrationsApi> = {
|
||||
byHost: () => ({ type: 'github' }),
|
||||
};
|
||||
|
||||
it('should filter out templates based on provided filter condition', async () => {
|
||||
const TemplateCardComponent = ({
|
||||
template,
|
||||
}: {
|
||||
template: TemplateEntityV1beta3;
|
||||
}) => (
|
||||
<div data-testid={template.metadata.name}>{template.metadata.name}</div>
|
||||
);
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[scmIntegrationsApiRef, mockIntegrationsApi]]}>
|
||||
<div data-testid="container">
|
||||
<TemplateList
|
||||
templateFilter={e => e.metadata.name === 't1'}
|
||||
TemplateCardComponent={TemplateCardComponent}
|
||||
/>
|
||||
</div>
|
||||
</TestApiProvider>,
|
||||
{ mountedRoutes: { '/': rootRouteRef } },
|
||||
);
|
||||
|
||||
expect(() => screen.getByTestId('t1')).toThrow();
|
||||
expect(screen.getByTestId('t2')).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
import { useEntityList } from '@backstage/plugin-catalog-react';
|
||||
import { Typography } from '@material-ui/core';
|
||||
import { TemplateCard } from '../TemplateCard';
|
||||
import { isTemplateEntity } from '../../lib/isTemplateEntity';
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@@ -38,8 +39,9 @@ export type TemplateListProps = {
|
||||
| undefined;
|
||||
group?: {
|
||||
title?: React.ReactNode;
|
||||
filter: (entity: Entity) => boolean;
|
||||
filter: (entity: TemplateEntityV1beta3) => boolean;
|
||||
};
|
||||
templateFilter?: (entity: TemplateEntityV1beta3) => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -48,12 +50,14 @@ export type TemplateListProps = {
|
||||
export const TemplateList = ({
|
||||
TemplateCardComponent,
|
||||
group,
|
||||
templateFilter,
|
||||
}: TemplateListProps) => {
|
||||
const { loading, error, entities } = useEntityList();
|
||||
const Card = TemplateCardComponent || TemplateCard;
|
||||
const maybeFilteredEntities = group
|
||||
? entities.filter(e => group.filter(e))
|
||||
: entities;
|
||||
const templateEntities = entities.filter(isTemplateEntity);
|
||||
const maybeFilteredEntities = (
|
||||
group ? templateEntities.filter(group.filter) : templateEntities
|
||||
).filter(e => (templateFilter ? !templateFilter(e) : true));
|
||||
|
||||
const titleComponent: React.ReactNode = (() => {
|
||||
if (group && group.title) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright 2023 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 { Entity } from '@backstage/catalog-model';
|
||||
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
|
||||
export const isTemplateEntity = (
|
||||
entity: Entity,
|
||||
): entity is TemplateEntityV1beta3 =>
|
||||
entity.apiVersion === 'scaffolder.backstage.io/v1beta3' &&
|
||||
entity.kind === 'Template';
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
} from '@backstage/plugin-scaffolder-react';
|
||||
|
||||
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import { TemplateGroupFilter } from '../TemplateListPage/TemplateGroups';
|
||||
import { TemplateGroupFilter } from '../TemplateListPage';
|
||||
import { DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS } from '../../extensions/default';
|
||||
|
||||
import {
|
||||
@@ -61,6 +61,7 @@ export type NextRouterProps = {
|
||||
}>;
|
||||
};
|
||||
groups?: TemplateGroupFilter[];
|
||||
templateFilter?: (entity: TemplateEntityV1beta3) => boolean;
|
||||
// todo(blam): rename this to formProps
|
||||
FormProps?: FormProps;
|
||||
contextMenu?: {
|
||||
@@ -111,6 +112,7 @@ export const Router = (props: PropsWithChildren<NextRouterProps>) => {
|
||||
TemplateCardComponent={TemplateCardComponent}
|
||||
contextMenu={props.contextMenu}
|
||||
groups={props.groups}
|
||||
templateFilter={props.templateFilter}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -207,4 +207,52 @@ describe('TemplateGroups', () => {
|
||||
{},
|
||||
);
|
||||
});
|
||||
|
||||
it('should filter out templates based on filter condition', async () => {
|
||||
const mockEntities = [
|
||||
{
|
||||
apiVersion: 'scaffolder.backstage.io/v1beta3',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
name: 't1',
|
||||
},
|
||||
spec: {},
|
||||
},
|
||||
{
|
||||
apiVersion: 'scaffolder.backstage.io/v1beta3',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
name: 't2',
|
||||
},
|
||||
spec: {},
|
||||
},
|
||||
];
|
||||
|
||||
(useEntityList as jest.Mock).mockReturnValue({
|
||||
entities: mockEntities,
|
||||
loading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
await renderInTestApp(
|
||||
<TestApiProvider apis={[[errorApiRef, {}]]}>
|
||||
<TemplateGroups
|
||||
groups={[{ title: 'all', filter: _ => true }]}
|
||||
templateFilter={e => e.metadata.name === 't1'}
|
||||
/>
|
||||
</TestApiProvider>,
|
||||
{
|
||||
mountedRoutes: {
|
||||
'/create': rootRouteRef,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(TemplateGroup).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
templates: [expect.objectContaining({ template: mockEntities[1] })],
|
||||
}),
|
||||
{},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,11 +15,7 @@
|
||||
*/
|
||||
import React, { useCallback } from 'react';
|
||||
|
||||
import {
|
||||
Entity,
|
||||
parseEntityRef,
|
||||
stringifyEntityRef,
|
||||
} from '@backstage/catalog-model';
|
||||
import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model';
|
||||
import { useEntityList } from '@backstage/plugin-catalog-react';
|
||||
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
|
||||
import { Progress, Link, DocsIcon } from '@backstage/core-components';
|
||||
@@ -33,17 +29,19 @@ import {
|
||||
import { TemplateGroup } from '@backstage/plugin-scaffolder-react/alpha';
|
||||
import { viewTechDocRouteRef, selectedTemplateRouteRef } from '../../routes';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { isTemplateEntity } from '../../lib/isTemplateEntity';
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export type TemplateGroupFilter = {
|
||||
title?: React.ReactNode;
|
||||
filter: (entity: Entity) => boolean;
|
||||
filter: (entity: TemplateEntityV1beta3) => boolean;
|
||||
};
|
||||
|
||||
export interface TemplateGroupsProps {
|
||||
groups: TemplateGroupFilter[];
|
||||
templateFilter?: (entity: TemplateEntityV1beta3) => boolean;
|
||||
TemplateCardComponent?: React.ComponentType<{
|
||||
template: TemplateEntityV1beta3;
|
||||
}>;
|
||||
@@ -51,7 +49,7 @@ export interface TemplateGroupsProps {
|
||||
|
||||
export const TemplateGroups = (props: TemplateGroupsProps) => {
|
||||
const { loading, error, entities } = useEntityList();
|
||||
const { groups, TemplateCardComponent } = props;
|
||||
const { groups, templateFilter, TemplateCardComponent } = props;
|
||||
const errorApi = useApi(errorApiRef);
|
||||
const app = useApp();
|
||||
const viewTechDocsLink = useRouteRef(viewTechDocRouteRef);
|
||||
@@ -90,7 +88,9 @@ export const TemplateGroups = (props: TemplateGroupsProps) => {
|
||||
<>
|
||||
{groups.map(({ title, filter }, index) => {
|
||||
const templates = entities
|
||||
.filter((e): e is TemplateEntityV1beta3 => filter(e))
|
||||
.filter(isTemplateEntity)
|
||||
.filter(e => (templateFilter ? !templateFilter(e) : true))
|
||||
.filter(filter)
|
||||
.map(template => {
|
||||
const { kind, namespace, name } = parseEntityRef(
|
||||
stringifyEntityRef(template),
|
||||
|
||||
@@ -44,6 +44,7 @@ export type TemplateListPageProps = {
|
||||
template: TemplateEntityV1beta3;
|
||||
}>;
|
||||
groups?: TemplateGroupFilter[];
|
||||
templateFilter?: (entity: TemplateEntityV1beta3) => boolean;
|
||||
contextMenu?: {
|
||||
editor?: boolean;
|
||||
actions?: boolean;
|
||||
@@ -68,7 +69,11 @@ const createGroupsWithOther = (
|
||||
|
||||
export const TemplateListPage = (props: TemplateListPageProps) => {
|
||||
const registerComponentLink = useRouteRef(registerComponentRouteRef);
|
||||
const { TemplateCardComponent, groups: givenGroups = [] } = props;
|
||||
const {
|
||||
TemplateCardComponent,
|
||||
groups: givenGroups = [],
|
||||
templateFilter,
|
||||
} = props;
|
||||
|
||||
const groups = givenGroups.length
|
||||
? createGroupsWithOther(givenGroups)
|
||||
@@ -111,6 +116,7 @@ export const TemplateListPage = (props: TemplateListPageProps) => {
|
||||
<CatalogFilterLayout.Content>
|
||||
<TemplateGroups
|
||||
groups={groups}
|
||||
templateFilter={templateFilter}
|
||||
TemplateCardComponent={TemplateCardComponent}
|
||||
/>
|
||||
</CatalogFilterLayout.Content>
|
||||
|
||||
Reference in New Issue
Block a user