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:
@@ -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';
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -18,6 +18,8 @@ export {
|
||||
scaffolderPlugin,
|
||||
scaffolderPlugin as plugin,
|
||||
ScaffolderPage,
|
||||
OwnerPicker,
|
||||
RepoUrlPicker,
|
||||
} from './plugin';
|
||||
export type { ScaffolderApi } from './api';
|
||||
export { ScaffolderClient, scaffolderApiRef } from './api';
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user