Merge pull request #10575 from backstage/blam/next-scaffolder/wizard
scaffolder/next: start implementing a Wizard page
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/core-components': patch
|
||||
---
|
||||
|
||||
Adding a `className` prop to the `MarkdownContent` component
|
||||
@@ -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
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
+1
-5
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -79,8 +79,10 @@ export type ScaffolderTaskOutput = {
|
||||
*/
|
||||
export type TemplateParameterSchema = {
|
||||
title: string;
|
||||
description?: string;
|
||||
steps: Array<{
|
||||
title: string;
|
||||
description?: string;
|
||||
schema: JsonObject;
|
||||
}>;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user