Skip to content

Commit

Permalink
feat(app) RHIDP-1803 enable field extensions
Browse files Browse the repository at this point in the history
This commit enables support for Scaffolder field extensions.  A plugin
can export a field extension as documented and then configure it to be
included in the list of enabled scaffolder field extensions using the
following configuration:

```yaml
dynamicPlugins:
  frontend:
    my-plugin:
      scaffolderFieldExtensions:
        - importName: SomeExportedFieldExtension
```

Signed-off-by: Stan Lewis <gashcrumb@gmail.com>
  • Loading branch information
gashcrumb committed May 13, 2024
1 parent f4a8802 commit 0623d74
Show file tree
Hide file tree
Showing 7 changed files with 321 additions and 8 deletions.
1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
"@backstage/plugin-org": "0.6.23",
"@backstage/plugin-permission-react": "0.4.21",
"@backstage/plugin-scaffolder": "1.19.2",
"@backstage/plugin-scaffolder-react": "^1.8.4",
"@backstage/plugin-search": "1.4.9",
"@backstage/plugin-search-react": "1.7.9",
"@backstage/plugin-user-settings": "0.8.4",
Expand Down
21 changes: 18 additions & 3 deletions packages/app/src/components/AppBase/AppBase.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { CatalogImportPage } from '@backstage/plugin-catalog-import';
import { HomepageCompositionRoot } from '@backstage/plugin-home';
import { RequirePermission } from '@backstage/plugin-permission-react';
import { ScaffolderPage } from '@backstage/plugin-scaffolder';
import { ScaffolderFieldExtensions } from '@backstage/plugin-scaffolder-react';
import { SearchPage as BackstageSearchPage } from '@backstage/plugin-search';
import { UserSettingsPage } from '@backstage/plugin-user-settings';
import React, { useContext } from 'react';
Expand All @@ -22,8 +23,13 @@ import { LearningPaths } from '../learningPaths/LearningPathsPage';
import { SearchPage } from '../search/SearchPage';

