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:
@@ -0,0 +1,6 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder': patch
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Move logic for constructing the template form to the backend, using a new `./parameter-schema` endpoint that returns the form schema for a given template.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
'@backstage/catalog-model': patch
|
||||
'@backstage/plugin-catalog-backend': patch
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Add version `backstage.io/v1beta2` schema for Template entities.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@backstage/plugin-scaffolder-backend': patch
|
||||
---
|
||||
|
||||
Fixed file path resolution for templates with a file location
|
||||
@@ -0,0 +1,124 @@
|
||||
/*
|
||||
* 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 {
|
||||
TemplateEntityV1beta2,
|
||||
templateEntityV1beta2Validator as validator,
|
||||
} from './TemplateEntityV1beta2';
|
||||
|
||||
describe('templateEntityV1beta2Validator', () => {
|
||||
let entity: TemplateEntityV1beta2;
|
||||
|
||||
beforeEach(() => {
|
||||
entity = {
|
||||
apiVersion: 'backstage.io/v1beta2',
|
||||
kind: 'Template',
|
||||
metadata: {
|
||||
name: 'test',
|
||||
},
|
||||
spec: {
|
||||
type: 'website',
|
||||
parameters: {
|
||||
required: ['storePath', 'owner'],
|
||||
properties: {
|
||||
owner: {
|
||||
type: 'string',
|
||||
title: 'Owner',
|
||||
description: 'Who is going to own this component',
|
||||
},
|
||||
storePath: {
|
||||
type: 'string',
|
||||
title: 'Store path',
|
||||
description: 'GitHub store path in org/repo format',
|
||||
},
|
||||
},
|
||||
},
|
||||
steps: [
|
||||
{
|
||||
id: 'fetch',
|
||||
name: 'Fetch',
|
||||
action: 'fetch:plan',
|
||||
parameters: {
|
||||
url: './template',
|
||||
},
|
||||
},
|
||||
],
|
||||
output: {
|
||||
fetchUrl: '{{ steps.fetch.output.targetUrl }}',
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('happy path: accepts valid data', async () => {
|
||||
await expect(validator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('ignores unknown apiVersion', async () => {
|
||||
(entity as any).apiVersion = 'backstage.io/v1beta0';
|
||||
await expect(validator.check(entity)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('ignores unknown kind', async () => {
|
||||
(entity as any).kind = 'Wizard';
|
||||
await expect(validator.check(entity)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing type', async () => {
|
||||
delete (entity as any).spec.type;
|
||||
await expect(validator.check(entity)).rejects.toThrow(/type/);
|
||||
});
|
||||
|
||||
it('accepts any other type', async () => {
|
||||
(entity as any).spec.type = 'hallo';
|
||||
await expect(validator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('accepts missing parameters', async () => {
|
||||
delete (entity as any).spec.parameters;
|
||||
await expect(validator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('accepts missing outputs', async () => {
|
||||
delete (entity as any).spec.outputs;
|
||||
await expect(validator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('rejects empty type', async () => {
|
||||
(entity as any).spec.type = '';
|
||||
await expect(validator.check(entity)).rejects.toThrow(/type/);
|
||||
});
|
||||
|
||||
it('rejects missing steps', async () => {
|
||||
delete (entity as any).spec.steps;
|
||||
await expect(validator.check(entity)).rejects.toThrow(/steps/);
|
||||
});
|
||||
|
||||
it('accepts step with missing id', async () => {
|
||||
delete (entity as any).spec.steps[0].id;
|
||||
await expect(validator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('accepts step with missing name', async () => {
|
||||
delete (entity as any).spec.steps[0].name;
|
||||
await expect(validator.check(entity)).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('rejects step with missing action', async () => {
|
||||
delete (entity as any).spec.steps[0].action;
|
||||
await expect(validator.check(entity)).rejects.toThrow(/action/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* 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 type { Entity, EntityMeta } from '../entity/Entity';
|
||||
import schema from '../schema/kinds/Template.v1beta2.schema.json';
|
||||
import entitySchema from '../schema/Entity.schema.json';
|
||||
import entityMetaSchema from '../schema/EntityMeta.schema.json';
|
||||
import commonSchema from '../schema/shared/common.schema.json';
|
||||
import { ajvCompiledJsonSchemaValidator } from './util';
|
||||
import { JsonObject } from '@backstage/config';
|
||||
|
||||
const API_VERSION = ['backstage.io/v1beta2'] as const;
|
||||
const KIND = 'Template' as const;
|
||||
|
||||
export interface TemplateEntityV1beta2 extends Entity {
|
||||
apiVersion: typeof API_VERSION[number];
|
||||
kind: typeof KIND;
|
||||
metadata: EntityMeta & {
|
||||
title?: string;
|
||||
};
|
||||
spec: {
|
||||
type: string;
|
||||
parameters?: JsonObject | JsonObject[];
|
||||
steps: Array<{
|
||||
id?: string;
|
||||
name?: string;
|
||||
action: string;
|
||||
parameters?: JsonObject;
|
||||
}>;
|
||||
output?: { [name: string]: string };
|
||||
};
|
||||
}
|
||||
|
||||
export const templateEntityV1beta2Validator = ajvCompiledJsonSchemaValidator(
|
||||
KIND,
|
||||
API_VERSION,
|
||||
schema,
|
||||
[commonSchema, entityMetaSchema, entitySchema],
|
||||
);
|
||||
@@ -57,6 +57,8 @@ export type {
|
||||
TemplateEntityV1alpha1 as TemplateEntity,
|
||||
TemplateEntityV1alpha1,
|
||||
} from './TemplateEntityV1alpha1';
|
||||
export { templateEntityV1beta2Validator } from './TemplateEntityV1beta2';
|
||||
export type { TemplateEntityV1beta2 } from './TemplateEntityV1beta2';
|
||||
export { userEntityV1alpha1Validator } from './UserEntityV1alpha1';
|
||||
export type {
|
||||
UserEntityV1alpha1 as UserEntity,
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema",
|
||||
"$id": "TemplateV1beta1",
|
||||
"description": "A Template describes a scaffolding task for use with the Scaffolder. It describes the required parameters as well as a series of steps that will be taken to execute the scaffolding task.",
|
||||
"examples": [
|
||||
{
|
||||
"apiVersion": "backstage.io/v1beta1",
|
||||
"kind": "Template",
|
||||
"metadata": {
|
||||
"name": "react-ssr-template",
|
||||
"title": "React SSR Template",
|
||||
"description": "Next.js application skeleton for creating isomorphic web applications.",
|
||||
"tags": ["recommended", "react"]
|
||||
},
|
||||
"spec": {
|
||||
"owner": "artist-relations-team",
|
||||
"type": "website",
|
||||
"parameters": {
|
||||
"required": ["name", "description"],
|
||||
"properties": {
|
||||
"name": {
|
||||
"title": "Name",
|
||||
"type": "string",
|
||||
"description": "Unique name of the component"
|
||||
},
|
||||
"description": {
|
||||
"title": "Description",
|
||||
"type": "string",
|
||||
"description": "Description of the component"
|
||||
}
|
||||
}
|
||||
},
|
||||
"steps": [
|
||||
{
|
||||
"id": "fetch",
|
||||
"name": "Fetch",
|
||||
"action": "fetch:plain",
|
||||
"parameters": {
|
||||
"url": "./template"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "publish",
|
||||
"name": "Publish to GitHub",
|
||||
"action": "publish:github",
|
||||
"parameters": {
|
||||
"repoUrl": "{{ parameters.repoUrl }}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"output": {
|
||||
"catalogInfoUrl": "{{ steps.publish.output.catalogInfoUrl }}"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "Entity"
|
||||
},
|
||||
{
|
||||
"type": "object",
|
||||
"required": ["spec"],
|
||||
"properties": {
|
||||
"apiVersion": {
|
||||
"enum": ["backstage.io/v1beta2"]
|
||||
},
|
||||
"kind": {
|
||||
"enum": ["Template"]
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"title": {
|
||||
"type": "string",
|
||||
"description": "The nice display name for the template.",
|
||||
"examples": ["React SSR Template"],
|
||||
"minLength": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"type": "object",
|
||||
"required": ["type", "steps"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"description": "The type of component. This field is optional but recommended. The software catalog accepts any type value, but an organization should take great care to establish a proper taxonomy for these. Tools including Backstage itself may read this field and behave differently depending on its value. For example, a website type component may present tooling in the Backstage interface that is specific to just websites.",
|
||||
"examples": ["service", "website", "library"],
|
||||
"minLength": 1
|
||||
},
|
||||
"parameters": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object",
|
||||
"description": "The JSONSchema describing the inputs for the template."
|
||||
},
|
||||
{
|
||||
"type": "array",
|
||||
"description": "A list of separate forms to collect parameters.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"description": "The JSONSchema describing the inputs for the template."
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"steps": {
|
||||
"type": "array",
|
||||
"description": "A list of steps to execute.",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"description": "A description of the step to execute.",
|
||||
"required": ["action"],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "The ID of the step, which can be used to refer to its outputs."
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "The name of the step, which will be displayed in the UI during the scaffolding process."
|
||||
},
|
||||
"action": {
|
||||
"type": "string",
|
||||
"description": "The name of the action to execute."
|
||||
},
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"description": "A templated object describing the inputs to the action."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"description": "A templated object describing the outputs of the scaffolding task.",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user