feat: started some work on making custom fields extensible using the composition API

Signed-off-by: blam <ben@blam.sh>
This commit is contained in:
blam
2021-05-14 21:57:54 +02:00
parent 81e6f5542c
commit 73a0f4e52b
7 changed files with 182 additions and 31 deletions
+15 -2
View File
@@ -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 = (
</Route>
<Route path="/catalog-import" element={<CatalogImportPage />} />
<Route path="/docs" element={<TechdocsPage />} />
<Route path="/create" element={<ScaffolderPage />} />
<Route
path="/create"
element={
<ScaffolderPage>
<RepoUrlPicker />
<OwnerPicker />
</ScaffolderPage>
}
/>
<Route path="/explore" element={<ExplorePage />} />
<Route
path="/tech-radar"
@@ -13,21 +13,6 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/*
* 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 { BackstagePlugin } from './types';
import { getComponentData } from '../extensions';
+21 -8
View File
@@ -20,12 +20,25 @@ 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 = () => (
<Routes>
<Route path="/" element={<ScaffolderPage />} />
<Route path="/templates/:templateName" element={<TemplatePage />} />
<Route path="/tasks/:taskId" element={<TaskPage />} />
<Route path="/actions" element={<ActionsPage />} />
</Routes>
);
export const Router = ({ children }: React.PropsWithChildren<{}>) => {
const [state, dispatch] = React.useReducer(extensionsReducer, {
fields: [],
});
return (
<ExtensionContext.Provider value={{ state, dispatch }}>
<ExtensionCollector>{children}</ExtensionCollector>
<Routes>
<Route path="/" element={<ScaffolderPage />} />
<Route path="/templates/:templateName" element={<TemplatePage />} />
<Route path="/tasks/:taskId" element={<TaskPage />} />
<Route path="/actions" element={<ActionsPage />} />
</Routes>
</ExtensionContext.Provider>
);
};
@@ -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 <Navigate to={rootLink()} />;
}
const customFields = state!.fields.reduce((acc, next) => {
acc[next.name] = next.component;
return acc;
}, {} as Record<string, any>);
console.log(customFields);
return (
<Page themeId="home">
<Header
@@ -194,7 +202,7 @@ export const TemplatePage = () => {
>
<MultistepJsonForm
formData={formState}
fields={{ RepoUrlPicker, OwnerPicker }}
fields={customFields}
onChange={handleChange}
onReset={handleFormReset}
onFinish={handleCreate}
+101 -4
View File
@@ -13,13 +13,110 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Extension, ComponentLoader } from '@backstage/core';
import { JsonValue } from '@backstage/config';
import {
Extension,
ComponentLoader,
createReactExtension,
getComponentData,
} from '@backstage/core';
import { FieldValidation } from '@rjsf/core';
import React from 'react';
import { useMount } from 'react-use';
export type FormExtensionOptions<T> = {
import {
childDiscoverer,
routeElementDiscoverer,
traverseElementTree,
createCollector,
} from '@backstage/core-api/src/extensions/traversal';
export type FieldExtensionOptions<T> = {
name: string;
component: ComponentLoader<T>;
validation: (data: JsonValue, field: FieldValidation) => void;
};
export function createScaffolderFormExtension<
export function createScaffolderFieldExtension<
T extends (props: any) => JSX.Element | null
>(options: FormExtensionOptions<T>): Extension<T> {}
>(options: FieldExtensionOptions<T>): Extension<T> {
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<unknown>[];
};
export type RegisterFieldExtensionAction = {
type: 'fields';
data: FieldExtensionOptions<unknown>[];
};
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<unknown>[],
(acc, node) => {
const data = getComponentData<FieldExtensionOptions<unknown>>(
node,
'scaffolder.extensions.field.v1',
);
if (data) {
acc.push(data);
}
},
),
},
});
context.dispatch({ data: fields, type: 'fields' });
});
return null;
};
+2
View File
@@ -18,6 +18,8 @@ export {
scaffolderPlugin,
scaffolderPlugin as plugin,
ScaffolderPage,
OwnerPicker,
RepoUrlPicker,
} from './plugin';
export type { ScaffolderApi } from './api';
export { ScaffolderClient, scaffolderApiRef } from './api';
+33
View File
@@ -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),