chore: starting to move some dependencies around a little bit
Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
@@ -43,7 +43,21 @@
|
||||
"@backstage/version-bridge": "workspace:^",
|
||||
"@material-ui/core": "^4.12.2",
|
||||
"@material-ui/icons": "^4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.57"
|
||||
"@material-ui/lab": "4.0.0-alpha.57",
|
||||
"@rjsf/core": "^3.2.1",
|
||||
"@rjsf/core-v5": "npm:@rjsf/core@^5.0.0-beta.14",
|
||||
"@rjsf/material-ui": "^3.2.1",
|
||||
"@rjsf/material-ui-v5": "npm:@rjsf/material-ui@^5.0.0-beta.14",
|
||||
"@rjsf/utils": "^5.0.0-beta.14",
|
||||
"@rjsf/validator-ajv6": "^5.0.0-beta.14",
|
||||
"@types/json-schema": "^7.0.9",
|
||||
"classnames": "^2.2.6",
|
||||
"json-schema": "^0.4.0",
|
||||
"json-schema-library": "^7.3.9",
|
||||
"lodash": "^4.17.21",
|
||||
"qs": "^6.9.4",
|
||||
"zod": "~3.18.0",
|
||||
"zod-to-json-schema": "~3.18.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^16.13.1 || ^17.0.0",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright 2021 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 {
|
||||
CustomFieldExtensionSchema,
|
||||
CustomFieldValidator,
|
||||
FieldExtensionOptions,
|
||||
FieldExtensionComponentProps,
|
||||
} from './types';
|
||||
import { Extension, attachComponentData } from '@backstage/core-plugin-api';
|
||||
|
||||
export const FIELD_EXTENSION_WRAPPER_KEY = 'scaffolder.extensions.wrapper.v1';
|
||||
export const FIELD_EXTENSION_KEY = 'scaffolder.extensions.field.v1';
|
||||
|
||||
/**
|
||||
* A type used to wrap up the FieldExtension to embed the ReturnValue and the InputProps
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type FieldExtensionComponent<_TReturnValue, _TInputProps> = () => null;
|
||||
|
||||
/**
|
||||
* Method for creating field extensions that can be used in the scaffolder
|
||||
* frontend form.
|
||||
* @public
|
||||
*/
|
||||
export function createScaffolderFieldExtension<
|
||||
TReturnValue = unknown,
|
||||
TInputProps = unknown,
|
||||
>(
|
||||
options: FieldExtensionOptions<TReturnValue, TInputProps>,
|
||||
): Extension<FieldExtensionComponent<TReturnValue, TInputProps>> {
|
||||
return {
|
||||
expose() {
|
||||
const FieldExtensionDataHolder: any = () => null;
|
||||
|
||||
attachComponentData(
|
||||
FieldExtensionDataHolder,
|
||||
FIELD_EXTENSION_KEY,
|
||||
options,
|
||||
);
|
||||
|
||||
return FieldExtensionDataHolder;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The Wrapping component for defining fields extensions inside
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const ScaffolderFieldExtensions: React.ComponentType<
|
||||
React.PropsWithChildren<{}>
|
||||
> = (): JSX.Element | null => null;
|
||||
|
||||
attachComponentData(
|
||||
ScaffolderFieldExtensions,
|
||||
FIELD_EXTENSION_WRAPPER_KEY,
|
||||
true,
|
||||
);
|
||||
|
||||
export type {
|
||||
CustomFieldExtensionSchema,
|
||||
CustomFieldValidator,
|
||||
FieldExtensionOptions,
|
||||
FieldExtensionComponentProps,
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
/*
|
||||
* Copyright 2021 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 { ApiHolder } from '@backstage/core-plugin-api';
|
||||
import { FieldValidation, FieldProps } from '@rjsf/core';
|
||||
import {
|
||||
UIOptionsType,
|
||||
FieldProps as FieldPropsV5,
|
||||
UiSchema as UiSchemaV5,
|
||||
FieldValidation as FieldValidationV5,
|
||||
} from '@rjsf/utils';
|
||||
import { JSONSchema7 } from 'json-schema';
|
||||
|
||||
/**
|
||||
* Field validation type for Custom Field Extensions.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type CustomFieldValidator<TFieldReturnValue> = (
|
||||
data: TFieldReturnValue,
|
||||
field: FieldValidation,
|
||||
context: { apiHolder: ApiHolder },
|
||||
) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Type for the Custom Field Extension schema.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type CustomFieldExtensionSchema = {
|
||||
returnValue: JSONSchema7;
|
||||
uiOptions?: JSONSchema7;
|
||||
};
|
||||
|
||||
/**
|
||||
* Type for the Custom Field Extension with the
|
||||
* name and components and validation function.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type FieldExtensionOptions<
|
||||
TFieldReturnValue = unknown,
|
||||
TInputProps = unknown,
|
||||
> = {
|
||||
name: string;
|
||||
component: (
|
||||
props: FieldExtensionComponentProps<TFieldReturnValue, TInputProps>,
|
||||
) => JSX.Element | null;
|
||||
validation?: CustomFieldValidator<TFieldReturnValue>;
|
||||
schema?: CustomFieldExtensionSchema;
|
||||
};
|
||||
|
||||
/**
|
||||
* Type for field extensions and being able to type
|
||||
* incoming props easier.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export interface FieldExtensionComponentProps<
|
||||
TFieldReturnValue,
|
||||
TUiOptions extends {} = {},
|
||||
> extends FieldProps<TFieldReturnValue> {
|
||||
uiSchema: FieldProps['uiSchema'] & {
|
||||
'ui:options'?: TUiOptions;
|
||||
};
|
||||
}
|
||||
@@ -15,3 +15,6 @@
|
||||
*/
|
||||
|
||||
export * from './routes';
|
||||
export * from './extensions';
|
||||
|
||||
export * from './next';
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* 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 { ReviewState } from './ReviewState';
|
||||
import { render } from '@testing-library/react';
|
||||
import { ParsedTemplateSchema } from './useTemplateSchema';
|
||||
|
||||
describe('ReviewState', () => {
|
||||
it('should render the text as normal with no options', () => {
|
||||
const formState = {
|
||||
name: 'John Doe',
|
||||
test: 'bob',
|
||||
};
|
||||
|
||||
const { getByRole } = render(
|
||||
<ReviewState formState={formState} schemas={[]} />,
|
||||
);
|
||||
|
||||
expect(getByRole('row', { name: 'Name John Doe' })).toBeInTheDocument();
|
||||
expect(getByRole('row', { name: 'Test bob' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should mask password ui:fields', () => {
|
||||
const formState = {
|
||||
name: 'John Doe',
|
||||
test: 'bob',
|
||||
};
|
||||
|
||||
const schemas: ParsedTemplateSchema[] = [
|
||||
{
|
||||
mergedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
'ui:widget': 'password',
|
||||
},
|
||||
},
|
||||
},
|
||||
schema: {},
|
||||
title: 'test',
|
||||
uiSchema: {},
|
||||
description: 'asd',
|
||||
},
|
||||
];
|
||||
|
||||
const { getByRole } = render(
|
||||
<ReviewState formState={formState} schemas={schemas} />,
|
||||
);
|
||||
|
||||
expect(getByRole('row', { name: 'Name ******' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should hide from review if show is not set', async () => {
|
||||
const formState = {
|
||||
name: 'John Doe',
|
||||
test: 'bob',
|
||||
};
|
||||
|
||||
const schemas: ParsedTemplateSchema[] = [
|
||||
{
|
||||
mergedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
'ui:widget': 'password',
|
||||
'ui:backstage': {
|
||||
review: {
|
||||
show: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
schema: {},
|
||||
title: 'test',
|
||||
uiSchema: {},
|
||||
description: 'asd',
|
||||
},
|
||||
];
|
||||
|
||||
const { queryByRole } = render(
|
||||
<ReviewState formState={formState} schemas={schemas} />,
|
||||
);
|
||||
|
||||
expect(queryByRole('row', { name: 'Name ******' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should allow for masking an option with a set text', () => {
|
||||
const formState = {
|
||||
name: 'John Doe',
|
||||
test: 'bob',
|
||||
};
|
||||
|
||||
const schemas: ParsedTemplateSchema[] = [
|
||||
{
|
||||
mergedSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
'ui:widget': 'password',
|
||||
'ui:backstage': {
|
||||
review: {
|
||||
mask: 'lols',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
schema: {},
|
||||
title: 'test',
|
||||
uiSchema: {},
|
||||
description: 'asd',
|
||||
},
|
||||
];
|
||||
|
||||
const { getByRole } = render(
|
||||
<ReviewState formState={formState} schemas={schemas} />,
|
||||
);
|
||||
|
||||
expect(getByRole('row', { name: 'Name lols' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* 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 { StructuredMetadataTable } from '@backstage/core-components';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { ParsedTemplateSchema } from './useTemplateSchema';
|
||||
import { Draft07 as JSONSchema } from 'json-schema-library';
|
||||
|
||||
interface ReviewStateProps {
|
||||
schemas: ParsedTemplateSchema[];
|
||||
formState: JsonObject;
|
||||
}
|
||||
|
||||
export const ReviewState = (props: ReviewStateProps) => {
|
||||
const reviewData = Object.fromEntries(
|
||||
Object.entries(props.formState).map(([key, value]) => {
|
||||
for (const step of props.schemas) {
|
||||
const parsedSchema = new JSONSchema(step.mergedSchema);
|
||||
const definitionInSchema = parsedSchema.getSchema(
|
||||
`#/${key}`,
|
||||
props.formState,
|
||||
);
|
||||
if (definitionInSchema) {
|
||||
const backstageReviewOptions =
|
||||
definitionInSchema['ui:backstage']?.review;
|
||||
|
||||
if (backstageReviewOptions) {
|
||||
if (backstageReviewOptions.mask) {
|
||||
return [key, backstageReviewOptions.mask];
|
||||
}
|
||||
if (backstageReviewOptions.show === false) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
if (definitionInSchema['ui:widget'] === 'password') {
|
||||
return [key, '******'];
|
||||
}
|
||||
}
|
||||
}
|
||||
return [key, value];
|
||||
}),
|
||||
);
|
||||
return <StructuredMetadataTable metadata={reviewData} />;
|
||||
};
|
||||
@@ -0,0 +1,355 @@
|
||||
/*
|
||||
* 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 { act, fireEvent } from '@testing-library/react';
|
||||
import type { RJSFValidationError } from '@rjsf/utils';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import { NextFieldExtensionComponentProps } from '../../../extensions/types';
|
||||
|
||||
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={[]} onComplete={jest.fn()} />,
|
||||
);
|
||||
|
||||
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 { getByRole } = await renderInTestApp(
|
||||
<Stepper manifest={manifest} extensions={[]} onComplete={jest.fn()} />,
|
||||
);
|
||||
|
||||
expect(getByRole('button', { name: 'Next' })).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent.click(getByRole('button', { name: 'Next' }));
|
||||
});
|
||||
|
||||
expect(getByRole('button', { name: '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 } = await renderInTestApp(
|
||||
<Stepper manifest={manifest} extensions={[]} onComplete={jest.fn()} />,
|
||||
);
|
||||
|
||||
await fireEvent.change(getByRole('textbox', { name: 'name' }), {
|
||||
target: { value: 'im a test value' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent.click(getByRole('button', { name: 'Next' }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent.click(getByRole('button', { name: 'Back' }));
|
||||
});
|
||||
|
||||
expect(getByRole('textbox', { name: 'name' })).toHaveValue(
|
||||
'im a test value',
|
||||
);
|
||||
});
|
||||
|
||||
it('should merge nested formData correctly in multiple steps', async () => {
|
||||
const Repo = ({
|
||||
onChange,
|
||||
}: NextFieldExtensionComponentProps<{ repository: string }, any>) => (
|
||||
<input
|
||||
aria-label="repo"
|
||||
type="text"
|
||||
onChange={e => onChange({ repository: e.target.value })}
|
||||
defaultValue=""
|
||||
/>
|
||||
);
|
||||
|
||||
const Owner = ({
|
||||
onChange,
|
||||
}: NextFieldExtensionComponentProps<{ owner: string }, any>) => (
|
||||
<input
|
||||
aria-label="owner"
|
||||
type="text"
|
||||
onChange={e => onChange({ owner: e.target.value })}
|
||||
defaultValue=""
|
||||
/>
|
||||
);
|
||||
|
||||
const manifest: TemplateParameterSchema = {
|
||||
steps: [
|
||||
{
|
||||
title: 'Step 1',
|
||||
schema: {
|
||||
properties: {
|
||||
first: {
|
||||
type: 'object',
|
||||
'ui:field': 'Repo',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Step 2',
|
||||
schema: {
|
||||
properties: {
|
||||
second: {
|
||||
type: 'object',
|
||||
'ui:field': 'Owner',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
title: 'React JSON Schema Form Test',
|
||||
};
|
||||
|
||||
const onComplete = jest.fn(async (values: Record<string, JsonValue>) => {
|
||||
expect(values).toEqual({
|
||||
first: { repository: 'Repo' },
|
||||
second: { owner: 'Owner' },
|
||||
});
|
||||
});
|
||||
|
||||
const { getByRole } = await renderInTestApp(
|
||||
<Stepper
|
||||
manifest={manifest}
|
||||
onComplete={onComplete}
|
||||
extensions={[
|
||||
{ name: 'Repo', component: Repo },
|
||||
{ name: 'Owner', component: Owner },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await fireEvent.change(getByRole('textbox', { name: 'repo' }), {
|
||||
target: { value: 'Repo' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent.click(getByRole('button', { name: 'Next' }));
|
||||
});
|
||||
|
||||
await fireEvent.change(getByRole('textbox', { name: 'owner' }), {
|
||||
target: { value: 'Owner' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent.click(getByRole('button', { name: 'Review' }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent.click(getByRole('button', { name: 'Create' }));
|
||||
});
|
||||
|
||||
expect(onComplete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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 }]}
|
||||
onComplete={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(getByText('im a custom field extension')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should transform default error message', async () => {
|
||||
const manifest: TemplateParameterSchema = {
|
||||
steps: [
|
||||
{
|
||||
title: 'Step 1',
|
||||
schema: {
|
||||
properties: {
|
||||
postcode: {
|
||||
type: 'string',
|
||||
pattern: '[A-Z][0-9][A-Z] [0-9][A-Z][0-9]',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
title: 'transformErrors Form Test',
|
||||
};
|
||||
|
||||
const transformErrors = (errors: RJSFValidationError[]) => {
|
||||
return errors.map(err =>
|
||||
err.property === '.postcode'
|
||||
? { ...err, message: 'invalid postcode' }
|
||||
: err,
|
||||
);
|
||||
};
|
||||
|
||||
const { getByText, getByRole } = await renderInTestApp(
|
||||
<Stepper
|
||||
manifest={manifest}
|
||||
extensions={[]}
|
||||
onComplete={jest.fn()}
|
||||
FormProps={{ transformErrors }}
|
||||
/>,
|
||||
);
|
||||
|
||||
await fireEvent.change(getByRole('textbox', { name: 'postcode' }), {
|
||||
target: { value: 'invalid' },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent.click(getByRole('button', { name: 'Review' }));
|
||||
});
|
||||
|
||||
expect(getByText('invalid postcode')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should grab the initial formData from the query', async () => {
|
||||
const manifest: TemplateParameterSchema = {
|
||||
steps: [
|
||||
{
|
||||
title: 'Step 1',
|
||||
schema: {
|
||||
properties: {
|
||||
firstName: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
title: 'initialize formData',
|
||||
};
|
||||
|
||||
const mockFormData = { firstName: 'John' };
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: {
|
||||
search: `?formData=${JSON.stringify(mockFormData)}`,
|
||||
},
|
||||
});
|
||||
|
||||
const { getByRole } = await renderInTestApp(
|
||||
<Stepper manifest={manifest} extensions={[]} onComplete={jest.fn()} />,
|
||||
);
|
||||
|
||||
expect(getByRole('textbox', { name: 'firstName' })).toHaveValue('John');
|
||||
});
|
||||
|
||||
it('should initialize formState with undefined form values', async () => {
|
||||
const manifest: TemplateParameterSchema = {
|
||||
steps: [
|
||||
{
|
||||
title: 'Step 1',
|
||||
schema: {
|
||||
properties: {
|
||||
firstName: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
title: 'initialize formData',
|
||||
};
|
||||
|
||||
const onComplete = jest.fn(async (values: Record<string, JsonValue>) => {
|
||||
expect(values).toHaveProperty('firstName');
|
||||
});
|
||||
|
||||
const { getByRole } = await renderInTestApp(
|
||||
<Stepper manifest={manifest} extensions={[]} onComplete={onComplete} />,
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent.click(getByRole('button', { name: 'Review' }));
|
||||
});
|
||||
|
||||
expect(getByRole('button', { name: 'Create' })).toBeInTheDocument();
|
||||
|
||||
await act(async () => {
|
||||
await fireEvent.click(getByRole('button', { name: 'Create' }));
|
||||
});
|
||||
|
||||
// flush promises
|
||||
return new Promise(process.nextTick);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* 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 {
|
||||
useAnalytics,
|
||||
useApiHolder,
|
||||
useRouteRefParams,
|
||||
} from '@backstage/core-plugin-api';
|
||||
import { JsonValue } from '@backstage/types';
|
||||
import {
|
||||
Stepper as MuiStepper,
|
||||
Step as MuiStep,
|
||||
StepLabel as MuiStepLabel,
|
||||
Button,
|
||||
makeStyles,
|
||||
} from '@material-ui/core';
|
||||
import { type IChangeEvent, withTheme } from '@rjsf/core-v5';
|
||||
import { ErrorSchema, FieldValidation } from '@rjsf/utils';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { NextFieldExtensionOptions } from '../../extensions';
|
||||
import { TemplateParameterSchema } from '../../../types';
|
||||
import { createAsyncValidators } from './createAsyncValidators';
|
||||
import { useTemplateSchema } from '../../hooks/useTemplateSchema';
|
||||
import { ReviewState } from './ReviewState';
|
||||
import validator from '@rjsf/validator-ajv6';
|
||||
import { selectedTemplateRouteRef } from '../../../routes';
|
||||
import { useFormData } from '../../hooks/useFormData';
|
||||
import { FormProps } from '../../types';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
backButton: {
|
||||
marginRight: theme.spacing(1),
|
||||
},
|
||||
|
||||
footer: {
|
||||
display: 'flex',
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'right',
|
||||
},
|
||||
formWrapper: {
|
||||
padding: theme.spacing(2),
|
||||
},
|
||||
}));
|
||||
|
||||
export type StepperProps = {
|
||||
manifest: TemplateParameterSchema;
|
||||
extensions: NextFieldExtensionOptions<any, any>[];
|
||||
onComplete: (values: Record<string, JsonValue>) => Promise<void>;
|
||||
FormProps?: FormProps;
|
||||
};
|
||||
|
||||
// TODO(blam): We require here, as the types in this package depend on @rjsf/core explicitly
|
||||
// which is what we're using here as the default types, it needs to depend on @rjsf/core-v5 because
|
||||
// of the re-writing we're doing. Once we've migrated, we can import this the exact same as before.
|
||||
const Form = withTheme(require('@rjsf/material-ui-v5').Theme);
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
*/
|
||||
export const Stepper = (props: StepperProps) => {
|
||||
const { templateName } = useRouteRefParams(selectedTemplateRouteRef);
|
||||
const analytics = useAnalytics();
|
||||
const { steps } = useTemplateSchema(props.manifest);
|
||||
const apiHolder = useApiHolder();
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
const [formState, setFormState] = useFormData();
|
||||
|
||||
const [errors, setErrors] = useState<
|
||||
undefined | Record<string, FieldValidation>
|
||||
>();
|
||||
const styles = useStyles();
|
||||
|
||||
const extensions = useMemo(() => {
|
||||
return Object.fromEntries(
|
||||
props.extensions.map(({ name, component }) => [name, component]),
|
||||
);
|
||||
}, [props.extensions]);
|
||||
|
||||
const validators = useMemo(() => {
|
||||
return Object.fromEntries(
|
||||
props.extensions.map(({ name, validation }) => [name, validation]),
|
||||
);
|
||||
}, [props.extensions]);
|
||||
|
||||
const validation = useMemo(() => {
|
||||
return createAsyncValidators(steps[activeStep]?.mergedSchema, validators, {
|
||||
apiHolder,
|
||||
});
|
||||
}, [steps, activeStep, validators, apiHolder]);
|
||||
|
||||
const handleBack = () => {
|
||||
setActiveStep(prevActiveStep => prevActiveStep - 1);
|
||||
};
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: IChangeEvent) =>
|
||||
setFormState(current => ({ ...current, ...e.formData })),
|
||||
[setFormState],
|
||||
);
|
||||
|
||||
const handleNext = async ({
|
||||
formData,
|
||||
}: {
|
||||
formData: Record<string, JsonValue>;
|
||||
}) => {
|
||||
// TODO(blam): What do we do about loading states, does each field extension get a chance
|
||||
// to display it's own loading? Or should we grey out the entire form.
|
||||
setErrors(undefined);
|
||||
|
||||
const returnedValidation = await validation(formData);
|
||||
|
||||
const hasErrors = Object.values(returnedValidation).some(
|
||||
i => i.__errors?.length,
|
||||
);
|
||||
|
||||
if (hasErrors) {
|
||||
setErrors(returnedValidation);
|
||||
} else {
|
||||
setErrors(undefined);
|
||||
setActiveStep(prevActiveStep => {
|
||||
const stepNum = prevActiveStep + 1;
|
||||
analytics.captureEvent('click', `Next Step (${stepNum})`);
|
||||
return stepNum;
|
||||
});
|
||||
}
|
||||
setFormState(current => ({ ...current, ...formData }));
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<MuiStepper activeStep={activeStep} alternativeLabel variant="elevation">
|
||||
{steps.map((step, index) => (
|
||||
<MuiStep key={index}>
|
||||
<MuiStepLabel>{step.title}</MuiStepLabel>
|
||||
</MuiStep>
|
||||
))}
|
||||
<MuiStep>
|
||||
<MuiStepLabel>Review</MuiStepLabel>
|
||||
</MuiStep>
|
||||
</MuiStepper>
|
||||
<div className={styles.formWrapper}>
|
||||
{activeStep < steps.length ? (
|
||||
<Form
|
||||
validator={validator}
|
||||
extraErrors={errors as unknown as ErrorSchema}
|
||||
formData={formState}
|
||||
formContext={{ formData: formState }}
|
||||
schema={steps[activeStep].schema}
|
||||
uiSchema={steps[activeStep].uiSchema}
|
||||
onSubmit={handleNext}
|
||||
fields={extensions}
|
||||
showErrorList={false}
|
||||
onChange={handleChange}
|
||||
{...(props.FormProps ?? {})}
|
||||
>
|
||||
<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>
|
||||
) : (
|
||||
<>
|
||||
<ReviewState formState={formState} schemas={steps} />
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
onClick={handleBack}
|
||||
className={styles.backButton}
|
||||
disabled={activeStep < 1}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
onClick={() => {
|
||||
props.onComplete(formState);
|
||||
const name =
|
||||
typeof formState.name === 'string'
|
||||
? formState.name
|
||||
: undefined;
|
||||
analytics.captureEvent(
|
||||
'create',
|
||||
name || `new ${templateName}`,
|
||||
);
|
||||
}}
|
||||
>
|
||||
Create
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
/*
|
||||
* 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 { NextCustomFieldValidator } from '../../../extensions';
|
||||
import { createAsyncValidators } from './createAsyncValidators';
|
||||
|
||||
describe('createAsyncValidators', () => {
|
||||
it('should call the correct functions for validation', async () => {
|
||||
const schema: JsonObject = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
'ui:field': 'NameField',
|
||||
},
|
||||
address: {
|
||||
type: 'object',
|
||||
'ui:field': 'AddressField',
|
||||
properties: {
|
||||
street: {
|
||||
type: 'string',
|
||||
},
|
||||
postcode: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const validators = { NameField: jest.fn(), AddressField: jest.fn() };
|
||||
|
||||
const validate = createAsyncValidators(schema, validators, {
|
||||
apiHolder: { get: jest.fn() },
|
||||
});
|
||||
|
||||
await validate({
|
||||
name: 'asd',
|
||||
address: { street: 'street', postcode: 'postcode' },
|
||||
});
|
||||
|
||||
expect(validators.NameField).toHaveBeenCalled();
|
||||
expect(validators.AddressField).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return the correct errors to the frontend', async () => {
|
||||
const schema: JsonObject = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
'ui:field': 'NameField',
|
||||
},
|
||||
address: {
|
||||
type: 'object',
|
||||
'ui:field': 'AddressField',
|
||||
properties: {
|
||||
street: {
|
||||
type: 'string',
|
||||
},
|
||||
postcode: {
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const NameField: NextCustomFieldValidator<string> = (
|
||||
value,
|
||||
{ addError },
|
||||
) => {
|
||||
if (!value) {
|
||||
addError('something is broken here!');
|
||||
}
|
||||
};
|
||||
|
||||
const AddressField: NextCustomFieldValidator<{
|
||||
street?: string;
|
||||
postcode?: string;
|
||||
}> = (value, { addError }) => {
|
||||
if (!value.postcode) {
|
||||
addError('postcode is missing!');
|
||||
}
|
||||
|
||||
if (!value.street) {
|
||||
addError('street is missing here!');
|
||||
}
|
||||
};
|
||||
|
||||
const validate = createAsyncValidators(
|
||||
schema,
|
||||
{
|
||||
NameField: NameField as NextCustomFieldValidator<unknown>,
|
||||
AddressField: AddressField as NextCustomFieldValidator<unknown>,
|
||||
},
|
||||
{
|
||||
apiHolder: { get: jest.fn() },
|
||||
},
|
||||
);
|
||||
|
||||
await expect(
|
||||
validate({
|
||||
name: 'asd',
|
||||
address: { street: 'street', postcode: 'postcode' },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
name: expect.objectContaining({
|
||||
__errors: [],
|
||||
}),
|
||||
address: expect.objectContaining({
|
||||
__errors: [],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
validate({
|
||||
name: 'asd',
|
||||
address: { street: '', postcode: 'postcode' },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
name: expect.objectContaining({
|
||||
__errors: [],
|
||||
}),
|
||||
address: expect.objectContaining({
|
||||
__errors: ['street is missing here!'],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
validate({
|
||||
name: '',
|
||||
address: { street: '', postcode: '' },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
name: expect.objectContaining({
|
||||
__errors: ['something is broken here!'],
|
||||
}),
|
||||
address: expect.objectContaining({
|
||||
__errors: ['postcode is missing!', 'street is missing here!'],
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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 { FieldValidation } from '@rjsf/utils';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { ApiHolder } from '@backstage/core-plugin-api';
|
||||
import { NextCustomFieldValidator } from '../../../extensions';
|
||||
import { Draft07 as JSONSchema } from 'json-schema-library';
|
||||
import { createFieldValidation } from './schema';
|
||||
|
||||
export const createAsyncValidators = (
|
||||
rootSchema: JsonObject,
|
||||
validators: Record<string, undefined | NextCustomFieldValidator<unknown>>,
|
||||
context: {
|
||||
apiHolder: ApiHolder;
|
||||
},
|
||||
) => {
|
||||
async function validate(formData: JsonObject, pathPrefix: string = '#') {
|
||||
const parsedSchema = new JSONSchema(rootSchema);
|
||||
const formValidation: Record<string, FieldValidation> = {};
|
||||
for (const [key, value] of Object.entries(formData)) {
|
||||
const definitionInSchema = parsedSchema.getSchema(
|
||||
`${pathPrefix}/${key}`,
|
||||
formData,
|
||||
);
|
||||
|
||||
if (definitionInSchema && 'ui:field' in definitionInSchema) {
|
||||
const validator = validators[definitionInSchema['ui:field']];
|
||||
if (validator) {
|
||||
const fieldValidation = createFieldValidation();
|
||||
try {
|
||||
await validator(value, fieldValidation, { ...context, formData });
|
||||
} catch (ex) {
|
||||
fieldValidation.addError(ex.message);
|
||||
}
|
||||
formValidation[key] = fieldValidation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return formValidation;
|
||||
}
|
||||
|
||||
return async (formData: JsonObject) => {
|
||||
return await validate(formData);
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
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,127 @@
|
||||
/*
|
||||
* 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 { FieldValidation, UiSchema } from '@rjsf/utils';
|
||||
|
||||
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 };
|
||||
};
|
||||
|
||||
/**
|
||||
* @alpha
|
||||
* Creates a field validation object for use in react jsonschema form
|
||||
*/
|
||||
export const createFieldValidation = (): FieldValidation => {
|
||||
const fieldValidation: FieldValidation = {
|
||||
__errors: [] as string[],
|
||||
addError: (message: string) => {
|
||||
fieldValidation.__errors?.push(message);
|
||||
},
|
||||
};
|
||||
|
||||
return fieldValidation;
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
export { Stepper } from './Stepper';
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
* Copyright 2021 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 {
|
||||
NextCustomFieldValidator,
|
||||
NextFieldExtensionOptions,
|
||||
NextFieldExtensionComponentProps,
|
||||
} from './types';
|
||||
import { Extension, attachComponentData } from '@backstage/core-plugin-api';
|
||||
import { UIOptionsType } from '@rjsf/utils';
|
||||
import { FieldExtensionComponent } from '../../extensions';
|
||||
|
||||
export const FIELD_EXTENSION_WRAPPER_KEY = 'scaffolder.extensions.wrapper.v1';
|
||||
export const FIELD_EXTENSION_KEY = 'scaffolder.extensions.field.v1';
|
||||
|
||||
/**
|
||||
* Method for creating field extensions that can be used in the scaffolder
|
||||
* frontend form.
|
||||
* @alpha
|
||||
*/
|
||||
export function createNextScaffolderFieldExtension<
|
||||
TReturnValue = unknown,
|
||||
TInputProps extends UIOptionsType = {},
|
||||
>(
|
||||
options: NextFieldExtensionOptions<TReturnValue, TInputProps>,
|
||||
): Extension<FieldExtensionComponent<TReturnValue, TInputProps>> {
|
||||
return {
|
||||
expose() {
|
||||
const FieldExtensionDataHolder: any = () => null;
|
||||
|
||||
attachComponentData(
|
||||
FieldExtensionDataHolder,
|
||||
FIELD_EXTENSION_KEY,
|
||||
options,
|
||||
);
|
||||
|
||||
return FieldExtensionDataHolder;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type {
|
||||
NextCustomFieldValidator,
|
||||
NextFieldExtensionOptions,
|
||||
NextFieldExtensionComponentProps,
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2021 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 { ApiHolder } from '@backstage/core-plugin-api';
|
||||
import {
|
||||
UIOptionsType,
|
||||
FieldProps as FieldPropsV5,
|
||||
UiSchema as UiSchemaV5,
|
||||
FieldValidation as FieldValidationV5,
|
||||
} from '@rjsf/utils';
|
||||
import { PropsWithChildren } from 'react';
|
||||
import { JsonObject } from '@backstage/types';
|
||||
import { CustomFieldExtensionSchema } from '../../extensions';
|
||||
|
||||
/**
|
||||
* Type for Field Extension Props for RJSF v5
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export interface NextFieldExtensionComponentProps<
|
||||
TFieldReturnValue,
|
||||
TUiOptions = {},
|
||||
> extends PropsWithChildren<FieldPropsV5<TFieldReturnValue>> {
|
||||
uiSchema?: UiSchemaV5<TFieldReturnValue> & {
|
||||
'ui:options'?: TUiOptions & UIOptionsType;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Field validation type for Custom Field Extensions.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export type NextCustomFieldValidator<TFieldReturnValue> = (
|
||||
data: TFieldReturnValue,
|
||||
field: FieldValidationV5,
|
||||
context: { apiHolder: ApiHolder; formData: JsonObject },
|
||||
) => void | Promise<void>;
|
||||
|
||||
/**
|
||||
* Type for the Custom Field Extension with the
|
||||
* name and components and validation function.
|
||||
*
|
||||
* @alpha
|
||||
*/
|
||||
export type NextFieldExtensionOptions<
|
||||
TFieldReturnValue = unknown,
|
||||
TInputProps = unknown,
|
||||
> = {
|
||||
name: string;
|
||||
component: (
|
||||
props: NextFieldExtensionComponentProps<TFieldReturnValue, TInputProps>,
|
||||
) => JSX.Element | null;
|
||||
validation?: NextCustomFieldValidator<TFieldReturnValue>;
|
||||
schema?: CustomFieldExtensionSchema;
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* 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 qs from 'qs';
|
||||
import { useState } from 'react';
|
||||
|
||||
export const useFormData = () => {
|
||||
return useState<Record<string, any>>(() => {
|
||||
const query = qs.parse(window.location.search, {
|
||||
ignoreQueryPrefix: true,
|
||||
});
|
||||
|
||||
try {
|
||||
return JSON.parse(query.formData as string);
|
||||
} catch (e) {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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,70 @@
|
||||
/*
|
||||
* 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/utils';
|
||||
import { TemplateParameterSchema } from '../../../types';
|
||||
import { extractSchemaFromStep } from './schema';
|
||||
|
||||
export interface ParsedTemplateSchema {
|
||||
uiSchema: UiSchema;
|
||||
mergedSchema: JsonObject;
|
||||
schema: JsonObject;
|
||||
title: string;
|
||||
description?: string;
|
||||
}
|
||||
export const useTemplateSchema = (
|
||||
manifest: TemplateParameterSchema,
|
||||
): { steps: ParsedTemplateSchema[] } => {
|
||||
const featureFlags = useApi(featureFlagsApiRef);
|
||||
const steps = manifest.steps.map(({ title, description, schema }) => ({
|
||||
title,
|
||||
description,
|
||||
mergedSchema: schema,
|
||||
...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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
export * from './components';
|
||||
export * from './extensions';
|
||||
@@ -0,0 +1,15 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
Reference in New Issue
Block a user