diff --git a/.changeset/empty-apple-pie.md b/.changeset/empty-apple-pie.md new file mode 100644 index 0000000000..8e9fecdafb --- /dev/null +++ b/.changeset/empty-apple-pie.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-components': patch +--- + +Adding a `className` prop to the `MarkdownContent` component diff --git a/.changeset/empty-apples-tie.md b/.changeset/empty-apples-tie.md new file mode 100644 index 0000000000..3ebfe0fc70 --- /dev/null +++ b/.changeset/empty-apples-tie.md @@ -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 diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 289af8d40f..3f748813f5 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -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 = ( + + entity?.metadata?.tags?.includes('recommended') ?? false, + }, + ]} + /> + } + > + + + + } /> 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 ( ( @@ -206,6 +208,7 @@ export function SelectComponent(props: SelectProps) { data-testid="select" displayEmpty multiple={multiple} + margin={margin} onChange={handleChange} onClick={handleClick} open={isOpen} diff --git a/plugins/scaffolder-backend/src/service/router.ts b/plugins/scaffolder-backend/src/service/router.ts index c7d9af8e64..2f72004afe 100644 --- a/plugins/scaffolder-backend/src/service/router.ts +++ b/plugins/scaffolder-backend/src/service/router.ts @@ -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, })), }); diff --git a/plugins/scaffolder/api-report.md b/plugins/scaffolder/api-report.md index 345c726e24..24ff8e210d 100644 --- a/plugins/scaffolder/api-report.md +++ b/plugins/scaffolder/api-report.md @@ -442,8 +442,10 @@ export type TemplateGroupFilter = { // @public export type TemplateParameterSchema = { title: string; + description?: string; steps: Array<{ title: string; + description?: string; schema: JsonObject; }>; }; diff --git a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx index c899b56683..b7e3122c31 100644 --- a/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx +++ b/plugins/scaffolder/src/components/fields/EntityPicker/EntityPicker.tsx @@ -102,8 +102,9 @@ export const EntityPicker = ( ) => { /> diff --git a/plugins/scaffolder/src/next/TemplateListPage/TemplateCard/TemplateCard.test.tsx b/plugins/scaffolder/src/next/TemplateListPage/TemplateCard/TemplateCard.test.tsx index 44e3a0bbbf..4648d47af1 100644 --- a/plugins/scaffolder/src/next/TemplateListPage/TemplateCard/TemplateCard.test.tsx +++ b/plugins/scaffolder/src/next/TemplateListPage/TemplateCard/TemplateCard.test.tsx @@ -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', () => { > , - { mountedRoutes: { '/': rootRouteRef } }, + { mountedRoutes: { '/': nextRouteRef } }, ); expect(getByText('bob')).toBeInTheDocument(); @@ -84,7 +84,7 @@ describe('TemplateCard', () => { > , - { mountedRoutes: { '/': rootRouteRef } }, + { mountedRoutes: { '/': nextRouteRef } }, ); const description = getByText('hello'); @@ -115,7 +115,7 @@ describe('TemplateCard', () => { > , - { mountedRoutes: { '/': rootRouteRef } }, + { mountedRoutes: { '/': nextRouteRef } }, ); expect(getByText('No description')).toBeInTheDocument(); @@ -145,7 +145,7 @@ describe('TemplateCard', () => { > , - { mountedRoutes: { '/': rootRouteRef } }, + { mountedRoutes: { '/': nextRouteRef } }, ); for (const tag of mockTemplate.metadata.tags!) { @@ -185,7 +185,7 @@ describe('TemplateCard', () => { , { mountedRoutes: { - '/': rootRouteRef, + '/': nextRouteRef, '/catalog/:kind/:namespace/:name': entityRouteRef, }, }, @@ -224,7 +224,7 @@ describe('TemplateCard', () => { , { mountedRoutes: { - '/': rootRouteRef, + '/': nextRouteRef, '/catalog/:kind/:namespace/:name': entityRouteRef, }, }, diff --git a/plugins/scaffolder/src/next/TemplateListPage/TemplateCard/TemplateCard.tsx b/plugins/scaffolder/src/next/TemplateListPage/TemplateCard/TemplateCard.tsx index 874c20a9ac..10039115e0 100644 --- a/plugins/scaffolder/src/next/TemplateListPage/TemplateCard/TemplateCard.tsx +++ b/plugins/scaffolder/src/next/TemplateListPage/TemplateCard/TemplateCard.tsx @@ -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(theme => ({ @@ -46,10 +46,11 @@ const useStyles = makeStyles(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 ( @@ -104,6 +110,7 @@ export const TemplateCard = (props: TemplateCardProps) => { diff --git a/plugins/scaffolder/src/next/TemplateListPage/TemplateListPage.test.tsx b/plugins/scaffolder/src/next/TemplateListPage/TemplateListPage.test.tsx index d5a76d2a47..26e2994c8a 100644 --- a/plugins/scaffolder/src/next/TemplateListPage/TemplateListPage.test.tsx +++ b/plugins/scaffolder/src/next/TemplateListPage/TemplateListPage.test.tsx @@ -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', () => { > , - { mountedRoutes: { '/': rootRouteRef } }, + { mountedRoutes: { '/': nextRouteRef } }, ); expect(getByPlaceholderText('Search')).toBeInTheDocument(); @@ -85,7 +85,7 @@ describe('TemplateListPage', () => { > , - { mountedRoutes: { '/': rootRouteRef } }, + { mountedRoutes: { '/': nextRouteRef } }, ); expect(getByRole('menuitem', { name: /All/ })).toBeInTheDocument(); @@ -108,7 +108,7 @@ describe('TemplateListPage', () => { > , - { mountedRoutes: { '/': rootRouteRef } }, + { mountedRoutes: { '/': nextRouteRef } }, ); expect(getByText('Categories')).toBeInTheDocument(); diff --git a/plugins/scaffolder/src/next/TemplateListPage/TemplateListPage.tsx b/plugins/scaffolder/src/next/TemplateListPage/TemplateListPage.tsx index 1b18dce805..003c3aa98b 100644 --- a/plugins/scaffolder/src/next/TemplateListPage/TemplateListPage.tsx +++ b/plugins/scaffolder/src/next/TemplateListPage/TemplateListPage.tsx @@ -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 ( - +
@@ -88,7 +88,7 @@ export const TemplateListPage = (props: TemplateListPageProps) => { diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/SecretsContext/SecretsContext.test.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/SecretsContext/SecretsContext.test.tsx deleted file mode 100644 index 37d35c9015..0000000000 --- a/plugins/scaffolder/src/next/TemplateWizardPage/SecretsContext/SecretsContext.test.tsx +++ /dev/null @@ -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 }) => ( - {children} - ), - }, - ); - expect(result.current.context?.secrets.foo).toEqual(undefined); - - act(() => result.current.hook.setSecret({ foo: 'bar' })); - - expect(result.current.context?.secrets.foo).toEqual('bar'); - }); -}); diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/SecretsContext/SecretsContext.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/SecretsContext/SecretsContext.tsx deleted file mode 100644 index d6ca47d41c..0000000000 --- a/plugins/scaffolder/src/next/TemplateWizardPage/SecretsContext/SecretsContext.tsx +++ /dev/null @@ -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; - setSecrets: React.Dispatch>>; -}; - -/** - * The actual context object. - */ -export const SecretsContext = createContext( - undefined, -); - -/** - * The Context Provider that holds the state for the secrets. - * - * @alpha - */ -export const SecretsContextProvider = ({ children }: PropsWithChildren<{}>) => { - const [secrets, setSecrets] = useState>({}); - - return ( - - {children} - - ); -}; - -/** - * 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) => { - setSecrets(currentSecrets => ({ ...currentSecrets, ...input })); - }, - [setSecrets], - ); - - return { setSecret }; -}; diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.test.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.test.tsx new file mode 100644 index 0000000000..2b091883ae --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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

im a custom field extension

; + }; + + const manifest: TemplateParameterSchema = { + title: 'Custom Fields', + steps: [ + { + title: 'Test', + schema: { + properties: { + name: { + type: 'string', + 'ui:field': 'Mock', + }, + }, + }, + }, + ], + }; + + const { getByText } = await renderInTestApp( + , + ); + + expect(getByText('im a custom field extension')).toBeInTheDocument(); + }); +}); diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx new file mode 100644 index 0000000000..19335d4993 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/Stepper.tsx @@ -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[]; +} + +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 ( + <> + + {steps.map((step, index) => ( + + {step.title} + + ))} + +
+
+
+ + +
+
+
+ + ); +}; diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/SecretsContext/index.ts b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/index.ts similarity index 85% rename from plugins/scaffolder/src/next/TemplateWizardPage/SecretsContext/index.ts rename to plugins/scaffolder/src/next/TemplateWizardPage/Stepper/index.ts index 65b530dea1..5532563e28 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/SecretsContext/index.ts +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/index.ts @@ -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'; diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/schema.test.ts b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/schema.test.ts new file mode 100644 index 0000000000..01e6cea3cc --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/schema.test.ts @@ -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, + }); + }); +}); diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/schema.ts b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/schema.ts new file mode 100644 index 0000000000..5a33a622bb --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/schema.ts @@ -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 }; +}; diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/useTemplateSchema.test.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/useTemplateSchema.test.tsx new file mode 100644 index 0000000000..9be6faa0fa --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/useTemplateSchema.test.tsx @@ -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 }) => ( + false }]]} + > + {children} + + ), + }); + + 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 }) => ( + false }]]} + > + {children} + + ), + }); + + 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 }) => ( + true }]]} + > + {children} + + ), + }); + + 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 }) => ( + false }]]} + > + {children} + + ), + }); + + const [first] = result.current.steps; + + expect(first.schema).toEqual({ + type: 'object', + properties: { + visibleField: { + type: 'string', + }, + }, + }); + }); + }); +}); diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/useTemplateSchema.ts b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/useTemplateSchema.ts new file mode 100644 index 0000000000..d5fe244a25 --- /dev/null +++ b/plugins/scaffolder/src/next/TemplateWizardPage/Stepper/useTemplateSchema.ts @@ -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, + }; +}; diff --git a/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.tsx b/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.tsx index 4d160548ca..f8733061e4 100644 --- a/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.tsx +++ b/plugins/scaffolder/src/next/TemplateWizardPage/TemplateWizardPage.tsx @@ -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[]; } -export const TemplateWizardPage = (_props: TemplateWizardPageProps) => { - return null; +const useStyles = makeStyles(() => ({ + 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 ; + } + + return ( + +
+ + {loading && } + {manifest && ( + + } + noPadding + titleTypographyProps={{ component: 'h2' }} + > + + + )} + + + ); }; diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index 5ea4ae49a4..7387232948 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -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, }), ); diff --git a/plugins/scaffolder/src/routes.ts b/plugins/scaffolder/src/routes.ts index 8d6875e351..f862b043c1 100644 --- a/plugins/scaffolder/src/routes.ts +++ b/plugins/scaffolder/src/routes.ts @@ -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, diff --git a/plugins/scaffolder/src/types.ts b/plugins/scaffolder/src/types.ts index 710af6ad23..d814119398 100644 --- a/plugins/scaffolder/src/types.ts +++ b/plugins/scaffolder/src/types.ts @@ -79,8 +79,10 @@ export type ScaffolderTaskOutput = { */ export type TemplateParameterSchema = { title: string; + description?: string; steps: Array<{ title: string; + description?: string; schema: JsonObject; }>; };