chore: starting to move some dependencies around a little bit

Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
blam
2022-12-22 12:01:58 +01:00
parent 31f32bbebc
commit 5f0fadc610
23 changed files with 215 additions and 86 deletions
+15 -1
View File
@@ -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;
};
}
+3
View File
@@ -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';
+15
View File
@@ -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.
*/