diff --git a/.changeset/smart-islands-judge.md b/.changeset/smart-islands-judge.md
new file mode 100644
index 0000000000..8b21666c4c
--- /dev/null
+++ b/.changeset/smart-islands-judge.md
@@ -0,0 +1,6 @@
+---
+'@backstage/plugin-scaffolder-react': patch
+'@backstage/plugin-scaffolder': patch
+---
+
+Scaffolder field extensions registered with `FormFieldBlueprint` are now collected in the `useCustomFieldExtensions` hook, enabling them for use in the scaffolder.
diff --git a/plugins/scaffolder-react/report-alpha.api.md b/plugins/scaffolder-react/report-alpha.api.md
index 2cc8416b3c..4f9f86d1ac 100644
--- a/plugins/scaffolder-react/report-alpha.api.md
+++ b/plugins/scaffolder-react/report-alpha.api.md
@@ -5,13 +5,17 @@
```ts
///
+import { AnyApiFactory } from '@backstage/frontend-plugin-api';
import { AnyApiRef } from '@backstage/core-plugin-api';
import { ApiHolder } from '@backstage/core-plugin-api';
+import { ApiRef } from '@backstage/frontend-plugin-api';
import { ComponentType } from 'react';
import { ConfigurableExtensionDataRef } from '@backstage/frontend-plugin-api';
import { CustomFieldValidator } from '@backstage/plugin-scaffolder-react';
import { Dispatch } from 'react';
import { ExtensionBlueprint } from '@backstage/frontend-plugin-api';
+import { ExtensionDefinition } from '@backstage/frontend-plugin-api';
+import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { FieldExtensionComponentProps } from '@backstage/plugin-scaffolder-react';
import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react';
import { FieldSchema } from '@backstage/plugin-scaffolder-react';
@@ -171,6 +175,34 @@ export type FormFieldExtensionData<
schema?: FieldSchema, z.output>;
};
+// @alpha (undocumented)
+export const formFieldsApi: ExtensionDefinition<{
+ config: {};
+ configInput: {};
+ output: ConfigurableExtensionDataRef;
+ inputs: {
+ formFields: ExtensionInput<
+ ConfigurableExtensionDataRef<
+ () => Promise,
+ 'scaffolder.form-field-loader',
+ {}
+ >,
+ {
+ singleton: false;
+ optional: false;
+ }
+ >;
+ };
+ kind: 'api';
+ name: 'form-fields';
+ params: {
+ factory: AnyApiFactory;
+ };
+}>;
+
+// @alpha (undocumented)
+export const formFieldsApiRef: ApiRef;
+
// @alpha (undocumented)
export type FormValidation = {
[name: string]: FieldValidation | FormValidation;
@@ -245,6 +277,12 @@ export type ScaffolderFormDecoratorContext<
) => void;
};
+// @alpha (undocumented)
+export interface ScaffolderFormFieldsApi {
+ // (undocumented)
+ getFormFields(): Promise;
+}
+
// @alpha (undocumented)
export function ScaffolderPageContextMenu(
props: ScaffolderPageContextMenuProps,
diff --git a/plugins/scaffolder-react/src/hooks/useCustomFieldExtensions.test.tsx b/plugins/scaffolder-react/src/hooks/useCustomFieldExtensions.test.tsx
new file mode 100644
index 0000000000..c21d43d6ff
--- /dev/null
+++ b/plugins/scaffolder-react/src/hooks/useCustomFieldExtensions.test.tsx
@@ -0,0 +1,129 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * 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, { PropsWithChildren } from 'react';
+import { createPlugin } from '@backstage/core-plugin-api';
+import { TestApiProvider, wrapInTestApp } from '@backstage/test-utils';
+import { renderHook, waitFor } from '@testing-library/react';
+import { ScaffolderFormFieldsApi, formFieldsApiRef } from '../alpha';
+import { useCustomFieldExtensions } from './useCustomFieldExtensions';
+import {
+ ScaffolderFieldExtensions,
+ createScaffolderFieldExtension,
+} from '../extensions';
+
+const plugin = createPlugin({
+ id: 'scaffolder',
+ apis: [],
+ routes: {},
+ externalRoutes: {},
+});
+
+describe('useCustomFieldExtensions', () => {
+ const mockFormFieldsApi: jest.Mocked = {
+ getFormFields: jest.fn(),
+ };
+ const wrapper = ({ children }: PropsWithChildren<{}>) =>
+ wrapInTestApp(
+
+ {children}
+ ,
+ );
+
+ beforeEach(() => {
+ jest.resetAllMocks();
+ });
+
+ it('should return field extensions from the React tree', async () => {
+ mockFormFieldsApi.getFormFields.mockResolvedValue([]);
+ const CustomFieldExtension = plugin.provide(
+ createScaffolderFieldExtension({
+ name: 'test',
+ component: () => Test
,
+ }),
+ );
+
+ const { result } = renderHook(
+ () =>
+ useCustomFieldExtensions(
+
+
+ ,
+ ),
+ {
+ wrapper,
+ },
+ );
+
+ expect(result.current).toEqual([expect.objectContaining({ name: 'test' })]);
+ });
+
+ it('should return field extensions from formFieldsApi', async () => {
+ mockFormFieldsApi.getFormFields.mockResolvedValue([
+ {
+ name: 'blueprint',
+ component: () => Test
,
+ },
+ ]);
+
+ const { result } = renderHook(() => useCustomFieldExtensions(), {
+ wrapper,
+ });
+
+ await waitFor(() => {
+ expect(result.current.length).toBeGreaterThan(0);
+ });
+
+ expect(result.current).toEqual([
+ expect.objectContaining({ name: 'blueprint' }),
+ ]);
+ });
+
+ it('should return field extensions from both sources', async () => {
+ mockFormFieldsApi.getFormFields.mockResolvedValue([
+ {
+ name: 'blueprint',
+ component: () => Test
,
+ },
+ ]);
+
+ const CustomFieldExtension = plugin.provide(
+ createScaffolderFieldExtension({
+ name: 'test',
+ component: () => Test
,
+ }),
+ );
+
+ const { result } = renderHook(
+ () =>
+ useCustomFieldExtensions(
+
+
+ ,
+ ),
+ {
+ wrapper,
+ },
+ );
+
+ await waitFor(() => {
+ expect(result.current).toHaveLength(2);
+ });
+
+ const fieldNames = result.current.map(field => field.name);
+ expect(fieldNames).toEqual(expect.arrayContaining(['test', 'blueprint']));
+ });
+});
diff --git a/plugins/scaffolder-react/src/hooks/useCustomFieldExtensions.ts b/plugins/scaffolder-react/src/hooks/useCustomFieldExtensions.ts
index 7e0aafd53f..cd67b33753 100644
--- a/plugins/scaffolder-react/src/hooks/useCustomFieldExtensions.ts
+++ b/plugins/scaffolder-react/src/hooks/useCustomFieldExtensions.ts
@@ -13,7 +13,9 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-import { useElementFilter } from '@backstage/core-plugin-api';
+import { useAsync, useMountEffect } from '@react-hookz/web';
+import { useApiHolder, useElementFilter } from '@backstage/core-plugin-api';
+import { formFieldsApiRef } from '../next';
import { FieldExtensionOptions } from '../extensions';
import {
FIELD_EXTENSION_KEY,
@@ -25,11 +27,22 @@ import {
* @public
*/
export const useCustomFieldExtensions = <
+ // todo(blam): this shouldn't be here, should remove this, but this is a breaking change to remove the generic.
TComponentDataType = FieldExtensionOptions,
>(
outlet: React.ReactNode,
) => {
- return useElementFilter(outlet, elements =>
+ // Get custom fields created with FormFieldBlueprint
+ const apiHolder = useApiHolder();
+ const formFieldsApi = apiHolder.get(formFieldsApiRef);
+ const [{ result: blueprintFields }, methods] = useAsync(
+ formFieldsApi?.getFormFields ?? (async () => []),
+ [],
+ );
+ useMountEffect(methods.execute);
+
+ // Get custom fields created with ScaffolderFieldExtensions
+ const outletFields = useElementFilter(outlet, elements =>
elements
.selectByComponentData({
key: FIELD_EXTENSION_WRAPPER_KEY,
@@ -38,4 +51,18 @@ export const useCustomFieldExtensions = <
key: FIELD_EXTENSION_KEY,
}),
);
+
+ // This should really be a different type moving foward, but we do this to keep type compatibility.
+ // should probably also move the defaults into the API eventually too, but that will come with the move
+ // to the new frontend system.
+ const blueprintsToLegacy: FieldExtensionOptions[] = blueprintFields?.map(
+ field => ({
+ component: field.component,
+ name: field.name,
+ validation: field.validation,
+ schema: field.schema?.schema,
+ }),
+ );
+
+ return [...blueprintsToLegacy, ...outletFields] as TComponentDataType[];
};
diff --git a/plugins/scaffolder/src/alpha/api/FormFieldsApi.ts b/plugins/scaffolder-react/src/next/api/FormFieldsApi.ts
similarity index 95%
rename from plugins/scaffolder/src/alpha/api/FormFieldsApi.ts
rename to plugins/scaffolder-react/src/next/api/FormFieldsApi.ts
index 35141463d5..7712b91a18 100644
--- a/plugins/scaffolder/src/alpha/api/FormFieldsApi.ts
+++ b/plugins/scaffolder-react/src/next/api/FormFieldsApi.ts
@@ -21,7 +21,7 @@ import {
} from '@backstage/frontend-plugin-api';
import { formFieldsApiRef } from './ref';
import { ScaffolderFormFieldsApi } from './types';
-import { FormFieldBlueprint } from '@backstage/plugin-scaffolder-react/alpha';
+import { FormFieldBlueprint } from '../blueprints';
import { FormField, OpaqueFormField } from '@internal/scaffolder';
class DefaultScaffolderFormFieldsApi implements ScaffolderFormFieldsApi {
@@ -40,6 +40,7 @@ class DefaultScaffolderFormFieldsApi implements ScaffolderFormFieldsApi {
}
}
+/** @alpha */
export const formFieldsApi = ApiBlueprint.makeWithOverrides({
name: 'form-fields',
inputs: {
diff --git a/plugins/scaffolder-react/src/next/api/index.ts b/plugins/scaffolder-react/src/next/api/index.ts
new file mode 100644
index 0000000000..ecc62e5acf
--- /dev/null
+++ b/plugins/scaffolder-react/src/next/api/index.ts
@@ -0,0 +1,19 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * 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.
+ */
+
+export { formFieldsApi } from './FormFieldsApi';
+export { formFieldsApiRef } from './ref';
+export type { ScaffolderFormFieldsApi } from './types';
diff --git a/plugins/scaffolder-react/src/next/api/ref.ts b/plugins/scaffolder-react/src/next/api/ref.ts
new file mode 100644
index 0000000000..eaa6f49c21
--- /dev/null
+++ b/plugins/scaffolder-react/src/next/api/ref.ts
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * 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 { createApiRef } from '@backstage/frontend-plugin-api';
+import { ScaffolderFormFieldsApi } from './types';
+
+/** @alpha */
+export const formFieldsApiRef = createApiRef({
+ id: 'plugin.scaffolder.form-fields',
+});
diff --git a/plugins/scaffolder-react/src/next/api/types.ts b/plugins/scaffolder-react/src/next/api/types.ts
new file mode 100644
index 0000000000..2c98111b1c
--- /dev/null
+++ b/plugins/scaffolder-react/src/next/api/types.ts
@@ -0,0 +1,22 @@
+/*
+ * Copyright 2024 The Backstage Authors
+ *
+ * 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 { FormFieldExtensionData } from '../blueprints';
+
+/** @alpha */
+export interface ScaffolderFormFieldsApi {
+ getFormFields(): Promise;
+}
diff --git a/plugins/scaffolder-react/src/next/index.ts b/plugins/scaffolder-react/src/next/index.ts
index c4e94f36a3..9c967a5db9 100644
--- a/plugins/scaffolder-react/src/next/index.ts
+++ b/plugins/scaffolder-react/src/next/index.ts
@@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+export * from './api';
export * from './components';
export * from './lib';
export * from './hooks';
diff --git a/plugins/scaffolder/report-alpha.api.md b/plugins/scaffolder/report-alpha.api.md
index 15ec32fa49..e272b57655 100644
--- a/plugins/scaffolder/report-alpha.api.md
+++ b/plugins/scaffolder/report-alpha.api.md
@@ -14,7 +14,7 @@ import { ExtensionInput } from '@backstage/frontend-plugin-api';
import { ExternalRouteRef } from '@backstage/frontend-plugin-api';
import { FieldExtensionOptions } from '@backstage/plugin-scaffolder-react';
import { FormField } from '@internal/scaffolder';
-import { FormFieldExtensionData } from '@backstage/plugin-scaffolder-react/alpha';
+import { formFieldsApiRef } from '@backstage/plugin-scaffolder-react/alpha';
import type { FormProps as FormProps_2 } from '@rjsf/core';
import { FormProps as FormProps_3 } from '@backstage/plugin-scaffolder-react';
import { FrontendPlugin } from '@backstage/frontend-plugin-api';
@@ -26,6 +26,7 @@ import { default as React_2 } from 'react';
import { ReviewStepProps } from '@backstage/plugin-scaffolder-react';
import { RouteRef } from '@backstage/frontend-plugin-api';
import { ScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha';
+import { ScaffolderFormFieldsApi } from '@backstage/plugin-scaffolder-react/alpha';
import { SubRouteRef } from '@backstage/frontend-plugin-api';
import { TemplateEntityV1beta3 } from '@backstage/plugin-scaffolder-common';
import { TemplateGroupFilter } from '@backstage/plugin-scaffolder-react';
@@ -52,6 +53,33 @@ const _default: FrontendPlugin<
}>;
},
{
+ 'api:scaffolder/form-fields': ExtensionDefinition<{
+ config: {};
+ configInput: {};
+ output: ConfigurableExtensionDataRef<
+ AnyApiFactory,
+ 'core.api.factory',
+ {}
+ >;
+ inputs: {
+ formFields: ExtensionInput<
+ ConfigurableExtensionDataRef<
+ () => Promise,
+ 'scaffolder.form-field-loader',
+ {}
+ >,
+ {
+ singleton: false;
+ optional: false;
+ }
+ >;
+ };
+ kind: 'api';
+ name: 'form-fields';
+ params: {
+ factory: AnyApiFactory;
+ };
+ }>;
'page:scaffolder': ExtensionDefinition<{
kind: 'page';
name: undefined;
@@ -129,33 +157,6 @@ const _default: FrontendPlugin<
factory: AnyApiFactory;
};
}>;
- 'api:scaffolder/form-fields': ExtensionDefinition<{
- config: {};
- configInput: {};
- output: ConfigurableExtensionDataRef<
- AnyApiFactory,
- 'core.api.factory',
- {}
- >;
- inputs: {
- formFields: ExtensionInput<
- ConfigurableExtensionDataRef<
- () => Promise,
- 'scaffolder.form-field-loader',
- {}
- >,
- {
- singleton: false;
- optional: false;
- }
- >;
- };
- kind: 'api';
- name: 'form-fields';
- params: {
- factory: AnyApiFactory;
- };
- }>;
}
>;
export default _default;
@@ -175,8 +176,7 @@ export class DefaultScaffolderFormDecoratorsApi
// @alpha (undocumented)
export const formDecoratorsApiRef: ApiRef;
-// @alpha (undocumented)
-export const formFieldsApiRef: ApiRef;
+export { formFieldsApiRef };
// @alpha @deprecated
export type FormProps = Pick<
@@ -197,11 +197,7 @@ export interface ScaffolderFormDecoratorsApi {
getFormDecorators(): Promise;
}
-// @alpha (undocumented)
-export interface ScaffolderFormFieldsApi {
- // (undocumented)
- getFormFields(): Promise;
-}
+export { ScaffolderFormFieldsApi };
// @public (undocumented)
export type ScaffolderTemplateEditorClassKey =
diff --git a/plugins/scaffolder/src/alpha/api/index.ts b/plugins/scaffolder/src/alpha/api/index.ts
index 0afefe17a5..07d4b65a3b 100644
--- a/plugins/scaffolder/src/alpha/api/index.ts
+++ b/plugins/scaffolder/src/alpha/api/index.ts
@@ -14,9 +14,6 @@
* limitations under the License.
*/
-export { formFieldsApiRef, formDecoratorsApiRef } from './ref';
-export type {
- ScaffolderFormFieldsApi,
- ScaffolderFormDecoratorsApi,
-} from './types';
+export { formDecoratorsApiRef } from './ref';
+export type { ScaffolderFormDecoratorsApi } from './types';
export { DefaultScaffolderFormDecoratorsApi } from './FormDecoratorsApi';
diff --git a/plugins/scaffolder/src/alpha/api/ref.ts b/plugins/scaffolder/src/alpha/api/ref.ts
index 9890a9a9b2..532669d0ad 100644
--- a/plugins/scaffolder/src/alpha/api/ref.ts
+++ b/plugins/scaffolder/src/alpha/api/ref.ts
@@ -15,12 +15,7 @@
*/
import { createApiRef } from '@backstage/frontend-plugin-api';
-import { ScaffolderFormFieldsApi, ScaffolderFormDecoratorsApi } from './types';
-
-/** @alpha */
-export const formFieldsApiRef = createApiRef({
- id: 'plugin.scaffolder.form-fields',
-});
+import { ScaffolderFormDecoratorsApi } from './types';
/** @alpha */
export const formDecoratorsApiRef = createApiRef({
diff --git a/plugins/scaffolder/src/alpha/api/types.ts b/plugins/scaffolder/src/alpha/api/types.ts
index 10f97a6a10..1d2d331adf 100644
--- a/plugins/scaffolder/src/alpha/api/types.ts
+++ b/plugins/scaffolder/src/alpha/api/types.ts
@@ -14,15 +14,7 @@
* limitations under the License.
*/
-import {
- FormFieldExtensionData,
- ScaffolderFormDecorator,
-} from '@backstage/plugin-scaffolder-react/alpha';
-
-/** @alpha */
-export interface ScaffolderFormFieldsApi {
- getFormFields(): Promise;
-}
+import { ScaffolderFormDecorator } from '@backstage/plugin-scaffolder-react/alpha';
/** @alpha */
export interface ScaffolderFormDecoratorsApi {
diff --git a/plugins/scaffolder/src/alpha/index.ts b/plugins/scaffolder/src/alpha/index.ts
index 53e2b8f0bf..d239f44b4f 100644
--- a/plugins/scaffolder/src/alpha/index.ts
+++ b/plugins/scaffolder/src/alpha/index.ts
@@ -25,4 +25,9 @@ export {
export { scaffolderTranslationRef } from '../translation';
export * from './api';
+export {
+ formFieldsApiRef,
+ type ScaffolderFormFieldsApi,
+} from '@backstage/plugin-scaffolder-react/alpha';
+
export { default } from './plugin';
diff --git a/plugins/scaffolder/src/alpha/plugin.tsx b/plugins/scaffolder/src/alpha/plugin.tsx
index f3f0e9ed9e..44ec1f5d4c 100644
--- a/plugins/scaffolder/src/alpha/plugin.tsx
+++ b/plugins/scaffolder/src/alpha/plugin.tsx
@@ -32,7 +32,7 @@ import {
scaffolderPage,
scaffolderApi,
} from './extensions';
-import { formFieldsApi } from './api/FormFieldsApi';
+import { formFieldsApi } from '@backstage/plugin-scaffolder-react/alpha';
/** @alpha */
export default createFrontendPlugin({
diff --git a/plugins/scaffolder/src/plugin.tsx b/plugins/scaffolder/src/plugin.tsx
index a6ccdef32c..17592b5826 100644
--- a/plugins/scaffolder/src/plugin.tsx
+++ b/plugins/scaffolder/src/plugin.tsx
@@ -80,6 +80,7 @@ import { RepoBranchPicker } from './components/fields/RepoBranchPicker/RepoBranc
import { RepoBranchPickerSchema } from './components/fields/RepoBranchPicker/schema';
import { formDecoratorsApiRef } from './alpha/api/ref';
import { DefaultScaffolderFormDecoratorsApi } from './alpha/api/FormDecoratorsApi';
+import { formFieldsApiRef } from '@backstage/plugin-scaffolder-react/alpha';
/**
* The main plugin export for the scaffolder.
@@ -109,6 +110,11 @@ export const scaffolderPlugin = createPlugin({
deps: {},
factory: () => DefaultScaffolderFormDecoratorsApi.create(),
}),
+ createApiFactory({
+ api: formFieldsApiRef,
+ deps: {},
+ factory: () => ({ getFormFields: async () => [] }),
+ }),
],
routes: {
root: rootRouteRef,