Merge pull request #16915 from minkimcello/mk/scaffolder-pages

Allow Scaffolder to accept props for custom `TemplateListPage` and `TemplateWizardPage`
This commit is contained in:
Ben Lambert
2023-03-21 13:17:21 +01:00
committed by GitHub
22 changed files with 226 additions and 107 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-common': patch
---
Export `typeguard` for `isTemplateEntityV1beta3`
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder': minor
---
Allow `TemplateListPage` and `TemplateWizardPage` to be passed in as props
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-react': minor
---
`scaffolder/next`: Export the `TemplateGroupFilter` and `TemplateGroups` and make an extensible component
+5
View File
@@ -10,6 +10,11 @@ import type { JsonValue } from '@backstage/types';
import { KindValidator } from '@backstage/catalog-model';
import type { UserEntity } from '@backstage/catalog-model';
// @public
export const isTemplateEntityV1beta3: (
entity: Entity,
) => entity is TemplateEntityV1beta3;
// @public
export type TaskSpec = TaskSpecV1beta3;
@@ -86,3 +86,13 @@ export const templateEntityV1beta3Validator: KindValidator = {
return validator(data) === data;
},
};
/**
* Typeguard for filtering entities and ensuring v1beta3 entities
* @public
*/
export const isTemplateEntityV1beta3 = (
entity: Entity,
): entity is TemplateEntityV1beta3 =>
entity.apiVersion === 'scaffolder.backstage.io/v1beta3' &&
entity.kind === 'Template';
+4 -1
View File
@@ -21,5 +21,8 @@
*/
export * from './TaskSpec';
export { templateEntityV1beta3Validator } from './TemplateEntityV1beta3';
export {
templateEntityV1beta3Validator,
isTemplateEntityV1beta3,
} from './TemplateEntityV1beta3';
export type { TemplateEntityV1beta3 } from './TemplateEntityV1beta3';
@@ -208,6 +208,12 @@ export const TemplateCategoryPicker: () => JSX.Element | null;
// @alpha
export const TemplateGroup: (props: TemplateGroupProps) => JSX.Element;
// @alpha (undocumented)
export type TemplateGroupFilter = {
title?: React_2.ReactNode;
filter: (entity: TemplateEntityV1beta3) => boolean;
};
// @alpha
export interface TemplateGroupProps {
// (undocumented)
@@ -229,6 +235,29 @@ export interface TemplateGroupProps {
title: React_2.ReactNode;
}
// @alpha (undocumented)
export const TemplateGroups: (props: TemplateGroupsProps) => JSX.Element | null;
// @alpha (undocumented)
export interface TemplateGroupsProps {
// (undocumented)
additionalLinksForEntity?: (template: TemplateEntityV1beta3) => {
icon: IconComponent;
text: string;
url: string;
}[];
// (undocumented)
groups: TemplateGroupFilter[];
// (undocumented)
onTemplateSelected?: (template: TemplateEntityV1beta3) => void;
// (undocumented)
TemplateCardComponent?: React_2.ComponentType<{
template: TemplateEntityV1beta3;
}>;
// (undocumented)
templateFilter?: (entity: TemplateEntityV1beta3) => boolean;
}
// @alpha
export const useFormDataFromQuery: (
initialState?: Record<string, JsonValue>,
@@ -142,20 +142,22 @@ export const TemplateCard = (props: TemplateCardProps) => {
</Grid>
<Grid item xs={12}>
<Grid container spacing={2}>
{props.additionalLinks?.map(({ icon, text, url }) => (
<Grid className={styles.linkText} item xs={6}>
{props.additionalLinks?.map(({ icon, text, url }, index) => (
<Grid className={styles.linkText} item xs={6} key={index}>
<CardLink icon={icon} text={text} url={url} />
</Grid>
))}
{template.metadata.links?.map(({ url, icon, title }) => (
<Grid className={styles.linkText} item xs={6}>
<CardLink
icon={iconResolver(icon)}
text={title || url}
url={url}
/>
</Grid>
))}
{template.metadata.links?.map(
({ url, icon, title }, index) => (
<Grid className={styles.linkText} item xs={6} key={index}>
<CardLink
icon={iconResolver(icon)}
text={title || url}
url={url}
/>
</Grid>
),
)}
</Grid>
</Grid>
</>
@@ -37,7 +37,6 @@ const checkedIcon = <CheckBoxIcon fontSize="small" />;
/**
* The Category Picker that is rendered on the left side for picking
* categories and filtering the template list.
*
* @alpha
*/
export const TemplateCategoryPicker = () => {
@@ -28,7 +28,6 @@ import { TemplateGroups } from './TemplateGroups';
import { renderInTestApp, TestApiProvider } from '@backstage/test-utils';
import { errorApiRef } from '@backstage/core-plugin-api';
import { TemplateGroup } from '@backstage/plugin-scaffolder-react/alpha';
import { rootRouteRef } from '../../routes';
describe('TemplateGroups', () => {
beforeEach(() => jest.clearAllMocks());
@@ -40,11 +39,6 @@ describe('TemplateGroups', () => {
<TestApiProvider apis={[[errorApiRef, {}]]}>
<TemplateGroups groups={[]} />
</TestApiProvider>,
{
mountedRoutes: {
'/create': rootRouteRef,
},
},
);
expect(await findByTestId('progress')).toBeInTheDocument();
@@ -62,11 +56,6 @@ describe('TemplateGroups', () => {
<TestApiProvider apis={[[errorApiRef, errorApi]]}>
<TemplateGroups groups={[]} />
</TestApiProvider>,
{
mountedRoutes: {
'/create': rootRouteRef,
},
},
);
expect(errorApi.post).toHaveBeenCalledWith(mockError);
@@ -83,11 +72,6 @@ describe('TemplateGroups', () => {
<TestApiProvider apis={[[errorApiRef, {}]]}>
<TemplateGroups groups={[]} />
</TestApiProvider>,
{
mountedRoutes: {
'/create': rootRouteRef,
},
},
);
expect(await findByText(/No templates found/)).toBeInTheDocument();
@@ -104,11 +88,6 @@ describe('TemplateGroups', () => {
<TestApiProvider apis={[[errorApiRef, {}]]}>
<TemplateGroups groups={[]} />
</TestApiProvider>,
{
mountedRoutes: {
'/create': rootRouteRef,
},
},
);
expect(await findByText(/No templates found/)).toBeInTheDocument();
@@ -144,11 +123,6 @@ describe('TemplateGroups', () => {
<TestApiProvider apis={[[errorApiRef, {}]]}>
<TemplateGroups groups={[{ title: 'all', filter: () => true }]} />
</TestApiProvider>,
{
mountedRoutes: {
'/create': rootRouteRef,
},
},
);
expect(TemplateGroup).toHaveBeenCalledWith(
@@ -193,11 +167,6 @@ describe('TemplateGroups', () => {
groups={[{ title: 'all', filter: e => e.metadata.name === 't1' }]}
/>
</TestApiProvider>,
{
mountedRoutes: {
'/create': rootRouteRef,
},
},
);
expect(TemplateGroup).toHaveBeenCalledWith(
@@ -241,11 +210,6 @@ describe('TemplateGroups', () => {
templateFilter={e => e.metadata.name === 't1'}
/>
</TestApiProvider>,
{
mountedRoutes: {
'/create': rootRouteRef,
},
},
);
expect(TemplateGroup).toHaveBeenCalledWith(
@@ -15,21 +15,15 @@
*/
import React, { useCallback } from 'react';
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';
import { Typography } from '@material-ui/core';
import {
errorApiRef,
useApi,
useApp,
useRouteRef,
} from '@backstage/core-plugin-api';
isTemplateEntityV1beta3,
TemplateEntityV1beta3,
} from '@backstage/plugin-scaffolder-common';
import { Progress, Link } from '@backstage/core-components';
import { Typography } from '@material-ui/core';
import { errorApiRef, IconComponent, useApi } from '@backstage/core-plugin-api';
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
@@ -39,28 +33,36 @@ export type TemplateGroupFilter = {
filter: (entity: TemplateEntityV1beta3) => boolean;
};
/**
* @alpha
*/
export interface TemplateGroupsProps {
groups: TemplateGroupFilter[];
templateFilter?: (entity: TemplateEntityV1beta3) => boolean;
TemplateCardComponent?: React.ComponentType<{
template: TemplateEntityV1beta3;
}>;
onTemplateSelected?: (template: TemplateEntityV1beta3) => void;
additionalLinksForEntity?: (template: TemplateEntityV1beta3) => {
icon: IconComponent;
text: string;
url: string;
}[];
}
/**
* @alpha
*/
export const TemplateGroups = (props: TemplateGroupsProps) => {
const { loading, error, entities } = useEntityList();
const { groups, templateFilter, TemplateCardComponent } = props;
const { groups, templateFilter, TemplateCardComponent, onTemplateSelected } =
props;
const errorApi = useApi(errorApiRef);
const app = useApp();
const viewTechDocsLink = useRouteRef(viewTechDocRouteRef);
const templateRoute = useRouteRef(selectedTemplateRouteRef);
const navigate = useNavigate();
const onSelected = useCallback(
(template: TemplateEntityV1beta3) => {
const { namespace, name } = parseEntityRef(stringifyEntityRef(template));
navigate(templateRoute({ namespace, templateName: name }));
onTemplateSelected?.(template);
},
[navigate, templateRoute],
[onTemplateSelected],
);
if (loading) {
@@ -88,24 +90,12 @@ export const TemplateGroups = (props: TemplateGroupsProps) => {
<>
{groups.map(({ title, filter }, index) => {
const templates = entities
.filter(isTemplateEntity)
.filter(isTemplateEntityV1beta3)
.filter(e => (templateFilter ? !templateFilter(e) : true))
.filter(filter)
.map(template => {
const { kind, namespace, name } = parseEntityRef(
stringifyEntityRef(template),
);
const additionalLinks =
template.metadata.annotations?.['backstage.io/techdocs-ref'] &&
viewTechDocsLink
? [
{
icon: app.getSystemIcon('docs') ?? DocsIcon,
text: 'View TechDocs',
url: viewTechDocsLink({ kind, namespace, name }),
},
]
: [];
props.additionalLinksForEntity?.(template) ?? [];
return {
template,
@@ -13,11 +13,4 @@
* 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';
export * from './TemplateGroups';
@@ -17,6 +17,7 @@ export * from './Stepper';
export * from './TemplateCard';
export * from './ReviewState';
export * from './TemplateGroup';
export * from './TemplateGroups';
export * from './Workflow';
export * from './TemplateOutputs';
export * from './Form';
+23 -3
View File
@@ -7,10 +7,13 @@
import { FormProps as FormProps_2 } from '@backstage/plugin-scaffolder-react/alpha';
import type { FormProps as FormProps_3 } from '@rjsf/core-v5';
import { LayoutOptions } from '@backstage/plugin-scaffolder-react';
import { NextFieldExtensionOptions } from '@backstage/plugin-scaffolder-react/alpha';
import { PropsWithChildren } from 'react';
import { default as React_2 } from 'react';
import { ScaffolderTaskOutput } from '@backstage/plugin-scaffolder-react';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react/alpha';
// @alpha @deprecated
export type FormProps = Pick<
@@ -28,6 +31,8 @@ export type NextRouterProps = {
TemplateOutputsComponent?: React_2.ComponentType<{
output?: ScaffolderTaskOutput;
}>;
TemplateListPageComponent?: React_2.ComponentType<TemplateListPageProps>;
TemplateWizardPageComponent?: React_2.ComponentType<TemplateWizardPageProps>;
};
groups?: TemplateGroupFilter[];
templateFilter?: (entity: TemplateEntityV1beta3) => boolean;
@@ -45,9 +50,24 @@ export const NextScaffolderPage: (
) => JSX.Element;
// @alpha (undocumented)
export type TemplateGroupFilter = {
title?: React_2.ReactNode;
filter: (entity: TemplateEntityV1beta3) => boolean;
export type TemplateListPageProps = {
TemplateCardComponent?: React_2.ComponentType<{
template: TemplateEntityV1beta3;
}>;
groups?: TemplateGroupFilter[];
templateFilter?: (entity: TemplateEntityV1beta3) => boolean;
contextMenu?: {
editor?: boolean;
actions?: boolean;
tasks?: boolean;
};
};
// @alpha (undocumented)
export type TemplateWizardPageProps = {
customFieldExtensions: NextFieldExtensionOptions<any, any>[];
layouts?: LayoutOptions[];
FormProps?: FormProps_2;
};
// (No @packageDocumentation comment for this package)
+2 -1
View File
@@ -16,7 +16,8 @@
export { NextScaffolderPage } from './plugin';
export {
type TemplateGroupFilter,
type NextRouterProps,
type FormProps,
type TemplateListPageProps,
type TemplateWizardPageProps,
} from './next';
@@ -16,7 +16,10 @@
import React, { ComponentType } from 'react';
import { Entity, stringifyEntityRef } from '@backstage/catalog-model';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import {
isTemplateEntityV1beta3,
TemplateEntityV1beta3,
} from '@backstage/plugin-scaffolder-common';
import {
Content,
ContentHeader,
@@ -28,7 +31,6 @@ import {
import { useEntityList } from '@backstage/plugin-catalog-react';
import { Typography } from '@material-ui/core';
import { TemplateCard } from '../TemplateCard';
import { isTemplateEntity } from '../../lib/isTemplateEntity';
/**
* @internal
@@ -54,7 +56,7 @@ export const TemplateList = ({
}: TemplateListProps) => {
const { loading, error, entities } = useEntityList();
const Card = TemplateCardComponent || TemplateCard;
const templateEntities = entities.filter(isTemplateEntity);
const templateEntities = entities.filter(isTemplateEntityV1beta3);
const maybeFilteredEntities = (
group ? templateEntities.filter(group.filter) : templateEntities
).filter(e => (templateFilter ? !templateFilter(e) : true));
@@ -47,6 +47,21 @@ describe('Router', () => {
expect(TemplateListPage).toHaveBeenCalled();
});
it('should render user-provided TemplateListPage', async () => {
const { getByText } = await renderInTestApp(
<Router
components={{
TemplateListPageComponent: () => <>foobar</>,
}}
/>,
{
routeEntries: ['/'],
},
);
expect(getByText('foobar')).toBeInTheDocument();
expect(TemplateListPage).not.toHaveBeenCalled();
});
});
describe('/templates/:templateName', () => {
@@ -58,6 +73,21 @@ describe('Router', () => {
expect(TemplateWizardPage).toHaveBeenCalled();
});
it('should render user-provided TemplateWizardPage', async () => {
const { getByText } = await renderInTestApp(
<Router
components={{
TemplateWizardPageComponent: () => <>foobar</>,
}}
/>,
{
routeEntries: ['/templates/default/foo'],
},
);
expect(getByText('foobar')).toBeInTheDocument();
expect(TemplateWizardPage).not.toHaveBeenCalled();
});
it('should pass through the FormProps property', async () => {
const transformErrorsMock = jest.fn();
+12 -5
View File
@@ -15,11 +15,15 @@
*/
import React, { PropsWithChildren } from 'react';
import { Routes, Route, useOutlet } from 'react-router-dom';
import { TemplateListPage } from '../TemplateListPage';
import { TemplateWizardPage } from '../TemplateWizardPage';
import { TemplateListPage, TemplateListPageProps } from '../TemplateListPage';
import {
TemplateWizardPage,
TemplateWizardPageProps,
} from '../TemplateWizardPage';
import {
NextFieldExtensionOptions,
FormProps,
TemplateGroupFilter,
} from '@backstage/plugin-scaffolder-react/alpha';
import {
ScaffolderTaskOutput,
@@ -29,7 +33,6 @@ import {
} from '@backstage/plugin-scaffolder-react';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { TemplateGroupFilter } from '../TemplateListPage';
import { DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS } from '../../extensions/default';
import {
@@ -59,6 +62,8 @@ export type NextRouterProps = {
TemplateOutputsComponent?: React.ComponentType<{
output?: ScaffolderTaskOutput;
}>;
TemplateListPageComponent?: React.ComponentType<TemplateListPageProps>;
TemplateWizardPageComponent?: React.ComponentType<TemplateWizardPageProps>;
};
groups?: TemplateGroupFilter[];
templateFilter?: (entity: TemplateEntityV1beta3) => boolean;
@@ -85,6 +90,8 @@ export const Router = (props: PropsWithChildren<NextRouterProps>) => {
TemplateCardComponent,
TemplateOutputsComponent,
TaskPageComponent = OngoingTask,
TemplateListPageComponent = TemplateListPage,
TemplateWizardPageComponent = TemplateWizardPage,
} = {},
} = props;
const outlet = useOutlet() || props.children;
@@ -108,7 +115,7 @@ export const Router = (props: PropsWithChildren<NextRouterProps>) => {
<Route
path="/"
element={
<TemplateListPage
<TemplateListPageComponent
TemplateCardComponent={TemplateCardComponent}
contextMenu={props.contextMenu}
groups={props.groups}
@@ -120,7 +127,7 @@ export const Router = (props: PropsWithChildren<NextRouterProps>) => {
path={selectedTemplateRouteRef.path}
element={
<SecretsContextProvider>
<TemplateWizardPage
<TemplateWizardPageComponent
customFieldExtensions={fieldExtensions}
layouts={customLayouts}
FormProps={props.FormProps}
@@ -14,14 +14,15 @@
* limitations under the License.
*/
import React from 'react';
import React, { useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { useRouteRef } from '@backstage/core-plugin-api';
import { useApp, useRouteRef } from '@backstage/core-plugin-api';
import {
Content,
ContentHeader,
DocsIcon,
Header,
Page,
SupportButton,
@@ -37,17 +38,24 @@ import {
import {
ScaffolderPageContextMenu,
TemplateCategoryPicker,
TemplateGroupFilter,
TemplateGroups,
} from '@backstage/plugin-scaffolder-react/alpha';
import { RegisterExistingButton } from './RegisterExistingButton';
import { TemplateGroupFilter, TemplateGroups } from './TemplateGroups';
import {
actionsRouteRef,
editRouteRef,
registerComponentRouteRef,
scaffolderListTaskRouteRef,
selectedTemplateRouteRef,
viewTechDocRouteRef,
} from '../../routes';
import { parseEntityRef, stringifyEntityRef } from '@backstage/catalog-model';
/**
* @alpha
*/
export type TemplateListPageProps = {
TemplateCardComponent?: React.ComponentType<{
template: TemplateEntityV1beta3;
@@ -76,6 +84,9 @@ const createGroupsWithOther = (
},
];
/**
* @alpha
*/
export const TemplateListPage = (props: TemplateListPageProps) => {
const registerComponentLink = useRouteRef(registerComponentRouteRef);
const {
@@ -87,6 +98,9 @@ export const TemplateListPage = (props: TemplateListPageProps) => {
const editorLink = useRouteRef(editRouteRef);
const actionsLink = useRouteRef(actionsRouteRef);
const tasksLink = useRouteRef(scaffolderListTaskRouteRef);
const viewTechDocsLink = useRouteRef(viewTechDocRouteRef);
const templateRoute = useRouteRef(selectedTemplateRouteRef);
const app = useApp();
const groups = givenGroups.length
? createGroupsWithOther(givenGroups)
@@ -107,6 +121,34 @@ export const TemplateListPage = (props: TemplateListPageProps) => {
: undefined,
};
const additionalLinksForEntity = useCallback(
(template: TemplateEntityV1beta3) => {
const { kind, namespace, name } = parseEntityRef(
stringifyEntityRef(template),
);
return template.metadata.annotations?.['backstage.io/techdocs-ref'] &&
viewTechDocsLink
? [
{
icon: app.getSystemIcon('docs') ?? DocsIcon,
text: 'View TechDocs',
url: viewTechDocsLink({ kind, namespace, name }),
},
]
: [];
},
[app, viewTechDocsLink],
);
const onTemplateSelected = useCallback(
(template: TemplateEntityV1beta3) => {
const { namespace, name } = parseEntityRef(stringifyEntityRef(template));
navigate(templateRoute({ namespace, templateName: name }));
},
[navigate, templateRoute],
);
return (
<EntityListProvider>
<Page themeId="website">
@@ -146,6 +188,8 @@ export const TemplateListPage = (props: TemplateListPageProps) => {
groups={groups}
templateFilter={templateFilter}
TemplateCardComponent={TemplateCardComponent}
onTemplateSelected={onTemplateSelected}
additionalLinksForEntity={additionalLinksForEntity}
/>
</CatalogFilterLayout.Content>
</CatalogFilterLayout>
@@ -14,4 +14,4 @@
* limitations under the License.
*/
export { TemplateListPage } from './TemplateListPage';
export type { TemplateGroupFilter } from './TemplateGroups';
export type { TemplateListPageProps } from './TemplateListPage';
@@ -41,6 +41,9 @@ import {
selectedTemplateRouteRef,
} from '../../routes';
/**
* @alpha
*/
export type TemplateWizardPageProps = {
customFieldExtensions: NextFieldExtensionOptions<any, any>[];
layouts?: LayoutOptions[];
@@ -14,3 +14,4 @@
* limitations under the License.
*/
export { TemplateWizardPage } from './TemplateWizardPage';
export type { TemplateWizardPageProps } from './TemplateWizardPage';