const AppBase = () => {
const { AppProvider, AppRouter, dynamicRoutes, entityTabOverrides } =
useContext(DynamicRootContext);
const {
AppProvider,
AppRouter,
dynamicRoutes,
entityTabOverrides,
scaffolderFieldExtensions,
} = useContext(DynamicRootContext);
return (
<AppProvider>
<AlertDisplay />
Expand All @@ -50,7 +56,16 @@ const AppBase = () => {
headerOptions={{ title: 'Software Templates' }}
/>
}
/>
>
<ScaffolderFieldExtensions>
{scaffolderFieldExtensions.map(
({ scope, module, importName, Component }) => (
<Component key={`${scope}-${module}-${importName}`} />
),
)}
</ScaffolderFieldExtensions>
scaffolderFieldExtensions
</Route>
<Route path="/api-docs" element={<ApiExplorerPage />} />
<Route
path="/catalog-import"
Expand Down
34 changes: 32 additions & 2 deletions packages/app/src/components/DynamicRoot/DynamicRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ export const DynamicRoot = ({
mountPoints,
routeBindings,
routeBindingTargets,
scaffolderFieldExtensions,
} = extractDynamicConfig(dynamicPlugins);

const requiredModules = [
...routeBindingTargets.map(({ scope, module }) => ({
scope,
Expand All @@ -92,6 +92,10 @@ export const DynamicRoot = ({
scope,
module,
})),
...scaffolderFieldExtensions.map(({ scope, module }) => ({
scope,
module,
})),
];

const staticPlugins = Object.keys(staticPluginStore).reduce(
Expand Down Expand Up @@ -290,12 +294,38 @@ export const DynamicRoot = ({
});
}

const scaffolderFieldExtensionComponents = scaffolderFieldExtensions.reduce<
{
scope: string;
module: string;
importName: string;
Component: React.ComponentType<{}>;
}[]
>((acc, { scope, module, importName }) => {
const extensionComponent = allPlugins[scope]?.[module]?.[importName];
if (extensionComponent) {
acc.push({
scope,
module,
importName,
Component: extensionComponent as React.ComponentType<unknown>,
});
} else {
// eslint-disable-next-line no-console
console.warn(
`Plugin ${scope} is not configured properly: ${module}.${importName} not found, ignoring scaffolderFieldExtension: ${importName}`,
);
}
return acc;
}, []);

setComponents({
AppProvider: app.current.getProvider(),
AppRouter: app.current.getRouter(),
dynamicRoutes: dynamicRoutesComponents,
mountPoints: mountPointComponents,
entityTabOverrides,
mountPoints: mountPointComponents,
scaffolderFieldExtensions: scaffolderFieldExtensionComponents,
});

afterInit().then(({ default: Component }) => {
Expand Down
11 changes: 9 additions & 2 deletions packages/app/src/components/DynamicRoot/DynamicRootContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,16 +78,23 @@ export type ComponentRegistry = {
AppProvider: React.ComponentType<React.PropsWithChildren>;
AppRouter: React.ComponentType<React.PropsWithChildren>;
dynamicRoutes: DynamicRootContextValue[];
mountPoints: { [mountPoint: string]: ScalprumMountPoint[] };
entityTabOverrides: Record<string, { title: string; mountPoint: string }>;
mountPoints: { [mountPoint: string]: ScalprumMountPoint[] };
scaffolderFieldExtensions: {
scope: string;
module: string;
importName: string;
Component: React.ComponentType<{}>;
}[];
};

const DynamicRootContext = createContext<ComponentRegistry>({
AppProvider: () => null,
AppRouter: () => null,
dynamicRoutes: [],
mountPoints: {},
entityTabOverrides: {},
mountPoints: {},
scaffolderFieldExtensions: [],
});

export default DynamicRootContext;
21 changes: 21 additions & 0 deletions packages/app/src/utils/dynamicUI/extractDynamicConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,12 @@ type ApiFactory = {
importName: string;
};

type ScaffolderFieldExtension = {
scope: string;
module: string;
importName: string;
};

type EntityTab = {
mountPoint: string;
path: string;
Expand Down Expand Up @@ -75,6 +81,7 @@ type CustomProperties = {
mountPoints?: MountPoint[];
appIcons?: AppIcon[];
apiFactories?: ApiFactory[];
scaffolderFieldExtensions?: ScaffolderFieldExtension[];
};

type FrontendConfig = {
Expand All @@ -93,6 +100,7 @@ type DynamicConfig = {
mountPoints: MountPoint[];
routeBindings: RouteBinding[];
routeBindingTargets: BindingTarget[];
scaffolderFieldExtensions: ScaffolderFieldExtension[];
};

/**
Expand All @@ -111,6 +119,7 @@ function extractDynamicConfig(
mountPoints: [],
routeBindings: [],
routeBindingTargets: [],
scaffolderFieldExtensions: [],
};
config.dynamicRoutes = Object.entries(frontend).reduce<DynamicRoute[]>(
(pluginSet, [scope, customProperties]) => {
Expand Down Expand Up @@ -188,6 +197,18 @@ function extractDynamicConfig(
},
[],
);
config.scaffolderFieldExtensions = Object.entries(frontend).reduce<
ScaffolderFieldExtension[]
>((accScaffolderFieldExtensions, [scope, { scaffolderFieldExtensions }]) => {
accScaffolderFieldExtensions.push(
...(scaffolderFieldExtensions ?? []).map(scaffolderFieldExtension => ({
module: scaffolderFieldExtension.module ?? 'PluginRoot',
importName: scaffolderFieldExtension.importName ?? 'default',
scope,
})),
);
return accScaffolderFieldExtensions;
}, []);
config.entityTabs = Object.entries(frontend).reduce<EntityTabEntry[]>(
(accEntityTabs, [scope, { entityTabs }]) => {
accEntityTabs.push(
Expand Down
29 changes: 29 additions & 0 deletions showcase-docs/dynamic-plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -689,3 +689,32 @@ Each plugin can expose multiple API Factories and each factory is required to de

- `importName` is an optional import name that reference a `AnyApiFactory<{}>` implementation. Defaults to `default` export.
- `module` is an optional argument which allows you to specify which set of assets you want to access within the plugin. If not provided, the default module named `PluginRoot` is used. This is the same as the key in `scalprum.exposedModules` key in plugin's `package.json`.

### Provide custom Scaffolder field extensions

The Backstage scaffolder component supports specifying [custom form fields](https://backstage.io/docs/features/software-templates/writing-custom-field-extensions/#creating-a-field-extension) for the software template wizard, for example:

```typescript
export const MyNewFieldExtension = scaffolderPlugin.provide(
createScaffolderFieldExtension({
name: 'MyNewFieldExtension',
component: MyNewField,
validation: myNewFieldValidator,
}),
);
```

These components can be contributed by plugins by exposing the scaffolder field extension component via the `scaffolderFieldExtensions` configuration:

```yaml
dynamicPlugins:
frontend:
<package_name>: # same as `scalprum.name` key in plugin's `package.json`
scaffolderFieldExtensions:
- importName: MyNewFieldExtension
```

A plugin can specify multiple field extensions, in which case each field extension will need to supply an `importName` for each field extension.

- `importName` is an optional import name that should reference the value returned the scaffolder field extension API
- `module` is an optional argument which allows you to specify which set of assets you want to access within the plugin. If not provided, the default module named `PluginRoot` is used. This is the same as the key in `scalprum.exposedModules` key in plugin's `package.json`.
Loading

0 comments on commit 0623d74

Please sign in to comment.