Merge pull request #4663 from backstage/mob/scaffolder-beta2

scaffolder-backend: initial support for beta2 templates, and refactored form schema creation
This commit is contained in:
Fredrik Adelöw
2021-02-24 14:09:50 +01:00
committed by GitHub
19 changed files with 791 additions and 187 deletions
@@ -45,6 +45,7 @@ import {
SystemEntity,
systemEntityV1alpha1Validator,
templateEntityV1alpha1Validator,
templateEntityV1beta2Validator,
UserEntity,
userEntityV1alpha1Validator,
} from '@backstage/catalog-model';
@@ -59,6 +60,7 @@ export class BuiltinKindsEntityProcessor implements CatalogProcessor {
groupEntityV1alpha1Validator,
locationEntityV1alpha1Validator,
templateEntityV1alpha1Validator,
templateEntityV1beta2Validator,
userEntityV1alpha1Validator,
systemEntityV1alpha1Validator,
domainEntityV1alpha1Validator,
@@ -14,7 +14,10 @@
* limitations under the License.
*/
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import {
TemplateEntityV1alpha1,
TemplateEntityV1beta2,
} from '@backstage/catalog-model';
import { CatalogApi } from '@backstage/catalog-client';
import { ConflictError, NotFoundError } from '@backstage/backend-common';
@@ -32,7 +35,7 @@ export class CatalogEntityClient {
async findTemplate(
templateName: string,
options?: { token?: string },
): Promise<TemplateEntityV1alpha1> {
): Promise<TemplateEntityV1alpha1 | TemplateEntityV1beta2> {
const { items: templates } = (await this.catalogClient.getEntities(
{
filter: {
@@ -41,7 +44,7 @@ export class CatalogEntityClient {
},
},
options,
)) as { items: TemplateEntityV1alpha1[] };
)) as { items: (TemplateEntityV1alpha1 | TemplateEntityV1beta2)[] };
if (templates.length !== 1) {
if (templates.length > 1) {
@@ -17,7 +17,7 @@
import { PassThrough } from 'stream';
import { Logger } from 'winston';
import * as winston from 'winston';
import { JsonValue } from '@backstage/config';
import { JsonValue, JsonObject } from '@backstage/config';
import { TaskBroker, Task } from './types';
import fs from 'fs-extra';
import path from 'path';
@@ -57,10 +57,11 @@ export class TaskWorker {
);
const templateCtx: {
parameters: JsonObject;
steps: {
[stepName: string]: { output: { [outputName: string]: JsonValue } };
};
} = { steps: {} };
} = { parameters: task.spec.values, steps: {} };
for (const step of task.spec.steps) {
const metadata = { stepId: step.id };
@@ -94,22 +95,20 @@ export class TaskWorker {
throw new Error(`Action '${step.action}' does not exist`);
}
const parameters: { [name: string]: JsonValue } = {};
for (const [name, maybeTemplateStr] of Object.entries(
step.parameters ?? {},
)) {
if (typeof maybeTemplateStr === 'string') {
const value = handlebars.compile(maybeTemplateStr, {
noEscape: true,
strict: true,
data: false,
preventIndent: true,
})(templateCtx);
parameters[name] = value;
} else {
parameters[name] = maybeTemplateStr;
}
}
const parameters = JSON.parse(
JSON.stringify(step.parameters),
(_key, value) => {
if (typeof value === 'string') {
return handlebars.compile(value, {
noEscape: true,
strict: true,
data: false,
preventIndent: true,
})(templateCtx);
}
return value;
},
);
const stepOutputs: { [name: string]: JsonValue } = {};
@@ -138,16 +137,19 @@ export class TaskWorker {
}
}
const output = Object.fromEntries(
Object.entries(task.spec.output).map(([name, templateStr]) => {
const value = handlebars.compile(templateStr, {
noEscape: true,
strict: true,
data: false,
preventIndent: true,
})(templateCtx);
return [name, value];
}),
const output = JSON.parse(
JSON.stringify(task.spec.output),
(_key, value) => {
if (typeof value === 'string') {
return handlebars.compile(value, {
noEscape: true,
strict: true,
data: false,
preventIndent: true,
})(templateCtx);
}
return value;
},
);
await task.complete('completed', { output });
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { resolve as resolvePath } from 'path';
import { resolve as resolvePath, dirname } from 'path';
import { JsonValue } from '@backstage/config';
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import { Logger } from 'winston';
@@ -39,7 +39,7 @@ export function templateEntityToSpec(
let url: string;
if (protocol === 'file') {
const path = resolvePath(location, template.spec.path || '.');
const path = resolvePath(dirname(location), template.spec.path || '.');
url = `file://${path}`;
} else {
@@ -86,6 +86,7 @@ export function templateEntityToSpec(
});
return {
values: {},
steps,
output: {
remoteUrl: '{{ steps.publish.output.remoteUrl }}',
@@ -43,11 +43,12 @@ export type DbTaskEventRow = {
};
export type TaskSpec = {
values: JsonObject;
steps: Array<{
id: string;
name: string;
action: string;
parameters?: { [name: string]: JsonValue };
parameters?: JsonObject;
}>;
output: { [name: string]: string };
};
@@ -45,10 +45,16 @@ import {
import { registerLegacyActions } from '../scaffolder/stages/legacy';
import { getWorkingDirectory } from './helpers';
import {
InputError,
NotFoundError,
PluginDatabaseManager,
} from '@backstage/backend-common';
import { CatalogApi } from '@backstage/catalog-client';
import {
TemplateEntityV1alpha1,
TemplateEntityV1beta2,
Entity,
} from '@backstage/catalog-model';
export interface RouterOptions {
preparers: PreparerBuilder;
@@ -62,6 +68,21 @@ export interface RouterOptions {
catalogClient: CatalogApi;
}
function isAlpha1Template(
entity: TemplateEntityV1alpha1 | TemplateEntityV1beta2,
): entity is TemplateEntityV1alpha1 {
return (
entity.apiVersion === 'backstage.io/v1alpha1' ||
entity.apiVersion === 'backstage.io/v1beta1'
);
}
function isBeta2Template(
entity: TemplateEntityV1alpha1 | TemplateEntityV1beta2,
): entity is TemplateEntityV1beta2 {
return entity.apiVersion === 'backstage.io/v1beta2';
}
export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
@@ -143,6 +164,11 @@ export async function createRouter(
const template = await entityClient.findTemplate(templateName, {
token: getBearerToken(req.headers.authorization),
});
if (!isAlpha1Template(template)) {
throw new InputError(
`This endpoint does not support templates with version ${template.apiVersion}`,
);
}
const validationResult: ValidatorResult = validate(
values,
@@ -231,6 +257,77 @@ export async function createRouter(
// NOTE: The v2 API is unstable
router
.get(
'/v2/templates/:namespace/:kind/:name/parameter-schema',
async (req, res) => {
const { namespace, kind, name } = req.params;
if (namespace !== 'default') {
throw new InputError(
`Invalid namespace, only 'default' namespace is supported`,
);
}
if (kind.toLowerCase() !== 'template') {
throw new InputError(
`Invalid kind, only 'Template' kind is supported`,
);
}
const template = await entityClient.findTemplate(name);
if (isBeta2Template(template)) {
const parameters = [template.spec.parameters ?? []].flat();
res.json({
title: template.metadata.title ?? template.metadata.name,
steps: parameters.map(schema => ({
title: schema.title ?? 'Fill in template parameters',
schema,
})),
});
} else if (isAlpha1Template(template)) {
res.json({
title: template.metadata.title ?? template.metadata.name,
steps: [
{
title: 'Fill in template parameters',
schema: template.spec.schema,
},
{
title: 'Choose owner and repo',
schema: {
type: 'object',
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string',
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string',
title: 'Store path',
description:
'A full URL to the repository that should be created. e.g https://github.com/backstage/new-repo',
},
access: {
type: 'string',
title: 'Access',
description:
'Who should have access, in org/team or user format',
},
},
},
},
],
});
} else {
throw new InputError(
`Unsupported apiVersion field in schema entity, ${
(template as Entity).apiVersion
}`,
);
}
},
)
.post('/v2/tasks', async (req, res) => {
const templateName: string = req.body.templateName;
const values: TemplaterValues = {
@@ -241,16 +338,43 @@ export async function createRouter(
};
const template = await entityClient.findTemplate(templateName);
const validationResult: ValidatorResult = validate(
values,
template.spec.schema,
);
let taskSpec;
if (isAlpha1Template(template)) {
const result = validate(values, template.spec.schema);
if (!validationResult.valid) {
res.status(400).json({ errors: validationResult.errors });
return;
if (!result.valid) {
res.status(400).json({ errors: result.errors });
return;
}
taskSpec = templateEntityToSpec(template, values);
} else if (isBeta2Template(template)) {
for (const parameters of [template.spec.parameters ?? []].flat()) {
const result = validate(values, parameters);
if (!result.valid) {
res.status(400).json({ errors: result.errors });
return;
}
}
taskSpec = {
values,
steps: template.spec.steps.map((step, index) => ({
...step,
id: step.id ?? `step-${index + 1}`,
name: step.name ?? step.action,
})),
output: template.spec.output ?? {},
};
} else {
throw new InputError(
`Unsupported apiVersion field in schema entity, ${
(template as Entity).apiVersion
}`,
);
}
const taskSpec = templateEntityToSpec(template, values);
const result = await taskBroker.dispatch(taskSpec);
res.status(201).json({ id: result.taskId });
+42
View File
@@ -14,6 +14,8 @@
* limitations under the License.
*/
import { EntityName } from '@backstage/catalog-model';
import { JsonObject } from '@backstage/config';
import {
createApiRef,
DiscoveryApi,
@@ -28,6 +30,14 @@ export const scaffolderApiRef = createApiRef<ScaffolderApi>({
description: 'Used to make requests towards the scaffolder backend',
});
type TemplateParameterSchema = {
title: string;
steps: Array<{
title: string;
schema: JsonObject;
}>;
};
export type LogEvent = {
type: 'log' | 'completion';
body: {
@@ -41,6 +51,10 @@ export type LogEvent = {
};
export interface ScaffolderApi {
getTemplateParameterSchema(
templateName: EntityName,
): Promise<TemplateParameterSchema>;
/**
* Executes the scaffolding of a component, given a template and its
* parameter values.
@@ -72,6 +86,34 @@ export class ScaffolderClient implements ScaffolderApi {
this.identityApi = options.identityApi;
}
async getTemplateParameterSchema(
templateName: EntityName,
): Promise<TemplateParameterSchema> {
const { namespace, kind, name } = templateName;
const token = await this.identityApi.getIdToken();
const baseUrl = await this.discoveryApi.getBaseUrl('scaffolder');
const templatePath = [namespace, kind, name]
.map(s => encodeURIComponent(s))
.join('/');
const url = `${baseUrl}/v2/templates/${templatePath}/parameter-schema`;
const response = await fetch(url, {
headers: {
...(token && { Authorization: `Bearer ${token}` }),
},
});
if (!response.ok) {
throw new Error(
`Failed to fetch template parameter schema, ${await response.text()}`,
);
}
const schema: TemplateParameterSchema = await response.json();
return schema;
}
/**
* Executes the scaffolding of a component, given a template and its
* parameter values.
@@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { JSONSchema } from '@backstage/catalog-model';
import { JsonObject } from '@backstage/config';
import { Content, StructuredMetadataTable } from '@backstage/core';
import {
Box,
@@ -28,16 +28,17 @@ import {
import { FormProps, IChangeEvent, withTheme } from '@rjsf/core';
import { Theme as MuiTheme } from '@rjsf/material-ui';
import React, { useState } from 'react';
import { transformSchemaToProps } from './schema';
const Form = withTheme(MuiTheme);
type Step = {
schema: JSONSchema;
label: string;
schema: JsonObject;
title: string;
} & Partial<Omit<FormProps<any>, 'schema'>>;
type Props = {
/**
* Steps for the form, each contains label and form schema
* Steps for the form, each contains title and form schema
*/
steps: Step[];
formData: Record<string, any>;
@@ -66,20 +67,21 @@ export const MultistepJsonForm = ({
return (
<>
<Stepper activeStep={activeStep} orientation="vertical">
{steps.map(({ label, schema, ...formProps }) => (
<StepUI key={label}>
<StepLabel>{label}</StepLabel>
<StepContent key={label}>
{steps.map(({ title, schema, ...formProps }) => (
<StepUI key={title}>
<StepLabel>
<Typography variant="h6">{title}</Typography>
</StepLabel>
<StepContent key={title}>
<Form
key={label}
noHtml5Validate
formData={formData}
onChange={onChange}
schema={schema as FormProps<any>['schema']}
onSubmit={e => {
if (e.errors.length === 0) handleNext();
}}
{...formProps}
{...transformSchemaToProps(schema)}
>
<Button disabled={activeStep === 0} onClick={handleBack}>
Back
@@ -0,0 +1,71 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { transformSchemaToProps } from './schema';
describe('transformSchemaToProps', () => {
it('transforms deep schema', () => {
const inputSchema = {
type: 'object',
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 = {
field1: {
'ui:derp': 'herp',
},
field2: {
fieldX: {
'ui:derp': 'xerp',
},
},
};
expect(transformSchemaToProps(inputSchema)).toEqual({
schema: expectedSchema,
uiSchema: expectedUiSchema,
});
});
});
@@ -0,0 +1,71 @@
/*
* Copyright 2021 Spotify AB
*
* 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/config';
import { FormProps } from '@rjsf/core';
function isObject(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function extractUiSchema(schema: JsonObject, uiSchema: JsonObject) {
const { properties } = schema;
if (!isObject(properties)) {
return;
}
for (const propName in properties) {
if (!properties.hasOwnProperty(propName)) {
continue;
}
const schemaNode = properties[propName];
if (!isObject(schemaNode)) {
continue;
}
if (schemaNode.type === 'object') {
const innerUiSchema = {};
uiSchema[propName] = innerUiSchema;
extractUiSchema(schemaNode, innerUiSchema);
} else {
for (const innerKey in schemaNode) {
if (!schemaNode.hasOwnProperty(innerKey)) {
continue;
}
const innerValue = schemaNode[innerKey];
if (innerKey.startsWith('ui:')) {
const innerUiSchema = uiSchema[propName] || {};
if (!isObject(innerUiSchema)) {
throw new TypeError('Unexpected non-object in uiSchema');
}
uiSchema[propName] = innerUiSchema;
innerUiSchema[innerKey] = innerValue;
delete schemaNode[innerKey];
}
}
}
}
}
export function transformSchemaToProps(
inputSchema: JsonObject,
): { schema: FormProps<any>['schema']; uiSchema: FormProps<any>['uiSchema'] } {
const schema = JSON.parse(JSON.stringify(inputSchema));
delete schema.title; // Rendered separately
const uiSchema = {};
extractUiSchema(schema, uiSchema);
return { schema, uiSchema };
}
@@ -14,7 +14,6 @@
* limitations under the License.
*/
import { ApiProvider, ApiRegistry, errorApiRef } from '@backstage/core';
import { CatalogApi, catalogApiRef } from '@backstage/plugin-catalog-react';
import { renderInTestApp, renderWithEffects } from '@backstage/test-utils';
import { lightTheme } from '@backstage/theme';
import { ThemeProvider } from '@material-ui/core';
@@ -25,45 +24,6 @@ import { ScaffolderApi, scaffolderApiRef } from '../../api';
import { rootRouteRef } from '../../routes';
import { TemplatePage } from './TemplatePage';
const templateMock = {
apiVersion: 'backstage.io/v1alpha1',
kind: 'Template',
metadata: {
annotations: {
'backstage.io/managed-by-location':
'file:/something/sample-templates/react-ssr-template/template.yaml',
},
name: 'react-ssr-template',
title: 'React SSR Template',
description:
'Next.js application skeleton for creating isomorphic web applications.',
tags: ['Recommended', 'React'],
uid: '55efc748-4a2b-460f-9e47-3f4fd23b46f7',
etag: 'MTM3YThjY2QtYTc1MS00MTFkLTk3YTAtNzgyMDg3MDVmZTVm',
generation: 1,
},
spec: {
processor: 'cookiecutter',
type: 'website',
path: '.',
schema: {
required: ['component_id', 'description'],
properties: {
component_id: {
title: 'Name',
type: 'string',
description: 'Unique name of the component',
},
description: {
title: 'Description',
type: 'string',
description: 'Description of the component',
},
},
},
},
};
jest.mock('react-router-dom', () => {
return {
...(jest.requireActual('react-router-dom') as any),
@@ -73,26 +33,28 @@ jest.mock('react-router-dom', () => {
};
});
const scaffolderApiMock: Partial<ScaffolderApi> = {
const scaffolderApiMock: jest.Mocked<ScaffolderApi> = {
scaffold: jest.fn(),
getTemplateParameterSchema: jest.fn(),
getTask: jest.fn(),
streamLogs: jest.fn(),
};
const catalogApiMock = {
getEntities: jest.fn() as jest.MockedFunction<CatalogApi['getEntities']>,
};
const errorApiMock = { post: jest.fn(), error$: jest.fn() };
const apis = ApiRegistry.from([
[scaffolderApiRef, scaffolderApiMock],
[errorApiRef, errorApiMock],
[catalogApiRef, catalogApiMock],
]);
describe('TemplatePage', () => {
beforeEach(() => jest.resetAllMocks());
it('renders correctly', async () => {
catalogApiMock.getEntities.mockResolvedValueOnce({ items: [templateMock] });
scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue({
title: 'React SSR Template',
steps: [],
});
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<TemplatePage />
@@ -113,7 +75,7 @@ describe('TemplatePage', () => {
const promise = new Promise<any>(res => {
resolve = res;
});
catalogApiMock.getEntities.mockReturnValueOnce(promise);
scaffolderApiMock.getTemplateParameterSchema.mockReturnValueOnce(promise);
const rendered = await renderInTestApp(
<ApiProvider apis={apis}>
<TemplatePage />
@@ -129,12 +91,17 @@ describe('TemplatePage', () => {
expect(rendered.queryByTestId('loading-progress')).toBeInTheDocument();
await act(async () => {
resolve!({ items: [templateMock] });
resolve!({
title: 'React SSR Template',
steps: [],
});
});
});
it('navigates away if no template was loaded', async () => {
catalogApiMock.getEntities.mockResolvedValueOnce({ items: [] });
scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue(
undefined as any,
);
const rendered = await renderWithEffects(
<ApiProvider apis={apis}>
@@ -13,7 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { TemplateEntityV1alpha1 } from '@backstage/catalog-model';
import {
Content,
errorApiRef,
@@ -24,9 +23,8 @@ import {
useApi,
useRouteRef,
} from '@backstage/core';
import { catalogApiRef } from '@backstage/plugin-catalog-react';
import { LinearProgress } from '@material-ui/core';
import { IChangeEvent } from '@rjsf/core';
import { FormValidation, IChangeEvent } from '@rjsf/core';
import parseGitUrl from 'git-url-parse';
import React, { useCallback, useState } from 'react';
import { generatePath, useNavigate, Navigate } from 'react-router';
@@ -36,50 +34,59 @@ import { scaffolderApiRef } from '../../api';
import { rootRouteRef } from '../../routes';
import { MultistepJsonForm } from '../MultistepJsonForm';
const useTemplate = (
templateName: string,
catalogApi: typeof catalogApiRef.T,
) => {
const { value, loading, error } = useAsync(async () => {
const response = await catalogApi.getEntities({
filter: { kind: 'Template', 'metadata.name': templateName },
});
return response.items as TemplateEntityV1alpha1[];
}, [catalogApi, templateName]);
return { template: value?.[0], loading, error };
const useTemplateParameterSchema = (templateName: string) => {
const scaffolderApi = useApi(scaffolderApiRef);
const { value, loading, error } = useAsync(
() =>
scaffolderApi.getTemplateParameterSchema({
name: templateName,
kind: 'template',
namespace: 'default',
}),
[scaffolderApi, templateName],
);
return { schema: value, loading, error };
};
const OWNER_REPO_SCHEMA = {
$schema: 'http://json-schema.org/draft-07/schema#' as const,
required: ['storePath', 'owner'],
properties: {
owner: {
type: 'string' as const,
title: 'Owner',
description: 'Who is going to own this component',
},
storePath: {
type: 'string' as const,
title: 'Store path',
description:
'A full URL to the repository that should be created. e.g https://github.com/backstage/new-repo',
},
access: {
type: 'string' as const,
title: 'Access',
description: 'Who should have access, in org/team or user format',
},
},
const storePathValidator = (
formData: { storePath?: string },
errors: FormValidation,
) => {
const { storePath } = formData;
if (!storePath) {
return errors;
}
try {
const parsedUrl = parseGitUrl(storePath);
if (!parsedUrl.resource || !parsedUrl.owner || !parsedUrl.name) {
if (parsedUrl.resource === 'dev.azure.com') {
errors.storePath.addError(
"The store path should be formatted like https://dev.azure.com/{org}/{project}/_git/{repo} for Azure URL's",
);
} else {
errors.storePath.addError(
'The store path should be a complete Git URL to the new repository location. For example: https://github.com/{owner}/{repo}',
);
}
}
} catch (ex) {
errors.storePath.addError(
`Failed validation of the store path with message ${ex.message}`,
);
}
return errors;
};
export const TemplatePage = () => {
const errorApi = useApi(errorApiRef);
const catalogApi = useApi(catalogApiRef);
const scaffolderApi = useApi(scaffolderApiRef);
const { templateName } = useParams();
const navigate = useNavigate();
const rootLink = useRouteRef(rootRouteRef);
const { template, loading } = useTemplate(templateName, catalogApi);
const { schema, loading, error } = useTemplateParameterSchema(templateName);
const [formState, setFormState] = useState({});
const handleFormReset = () => setFormState({});
@@ -98,17 +105,12 @@ export const TemplatePage = () => {
}
};
if (!loading && !template) {
errorApi.post(new Error('Template was not found.'));
if (error) {
errorApi.post(new Error(`Failed to load template, ${error}`));
return <Navigate to={rootLink()} />;
}
if (template && !template?.spec?.schema) {
errorApi.post(
new Error(
'Template schema is corrupted, please check the template.yaml file.',
),
);
if (!loading && !schema) {
errorApi.post(new Error('Template was not found.'));
return <Navigate to={rootLink()} />;
}
@@ -125,51 +127,24 @@ export const TemplatePage = () => {
/>
<Content>
{loading && <LinearProgress data-testid="loading-progress" />}
{template && (
<InfoCard title={template.metadata.title} noPadding>
{schema && (
<InfoCard title={schema.title} noPadding>
<MultistepJsonForm
formData={formState}
onChange={handleChange}
onReset={handleFormReset}
onFinish={handleCreate}
steps={[
{
label: 'Fill in template parameters',
schema: template.spec.schema,
},
{
label: 'Choose owner and repo',
schema: OWNER_REPO_SCHEMA,
validate: (formData, errors) => {
const { storePath } = formData;
try {
const parsedUrl = parseGitUrl(storePath);
if (
!parsedUrl.resource ||
!parsedUrl.owner ||
!parsedUrl.name
) {
if (parsedUrl.resource === 'dev.azure.com') {
errors.storePath.addError(
"The store path should be formatted like https://dev.azure.com/{org}/{project}/_git/{repo} for Azure URL's",
);
} else {
errors.storePath.addError(
'The store path should be a complete Git URL to the new repository location. For example: https://github.com/{owner}/{repo}',
);
}
}
} catch (ex) {
errors.storePath.addError(
`Failed validation of the store pathn with message ${ex.message}`,
);
}
return errors;
},
},
]}
steps={schema.steps.map(step => {
// TODO: Using this workaround to keep storePath validation, but we should replace
// it with a custom store path selection widget
if ((step.schema as any)?.properties?.storePath) {
return {
...step,
validate: (a, b) => storePathValidator(a, b),
};
}
return step;
})}
/>
</InfoCard>
)}