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 bf1017f commit 511cd50
Show file tree
Hide file tree
Showing 9 changed files with 180 additions and 9 deletions.
1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
"@backstage/plugin-org": "0.6.24",
"@backstage/plugin-permission-react": "0.4.22",
"@backstage/plugin-scaffolder": "1.19.3",
"@backstage/plugin-scaffolder-react": "1.8.4",
"@backstage/plugin-search": "1.4.10",
"@backstage/plugin-search-react": "1.7.10",
"@backstage/plugin-user-settings": "0.8.5",
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;
55 changes: 55 additions & 0 deletions packages/app/src/utils/dynamicUI/extractDynamicConfig.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ describe('extractDynamicConfig', () => {
appIcons: [],
routeBindingTargets: [],
apiFactories: [],
scaffolderFieldExtensions: [],
});
});

Expand Down Expand Up @@ -428,6 +429,59 @@ describe('extractDynamicConfig', () => {
],
},
],
[
'a scaffolder field extension',
{
scaffolderFieldExtensions: [{ importName: 'foo', module: 'FooRoot' }],
},
{
scaffolderFieldExtensions: [
{
importName: 'foo',
module: 'FooRoot',
scope: 'janus-idp.plugin-foo',
},
],
},
],
[
'a scaffolder field extension; default module',
{
scaffolderFieldExtensions: [{ importName: 'foo' }],
},
{
scaffolderFieldExtensions: [
{
importName: 'foo',
module: 'PluginRoot',
scope: 'janus-idp.plugin-foo',
},
],
},
],
[
'multiple scaffolder field extensions',
{
scaffolderFieldExtensions: [
{ importName: 'foo', module: 'FooRoot' },
{ importName: 'bar', module: 'BarRoot' },
],
},
{
scaffolderFieldExtensions: [
{
importName: 'foo',
module: 'FooRoot',
scope: 'janus-idp.plugin-foo',
},
{
importName: 'bar',
module: 'BarRoot',
scope: 'janus-idp.plugin-foo',
},
],
},
],
])('parses %s', (_, source: any, output) => {
const config = extractDynamicConfig({
frontend: { 'janus-idp.plugin-foo': source },
Expand All @@ -440,6 +494,7 @@ describe('extractDynamicConfig', () => {
mountPoints: [],
appIcons: [],
apiFactories: [],
scaffolderFieldExtensions: [],
...output,
});
});
Expand Down
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
1 change: 1 addition & 0 deletions packages/app/src/utils/test/TestRoot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ const TestRoot = ({ children }: PropsWithChildren<{}>) => {
dynamicRoutes: [],
mountPoints: {},
entityTabOverrides: {},
scaffolderFieldExtensions: [],
}}
>
{children}
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`.
16 changes: 14 additions & 2 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3122,7 +3122,7 @@
zen-observable "^0.10.0"
zod "^3.22.4"

"@backstage/core-plugin-api@1.9.2", "@backstage/core-plugin-api@^1.8.0", "@backstage/core-plugin-api@^1.8.2", "@backstage/core-plugin-api@^1.9.2":
"@backstage/core-plugin-api@1.9.2", "@backstage/core-plugin-api@^1.9.2":
version "1.9.2"
resolved "https://registry.yarnpkg.com/@backstage/core-plugin-api/-/core-plugin-api-1.9.2.tgz#1a75865e567708829f5a8056ad23ea94233f4b7f"
integrity sha512-VbMzgbp5c14B+xi5qFDXEd/LMsrM9D9IpU9tLPSaN2fn9FWhxmeHILNaiLHO2mdLd6RxLopKKbKWduBYbqyu5Q==
Expand All @@ -3134,6 +3134,18 @@
"@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0"
history "^5.0.0"

"@backstage/core-plugin-api@^1.8.0", "@backstage/core-plugin-api@^1.8.2":
version "1.9.1"
resolved "https://registry.npmjs.org/@backstage/core-plugin-api/-/core-plugin-api-1.9.1.tgz#3ad8b7ee247198bb59fcd3b146092e4f9512a5de"
integrity sha512-hV/U08XkgcEgE8YmwfK/onF2V/BlXaq0GxsalNJ5UarQde1XtRLydCg3NJ6oHTqrmzgcLPBAiOzSs+v5Z/SV5A==
dependencies:
"@backstage/config" "^1.2.0"
"@backstage/errors" "^1.2.4"
"@backstage/types" "^1.1.1"
"@backstage/version-bridge" "^1.0.7"
"@types/react" "^16.13.1 || ^17.0.0 || ^18.0.0"
history "^5.0.0"

"@backstage/dev-utils@1.0.31":
version "1.0.31"
resolved "https://registry.yarnpkg.com/@backstage/dev-utils/-/dev-utils-1.0.31.tgz#db72e72df2d0b234501ac90878f0e0836e50a03c"
Expand Down Expand Up @@ -4444,7 +4456,7 @@
zod "^3.22.4"
zod-to-json-schema "^3.20.4"

"@backstage/plugin-scaffolder-react@^1.8.4":
"@backstage/plugin-scaffolder-react@1.8.4", "@backstage/plugin-scaffolder-react@^1.8.4":
version "1.8.4"
resolved "https://registry.yarnpkg.com/@backstage/plugin-scaffolder-react/-/plugin-scaffolder-react-1.8.4.tgz#cb2797bd94b60d4e0c65e9c25792bf161f5f611a"
integrity sha512-RpOJ9Ou7GUT9gJhQh1ZYz4hV99KU8mwfsmyIEITHp/bEjeschea+hSxHs3iT3a6p/e9ooXsSkOpwigHpOSmjJw==
Expand Down

0 comments on commit 511cd50

Please sign in to comment.