Merge pull request #10575 from backstage/blam/next-scaffolder/wizard

scaffolder/next: start implementing a Wizard page
This commit is contained in:
Ben Lambert
2022-07-29 12:07:39 +02:00
committed by GitHub
26 changed files with 1262 additions and 155 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/core-components': patch
---
Adding a `className` prop to the `MarkdownContent` component
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-scaffolder': minor
'@backstage/plugin-scaffolder-backend': minor
---
Starting the implementation of the Wizard page for the `next` scaffolder plugin
+19
View File
@@ -62,6 +62,7 @@ import { NewRelicPage } from '@backstage/plugin-newrelic';
import {
ScaffolderFieldExtensions,
ScaffolderPage,
NextScaffolderPage,
scaffolderPlugin,
} from '@backstage/plugin-scaffolder';
import { SearchPage } from '@backstage/plugin-search';
@@ -212,6 +213,24 @@ const routes = (
<LowerCaseValuePickerFieldExtension />
</ScaffolderFieldExtensions>
</Route>
<Route
path="/create/next"
element={
<NextScaffolderPage
groups={[
{
title: 'Recommended',
filter: entity =>
entity?.metadata?.tags?.includes('recommended') ?? false,
},
]}
/>
}
>
<ScaffolderFieldExtensions>
<LowerCaseValuePickerFieldExtension />
</ScaffolderFieldExtensions>
</Route>
<Route path="/explore" element={<ExplorePage />} />
<Route
path="/tech-radar"
@@ -70,6 +70,7 @@ type Props = {
linkTarget?: Options['linkTarget'];
transformLinkUri?: (href: string) => string;
transformImageUri?: (href: string) => string;
className?: string;
};
const components: Options['components'] = {
@@ -99,12 +100,13 @@ export function MarkdownContent(props: Props) {
linkTarget,
transformLinkUri,
transformImageUri,
className,
} = props;
const classes = useStyles();
return (
<ReactMarkdown
remarkPlugins={dialect === 'gfm' ? [gfm] : []}
className={classes.markdown}
className={`${classes.markdown} ${className}`}
children={content}
components={components}
linkTarget={linkTarget}
@@ -134,6 +134,7 @@ export type SelectProps = {
triggerReset?: boolean;
native?: boolean;
disabled?: boolean;
margin?: 'dense' | 'none';
};
/** @public */
@@ -148,6 +149,7 @@ export function SelectComponent(props: SelectProps) {
triggerReset,
native = false,
disabled = false,
margin,
} = props;
const classes = useStyles();
const [value, setValue] = useState<SelectedItems>(
@@ -206,6 +208,7 @@ export function SelectComponent(props: SelectProps) {
data-testid="select"
displayEmpty
multiple={multiple}
margin={margin}
onChange={handleChange}
onClick={handleClick}
open={isOpen}
@@ -156,8 +156,10 @@ export async function createRouter(
const parameters = [template.spec.parameters ?? []].flat();
res.json({
title: template.metadata.title ?? template.metadata.name,
description: template.metadata.description,
steps: parameters.map(schema => ({
title: schema.title ?? 'Fill in template parameters',
title: schema.title ?? 'Please enter the following information',
description: schema.description,
schema,
})),
});
+2
View File
@@ -442,8 +442,10 @@ export type TemplateGroupFilter = {
// @public
export type TemplateParameterSchema = {
title: string;
description?: string;
steps: Array<{
title: string;
description?: string;
schema: JsonObject;
}>;
};
@@ -102,8 +102,9 @@ export const EntityPicker = (
<TextField
{...params}
label={title}
margin="normal"
margin="dense"
helperText={description}
FormHelperTextProps={{ margin: 'dense', style: { marginLeft: 0 } }}
variant="outlined"
required={required}
InputProps={params.InputProps}
@@ -16,7 +16,6 @@
import React, { PropsWithChildren } from 'react';
import { Routes, Route, useOutlet } from 'react-router';
import { TemplateListPage } from '../TemplateListPage';
import { SecretsContextProvider } from '../TemplateWizardPage/SecretsContext';
import { TemplateWizardPage } from '../TemplateWizardPage';
import {
FieldExtensionOptions,
@@ -28,7 +27,8 @@ import {
import { useElementFilter } from '@backstage/core-plugin-api';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { TemplateGroupFilter } from '../TemplateListPage/TemplateGroups';
import { selectedTemplateRouteRef } from '../../routes';
import { nextSelectedTemplateRouteRef } from '../../routes';
import { SecretsContextProvider } from '../../components/secrets/SecretsContext';
/**
* The Props for the Scaffolder Router
@@ -87,7 +87,7 @@ export const Router = (props: PropsWithChildren<NextRouterProps>) => {
/>
<Route
path={selectedTemplateRouteRef.path}
path={nextSelectedTemplateRouteRef.path}
element={
<SecretsContextProvider>
<TemplateWizardPage customFieldExtensions={fieldExtensions} />
@@ -25,7 +25,7 @@ import {
} from '@backstage/test-utils';
import { TemplateCard } from './TemplateCard';
import React from 'react';
import { rootRouteRef } from '../../../routes';
import { nextRouteRef } from '../../../routes';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { RELATION_OWNED_BY } from '@backstage/catalog-model';
@@ -54,7 +54,7 @@ describe('TemplateCard', () => {
>
<TemplateCard template={mockTemplate} />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
{ mountedRoutes: { '/': nextRouteRef } },
);
expect(getByText('bob')).toBeInTheDocument();
@@ -84,7 +84,7 @@ describe('TemplateCard', () => {
>
<TemplateCard template={mockTemplate} />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
{ mountedRoutes: { '/': nextRouteRef } },
);
const description = getByText('hello');
@@ -115,7 +115,7 @@ describe('TemplateCard', () => {
>
<TemplateCard template={mockTemplate} />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
{ mountedRoutes: { '/': nextRouteRef } },
);
expect(getByText('No description')).toBeInTheDocument();
@@ -145,7 +145,7 @@ describe('TemplateCard', () => {
>
<TemplateCard template={mockTemplate} />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
{ mountedRoutes: { '/': nextRouteRef } },
);
for (const tag of mockTemplate.metadata.tags!) {
@@ -185,7 +185,7 @@ describe('TemplateCard', () => {
</TestApiProvider>,
{
mountedRoutes: {
'/': rootRouteRef,
'/': nextRouteRef,
'/catalog/:kind/:namespace/:name': entityRouteRef,
},
},
@@ -224,7 +224,7 @@ describe('TemplateCard', () => {
</TestApiProvider>,
{
mountedRoutes: {
'/': rootRouteRef,
'/': nextRouteRef,
'/catalog/:kind/:namespace/:name': entityRouteRef,
},
},
@@ -36,7 +36,7 @@ import {
getEntityRelations,
} from '@backstage/plugin-catalog-react';
import { useRouteRef } from '@backstage/core-plugin-api';
import { selectedTemplateRouteRef } from '../../../routes';
import { nextSelectedTemplateRouteRef } from '../../../routes';
import { BackstageTheme } from '@backstage/theme';
const useStyles = makeStyles<BackstageTheme>(theme => ({
@@ -46,10 +46,11 @@ const useStyles = makeStyles<BackstageTheme>(theme => ({
display: '-webkit-box',
'-webkit-line-clamp': 10,
'-webkit-box-orient': 'vertical',
},
markdown: {
/** to make the styles for React Markdown not leak into the description */
'& p:first-child': {
'& :first-child': {
marginTop: 0,
marginBottom: theme.spacing(2),
},
},
label: {
@@ -94,9 +95,14 @@ export const TemplateCard = (props: TemplateCardProps) => {
const { template } = props;
const styles = useStyles();
const ownedByRelations = getEntityRelations(template, RELATION_OWNED_BY);
const templateRoute = useRouteRef(selectedTemplateRouteRef);
const { name, namespace } = parseEntityRef(stringifyEntityRef(template));
const href = templateRoute({ templateName: name, namespace: namespace });
const templateRoute = useRouteRef(nextSelectedTemplateRouteRef);
const { name, namespace } = parseEntityRef(
stringifyEntityRef(props.template),
);
const href = templateRoute({
templateName: name,
namespace: namespace,
});
return (
<Card>
@@ -104,6 +110,7 @@ export const TemplateCard = (props: TemplateCardProps) => {
<CardContent>
<Box className={styles.box}>
<MarkdownContent
className={styles.markdown}
content={template.metadata.description ?? 'No description'}
/>
</Box>
@@ -25,7 +25,7 @@ import {
TestApiProvider,
} from '@backstage/test-utils';
import React from 'react';
import { rootRouteRef } from '../../routes';
import { nextRouteRef } from '../../routes';
import { TemplateListPage } from './TemplateListPage';
describe('TemplateListPage', () => {
@@ -63,7 +63,7 @@ describe('TemplateListPage', () => {
>
<TemplateListPage />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
{ mountedRoutes: { '/': nextRouteRef } },
);
expect(getByPlaceholderText('Search')).toBeInTheDocument();
@@ -85,7 +85,7 @@ describe('TemplateListPage', () => {
>
<TemplateListPage />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
{ mountedRoutes: { '/': nextRouteRef } },
);
expect(getByRole('menuitem', { name: /All/ })).toBeInTheDocument();
@@ -108,7 +108,7 @@ describe('TemplateListPage', () => {
>
<TemplateListPage />
</TestApiProvider>,
{ mountedRoutes: { '/': rootRouteRef } },
{ mountedRoutes: { '/': nextRouteRef } },
);
expect(getByText('Categories')).toBeInTheDocument();
@@ -52,15 +52,15 @@ const defaultGroup: TemplateGroupFilter = {
export const TemplateListPage = (props: TemplateListPageProps) => {
const registerComponentLink = useRouteRef(registerComponentRouteRef);
const { TemplateCardComponent, groups = [defaultGroup] } = props;
const { TemplateCardComponent, groups = [] } = props;
return (
<EntityListProvider>
<Page themeId="home">
<Page themeId="website">
<Header
pageTitleOverride="Create a New Component"
title="Create a New Component"
subtitle="Create new software components using standard templates"
pageTitleOverride="Create a new component"
title="Create a new component"
subtitle="Create new software components using standard templates in your organization"
/>
<Content>
<ContentHeader title="Available Templates">
@@ -88,7 +88,7 @@ export const TemplateListPage = (props: TemplateListPageProps) => {
</CatalogFilterLayout.Filters>
<CatalogFilterLayout.Content>
<TemplateGroups
groups={groups}
groups={[...groups, defaultGroup]}
TemplateCardComponent={TemplateCardComponent}
/>
</CatalogFilterLayout.Content>
@@ -1,43 +0,0 @@
/*
* 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 React, { useContext } from 'react';
import {
useTemplateSecrets,
SecretsContextProvider,
SecretsContext,
} from './SecretsContext';
import { renderHook, act } from '@testing-library/react-hooks';
describe('SecretsContext', () => {
it('should allow the setting of secrets in the context', async () => {
const { result } = renderHook(
() => ({
hook: useTemplateSecrets(),
context: useContext(SecretsContext),
}),
{
wrapper: ({ children }) => (
<SecretsContextProvider>{children}</SecretsContextProvider>
),
},
);
expect(result.current.context?.secrets.foo).toEqual(undefined);
act(() => result.current.hook.setSecret({ foo: 'bar' }));
expect(result.current.context?.secrets.foo).toEqual('bar');
});
});
@@ -1,73 +0,0 @@
/*
* 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 React, {
useState,
useCallback,
useContext,
createContext,
PropsWithChildren,
} from 'react';
type SecretsContextContents = {
secrets: Record<string, string>;
setSecrets: React.Dispatch<React.SetStateAction<Record<string, string>>>;
};
/**
* The actual context object.
*/
export const SecretsContext = createContext<SecretsContextContents | undefined>(
undefined,
);
/**
* The Context Provider that holds the state for the secrets.
*
* @alpha
*/
export const SecretsContextProvider = ({ children }: PropsWithChildren<{}>) => {
const [secrets, setSecrets] = useState<Record<string, string>>({});
return (
<SecretsContext.Provider value={{ secrets, setSecrets }}>
{children}
</SecretsContext.Provider>
);
};
/**
* Hook to access the secrets context.
* @alpha
*/
export const useTemplateSecrets = () => {
const value = useContext(SecretsContext);
if (!value) {
throw new Error(
'useTemplateSecrets must be used within a SecretsContextProvider',
);
}
const { setSecrets } = value;
const setSecret = useCallback(
(input: Record<string, string>) => {
setSecrets(currentSecrets => ({ ...currentSecrets, ...input }));
},
[setSecrets],
);
return { setSecret };
};
@@ -0,0 +1,136 @@
/*
* 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 React from 'react';
import { TemplateParameterSchema } from '../../../types';
import { Stepper } from './Stepper';
import { renderInTestApp } from '@backstage/test-utils';
import { fireEvent } from '@testing-library/react';
describe('Stepper', () => {
it('should render the step titles for each step of the manifest', async () => {
const manifest: TemplateParameterSchema = {
steps: [
{ title: 'Step 1', schema: { properties: {} } },
{ title: 'Step 2', schema: { properties: {} } },
],
title: 'React JSON Schema Form Test',
};
const { getByText } = await renderInTestApp(
<Stepper manifest={manifest} extensions={[]} />,
);
for (const step of manifest.steps) {
expect(getByText(step.title)).toBeInTheDocument();
}
});
it('should render next / review button', async () => {
const manifest: TemplateParameterSchema = {
steps: [
{ title: 'Step 1', schema: { properties: {} } },
{ title: 'Step 2', schema: { properties: {} } },
],
title: 'React JSON Schema Form Test',
};
const { getByText } = await renderInTestApp(
<Stepper manifest={manifest} extensions={[]} />,
);
expect(getByText('Next')).toBeInTheDocument();
await fireEvent.click(getByText('Next'));
expect(getByText('Review')).toBeInTheDocument();
});
it('should remember the state of the form when cycling through the pages', async () => {
const manifest: TemplateParameterSchema = {
steps: [
{
title: 'Step 1',
schema: {
properties: {
name: {
type: 'string',
},
},
},
},
{
title: 'Step 2',
schema: {
properties: {
description: {
type: 'string',
},
},
},
},
],
title: 'React JSON Schema Form Test',
};
const { getByRole, getByText } = await renderInTestApp(
<Stepper manifest={manifest} extensions={[]} />,
);
await fireEvent.change(getByRole('textbox', { name: 'name' }), {
target: { value: 'im a test value' },
});
await fireEvent.click(getByText('Next'));
await fireEvent.click(getByText('Back'));
expect(getByRole('textbox', { name: 'name' })).toHaveValue(
'im a test value',
);
});
it('should render custom field extensions properly', async () => {
const MockComponent = () => {
return <h1>im a custom field extension</h1>;
};
const manifest: TemplateParameterSchema = {
title: 'Custom Fields',
steps: [
{
title: 'Test',
schema: {
properties: {
name: {
type: 'string',
'ui:field': 'Mock',
},
},
},
},
],
};
const { getByText } = await renderInTestApp(
<Stepper
manifest={manifest}
extensions={[{ name: 'Mock', component: MockComponent }]}
/>,
);
expect(getByText('im a custom field extension')).toBeInTheDocument();
});
});
@@ -0,0 +1,107 @@
/*
* 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 { JsonObject } from '@backstage/types';
import {
Stepper as MuiStepper,
Step as MuiStep,
StepLabel as MuiStepLabel,
Button,
makeStyles,
} from '@material-ui/core';
import { withTheme } from '@rjsf/core';
import { Theme as MuiTheme } from '@rjsf/material-ui';
import React, { useMemo, useState } from 'react';
import { FieldExtensionOptions } from '../../../extensions';
import { TemplateParameterSchema } from '../../../types';
import { useTemplateSchema } from './useTemplateSchema';
const useStyles = makeStyles(theme => ({
backButton: {
marginRight: theme.spacing(1),
},
footer: {
display: 'flex',
flexDirection: 'row',
justifyContent: 'right',
},
formWrapper: {
padding: theme.spacing(2),
},
}));
export interface StepperProps {
manifest: TemplateParameterSchema;
extensions: FieldExtensionOptions<any, any>[];
}
const Form = withTheme(MuiTheme);
export const Stepper = (props: StepperProps) => {
const { steps } = useTemplateSchema(props.manifest);
const [activeStep, setActiveStep] = useState(0);
const [formState, setFormState] = useState({});
const styles = useStyles();
const extensions = useMemo(() => {
return Object.fromEntries(
props.extensions.map(({ name, component }) => [name, component]),
);
}, [props.extensions]);
const handleBack = () => {
setActiveStep(prevActiveStep => prevActiveStep - 1);
};
const handleNext = ({ formData }: { formData: JsonObject }) => {
setActiveStep(prevActiveStep => prevActiveStep + 1);
setFormState(current => ({ ...current, ...formData }));
};
return (
<>
<MuiStepper activeStep={activeStep} alternativeLabel variant="elevation">
{steps.map((step, index) => (
<MuiStep key={index}>
<MuiStepLabel>{step.title}</MuiStepLabel>
</MuiStep>
))}
</MuiStepper>
<div className={styles.formWrapper}>
<Form
formData={formState}
schema={steps[activeStep].schema}
uiSchema={steps[activeStep].uiSchema}
onSubmit={handleNext}
fields={extensions}
showErrorList={false}
>
<div className={styles.footer}>
<Button
onClick={handleBack}
className={styles.backButton}
disabled={activeStep < 1}
>
Back
</Button>
<Button variant="contained" color="primary" type="submit">
{activeStep === steps.length - 1 ? 'Review' : 'Next'}
</Button>
</div>
</Form>
</div>
</>
);
};
@@ -13,8 +13,4 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {
useTemplateSecrets,
SecretsContext,
SecretsContextProvider,
} from './SecretsContext';
export { Stepper } from './Stepper';
@@ -0,0 +1,414 @@
/*
* 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 { JsonObject } from '@backstage/types';
import { extractSchemaFromStep } from './schema';
describe('extractSchemaFromStep', () => {
it('transforms deep schema', () => {
const inputSchema: JsonObject = {
type: 'object',
'ui:welp': 'warp',
properties: {
field1: {
type: 'string',
'ui:derp': 'herp',
},
field2: {
type: 'object',
properties: {
fieldX: {
type: 'string',
'ui:derp': 'xerp',
},
},
},
},
};
const expectedSchema = {
type: 'object',
properties: {
field1: {
type: 'string',
},
field2: {
type: 'object',
properties: {
fieldX: {
type: 'string',
},
},
},
},
};
const expectedUiSchema = {
'ui:welp': 'warp',
field1: {
'ui:derp': 'herp',
},
field2: {
fieldX: {
'ui:derp': 'xerp',
},
},
};
expect(extractSchemaFromStep(inputSchema)).toEqual({
schema: expectedSchema,
uiSchema: expectedUiSchema,
});
});
it('transforms schema with anyOf fields', () => {
const inputSchema: JsonObject = {
type: 'object',
anyOf: [
{
properties: {
field3: {
type: 'string',
default: 'Value 1',
'ui:readonly': true,
},
},
},
{
properties: {
field3: {
type: 'string',
default: 'Value 2',
'ui:readonly': true,
},
},
},
],
oneOf: [
{
properties: {
field4: {
type: 'string',
default: 'Value 1',
'ui:readonly': true,
},
},
},
],
allOf: [
{
properties: {
field5: {
type: 'string',
default: 'Value 1',
'ui:readonly': true,
},
},
},
],
properties: {
field1: {
type: 'object',
anyOf: [
{
properties: {
field3: {
type: 'string',
default: 'Value 1',
'ui:readonly': true,
},
},
},
{
properties: {
field3: {
type: 'string',
default: 'Value 2',
'ui:readonly': true,
},
},
},
],
oneOf: [
{
properties: {
field4: {
type: 'string',
default: 'Value 1',
'ui:readonly': true,
},
},
},
],
allOf: [
{
properties: {
field5: {
type: 'string',
default: 'Value 1',
'ui:readonly': true,
},
},
},
],
},
field2: {
type: 'string',
'ui:derp': 'xerp',
},
},
};
const expectedSchema = {
type: 'object',
anyOf: [
{
properties: {
field3: {
type: 'string',
default: 'Value 1',
},
},
},
{
properties: {
field3: {
type: 'string',
default: 'Value 2',
},
},
},
],
oneOf: [
{
properties: {
field4: {
type: 'string',
default: 'Value 1',
},
},
},
],
allOf: [
{
properties: {
field5: {
type: 'string',
default: 'Value 1',
},
},
},
],
properties: {
field1: {
type: 'object',
anyOf: [
{
properties: {
field3: {
type: 'string',
default: 'Value 1',
},
},
},
{
properties: {
field3: {
type: 'string',
default: 'Value 2',
},
},
},
],
oneOf: [
{
properties: {
field4: {
type: 'string',
default: 'Value 1',
},
},
},
],
allOf: [
{
properties: {
field5: {
type: 'string',
default: 'Value 1',
},
},
},
],
},
field2: {
type: 'string',
},
},
};
const expectedUiSchema = {
field3: {
'ui:readonly': true,
},
field4: {
'ui:readonly': true,
},
field5: {
'ui:readonly': true,
},
field1: {
field3: {
'ui:readonly': true,
},
field4: {
'ui:readonly': true,
},
field5: {
'ui:readonly': true,
},
},
field2: {
'ui:derp': 'xerp',
},
};
expect(extractSchemaFromStep(inputSchema)).toEqual({
schema: expectedSchema,
uiSchema: expectedUiSchema,
});
});
it('transforms schema with dependencies', () => {
const inputSchema: JsonObject = {
type: 'object',
properties: {
name: {
type: 'string',
},
credit_card: {
type: 'number',
},
},
required: ['name'],
dependencies: {
credit_card: {
properties: {
billing_address: {
type: 'string',
'ui:widget': 'textarea',
},
},
required: ['billing_address'],
},
},
};
const expectedSchema = {
type: 'object',
properties: {
name: {
type: 'string',
},
credit_card: {
type: 'number',
},
},
required: ['name'],
dependencies: {
credit_card: {
properties: {
billing_address: {
type: 'string',
},
},
required: ['billing_address'],
},
},
};
const expectedUiSchema = {
billing_address: {
'ui:widget': 'textarea',
},
credit_card: {},
name: {},
};
expect(extractSchemaFromStep(inputSchema)).toEqual({
schema: expectedSchema,
uiSchema: expectedUiSchema,
});
});
it('transforms schema with array items', () => {
const inputSchema: JsonObject = {
type: 'object',
properties: {
person: {
type: 'array',
items: {
type: 'object',
properties: {
name: {
type: 'string',
},
address: {
type: 'string',
'ui:widget': 'textarea',
},
},
},
},
accountNumber: {
type: 'number',
},
},
};
const expectedSchema = {
type: 'object',
properties: {
person: {
type: 'array',
items: {
type: 'object',
properties: {
name: {
type: 'string',
},
address: {
type: 'string',
},
},
},
},
accountNumber: {
type: 'number',
},
},
};
const expectedUiSchema = {
accountNumber: {},
person: {
items: {
name: {},
address: {
'ui:widget': 'textarea',
},
},
},
};
expect(extractSchemaFromStep(inputSchema)).toEqual({
schema: expectedSchema,
uiSchema: expectedUiSchema,
});
});
});
@@ -0,0 +1,112 @@
/*
* 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 { JsonObject } from '@backstage/types';
import { UiSchema } from '@rjsf/core';
function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function extractUiSchema(schema: JsonObject, uiSchema: JsonObject) {
if (!isObject(schema)) {
return;
}
const { properties, items, anyOf, oneOf, allOf, dependencies } = schema;
for (const propName in schema) {
if (!schema.hasOwnProperty(propName)) {
continue;
}
if (propName.startsWith('ui:')) {
uiSchema[propName] = schema[propName];
delete schema[propName];
}
}
if (isObject(properties)) {
for (const propName in properties) {
if (!properties.hasOwnProperty(propName)) {
continue;
}
const schemaNode = properties[propName];
if (!isObject(schemaNode)) {
continue;
}
const innerUiSchema = {};
uiSchema[propName] = innerUiSchema;
extractUiSchema(schemaNode, innerUiSchema);
}
}
if (isObject(items)) {
const innerUiSchema = {};
uiSchema.items = innerUiSchema;
extractUiSchema(items, innerUiSchema);
}
if (Array.isArray(anyOf)) {
for (const schemaNode of anyOf) {
if (!isObject(schemaNode)) {
continue;
}
extractUiSchema(schemaNode, uiSchema);
}
}
if (Array.isArray(oneOf)) {
for (const schemaNode of oneOf) {
if (!isObject(schemaNode)) {
continue;
}
extractUiSchema(schemaNode, uiSchema);
}
}
if (Array.isArray(allOf)) {
for (const schemaNode of allOf) {
if (!isObject(schemaNode)) {
continue;
}
extractUiSchema(schemaNode, uiSchema);
}
}
if (isObject(dependencies)) {
for (const depName of Object.keys(dependencies)) {
const schemaNode = dependencies[depName];
if (!isObject(schemaNode)) {
continue;
}
extractUiSchema(schemaNode, uiSchema);
}
}
}
/**
* @alpha
* Takes a step from a Backstage Template Manifest and converts it to a JSON Schema and UI Schema for rjsf
*/
export const extractSchemaFromStep = (
inputStep: JsonObject,
): { uiSchema: UiSchema; schema: JsonObject } => {
const uiSchema: UiSchema = {};
const returnSchema: JsonObject = JSON.parse(JSON.stringify(inputStep));
extractUiSchema(returnSchema, uiSchema);
return { uiSchema, schema: returnSchema };
};
@@ -0,0 +1,236 @@
/*
* 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 { TemplateParameterSchema } from '../../../types';
import { useTemplateSchema } from './useTemplateSchema';
import { renderHook } from '@testing-library/react-hooks';
import { TestApiProvider } from '@backstage/test-utils';
import React from 'react';
import { featureFlagsApiRef } from '@backstage/core-plugin-api';
describe('useTemplateSchema', () => {
it('should generate the correct schema', () => {
const manifest: TemplateParameterSchema = {
title: 'Test Template',
description: 'Test Template Description',
steps: [
{
title: 'Step 1',
description: 'Step 1 Description',
schema: {
type: 'object',
properties: {
field1: { type: 'string', 'ui:field': 'MyCoolComponent' },
},
},
},
{
title: 'Step 2',
description: 'Step 2 Description',
schema: {
type: 'object',
properties: {
field2: { type: 'string', 'ui:field': 'MyCoolerComponent' },
},
},
},
],
};
const { result } = renderHook(() => useTemplateSchema(manifest), {
wrapper: ({ children }) => (
<TestApiProvider
apis={[[featureFlagsApiRef, { isActive: () => false }]]}
>
{children}
</TestApiProvider>
),
});
const [first, second] = result.current.steps;
expect(first.uiSchema).toEqual({
field1: { 'ui:field': 'MyCoolComponent' },
});
expect(first.schema).toEqual({
type: 'object',
properties: {
field1: { type: 'string' },
},
});
expect(second.uiSchema).toEqual({
field2: { 'ui:field': 'MyCoolerComponent' },
});
expect(second.schema).toEqual({
type: 'object',
properties: {
field2: { type: 'string' },
},
});
});
describe('FeatureFlags', () => {
it('should use featureFlags property to skip a step if the whole step is disabled', () => {
const manifest: TemplateParameterSchema = {
title: 'Test Template',
description: 'Test Template Description',
steps: [
{
title: 'Step 1',
description: 'Step 1 Description',
schema: {
type: 'object',
'ui:backstage': {
featureFlag: 'my-feature-flag',
},
properties: {
field1: { type: 'string', 'ui:field': 'MyCoolComponent' },
},
},
},
{
title: 'Step 2',
description: 'Step 2 Description',
schema: {
type: 'object',
properties: {
field2: { type: 'string', 'ui:field': 'MyCoolerComponent' },
},
},
},
],
};
const { result } = renderHook(() => useTemplateSchema(manifest), {
wrapper: ({ children }) => (
<TestApiProvider
apis={[[featureFlagsApiRef, { isActive: () => false }]]}
>
{children}
</TestApiProvider>
),
});
expect(result.current.steps).toHaveLength(1);
});
it('should use featureFlags property to enable a step if the whole step is enabled', () => {
const manifest: TemplateParameterSchema = {
title: 'Test Template',
description: 'Test Template Description',
steps: [
{
title: 'Step 1',
description: 'Step 1 Description',
schema: {
type: 'object',
'ui:backstage': {
featureFlag: 'my-feature-flag',
},
properties: {
field1: { type: 'string', 'ui:field': 'MyCoolComponent' },
},
},
},
{
title: 'Step 2',
description: 'Step 2 Description',
schema: {
type: 'object',
properties: {
field2: { type: 'string', 'ui:field': 'MyCoolerComponent' },
},
},
},
],
};
const { result } = renderHook(() => useTemplateSchema(manifest), {
wrapper: ({ children }) => (
<TestApiProvider
apis={[[featureFlagsApiRef, { isActive: () => true }]]}
>
{children}
</TestApiProvider>
),
});
expect(result.current.steps).toHaveLength(2);
});
it('should filter out the particular property if the featureFlag is disabled', () => {
const manifest: TemplateParameterSchema = {
title: 'Test Template',
description: 'Test Template Description',
steps: [
{
title: 'Step 1',
description: 'Step 1 Description',
schema: {
type: 'object',
properties: {
field1: {
type: 'string',
'ui:field': 'MyCoolComponent',
'ui:backstage': {
featureFlag: 'my-feature-flag',
},
},
visibleField: {
type: 'string',
'ui:field': 'MyCoolComponent',
},
},
},
},
{
title: 'Step 2',
description: 'Step 2 Description',
schema: {
type: 'object',
properties: {
field2: { type: 'string', 'ui:field': 'MyCoolerComponent' },
},
},
},
],
};
const { result } = renderHook(() => useTemplateSchema(manifest), {
wrapper: ({ children }) => (
<TestApiProvider
apis={[[featureFlagsApiRef, { isActive: () => false }]]}
>
{children}
</TestApiProvider>
),
});
const [first] = result.current.steps;
expect(first.schema).toEqual({
type: 'object',
properties: {
visibleField: {
type: 'string',
},
},
});
});
});
});
@@ -0,0 +1,69 @@
/*
* 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 { featureFlagsApiRef, useApi } from '@backstage/core-plugin-api';
import { JsonObject } from '@backstage/types';
import { UiSchema } from '@rjsf/core';
import { TemplateParameterSchema } from '../../../types';
import { extractSchemaFromStep } from './schema';
export const useTemplateSchema = (
manifest: TemplateParameterSchema,
): {
steps: {
uiSchema: UiSchema;
schema: JsonObject;
title: string;
description?: string;
}[];
} => {
const featureFlags = useApi(featureFlagsApiRef);
const steps = manifest.steps.map(({ title, description, schema }) => ({
title,
description,
...extractSchemaFromStep(schema),
}));
const returningSteps = steps
// Filter out steps that are not enabled with the feature flags
.filter(step => {
const stepFeatureFlag = step.uiSchema['ui:backstage']?.featureFlag;
return stepFeatureFlag ? featureFlags.isActive(stepFeatureFlag) : true;
})
// Then filter out the properties that are not enabled with feature flag
.map(step => ({
...step,
schema: {
...step.schema,
// Title is rendered at the top of the page, so let's ignore this from jsonschemaform
title: undefined,
properties: Object.fromEntries(
Object.entries(step.schema.properties as JsonObject).filter(
([key]) => {
const stepFeatureFlag =
step.uiSchema[key]?.['ui:backstage']?.featureFlag;
return stepFeatureFlag
? featureFlags.isActive(stepFeatureFlag)
: true;
},
),
),
},
}));
return {
steps: returningSteps,
};
};
@@ -13,13 +13,103 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React, { useEffect } from 'react';
import {
Page,
Header,
Content,
Progress,
InfoCard,
MarkdownContent,
} from '@backstage/core-components';
import { FieldExtensionOptions } from '../../extensions';
import { Navigate, useParams } from 'react-router';
import { stringifyEntityRef } from '@backstage/catalog-model';
import { errorApiRef, useApi, useRouteRef } from '@backstage/core-plugin-api';
import { scaffolderApiRef } from '../../api';
import useAsync from 'react-use/lib/useAsync';
import { makeStyles } from '@material-ui/core';
import { Stepper } from './Stepper';
import { BackstageTheme } from '@backstage/theme';
import { nextRouteRef } from '../../routes';
export interface TemplateWizardPageProps {
customFieldExtensions: FieldExtensionOptions<any, any>[];
}
export const TemplateWizardPage = (_props: TemplateWizardPageProps) => {
return null;
const useStyles = makeStyles<BackstageTheme>(() => ({
markdown: {
/** to make the styles for React Markdown not leak into the description */
'& :first-child': {
marginTop: 0,
},
'& :last-child': {
marginBottom: 0,
},
},
}));
const useTemplateParameterSchema = (templateRef: string) => {
const scaffolderApi = useApi(scaffolderApiRef);
const { value, loading, error } = useAsync(
() => scaffolderApi.getTemplateParameterSchema(templateRef),
[scaffolderApi, templateRef],
);
return { manifest: value, loading, error };
};
export const TemplateWizardPage = (props: TemplateWizardPageProps) => {
const styles = useStyles();
const rootRef = useRouteRef(nextRouteRef);
const { templateName, namespace } = useParams();
const errorApi = useApi(errorApiRef);
const { loading, manifest, error } = useTemplateParameterSchema(
stringifyEntityRef({
kind: 'Template',
namespace,
name: templateName,
}),
);
useEffect(() => {
if (error) {
errorApi.post(new Error(`Failed to load template, ${error}`));
}
}, [error, errorApi]);
if (error) {
return <Navigate to={rootRef()} />;
}
return (
<Page themeId="website">
<Header
pageTitleOverride="Create a new component"
title="Create a new component"
subtitle="Create new software components using standard templates in your organization"
/>
<Content>
{loading && <Progress />}
{manifest && (
<InfoCard
title={manifest.title}
subheader={
<MarkdownContent
className={styles.markdown}
content={manifest.description ?? 'No description'}
/>
}
noPadding
titleTypographyProps={{ component: 'h2' }}
>
<Stepper
manifest={manifest}
extensions={props.customFieldExtensions}
/>
</InfoCard>
)}
</Content>
</Page>
);
};
+6 -2
View File
@@ -23,7 +23,11 @@ import { OwnerPicker } from './components/fields/OwnerPicker/OwnerPicker';
import { repoPickerValidation } from './components/fields/RepoUrlPicker';
import { RepoUrlPicker } from './components/fields/RepoUrlPicker/RepoUrlPicker';
import { createScaffolderFieldExtension } from './extensions';
import { registerComponentRouteRef, rootRouteRef } from './routes';
import {
nextRouteRef,
registerComponentRouteRef,
rootRouteRef,
} from './routes';
import {
createApiFactory,
createPlugin,
@@ -162,6 +166,6 @@ export const NextScaffolderPage = scaffolderPlugin.provide(
createRoutableExtension({
name: 'NextScaffolderPage',
component: () => import('./next/Router').then(m => m.Router),
mountPoint: rootRouteRef,
mountPoint: nextRouteRef,
}),
);
+10
View File
@@ -37,12 +37,22 @@ export const legacySelectedTemplateRouteRef = createSubRouteRef({
path: '/templates/:templateName',
});
export const nextRouteRef = createRouteRef({
id: 'scaffolder/next',
});
export const selectedTemplateRouteRef = createSubRouteRef({
id: 'scaffolder/selected-template',
parent: rootRouteRef,
path: '/templates/:namespace/:templateName',
});
export const nextSelectedTemplateRouteRef = createSubRouteRef({
id: 'scaffolder/next/selected-template',
parent: nextRouteRef,
path: '/templates/:namespace/:templateName',
});
export const scaffolderTaskRouteRef = createSubRouteRef({
id: 'scaffolder/task',
parent: rootRouteRef,
+2
View File
@@ -79,8 +79,10 @@ export type ScaffolderTaskOutput = {
*/
export type TemplateParameterSchema = {
title: string;
description?: string;
steps: Array<{
title: string;
description?: string;
schema: JsonObject;
}>;
};