From 81e6f5542ccf8f515882d1427b86e1380c63906c Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 12 May 2021 10:09:46 +0200 Subject: [PATCH 01/18] chore: starting to addthje scaffolder form extension point Signed-off-by: blam --- .../core-api/src/extensions/extensions.tsx | 2 +- packages/core-api/src/extensions/index.ts | 1 + plugins/scaffolder/src/extensions/index.tsx | 25 +++++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 plugins/scaffolder/src/extensions/index.tsx diff --git a/packages/core-api/src/extensions/extensions.tsx b/packages/core-api/src/extensions/extensions.tsx index 9f6412f960..d16f24c229 100644 --- a/packages/core-api/src/extensions/extensions.tsx +++ b/packages/core-api/src/extensions/extensions.tsx @@ -20,7 +20,7 @@ import { BackstagePlugin, Extension } from '../plugin/types'; import { RouteRef, useRouteRef } from '../routing'; import { attachComponentData } from './componentData'; -type ComponentLoader = +export type ComponentLoader = | { lazy: () => Promise; } diff --git a/packages/core-api/src/extensions/index.ts b/packages/core-api/src/extensions/index.ts index 26a0c597b1..2f80b1c42c 100644 --- a/packages/core-api/src/extensions/index.ts +++ b/packages/core-api/src/extensions/index.ts @@ -20,3 +20,4 @@ export { createRoutableExtension, createComponentExtension, } from './extensions'; +export type { ComponentLoader } from './extensions'; diff --git a/plugins/scaffolder/src/extensions/index.tsx b/plugins/scaffolder/src/extensions/index.tsx new file mode 100644 index 0000000000..71d762739d --- /dev/null +++ b/plugins/scaffolder/src/extensions/index.tsx @@ -0,0 +1,25 @@ +/* + * 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 { Extension, ComponentLoader } from '@backstage/core'; + +export type FormExtensionOptions = { + name: string; + component: ComponentLoader; +}; + +export function createScaffolderFormExtension< + T extends (props: any) => JSX.Element | null +>(options: FormExtensionOptions): Extension {} From 73a0f4e52b7bbd5a2cbf52672cd33165024fb6e4 Mon Sep 17 00:00:00 2001 From: blam Date: Fri, 14 May 2021 21:57:54 +0200 Subject: [PATCH 02/18] feat: started some work on making custom fields extensible using the composition API Signed-off-by: blam --- packages/app/src/App.tsx | 17 ++- packages/core-api/src/plugin/collectors.ts | 15 --- plugins/scaffolder/src/components/Router.tsx | 29 +++-- .../components/TemplatePage/TemplatePage.tsx | 12 +- plugins/scaffolder/src/extensions/index.tsx | 105 +++++++++++++++++- plugins/scaffolder/src/index.ts | 2 + plugins/scaffolder/src/plugin.ts | 33 ++++++ 7 files changed, 182 insertions(+), 31 deletions(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index c5e5033973..e01844e233 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -42,7 +42,12 @@ import { GcpProjectsPage } from '@backstage/plugin-gcp-projects'; import { GraphiQLPage } from '@backstage/plugin-graphiql'; import { LighthousePage } from '@backstage/plugin-lighthouse'; import { NewRelicPage } from '@backstage/plugin-newrelic'; -import { ScaffolderPage, scaffolderPlugin } from '@backstage/plugin-scaffolder'; +import { + ScaffolderPage, + scaffolderPlugin, + OwnerPicker, + RepoUrlPicker, +} from '@backstage/plugin-scaffolder'; import { SearchPage, SearchPageNext } from '@backstage/plugin-search'; import { TechRadarPage } from '@backstage/plugin-tech-radar'; import { TechdocsPage } from '@backstage/plugin-techdocs'; @@ -107,7 +112,15 @@ const routes = ( } /> } /> - } /> + + + + + } + /> } /> ( - - } /> - } /> - } /> - } /> - -); +export const Router = ({ children }: React.PropsWithChildren<{}>) => { + const [state, dispatch] = React.useReducer(extensionsReducer, { + fields: [], + }); + return ( + + {children} + + } /> + } /> + } /> + } /> + + + ); +}; diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 12930db2cc..fb9ae1fb3d 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -26,7 +26,7 @@ import { import { LinearProgress } from '@material-ui/core'; import { FormValidation, IChangeEvent } from '@rjsf/core'; import parseGitUrl from 'git-url-parse'; -import React, { useCallback, useState } from 'react'; +import React, { useCallback, useContext, useState } from 'react'; import { generatePath, useNavigate, Navigate } from 'react-router'; import { useParams } from 'react-router-dom'; import { useAsync } from 'react-use'; @@ -35,6 +35,7 @@ import { rootRouteRef } from '../../routes'; import { MultistepJsonForm } from '../MultistepJsonForm'; import { RepoUrlPicker, OwnerPicker } from '../fields'; import { JsonObject } from '@backstage/config'; +import { ExtensionContext } from '../../extensions'; const useTemplateParameterSchema = (templateName: string) => { const scaffolderApi = useApi(scaffolderApiRef); @@ -148,6 +149,7 @@ export const TemplatePage = () => { const { schema, loading, error } = useTemplateParameterSchema(templateName); const [formState, setFormState] = useState({}); const handleFormReset = () => setFormState({}); + const { state } = useContext(ExtensionContext)!; const handleChange = useCallback( (e: IChangeEvent) => setFormState(e.formData), @@ -173,6 +175,12 @@ export const TemplatePage = () => { return ; } + const customFields = state!.fields.reduce((acc, next) => { + acc[next.name] = next.component; + return acc; + }, {} as Record); + + console.log(customFields); return (
{ > = { +import { + childDiscoverer, + routeElementDiscoverer, + traverseElementTree, + createCollector, +} from '@backstage/core-api/src/extensions/traversal'; + +export type FieldExtensionOptions = { name: string; component: ComponentLoader; + validation: (data: JsonValue, field: FieldValidation) => void; }; -export function createScaffolderFormExtension< +export function createScaffolderFieldExtension< T extends (props: any) => JSX.Element | null ->(options: FormExtensionOptions): Extension {} +>(options: FieldExtensionOptions): Extension { + const componentInData = + 'lazy' in options.component + ? React.lazy(() => + options.component.lazy().then(component => ({ default: component })), + ) + : options.component.sync; + + return createReactExtension({ + data: { + 'scaffolder.extensions.field.v1': { + ...options, + component: componentInData, + }, + }, + component: options.component, + }); +} + +export type ExtensionState = { + fields: FieldExtensionOptions[]; +}; +export type RegisterFieldExtensionAction = { + type: 'fields'; + data: FieldExtensionOptions[]; +}; + +export type ExtensionAction = RegisterFieldExtensionAction; +export type ExtensionDispatch = (action: ExtensionAction) => void; + +export const ExtensionContext = React.createContext< + { state: ExtensionState; dispatch: ExtensionDispatch } | undefined +>(undefined); + +export const extensionsReducer = ( + state: ExtensionState, + action: ExtensionAction, +): ExtensionState => { + if (action.type === 'fields') { + return { + ...state, + fields: [...state.fields, ...action.data], + }; + } + return state; +}; + +export const ExtensionCollector = ({ + children, +}: React.PropsWithChildren<{}>) => { + const context = React.useContext(ExtensionContext); + + if (!context) { + throw new Error('ExtensionsCollector must be used in a ExtensionsContext'); + } + + useMount(() => { + const { fields } = traverseElementTree({ + root: children, + discoverers: [childDiscoverer, routeElementDiscoverer], + collectors: { + fields: createCollector( + () => [] as FieldExtensionOptions[], + (acc, node) => { + const data = getComponentData>( + node, + 'scaffolder.extensions.field.v1', + ); + + if (data) { + acc.push(data); + } + }, + ), + }, + }); + context.dispatch({ data: fields, type: 'fields' }); + }); + + return null; +}; diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index e0b574e6c6..66eff47e5d 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -18,6 +18,8 @@ export { scaffolderPlugin, scaffolderPlugin as plugin, ScaffolderPage, + OwnerPicker, + RepoUrlPicker, } from './plugin'; export type { ScaffolderApi } from './api'; export { ScaffolderClient, scaffolderApiRef } from './api'; diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index be47daec46..460f3f0e60 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -14,6 +14,7 @@ * limitations under the License. */ +import { JsonValue } from '@backstage/config'; import { createApiFactory, createPlugin, @@ -21,8 +22,11 @@ import { discoveryApiRef, identityApiRef, } from '@backstage/core'; +import { OwnerPicker as OwnerPickerComponent } from './components/fields/OwnerPicker'; +import { RepoUrlPicker as RepoUrlPickerComponent } from './components/fields/RepoUrlPicker'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { scaffolderApiRef, ScaffolderClient } from './api'; +import { createScaffolderFieldExtension } from './extensions'; import { rootRouteRef, registerComponentRouteRef } from './routes'; export const scaffolderPlugin = createPlugin({ @@ -47,6 +51,35 @@ export const scaffolderPlugin = createPlugin({ }, }); +export const RepoUrlPicker = scaffolderPlugin.provide( + createScaffolderFieldExtension({ + // TODO: work out how to type this component part so we can enforce FieldComponent from RJSF + component: { + sync: RepoUrlPickerComponent, + }, + name: 'RepoUrlPicker', + validation: (value: JsonValue, validation) => { + try { + const { host, searchParams } = new URL(`https://${value}`); + if (!host || !searchParams.get('owner') || !searchParams.get('repo')) { + validation.addError('Incomplete repository location provided'); + } + } catch { + validation.addError('Unable to parse the Repository URL'); + } + }, + }), +); + +export const OwnerPicker = scaffolderPlugin.provide( + createScaffolderFieldExtension({ + component: { + sync: OwnerPickerComponent, + }, + name: 'OwnerPicker', + validation: () => {}, + }), +); export const ScaffolderPage = scaffolderPlugin.provide( createRoutableExtension({ component: () => import('./components/Router').then(m => m.Router), From 6bba66c0d65e879c33d4357a96006813df50179d Mon Sep 17 00:00:00 2001 From: blam Date: Sat, 15 May 2021 22:10:26 +0200 Subject: [PATCH 03/18] chore: added some more refactor to validate now TODO Signed-off-by: blam --- .../components/TemplatePage/TemplatePage.tsx | 59 ++++++++++--------- plugins/scaffolder/src/extensions/index.tsx | 47 ++++++++------- plugins/scaffolder/src/plugin.ts | 8 +-- 3 files changed, 56 insertions(+), 58 deletions(-) diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index fb9ae1fb3d..18e6962800 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -24,7 +24,12 @@ import { useRouteRef, } from '@backstage/core'; import { LinearProgress } from '@material-ui/core'; -import { FormValidation, IChangeEvent } from '@rjsf/core'; +import { + Field, + FieldValidation, + FormValidation, + IChangeEvent, +} from '@rjsf/core'; import parseGitUrl from 'git-url-parse'; import React, { useCallback, useContext, useState } from 'react'; import { generatePath, useNavigate, Navigate } from 'react-router'; @@ -34,7 +39,7 @@ import { scaffolderApiRef } from '../../api'; import { rootRouteRef } from '../../routes'; import { MultistepJsonForm } from '../MultistepJsonForm'; import { RepoUrlPicker, OwnerPicker } from '../fields'; -import { JsonObject } from '@backstage/config'; +import { JsonObject, JsonValue } from '@backstage/config'; import { ExtensionContext } from '../../extensions'; const useTemplateParameterSchema = (templateName: string) => { @@ -55,7 +60,10 @@ function isObject(obj: unknown): obj is JsonObject { return typeof obj === 'object' && obj !== null && !Array.isArray(obj); } -export const createValidator = (rootSchema: JsonObject) => { +export const createValidator = ( + rootSchema: JsonObject, + validators: Record void>, +) => { function validate( schema: JsonObject, formData: JsonObject, @@ -67,7 +75,7 @@ export const createValidator = (rootSchema: JsonObject) => { } for (const [key, propData] of Object.entries(formData)) { - const propErrors = errors[key]; + const propValidation = errors[key]; if (isObject(propData)) { const propSchemaProps = schemaProps[key]; @@ -75,27 +83,16 @@ export const createValidator = (rootSchema: JsonObject) => { validate( propSchemaProps, propData as JsonObject, - propErrors as FormValidation, + propValidation as FormValidation, ); } } else { const propSchema = schemaProps[key]; - if ( - isObject(propSchema) && - propSchema['ui:field'] === 'RepoUrlPicker' - ) { - try { - const { host, searchParams } = new URL(`https://${propData}`); - if ( - !host || - !searchParams.get('owner') || - !searchParams.get('repo') - ) { - propErrors.addError('Incomplete repository location provided'); - } - } catch { - propErrors.addError('Unable to parse the Repository URL'); - } + const fieldName = + isObject(propSchema) && (propSchema['ui:field'] as string); + const validator = fieldName && validators[fieldName]; + if (validator) { + validator(propData, propValidation); } } } @@ -175,12 +172,18 @@ export const TemplatePage = () => { return ; } - const customFields = state!.fields.reduce((acc, next) => { - acc[next.name] = next.component; - return acc; - }, {} as Record); + const { components, validation } = state!.fields.reduce( + (acc, next) => { + acc.components[next.name] = next.component; + acc.validation[next.name] = next.validation; + return acc; + }, + { components: {}, validation: {} } as { + components: Record; + validation: Record void>; + }, + ); - console.log(customFields); return (
{ > { return { ...step, - validate: createValidator(step.schema), + validate: createValidator(step.schema, validation), }; })} /> diff --git a/plugins/scaffolder/src/extensions/index.tsx b/plugins/scaffolder/src/extensions/index.tsx index 32fa536507..ac83985411 100644 --- a/plugins/scaffolder/src/extensions/index.tsx +++ b/plugins/scaffolder/src/extensions/index.tsx @@ -19,8 +19,9 @@ import { ComponentLoader, createReactExtension, getComponentData, + attachComponentData, } from '@backstage/core'; -import { FieldValidation } from '@rjsf/core'; +import { FieldValidation, Field } from '@rjsf/core'; import React from 'react'; import { useMount } from 'react-use'; @@ -31,39 +32,37 @@ import { createCollector, } from '@backstage/core-api/src/extensions/traversal'; -export type FieldExtensionOptions = { +export type FieldExtensionOptions = { name: string; - component: ComponentLoader; + component: Field; validation: (data: JsonValue, field: FieldValidation) => void; }; -export function createScaffolderFieldExtension< - T extends (props: any) => JSX.Element | null ->(options: FieldExtensionOptions): Extension { - const componentInData = - 'lazy' in options.component - ? React.lazy(() => - options.component.lazy().then(component => ({ default: component })), - ) - : options.component.sync; +export function createScaffolderFieldExtension( + options: FieldExtensionOptions, +): Extension { + const extensionData = { + 'scaffolder.extensions.field.v1': options, + }; - return createReactExtension({ - data: { - 'scaffolder.extensions.field.v1': { - ...options, - component: componentInData, - }, + const Result = (props: any) => ; + + for (const [key, value] of Object.entries(extensionData)) { + attachComponentData(Result, key, value); + } + return { + expose() { + return Result; }, - component: options.component, - }); + }; } export type ExtensionState = { - fields: FieldExtensionOptions[]; + fields: FieldExtensionOptions[]; }; export type RegisterFieldExtensionAction = { type: 'fields'; - data: FieldExtensionOptions[]; + data: FieldExtensionOptions[]; }; export type ExtensionAction = RegisterFieldExtensionAction; @@ -101,9 +100,9 @@ export const ExtensionCollector = ({ discoverers: [childDiscoverer, routeElementDiscoverer], collectors: { fields: createCollector( - () => [] as FieldExtensionOptions[], + () => [] as FieldExtensionOptions[], (acc, node) => { - const data = getComponentData>( + const data = getComponentData( node, 'scaffolder.extensions.field.v1', ); diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index 460f3f0e60..85456b7e2e 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -54,9 +54,7 @@ export const scaffolderPlugin = createPlugin({ export const RepoUrlPicker = scaffolderPlugin.provide( createScaffolderFieldExtension({ // TODO: work out how to type this component part so we can enforce FieldComponent from RJSF - component: { - sync: RepoUrlPickerComponent, - }, + component: RepoUrlPickerComponent, name: 'RepoUrlPicker', validation: (value: JsonValue, validation) => { try { @@ -73,9 +71,7 @@ export const RepoUrlPicker = scaffolderPlugin.provide( export const OwnerPicker = scaffolderPlugin.provide( createScaffolderFieldExtension({ - component: { - sync: OwnerPickerComponent, - }, + component: OwnerPickerComponent, name: 'OwnerPicker', validation: () => {}, }), From feb0cc5f54a2dbdc109e28e827f6fd9488cca366 Mon Sep 17 00:00:00 2001 From: blam Date: Sun, 16 May 2021 16:45:05 +0200 Subject: [PATCH 04/18] feat(scaffolder/fields): try a simpler way to register Signed-off-by: blam --- plugins/scaffolder/src/api.ts | 35 ++++++- plugins/scaffolder/src/components/Router.tsx | 14 +-- .../components/TemplatePage/TemplatePage.tsx | 39 +++----- plugins/scaffolder/src/extensions/index.tsx | 94 ++----------------- 4 files changed, 55 insertions(+), 127 deletions(-) diff --git a/plugins/scaffolder/src/api.ts b/plugins/scaffolder/src/api.ts index c7b1594071..4a0ecc0161 100644 --- a/plugins/scaffolder/src/api.ts +++ b/plugins/scaffolder/src/api.ts @@ -15,7 +15,7 @@ */ import { EntityName } from '@backstage/catalog-model'; -import { JsonObject } from '@backstage/config'; +import { JsonObject, JsonValue } from '@backstage/config'; import { createApiRef, DiscoveryApi, @@ -24,6 +24,7 @@ import { } from '@backstage/core'; import { ResponseError } from '@backstage/errors'; import { ScmIntegrationRegistry } from '@backstage/integration'; +import { Field, FieldValidation } from '@rjsf/core'; import ObservableImpl from 'zen-observable'; import { ListActionsResponse, ScaffolderTask, Status } from './types'; @@ -52,6 +53,12 @@ export type LogEvent = { taskId: string; }; +export type CustomField = { + name: string; + component: Field; + validation: (data: JsonValue, field: FieldValidation) => void; +}; + export interface ScaffolderApi { getTemplateParameterSchema( templateName: EntityName, @@ -75,6 +82,13 @@ export interface ScaffolderApi { // Returns a list of all installed actions. listActions(): Promise; + // Register Custom Fields + registerCustomField(field: CustomField): void; + getCustomFields(): { + components: Map; + validators: Map void>; + }; + streamLogs({ taskId, after, @@ -88,6 +102,7 @@ export class ScaffolderClient implements ScaffolderApi { private readonly discoveryApi: DiscoveryApi; private readonly identityApi: IdentityApi; private readonly scmIntegrationsApi: ScmIntegrationRegistry; + private readonly customFields: Map = new Map(); constructor(options: { discoveryApi: DiscoveryApi; @@ -136,6 +151,24 @@ export class ScaffolderClient implements ScaffolderApi { return schema; } + registerCustomField(field: CustomField) { + this.customFields.set(field.name, field); + } + + getCustomFields() { + return [...this.customFields.entries()].reduce( + (previous, [name, field]) => { + previous.components.set(name, field.component); + previous.validators.set(name, field.validation); + return previous; + }, + { + components: new Map(), + validators: new Map(), + }, + ); + } + /** * Executes the scaffolding of a component, given a template and its * parameter values. diff --git a/plugins/scaffolder/src/components/Router.tsx b/plugins/scaffolder/src/components/Router.tsx index deff014120..aeb90b37d4 100644 --- a/plugins/scaffolder/src/components/Router.tsx +++ b/plugins/scaffolder/src/components/Router.tsx @@ -20,25 +20,17 @@ import { ScaffolderPage } from './ScaffolderPage'; import { TemplatePage } from './TemplatePage'; import { TaskPage } from './TaskPage'; import { ActionsPage } from './ActionsPage'; -import { - ExtensionContext, - ExtensionCollector, - extensionsReducer, -} from '../extensions'; export const Router = ({ children }: React.PropsWithChildren<{}>) => { - const [state, dispatch] = React.useReducer(extensionsReducer, { - fields: [], - }); return ( - - {children} + <> } /> } /> } /> } /> - + {children} + ); }; diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 18e6962800..0aad67a1c0 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -24,23 +24,16 @@ import { useRouteRef, } from '@backstage/core'; import { LinearProgress } from '@material-ui/core'; -import { - Field, - FieldValidation, - FormValidation, - IChangeEvent, -} from '@rjsf/core'; +import { FieldValidation, FormValidation, IChangeEvent } from '@rjsf/core'; import parseGitUrl from 'git-url-parse'; -import React, { useCallback, useContext, useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { generatePath, useNavigate, Navigate } from 'react-router'; import { useParams } from 'react-router-dom'; import { useAsync } from 'react-use'; import { scaffolderApiRef } from '../../api'; import { rootRouteRef } from '../../routes'; import { MultistepJsonForm } from '../MultistepJsonForm'; -import { RepoUrlPicker, OwnerPicker } from '../fields'; import { JsonObject, JsonValue } from '@backstage/config'; -import { ExtensionContext } from '../../extensions'; const useTemplateParameterSchema = (templateName: string) => { const scaffolderApi = useApi(scaffolderApiRef); @@ -62,7 +55,10 @@ function isObject(obj: unknown): obj is JsonObject { export const createValidator = ( rootSchema: JsonObject, - validators: Record void>, + validators: Map< + string, + (value: JsonValue, validation: FieldValidation) => void + >, ) => { function validate( schema: JsonObject, @@ -90,9 +86,8 @@ export const createValidator = ( const propSchema = schemaProps[key]; const fieldName = isObject(propSchema) && (propSchema['ui:field'] as string); - const validator = fieldName && validators[fieldName]; - if (validator) { - validator(propData, propValidation); + if (fieldName && validators.has(fieldName)) { + validators.get(fieldName)!(propData as JsonValue, propValidation); } } } @@ -146,7 +141,6 @@ export const TemplatePage = () => { const { schema, loading, error } = useTemplateParameterSchema(templateName); const [formState, setFormState] = useState({}); const handleFormReset = () => setFormState({}); - const { state } = useContext(ExtensionContext)!; const handleChange = useCallback( (e: IChangeEvent) => setFormState(e.formData), @@ -172,18 +166,7 @@ export const TemplatePage = () => { return ; } - const { components, validation } = state!.fields.reduce( - (acc, next) => { - acc.components[next.name] = next.component; - acc.validation[next.name] = next.validation; - return acc; - }, - { components: {}, validation: {} } as { - components: Record; - validation: Record void>; - }, - ); - + const { components, validators } = scaffolderApi.getCustomFields(); return (
{ > { return { ...step, - validate: createValidator(step.schema, validation), + validate: createValidator(step.schema, validators), }; })} /> diff --git a/plugins/scaffolder/src/extensions/index.tsx b/plugins/scaffolder/src/extensions/index.tsx index ac83985411..d448a89960 100644 --- a/plugins/scaffolder/src/extensions/index.tsx +++ b/plugins/scaffolder/src/extensions/index.tsx @@ -14,23 +14,9 @@ * limitations under the License. */ import { JsonValue } from '@backstage/config'; -import { - Extension, - ComponentLoader, - createReactExtension, - getComponentData, - attachComponentData, -} from '@backstage/core'; +import { Extension, useApi } from '@backstage/core'; import { FieldValidation, Field } from '@rjsf/core'; -import React from 'react'; -import { useMount } from 'react-use'; - -import { - childDiscoverer, - routeElementDiscoverer, - traverseElementTree, - createCollector, -} from '@backstage/core-api/src/extensions/traversal'; +import { scaffolderApiRef } from '../api'; export type FieldExtensionOptions = { name: string; @@ -41,81 +27,15 @@ export type FieldExtensionOptions = { export function createScaffolderFieldExtension( options: FieldExtensionOptions, ): Extension { - const extensionData = { - 'scaffolder.extensions.field.v1': options, + const WrappingRegister = () => { + const scaffolderApi = useApi(scaffolderApiRef); + scaffolderApi.registerCustomField(options); + return null; }; - const Result = (props: any) => ; - - for (const [key, value] of Object.entries(extensionData)) { - attachComponentData(Result, key, value); - } return { expose() { - return Result; + return WrappingRegister; }, }; } - -export type ExtensionState = { - fields: FieldExtensionOptions[]; -}; -export type RegisterFieldExtensionAction = { - type: 'fields'; - data: FieldExtensionOptions[]; -}; - -export type ExtensionAction = RegisterFieldExtensionAction; -export type ExtensionDispatch = (action: ExtensionAction) => void; - -export const ExtensionContext = React.createContext< - { state: ExtensionState; dispatch: ExtensionDispatch } | undefined ->(undefined); - -export const extensionsReducer = ( - state: ExtensionState, - action: ExtensionAction, -): ExtensionState => { - if (action.type === 'fields') { - return { - ...state, - fields: [...state.fields, ...action.data], - }; - } - return state; -}; - -export const ExtensionCollector = ({ - children, -}: React.PropsWithChildren<{}>) => { - const context = React.useContext(ExtensionContext); - - if (!context) { - throw new Error('ExtensionsCollector must be used in a ExtensionsContext'); - } - - useMount(() => { - const { fields } = traverseElementTree({ - root: children, - discoverers: [childDiscoverer, routeElementDiscoverer], - collectors: { - fields: createCollector( - () => [] as FieldExtensionOptions[], - (acc, node) => { - const data = getComponentData( - node, - 'scaffolder.extensions.field.v1', - ); - - if (data) { - acc.push(data); - } - }, - ), - }, - }); - context.dispatch({ data: fields, type: 'fields' }); - }); - - return null; -}; From 11248047443c5dc945abac01408d48cfa8e2d69b Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 18 May 2021 21:26:10 +0200 Subject: [PATCH 05/18] chore: re-add the attachComponentData again, it's a little nicer for now Signed-off-by: blam --- plugins/scaffolder/src/components/Router.tsx | 3 +- plugins/scaffolder/src/extensions/index.tsx | 34 ++++++++++---------- plugins/scaffolder/src/plugin.ts | 22 ++++++------- 3 files changed, 29 insertions(+), 30 deletions(-) diff --git a/plugins/scaffolder/src/components/Router.tsx b/plugins/scaffolder/src/components/Router.tsx index aeb90b37d4..fab5eb50c8 100644 --- a/plugins/scaffolder/src/components/Router.tsx +++ b/plugins/scaffolder/src/components/Router.tsx @@ -21,7 +21,7 @@ import { TemplatePage } from './TemplatePage'; import { TaskPage } from './TaskPage'; import { ActionsPage } from './ActionsPage'; -export const Router = ({ children }: React.PropsWithChildren<{}>) => { +export const Router = () => { return ( <> @@ -30,7 +30,6 @@ export const Router = ({ children }: React.PropsWithChildren<{}>) => { } /> } /> - {children} ); }; diff --git a/plugins/scaffolder/src/extensions/index.tsx b/plugins/scaffolder/src/extensions/index.tsx index d448a89960..acc3de38a0 100644 --- a/plugins/scaffolder/src/extensions/index.tsx +++ b/plugins/scaffolder/src/extensions/index.tsx @@ -13,29 +13,29 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { JsonValue } from '@backstage/config'; -import { Extension, useApi } from '@backstage/core'; -import { FieldValidation, Field } from '@rjsf/core'; -import { scaffolderApiRef } from '../api'; +import { Extension, attachComponentData } from '@backstage/core'; +import { FieldValidation, FieldProps } from '@rjsf/core'; -export type FieldExtensionOptions = { +export type FieldExtensionOptions = { name: string; - component: Field; - validation: (data: JsonValue, field: FieldValidation) => void; + component: (props: FieldProps) => JSX.Element | null; + validation?: (data: T, field: FieldValidation) => void; }; -export function createScaffolderFieldExtension( - options: FieldExtensionOptions, -): Extension { - const WrappingRegister = () => { - const scaffolderApi = useApi(scaffolderApiRef); - scaffolderApi.registerCustomField(options); - return null; - }; - +export function createScaffolderFieldExtension( + options: FieldExtensionOptions, +): Extension { return { expose() { - return WrappingRegister; + const FieldExtensionDataHolder: any = () => null; + + attachComponentData( + FieldExtensionDataHolder, + 'scaffolder.extensions.field.v1', + options, + ); + + return FieldExtensionDataHolder; }, }; } diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index 85456b7e2e..0177425098 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -22,8 +22,8 @@ import { discoveryApiRef, identityApiRef, } from '@backstage/core'; -import { OwnerPicker as OwnerPickerComponent } from './components/fields/OwnerPicker'; -import { RepoUrlPicker as RepoUrlPickerComponent } from './components/fields/RepoUrlPicker'; +import { OwnerPicker } from './components/fields/OwnerPicker'; +import { RepoUrlPicker } from './components/fields/RepoUrlPicker'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { scaffolderApiRef, ScaffolderClient } from './api'; import { createScaffolderFieldExtension } from './extensions'; @@ -51,12 +51,12 @@ export const scaffolderPlugin = createPlugin({ }, }); -export const RepoUrlPicker = scaffolderPlugin.provide( - createScaffolderFieldExtension({ - // TODO: work out how to type this component part so we can enforce FieldComponent from RJSF - component: RepoUrlPickerComponent, +export const RepoUrlPickerFieldExtension = scaffolderPlugin.provide( + createScaffolderFieldExtension({ + // TODO(blam): work out how to fix these types properly. + component: RepoUrlPicker, name: 'RepoUrlPicker', - validation: (value: JsonValue, validation) => { + validation: (value, validation) => { try { const { host, searchParams } = new URL(`https://${value}`); if (!host || !searchParams.get('owner') || !searchParams.get('repo')) { @@ -69,11 +69,11 @@ export const RepoUrlPicker = scaffolderPlugin.provide( }), ); -export const OwnerPicker = scaffolderPlugin.provide( - createScaffolderFieldExtension({ - component: OwnerPickerComponent, +export const OwnerPickerFieldExtension = scaffolderPlugin.provide( + createScaffolderFieldExtension({ + // TODO(blam): work out how to fix these types properly. + component: OwnerPicker, name: 'OwnerPicker', - validation: () => {}, }), ); export const ScaffolderPage = scaffolderPlugin.provide( From bcead6b7992f542c0df68c04c8a2b4b45555cbbe Mon Sep 17 00:00:00 2001 From: blam Date: Tue, 18 May 2021 22:08:40 +0200 Subject: [PATCH 06/18] chore: simplify logic a little more and use getComponentData Signed-off-by: blam --- packages/app/src/App.tsx | 17 ++++----- plugins/scaffolder/src/api.ts | 26 -------------- plugins/scaffolder/src/components/Router.tsx | 35 ++++++++++++++----- .../components/TemplatePage/TemplatePage.tsx | 21 ++++++++--- plugins/scaffolder/src/extensions/index.tsx | 2 +- plugins/scaffolder/src/index.ts | 4 +-- 6 files changed, 51 insertions(+), 54 deletions(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index e01844e233..2216dc9d01 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -45,8 +45,8 @@ import { NewRelicPage } from '@backstage/plugin-newrelic'; import { ScaffolderPage, scaffolderPlugin, - OwnerPicker, - RepoUrlPicker, + OwnerPickerFieldExtension, + RepoUrlPickerFieldExtension, } from '@backstage/plugin-scaffolder'; import { SearchPage, SearchPageNext } from '@backstage/plugin-search'; import { TechRadarPage } from '@backstage/plugin-tech-radar'; @@ -112,15 +112,10 @@ const routes = ( } /> } /> - - - - - } - /> + }> + + + } /> ; - // Register Custom Fields - registerCustomField(field: CustomField): void; - getCustomFields(): { - components: Map; - validators: Map void>; - }; - streamLogs({ taskId, after, @@ -102,7 +95,6 @@ export class ScaffolderClient implements ScaffolderApi { private readonly discoveryApi: DiscoveryApi; private readonly identityApi: IdentityApi; private readonly scmIntegrationsApi: ScmIntegrationRegistry; - private readonly customFields: Map = new Map(); constructor(options: { discoveryApi: DiscoveryApi; @@ -151,24 +143,6 @@ export class ScaffolderClient implements ScaffolderApi { return schema; } - registerCustomField(field: CustomField) { - this.customFields.set(field.name, field); - } - - getCustomFields() { - return [...this.customFields.entries()].reduce( - (previous, [name, field]) => { - previous.components.set(name, field.component); - previous.validators.set(name, field.validation); - return previous; - }, - { - components: new Map(), - validators: new Map(), - }, - ); - } - /** * Executes the scaffolding of a component, given a template and its * parameter values. diff --git a/plugins/scaffolder/src/components/Router.tsx b/plugins/scaffolder/src/components/Router.tsx index fab5eb50c8..8abdc7c09f 100644 --- a/plugins/scaffolder/src/components/Router.tsx +++ b/plugins/scaffolder/src/components/Router.tsx @@ -15,21 +15,38 @@ */ import React from 'react'; -import { Routes, Route } from 'react-router'; +import { Routes, Route, useOutlet } from 'react-router'; import { ScaffolderPage } from './ScaffolderPage'; import { TemplatePage } from './TemplatePage'; import { TaskPage } from './TaskPage'; import { ActionsPage } from './ActionsPage'; +import { getComponentData } from '@backstage/core'; +import { FieldExtensionOptions } from '../extensions'; export const Router = () => { + const children = useOutlet(); + const fieldExtensions = React.Children.map(children ?? [], child => { + if (!React.isValidElement(child)) { + return null; + } + + const data = getComponentData>( + child, + 'scaffolder.extensions.field.v1', + ); + + return data; + }).filter(Boolean); + return ( - <> - - } /> - } /> - } /> - } /> - - + + } /> + } + /> + } /> + } /> + ); }; diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 0aad67a1c0..00d4fbae16 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -34,6 +34,7 @@ import { scaffolderApiRef } from '../../api'; import { rootRouteRef } from '../../routes'; import { MultistepJsonForm } from '../MultistepJsonForm'; import { JsonObject, JsonValue } from '@backstage/config'; +import { FieldExtensionOptions } from '../../extensions'; const useTemplateParameterSchema = (templateName: string) => { const scaffolderApi = useApi(scaffolderApiRef); @@ -132,7 +133,11 @@ const storePathValidator = ( return errors; }; -export const TemplatePage = () => { +export const TemplatePage = ({ + customFieldExtensions = [], +}: { + customFieldExtensions: FieldExtensionOptions[]; +}) => { const errorApi = useApi(errorApiRef); const scaffolderApi = useApi(scaffolderApiRef); const { templateName } = useParams(); @@ -141,7 +146,6 @@ export const TemplatePage = () => { const { schema, loading, error } = useTemplateParameterSchema(templateName); const [formState, setFormState] = useState({}); const handleFormReset = () => setFormState({}); - const handleChange = useCallback( (e: IChangeEvent) => setFormState(e.formData), [setFormState], @@ -166,7 +170,14 @@ export const TemplatePage = () => { return ; } - const { components, validators } = scaffolderApi.getCustomFields(); + const customFieldComponents = Object.fromEntries( + customFieldExtensions.map(({ name, component }) => [name, component]), + ); + + const customFieldValidators = Object.fromEntries( + customFieldExtensions.map(({ name, validation }) => [name, validation]), + ); + return (
{ > { return { ...step, - validate: createValidator(step.schema, validators), + validate: createValidator(step.schema, customFieldValidators), }; })} /> diff --git a/plugins/scaffolder/src/extensions/index.tsx b/plugins/scaffolder/src/extensions/index.tsx index acc3de38a0..307bdd8aeb 100644 --- a/plugins/scaffolder/src/extensions/index.tsx +++ b/plugins/scaffolder/src/extensions/index.tsx @@ -24,7 +24,7 @@ export type FieldExtensionOptions = { export function createScaffolderFieldExtension( options: FieldExtensionOptions, -): Extension { +): Extension<() => null> { return { expose() { const FieldExtensionDataHolder: any = () => null; diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index 66eff47e5d..bda87eee5f 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -18,8 +18,8 @@ export { scaffolderPlugin, scaffolderPlugin as plugin, ScaffolderPage, - OwnerPicker, - RepoUrlPicker, + OwnerPickerFieldExtension, + RepoUrlPickerFieldExtension, } from './plugin'; export type { ScaffolderApi } from './api'; export { ScaffolderClient, scaffolderApiRef } from './api'; From ca185ff577d094803ba46952a7e0eff8f5106e11 Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 19 May 2021 11:41:27 +0200 Subject: [PATCH 07/18] feat(scaffolder): added some simple extractors to grab the data Signed-off-by: blam --- packages/app/src/App.tsx | 7 +- plugins/scaffolder/src/components/Router.tsx | 25 +++---- .../components/TemplatePage/TemplatePage.tsx | 8 +- plugins/scaffolder/src/extensions/helpers.ts | 73 +++++++++++++++++++ plugins/scaffolder/src/extensions/index.tsx | 21 +++++- plugins/scaffolder/src/index.ts | 1 + plugins/scaffolder/src/plugin.ts | 10 ++- 7 files changed, 123 insertions(+), 22 deletions(-) create mode 100644 plugins/scaffolder/src/extensions/helpers.ts diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 2216dc9d01..354c047f91 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -47,6 +47,7 @@ import { scaffolderPlugin, OwnerPickerFieldExtension, RepoUrlPickerFieldExtension, + ScaffolderCustomFields, } from '@backstage/plugin-scaffolder'; import { SearchPage, SearchPageNext } from '@backstage/plugin-search'; import { TechRadarPage } from '@backstage/plugin-tech-radar'; @@ -113,8 +114,10 @@ const routes = ( } /> } /> }> - - + + + + } /> { - const children = useOutlet(); - const fieldExtensions = React.Children.map(children ?? [], child => { - if (!React.isValidElement(child)) { - return null; - } + const outlet = useOutlet(); - const data = getComponentData>( - child, - 'scaffolder.extensions.field.v1', - ); - - return data; - }).filter(Boolean); + const fieldExtensions = collect( + collectChildren(outlet, FIELD_EXTENSION_WRAPPER_KEY).flat(), + FIELD_EXTENSION_KEY, + ); return ( diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index 00d4fbae16..b946c89dfc 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -56,9 +56,9 @@ function isObject(obj: unknown): obj is JsonObject { export const createValidator = ( rootSchema: JsonObject, - validators: Map< + validators: Record< string, - (value: JsonValue, validation: FieldValidation) => void + undefined | ((value: JsonValue, validation: FieldValidation) => void) >, ) => { function validate( @@ -87,8 +87,8 @@ export const createValidator = ( const propSchema = schemaProps[key]; const fieldName = isObject(propSchema) && (propSchema['ui:field'] as string); - if (fieldName && validators.has(fieldName)) { - validators.get(fieldName)!(propData as JsonValue, propValidation); + if (fieldName && typeof validators[fieldName] === 'function') { + validators[fieldName]!(propData as JsonValue, propValidation); } } } diff --git a/plugins/scaffolder/src/extensions/helpers.ts b/plugins/scaffolder/src/extensions/helpers.ts new file mode 100644 index 0000000000..92fa06d372 --- /dev/null +++ b/plugins/scaffolder/src/extensions/helpers.ts @@ -0,0 +1,73 @@ +/* + * 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 React from 'react'; +import { getComponentData } from '@backstage/core'; + +export const collect = ( + children: React.ReactNode, + componentDataKey: string, +) => { + const stack = [children]; + const found: T[] = []; + + while (stack.length) { + const current: React.ReactNode = stack.pop()!; + + React.Children.forEach(current, child => { + if (!React.isValidElement(child)) { + return; + } + + const data = getComponentData(child, componentDataKey); + if (data) { + found.push(data); + } + + if (child.props.children) { + stack.push(child.props.children); + } + }); + } + + return found; +}; + +export const collectChildren = ( + component: React.ReactNode, + componentDataKey: string, +) => { + const stack = [component]; + const found: React.ReactNode[] = []; + + while (stack.length) { + const current: React.ReactNode = stack.pop()!; + + React.Children.forEach(current, child => { + if (!React.isValidElement(child)) { + return; + } + + if (child.props.children) { + if (getComponentData(child, componentDataKey)) { + found.push(child.props.children); + } + stack.push(child.props.children); + } + }); + } + + return found; +}; diff --git a/plugins/scaffolder/src/extensions/index.tsx b/plugins/scaffolder/src/extensions/index.tsx index 307bdd8aeb..3a43c713d7 100644 --- a/plugins/scaffolder/src/extensions/index.tsx +++ b/plugins/scaffolder/src/extensions/index.tsx @@ -22,6 +22,9 @@ export type FieldExtensionOptions = { validation?: (data: T, field: FieldValidation) => void; }; +export const FIELD_EXTENSION_WRAPPER_KEY = 'scaffolder.extensions.wrapper.v1'; +export const FIELD_EXTENSION_KEY = 'scaffolder.extensions.field.v1'; + export function createScaffolderFieldExtension( options: FieldExtensionOptions, ): Extension<() => null> { @@ -31,7 +34,7 @@ export function createScaffolderFieldExtension( attachComponentData( FieldExtensionDataHolder, - 'scaffolder.extensions.field.v1', + FIELD_EXTENSION_KEY, options, ); @@ -39,3 +42,19 @@ export function createScaffolderFieldExtension( }, }; } + +export function createScaffolderFieldExtensionWrapper(): Extension<() => null> { + return { + expose() { + const FieldExtensionWrapperDataHolder: any = () => null; + + attachComponentData( + FieldExtensionWrapperDataHolder, + FIELD_EXTENSION_WRAPPER_KEY, + true, + ); + + return FieldExtensionWrapperDataHolder; + }, + }; +} diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index bda87eee5f..8e4415e3c1 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -20,6 +20,7 @@ export { ScaffolderPage, OwnerPickerFieldExtension, RepoUrlPickerFieldExtension, + ScaffolderCustomFields, } from './plugin'; export type { ScaffolderApi } from './api'; export { ScaffolderClient, scaffolderApiRef } from './api'; diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index 0177425098..796882be03 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -26,7 +26,10 @@ import { OwnerPicker } from './components/fields/OwnerPicker'; import { RepoUrlPicker } from './components/fields/RepoUrlPicker'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { scaffolderApiRef, ScaffolderClient } from './api'; -import { createScaffolderFieldExtension } from './extensions'; +import { + createScaffolderFieldExtension, + createScaffolderFieldExtensionWrapper, +} from './extensions'; import { rootRouteRef, registerComponentRouteRef } from './routes'; export const scaffolderPlugin = createPlugin({ @@ -76,9 +79,14 @@ export const OwnerPickerFieldExtension = scaffolderPlugin.provide( name: 'OwnerPicker', }), ); + export const ScaffolderPage = scaffolderPlugin.provide( createRoutableExtension({ component: () => import('./components/Router').then(m => m.Router), mountPoint: rootRouteRef, }), ); + +export const ScaffolderCustomFields = scaffolderPlugin.provide( + createScaffolderFieldExtensionWrapper(), +); From 5a6ba065adf84157f1f14a1905cb5a09c60e3bad Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 19 May 2021 14:41:43 +0200 Subject: [PATCH 08/18] chore: reworking a little more to simplify again Signed-off-by: blam --- .../core-api/src/extensions/extensions.tsx | 2 +- plugins/scaffolder/src/components/Router.tsx | 5 +++-- .../fields/OwnerPicker/OwnerPicker.tsx | 6 +++--- .../fields/RepoUrlPicker/RepoUrlPicker.tsx | 6 +++--- plugins/scaffolder/src/extensions/helpers.ts | 2 +- plugins/scaffolder/src/extensions/index.tsx | 19 ++++--------------- plugins/scaffolder/src/index.ts | 2 +- plugins/scaffolder/src/plugin.ts | 16 +++------------- 8 files changed, 19 insertions(+), 39 deletions(-) diff --git a/packages/core-api/src/extensions/extensions.tsx b/packages/core-api/src/extensions/extensions.tsx index d16f24c229..9f6412f960 100644 --- a/packages/core-api/src/extensions/extensions.tsx +++ b/packages/core-api/src/extensions/extensions.tsx @@ -20,7 +20,7 @@ import { BackstagePlugin, Extension } from '../plugin/types'; import { RouteRef, useRouteRef } from '../routing'; import { attachComponentData } from './componentData'; -export type ComponentLoader = +type ComponentLoader = | { lazy: () => Promise; } diff --git a/plugins/scaffolder/src/components/Router.tsx b/plugins/scaffolder/src/components/Router.tsx index 8561da1421..d80e75cd38 100644 --- a/plugins/scaffolder/src/components/Router.tsx +++ b/plugins/scaffolder/src/components/Router.tsx @@ -20,17 +20,18 @@ import { ScaffolderPage } from './ScaffolderPage'; import { TemplatePage } from './TemplatePage'; import { TaskPage } from './TaskPage'; import { ActionsPage } from './ActionsPage'; + import { FieldExtensionOptions, FIELD_EXTENSION_WRAPPER_KEY, FIELD_EXTENSION_KEY, } from '../extensions'; -import { collect, collectChildren } from '../extensions/helpers'; +import { collectComponentData, collectChildren } from '../extensions/helpers'; export const Router = () => { const outlet = useOutlet(); - const fieldExtensions = collect( + const fieldExtensions = collectComponentData( collectChildren(outlet, FIELD_EXTENSION_WRAPPER_KEY).flat(), FIELD_EXTENSION_KEY, ); diff --git a/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx b/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx index 0091a6741e..39183a856f 100644 --- a/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx +++ b/plugins/scaffolder/src/components/fields/OwnerPicker/OwnerPicker.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import React from 'react'; -import { Field } from '@rjsf/core'; +import { FieldProps } from '@rjsf/core'; import { catalogApiRef } from '@backstage/plugin-catalog-react'; import { useApi } from '@backstage/core'; import { useAsync } from 'react-use'; @@ -39,14 +39,14 @@ const entityRef = (entity: Entity | undefined): string => { return `${kindPart}${namespacePart}${name}`; }; -export const OwnerPicker: Field = ({ +export const OwnerPicker = ({ onChange, schema: { title = 'Owner', description = 'The owner of the component' }, required, uiSchema, rawErrors, formData, -}) => { +}: FieldProps) => { const allowedKinds = (uiSchema['ui:options']?.allowedKinds || [ 'Group', 'User', diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx index 31b4c4fa9b..49d16169ac 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/RepoUrlPicker.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ import React, { useCallback, useEffect } from 'react'; -import { Field } from '@rjsf/core'; +import { FieldProps } from '@rjsf/core'; import { useApi, Progress } from '@backstage/core'; import { scaffolderApiRef } from '../../../api'; import { useAsync } from 'react-use'; @@ -69,12 +69,12 @@ function serializeFormData(data: { return `${data.host}?${params.toString()}`; } -export const RepoUrlPicker: Field = ({ +export const RepoUrlPicker = ({ onChange, uiSchema, rawErrors, formData, -}) => { +}: FieldProps) => { const api = useApi(scaffolderApiRef); const allowedHosts = uiSchema['ui:options']?.allowedHosts as string[]; diff --git a/plugins/scaffolder/src/extensions/helpers.ts b/plugins/scaffolder/src/extensions/helpers.ts index 92fa06d372..2eac9780e5 100644 --- a/plugins/scaffolder/src/extensions/helpers.ts +++ b/plugins/scaffolder/src/extensions/helpers.ts @@ -16,7 +16,7 @@ import React from 'react'; import { getComponentData } from '@backstage/core'; -export const collect = ( +export const collectComponentData = ( children: React.ReactNode, componentDataKey: string, ) => { diff --git a/plugins/scaffolder/src/extensions/index.tsx b/plugins/scaffolder/src/extensions/index.tsx index 3a43c713d7..d794ac71e4 100644 --- a/plugins/scaffolder/src/extensions/index.tsx +++ b/plugins/scaffolder/src/extensions/index.tsx @@ -13,8 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + import { Extension, attachComponentData } from '@backstage/core'; import { FieldValidation, FieldProps } from '@rjsf/core'; +import React from 'react'; export type FieldExtensionOptions = { name: string; @@ -43,18 +45,5 @@ export function createScaffolderFieldExtension( }; } -export function createScaffolderFieldExtensionWrapper(): Extension<() => null> { - return { - expose() { - const FieldExtensionWrapperDataHolder: any = () => null; - - attachComponentData( - FieldExtensionWrapperDataHolder, - FIELD_EXTENSION_WRAPPER_KEY, - true, - ); - - return FieldExtensionWrapperDataHolder; - }, - }; -} +export const ScaffolderCustomFields: React.ComponentType = () => null; +attachComponentData(ScaffolderCustomFields, FIELD_EXTENSION_WRAPPER_KEY, true); diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index 8e4415e3c1..b92fd9d4bc 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -20,7 +20,7 @@ export { ScaffolderPage, OwnerPickerFieldExtension, RepoUrlPickerFieldExtension, - ScaffolderCustomFields, } from './plugin'; +export { ScaffolderCustomFields } from './extensions'; export type { ScaffolderApi } from './api'; export { ScaffolderClient, scaffolderApiRef } from './api'; diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index 796882be03..1ffe3bdb81 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { JsonValue } from '@backstage/config'; import { createApiFactory, createPlugin, @@ -26,10 +25,7 @@ import { OwnerPicker } from './components/fields/OwnerPicker'; import { RepoUrlPicker } from './components/fields/RepoUrlPicker'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { scaffolderApiRef, ScaffolderClient } from './api'; -import { - createScaffolderFieldExtension, - createScaffolderFieldExtensionWrapper, -} from './extensions'; +import { createScaffolderFieldExtension } from './extensions'; import { rootRouteRef, registerComponentRouteRef } from './routes'; export const scaffolderPlugin = createPlugin({ @@ -55,8 +51,7 @@ export const scaffolderPlugin = createPlugin({ }); export const RepoUrlPickerFieldExtension = scaffolderPlugin.provide( - createScaffolderFieldExtension({ - // TODO(blam): work out how to fix these types properly. + createScaffolderFieldExtension({ component: RepoUrlPicker, name: 'RepoUrlPicker', validation: (value, validation) => { @@ -73,8 +68,7 @@ export const RepoUrlPickerFieldExtension = scaffolderPlugin.provide( ); export const OwnerPickerFieldExtension = scaffolderPlugin.provide( - createScaffolderFieldExtension({ - // TODO(blam): work out how to fix these types properly. + createScaffolderFieldExtension({ component: OwnerPicker, name: 'OwnerPicker', }), @@ -86,7 +80,3 @@ export const ScaffolderPage = scaffolderPlugin.provide( mountPoint: rootRouteRef, }), ); - -export const ScaffolderCustomFields = scaffolderPlugin.provide( - createScaffolderFieldExtensionWrapper(), -); From 09fad54e124bc9f2676d874b3195edf4493b2b2d Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 19 May 2021 18:47:05 +0200 Subject: [PATCH 09/18] tests: added some tests for the new composability helpers Signed-off-by: blam --- plugins/scaffolder/src/components/Router.tsx | 12 +- .../src/extensions/helpers.test.tsx | 107 ++++++++++++++++++ 2 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 plugins/scaffolder/src/extensions/helpers.test.tsx diff --git a/plugins/scaffolder/src/components/Router.tsx b/plugins/scaffolder/src/components/Router.tsx index d80e75cd38..164182036d 100644 --- a/plugins/scaffolder/src/components/Router.tsx +++ b/plugins/scaffolder/src/components/Router.tsx @@ -14,7 +14,7 @@ * limitations under the License. */ -import React from 'react'; +import React, { useMemo } from 'react'; import { Routes, Route, useOutlet } from 'react-router'; import { ScaffolderPage } from './ScaffolderPage'; import { TemplatePage } from './TemplatePage'; @@ -31,9 +31,13 @@ import { collectComponentData, collectChildren } from '../extensions/helpers'; export const Router = () => { const outlet = useOutlet(); - const fieldExtensions = collectComponentData( - collectChildren(outlet, FIELD_EXTENSION_WRAPPER_KEY).flat(), - FIELD_EXTENSION_KEY, + const fieldExtensions = useMemo( + () => + collectComponentData( + collectChildren(outlet, FIELD_EXTENSION_WRAPPER_KEY).flat(), + FIELD_EXTENSION_KEY, + ), + [outlet], ); return ( diff --git a/plugins/scaffolder/src/extensions/helpers.test.tsx b/plugins/scaffolder/src/extensions/helpers.test.tsx new file mode 100644 index 0000000000..ed3b74a210 --- /dev/null +++ b/plugins/scaffolder/src/extensions/helpers.test.tsx @@ -0,0 +1,107 @@ +/* + * 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 React from 'react'; +import { collectComponentData, collectChildren } from './helpers'; +import { attachComponentData } from '@backstage/core'; + +describe('Extension Helpers', () => { + const createElementWithComponentData = ({ + type, + data, + }: { + type: string; + data: any; + }) => { + const element: React.ComponentType = () => null; + attachComponentData(element, type, data); + return element; + }; + + describe('collectChildren', () => { + it('should return the children of the component which has the correct componentData flag', () => { + const SearchElement = createElementWithComponentData({ + type: 'find.me', + data: {}, + }); + + const DontCareAboutme = createElementWithComponentData({ + type: 'dont.find.me', + data: {}, + }); + + const child1 = ( +
+ hello +
+ ); + + const child2 = ( +
+

Hello2

+
+ ); + + const testCase = ( +
+ + {child1} + {child1} + + {child2} + +

Hello!

+ {child1} +
+
+ ); + + const children = collectChildren(testCase, 'find.me'); + + expect(children).toEqual([[child1, child1], child2, child1]); + }); + }); + + describe('collectComponentData', () => { + it('should return the componentData for particular nodes', () => { + const componentData1 = { help: 'im something' }; + const componentData2 = { help: 'im something else' }; + + const FirstElement = createElementWithComponentData({ + type: 'find.me', + data: componentData1, + }); + + const SecondElement = createElementWithComponentData({ + type: 'dont.find.me', + data: componentData2, + }); + + const testCase = [ + , + , + , + , + ]; + const returnedData = collectComponentData(testCase, 'find.me'); + + expect(returnedData).toEqual([ + componentData1, + componentData1, + componentData1, + ]); + }); + }); +}); From f487ad12e08d6264ea29a3786ba8d79828d8672f Mon Sep 17 00:00:00 2001 From: blam Date: Wed, 19 May 2021 21:13:18 +0200 Subject: [PATCH 10/18] chore: fixing some parts of the build Signed-off-by: blam --- packages/core-api/src/extensions/index.ts | 1 - .../TemplatePage/TemplatePage.test.tsx | 23 +++++++++++-------- .../components/TemplatePage/TemplatePage.tsx | 2 +- 3 files changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/core-api/src/extensions/index.ts b/packages/core-api/src/extensions/index.ts index 2f80b1c42c..26a0c597b1 100644 --- a/packages/core-api/src/extensions/index.ts +++ b/packages/core-api/src/extensions/index.ts @@ -20,4 +20,3 @@ export { createRoutableExtension, createComponentExtension, } from './extensions'; -export type { ComponentLoader } from './extensions'; diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index 2a21e9da72..ee23da2f82 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -200,20 +200,23 @@ describe('TemplatePage', () => { describe('createValidator', () => { it('should validate deep schema', () => { - const validator = createValidator({ - type: 'object', - properties: { - foo: { - type: 'object', - properties: { - bar: { - type: 'string', - 'ui:field': 'RepoUrlPicker', + const validator = createValidator( + { + type: 'object', + properties: { + foo: { + type: 'object', + properties: { + bar: { + type: 'string', + 'ui:field': 'RepoUrlPicker', + }, }, }, }, }, - }); + {}, + ); const errors = { foo: { bar: { addError: jest.fn() } } }; validator({ foo: { bar: 'github.com?owner=a' } }, errors as any); diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx index b946c89dfc..6f212d9d75 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.tsx @@ -136,7 +136,7 @@ const storePathValidator = ( export const TemplatePage = ({ customFieldExtensions = [], }: { - customFieldExtensions: FieldExtensionOptions[]; + customFieldExtensions?: FieldExtensionOptions[]; }) => { const errorApi = useApi(errorApiRef); const scaffolderApi = useApi(scaffolderApiRef); From 3d60be7c9564751c46e72962050289072008eb5c Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 20 May 2021 09:22:54 +0200 Subject: [PATCH 11/18] chore: fix deps for external depencies now we export some JSX things Signed-off-by: blam --- plugins/scaffolder/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/plugins/scaffolder/package.json b/plugins/scaffolder/package.json index 6accd83112..f1ad6c50bc 100644 --- a/plugins/scaffolder/package.json +++ b/plugins/scaffolder/package.json @@ -44,6 +44,7 @@ "@material-ui/lab": "4.0.0-alpha.45", "@rjsf/core": "^2.4.0", "@rjsf/material-ui": "^2.4.0", + "@types/react": "^16.9", "classnames": "^2.2.6", "json-schema": "^0.3.0", "git-url-parse": "^11.4.4", From bfe8306b98936968f28e3946fceb4c2a9a9c7140 Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 20 May 2021 09:30:18 +0200 Subject: [PATCH 12/18] chore: updating create app to the new format Signed-off-by: blam --- .../default-app/packages/app/src/App.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/packages/create-app/templates/default-app/packages/app/src/App.tsx b/packages/create-app/templates/default-app/packages/app/src/App.tsx index 026771ce4f..ebd420f832 100644 --- a/packages/create-app/templates/default-app/packages/app/src/App.tsx +++ b/packages/create-app/templates/default-app/packages/app/src/App.tsx @@ -13,7 +13,13 @@ import { catalogPlugin, } from '@backstage/plugin-catalog'; import {CatalogImportPage, catalogImportPlugin} from '@backstage/plugin-catalog-import'; -import { ScaffolderPage, scaffolderPlugin } from '@backstage/plugin-scaffolder'; +import { + OwnerPickerFieldExtension, + RepoUrlPickerFieldExtension, + ScaffolderCustomFields, + ScaffolderPage, + scaffolderPlugin +} from '@backstage/plugin-scaffolder'; import { SearchPage } from '@backstage/plugin-search'; import { TechRadarPage } from '@backstage/plugin-tech-radar'; import { TechdocsPage } from '@backstage/plugin-techdocs'; @@ -51,7 +57,12 @@ const routes = ( {entityPage}
} /> - } /> + }> + + + + + } /> Date: Thu, 20 May 2021 09:38:59 +0200 Subject: [PATCH 13/18] chore: added changeset Signed-off-by: blam --- .changeset/brave-lemons-hope.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .changeset/brave-lemons-hope.md diff --git a/.changeset/brave-lemons-hope.md b/.changeset/brave-lemons-hope.md new file mode 100644 index 0000000000..491322c827 --- /dev/null +++ b/.changeset/brave-lemons-hope.md @@ -0,0 +1,33 @@ +--- +'@backstage/plugin-scaffolder': minor +'@backstage/create-app': patch +--- + +Scaffolder Field Extensions are here! This means you'll now have to change how the `ScaffolderPage` is wired up in your `app/src/App.tsx` to pass in the custom fields to the Scaffolder. + +You'll need to move this: + +```tsx +} /> +``` + +To this: + +```tsx +import { + ScaffolderCustomFields, + RepoUrlPickerFieldExtension, + OwnerPickerFieldExtension, +} from '@backstage/plugin-scaffolder'; + +}> + + + + +; +``` + +Failure to do this will result in no component being rendered for the custom field's like `OwnerPicker` and `RepoUrlPicker`. + +More documentation on how to write your own `FieldExtensions` to follow. From 5a744b6b6a824adfd01a07ccf6fa912936c87fde Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 20 May 2021 10:09:35 +0200 Subject: [PATCH 14/18] chore: reworking how the tests work and testing the validation seperately Signed-off-by: blam --- .../TemplatePage/TemplatePage.test.tsx | 38 ------------ .../components/fields/RepoUrlPicker/index.ts | 1 + .../fields/RepoUrlPicker/validation.test.ts | 62 +++++++++++++++++++ .../fields/RepoUrlPicker/validation.ts | 30 +++++++++ plugins/scaffolder/src/plugin.ts | 16 ++--- 5 files changed, 98 insertions(+), 49 deletions(-) create mode 100644 plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.test.ts create mode 100644 plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index ee23da2f82..5a2a7186c1 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -197,41 +197,3 @@ describe('TemplatePage', () => { expect(await findByText('Reset')).toBeInTheDocument(); }); }); - -describe('createValidator', () => { - it('should validate deep schema', () => { - const validator = createValidator( - { - type: 'object', - properties: { - foo: { - type: 'object', - properties: { - bar: { - type: 'string', - 'ui:field': 'RepoUrlPicker', - }, - }, - }, - }, - }, - {}, - ); - - const errors = { foo: { bar: { addError: jest.fn() } } }; - validator({ foo: { bar: 'github.com?owner=a' } }, errors as any); - expect(errors.foo.bar.addError).toHaveBeenCalledWith( - 'Incomplete repository location provided', - ); - jest.resetAllMocks(); - - validator({ foo: { bar: 'github.com?repo=b' } }, errors as any); - expect(errors.foo.bar.addError).toHaveBeenCalledWith( - 'Incomplete repository location provided', - ); - jest.resetAllMocks(); - - validator({ foo: { bar: 'github.com?owner=a&repo=b' } }, errors as any); - expect(errors.foo.bar.addError).not.toHaveBeenCalled(); - }); -}); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/index.ts b/plugins/scaffolder/src/components/fields/RepoUrlPicker/index.ts index b0f3df61d9..32bb6ee7f7 100644 --- a/plugins/scaffolder/src/components/fields/RepoUrlPicker/index.ts +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/index.ts @@ -14,3 +14,4 @@ * limitations under the License. */ export { RepoUrlPicker } from './RepoUrlPicker'; +export { repoPickerValidation } from './validation'; diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.test.ts b/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.test.ts new file mode 100644 index 0000000000..7e05ca7851 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.test.ts @@ -0,0 +1,62 @@ +/* + * 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 { repoPickerValidation } from './validation'; +import { FieldValidation } from '@rjsf/core'; + +describe('RepoPicker Validation', () => { + const fieldValidator = () => + (({ + addError: jest.fn(), + } as unknown) as FieldValidation); + + it('validaties when no repo', () => { + const mockFieldValidation = fieldValidator(); + + repoPickerValidation('github.com?owner=a', mockFieldValidation); + + expect(mockFieldValidation.addError).toHaveBeenCalledWith( + 'Incomplete repository location provided', + ); + }); + + it('validates when no owner', () => { + const mockFieldValidation = fieldValidator(); + + repoPickerValidation('github.com?repo=a', mockFieldValidation); + + expect(mockFieldValidation.addError).toHaveBeenCalledWith( + 'Incomplete repository location provided', + ); + }); + + it('validates when not a real url', () => { + const mockFieldValidation = fieldValidator(); + + repoPickerValidation('', mockFieldValidation); + + expect(mockFieldValidation.addError).toHaveBeenCalledWith( + 'Unable to parse the Repository URL', + ); + }); + + it('validates properly with proper input', () => { + const mockFieldValidation = fieldValidator(); + + repoPickerValidation('github.com?owner=a&repo=b', mockFieldValidation); + + expect(mockFieldValidation.addError).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts b/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts new file mode 100644 index 0000000000..f573838081 --- /dev/null +++ b/plugins/scaffolder/src/components/fields/RepoUrlPicker/validation.ts @@ -0,0 +1,30 @@ +/* + * 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 { FieldValidation } from '@rjsf/core'; + +export const repoPickerValidation = ( + value: string, + validation: FieldValidation, +) => { + try { + const { host, searchParams } = new URL(`https://${value}`); + if (!host || !searchParams.get('owner') || !searchParams.get('repo')) { + validation.addError('Incomplete repository location provided'); + } + } catch { + validation.addError('Unable to parse the Repository URL'); + } +}; diff --git a/plugins/scaffolder/src/plugin.ts b/plugins/scaffolder/src/plugin.ts index 1ffe3bdb81..5991ae4bc3 100644 --- a/plugins/scaffolder/src/plugin.ts +++ b/plugins/scaffolder/src/plugin.ts @@ -22,7 +22,10 @@ import { identityApiRef, } from '@backstage/core'; import { OwnerPicker } from './components/fields/OwnerPicker'; -import { RepoUrlPicker } from './components/fields/RepoUrlPicker'; +import { + RepoUrlPicker, + repoPickerValidation, +} from './components/fields/RepoUrlPicker'; import { scmIntegrationsApiRef } from '@backstage/integration-react'; import { scaffolderApiRef, ScaffolderClient } from './api'; import { createScaffolderFieldExtension } from './extensions'; @@ -54,16 +57,7 @@ export const RepoUrlPickerFieldExtension = scaffolderPlugin.provide( createScaffolderFieldExtension({ component: RepoUrlPicker, name: 'RepoUrlPicker', - validation: (value, validation) => { - try { - const { host, searchParams } = new URL(`https://${value}`); - if (!host || !searchParams.get('owner') || !searchParams.get('repo')) { - validation.addError('Incomplete repository location provided'); - } - } catch { - validation.addError('Unable to parse the Repository URL'); - } - }, + validation: repoPickerValidation, }), ); From b35d36ccc1f62d677c06af9ba64b568ea78a6a8d Mon Sep 17 00:00:00 2001 From: blam Date: Thu, 20 May 2021 10:17:25 +0200 Subject: [PATCH 15/18] chore: fixing tests Signed-off-by: blam --- .../src/components/TemplatePage/TemplatePage.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index 5a2a7186c1..0a946862ef 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -23,7 +23,7 @@ import { act } from 'react-dom/test-utils'; import { MemoryRouter, Route } from 'react-router'; import { ScaffolderApi, scaffolderApiRef } from '../../api'; import { rootRouteRef } from '../../routes'; -import { createValidator, TemplatePage } from './TemplatePage'; +import { TemplatePage } from './TemplatePage'; jest.mock('react-router-dom', () => { return { From f0cfac64d2d1cc0d86c0366d5280d4cb9a43acc2 Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 31 May 2021 10:35:54 +0200 Subject: [PATCH 16/18] chore: rename to ScaffolderFieldExtensions Signed-off-by: blam --- .changeset/brave-lemons-hope.md | 6 +++--- packages/app/src/App.tsx | 6 +++--- .../templates/default-app/packages/app/src/App.tsx | 6 +++--- plugins/scaffolder/src/extensions/index.tsx | 8 ++++++-- plugins/scaffolder/src/index.ts | 2 +- 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.changeset/brave-lemons-hope.md b/.changeset/brave-lemons-hope.md index 491322c827..c1e2e3f3bd 100644 --- a/.changeset/brave-lemons-hope.md +++ b/.changeset/brave-lemons-hope.md @@ -15,16 +15,16 @@ To this: ```tsx import { - ScaffolderCustomFields, + ScaffolderFieldExtensions, RepoUrlPickerFieldExtension, OwnerPickerFieldExtension, } from '@backstage/plugin-scaffolder'; }> - + - + ; ``` diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 354c047f91..90d5463c59 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -47,7 +47,7 @@ import { scaffolderPlugin, OwnerPickerFieldExtension, RepoUrlPickerFieldExtension, - ScaffolderCustomFields, + ScaffolderFieldExtensions, } from '@backstage/plugin-scaffolder'; import { SearchPage, SearchPageNext } from '@backstage/plugin-search'; import { TechRadarPage } from '@backstage/plugin-tech-radar'; @@ -114,10 +114,10 @@ const routes = ( } /> } /> }> - + - + } /> } /> }> - + - + } /> ( }; } -export const ScaffolderCustomFields: React.ComponentType = () => null; -attachComponentData(ScaffolderCustomFields, FIELD_EXTENSION_WRAPPER_KEY, true); +export const ScaffolderFieldExtensions: React.ComponentType = () => null; +attachComponentData( + ScaffolderFieldExtensions, + FIELD_EXTENSION_WRAPPER_KEY, + true, +); diff --git a/plugins/scaffolder/src/index.ts b/plugins/scaffolder/src/index.ts index b92fd9d4bc..c22597f373 100644 --- a/plugins/scaffolder/src/index.ts +++ b/plugins/scaffolder/src/index.ts @@ -21,6 +21,6 @@ export { OwnerPickerFieldExtension, RepoUrlPickerFieldExtension, } from './plugin'; -export { ScaffolderCustomFields } from './extensions'; +export { ScaffolderFieldExtensions } from './extensions'; export type { ScaffolderApi } from './api'; export { ScaffolderClient, scaffolderApiRef } from './api'; From c2c83f46a78ece5ab0d3d57b39e4a231c7b8af65 Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 31 May 2021 10:58:21 +0200 Subject: [PATCH 17/18] chore: making the changes backwards compatible with default extensions being rendered when nothing supplied, and updating changeset to reflect This Signed-off-by: blam --- .changeset/brave-lemons-hope.md | 9 +++-- packages/app/src/App.tsx | 7 +--- .../default-app/packages/app/src/App.tsx | 10 +----- plugins/scaffolder/src/components/Router.tsx | 19 ++++++----- plugins/scaffolder/src/extensions/default.ts | 33 +++++++++++++++++++ plugins/scaffolder/src/extensions/index.tsx | 12 +++---- plugins/scaffolder/src/extensions/types.ts | 22 +++++++++++++ 7 files changed, 77 insertions(+), 35 deletions(-) create mode 100644 plugins/scaffolder/src/extensions/default.ts create mode 100644 plugins/scaffolder/src/extensions/types.ts diff --git a/.changeset/brave-lemons-hope.md b/.changeset/brave-lemons-hope.md index c1e2e3f3bd..64c0d59538 100644 --- a/.changeset/brave-lemons-hope.md +++ b/.changeset/brave-lemons-hope.md @@ -1,9 +1,8 @@ --- -'@backstage/plugin-scaffolder': minor -'@backstage/create-app': patch +'@backstage/plugin-scaffolder': patch --- -Scaffolder Field Extensions are here! This means you'll now have to change how the `ScaffolderPage` is wired up in your `app/src/App.tsx` to pass in the custom fields to the Scaffolder. +Scaffolder Field Extensions are here! This means you'll now the ability to create custom field extensions and have the Scaffolder use the components when collecting information from the user in the wizard. By default we supply the `RepoUrlPicker` and the `OwnerPicker`, but if you want to provide some more extensions or override the built on ones you will have to change how the `ScaffolderPage` is wired up in your `app/src/App.tsx` to pass in the custom fields to the Scaffolder. You'll need to move this: @@ -24,10 +23,10 @@ import { + + {/*Any other extensions you want to provide*/} ; ``` -Failure to do this will result in no component being rendered for the custom field's like `OwnerPicker` and `RepoUrlPicker`. - More documentation on how to write your own `FieldExtensions` to follow. diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 90d5463c59..191814ca5a 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -113,12 +113,7 @@ const routes = ( } /> } /> - }> - - - - - + } /> } /> } /> - }> - - - - - + } /> } /> { const outlet = useOutlet(); - const fieldExtensions = useMemo( - () => - collectComponentData( - collectChildren(outlet, FIELD_EXTENSION_WRAPPER_KEY).flat(), - FIELD_EXTENSION_KEY, - ), - [outlet], - ); + const fieldExtensions = useMemo(() => { + const registeredExtensions = collectComponentData( + collectChildren(outlet, FIELD_EXTENSION_WRAPPER_KEY).flat(), + FIELD_EXTENSION_KEY, + ); + + return registeredExtensions.length + ? registeredExtensions + : DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS; + }, [outlet]); return ( diff --git a/plugins/scaffolder/src/extensions/default.ts b/plugins/scaffolder/src/extensions/default.ts new file mode 100644 index 0000000000..2da0dc57ba --- /dev/null +++ b/plugins/scaffolder/src/extensions/default.ts @@ -0,0 +1,33 @@ +/* + * 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 { OwnerPicker } from '../components/fields/OwnerPicker'; +import { + RepoUrlPicker, + repoPickerValidation, +} from '../components/fields/RepoUrlPicker'; +import { FieldExtensionOptions } from './types'; + +export const DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS: FieldExtensionOptions[] = [ + { + component: RepoUrlPicker, + name: 'RepoUrlPicker', + validation: repoPickerValidation, + }, + { + component: OwnerPicker, + name: 'OwnerPicker', + }, +]; diff --git a/plugins/scaffolder/src/extensions/index.tsx b/plugins/scaffolder/src/extensions/index.tsx index 874c6c3a18..bb53329342 100644 --- a/plugins/scaffolder/src/extensions/index.tsx +++ b/plugins/scaffolder/src/extensions/index.tsx @@ -15,14 +15,8 @@ */ import { Extension, attachComponentData } from '@backstage/core'; -import { FieldValidation, FieldProps } from '@rjsf/core'; import React from 'react'; - -export type FieldExtensionOptions = { - name: string; - component: (props: FieldProps) => JSX.Element | null; - validation?: (data: T, field: FieldValidation) => void; -}; +import { FieldExtensionOptions } from './types'; export const FIELD_EXTENSION_WRAPPER_KEY = 'scaffolder.extensions.wrapper.v1'; export const FIELD_EXTENSION_KEY = 'scaffolder.extensions.field.v1'; @@ -51,3 +45,7 @@ attachComponentData( FIELD_EXTENSION_WRAPPER_KEY, true, ); + +export type { FieldExtensionOptions }; + +export { DEFAULT_SCAFFOLDER_FIELD_EXTENSIONS } from './default'; diff --git a/plugins/scaffolder/src/extensions/types.ts b/plugins/scaffolder/src/extensions/types.ts new file mode 100644 index 0000000000..5e7de9087f --- /dev/null +++ b/plugins/scaffolder/src/extensions/types.ts @@ -0,0 +1,22 @@ +/* + * 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 { FieldValidation, FieldProps } from '@rjsf/core'; + +export type FieldExtensionOptions = { + name: string; + component: (props: FieldProps) => JSX.Element | null; + validation?: (data: T, field: FieldValidation) => void; +}; From 2ef59bf90a28c109327e98390ed6251365cd398b Mon Sep 17 00:00:00 2001 From: blam Date: Mon, 31 May 2021 11:03:23 +0200 Subject: [PATCH 18/18] chore: fix typescript issues Signed-off-by: blam --- packages/app/src/App.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index 191814ca5a..c5e5033973 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -42,13 +42,7 @@ import { GcpProjectsPage } from '@backstage/plugin-gcp-projects'; import { GraphiQLPage } from '@backstage/plugin-graphiql'; import { LighthousePage } from '@backstage/plugin-lighthouse'; import { NewRelicPage } from '@backstage/plugin-newrelic'; -import { - ScaffolderPage, - scaffolderPlugin, - OwnerPickerFieldExtension, - RepoUrlPickerFieldExtension, - ScaffolderFieldExtensions, -} from '@backstage/plugin-scaffolder'; +import { ScaffolderPage, scaffolderPlugin } from '@backstage/plugin-scaffolder'; import { SearchPage, SearchPageNext } from '@backstage/plugin-search'; import { TechRadarPage } from '@backstage/plugin-tech-radar'; import { TechdocsPage } from '@backstage/plugin-techdocs';