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,