diff --git a/.changeset/olive-otters-marry.md b/.changeset/olive-otters-marry.md new file mode 100644 index 0000000000..a90e4dc5b9 --- /dev/null +++ b/.changeset/olive-otters-marry.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-react': patch +--- + +Fixed `backstage:featureFlag` in `scaffolder/next` by sorting out `manifest.steps`. diff --git a/plugins/scaffolder-react/alpha-api-report.md b/plugins/scaffolder-react/alpha-api-report.md index 3d9361bf18..615b54df84 100644 --- a/plugins/scaffolder-react/alpha-api-report.md +++ b/plugins/scaffolder-react/alpha-api-report.md @@ -290,6 +290,11 @@ export interface TemplateGroupsProps { templateFilter?: (entity: TemplateEntityV1beta3) => boolean; } +// @alpha +export const useFilteredSchemaProperties: ( + manifest: TemplateParameterSchema | undefined, +) => TemplateParameterSchema | undefined; + // @alpha export const useFormDataFromQuery: ( initialState?: Record, diff --git a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx index e7c56378a7..c02f3c2757 100644 --- a/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx +++ b/plugins/scaffolder-react/src/next/components/Workflow/Workflow.tsx @@ -27,6 +27,8 @@ import { errorApiRef, useApi } from '@backstage/core-plugin-api'; import { useTemplateParameterSchema } from '../../hooks/useTemplateParameterSchema'; import { Stepper, type StepperProps } from '../Stepper/Stepper'; import { SecretsContextProvider } from '../../../secrets/SecretsContext'; + +import { useFilteredSchemaProperties } from '../../hooks/useFilteredSchemaProperties'; import { ReviewStepProps } from '@backstage/plugin-scaffolder-react'; const useStyles = makeStyles(() => ({ @@ -81,6 +83,8 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { const { loading, manifest, error } = useTemplateParameterSchema(templateRef); + const sortedManifest = useFilteredSchemaProperties(manifest); + useEffect(() => { if (error) { errorApi.post(new Error(`Failed to load template, ${error}`)); @@ -94,19 +98,25 @@ export const Workflow = (workflowProps: WorkflowProps): JSX.Element | null => { return ( {loading && } - {manifest && ( + {sortedManifest && ( } noPadding titleTypographyProps={{ component: 'h2' }} > - + )} diff --git a/plugins/scaffolder-react/src/next/hooks/index.ts b/plugins/scaffolder-react/src/next/hooks/index.ts index ca8ce10eff..63f8cda4e9 100644 --- a/plugins/scaffolder-react/src/next/hooks/index.ts +++ b/plugins/scaffolder-react/src/next/hooks/index.ts @@ -19,3 +19,4 @@ export { type ParsedTemplateSchema, } from './useTemplateSchema'; export { useTemplateParameterSchema } from './useTemplateParameterSchema'; +export { useFilteredSchemaProperties } from './useFilteredSchemaProperties'; diff --git a/plugins/scaffolder-react/src/next/hooks/useFilteredSchemaProperties.test.tsx b/plugins/scaffolder-react/src/next/hooks/useFilteredSchemaProperties.test.tsx new file mode 100644 index 0000000000..3e568fa7bd --- /dev/null +++ b/plugins/scaffolder-react/src/next/hooks/useFilteredSchemaProperties.test.tsx @@ -0,0 +1,232 @@ +/* + * Copyright 2023 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 { renderHook } from '@testing-library/react'; +import { useFilteredSchemaProperties } from './useFilteredSchemaProperties'; +import { TemplateParameterSchema } from '../../types'; +import { TestApiProvider } from '@backstage/test-utils'; +import React from 'react'; +import { featureFlagsApiRef } from '@backstage/core-plugin-api'; + +const mockFeatureFlagApi = { + isActive: jest.fn(), +}; + +describe('useFilteredSchemaProperties', () => { + it('should return the same manifest if no feature flag is set', () => { + mockFeatureFlagApi.isActive.mockReturnValue(true); + + const manifest: TemplateParameterSchema = { + title: 'Test Action template', + description: 'scaffolder v1beta3 template demo', + steps: [ + { + title: 'Fill in some steps', + schema: { + type: 'object', + 'backstage:featureFlag': 'experimental-feature', + properties: { + name: { + description: 'Unique name of the component', + title: 'Name', + type: 'string', + 'ui:autofocus': true, + }, + }, + }, + }, + { + title: 'Choose a location', + schema: { + type: 'object', + properties: { + repoUrl: { + type: 'string', + title: 'Repository Location', + 'ui:field': 'RepoUrlPicker', + }, + }, + }, + }, + ], + }; + + const filteredManifest = renderHook( + () => useFilteredSchemaProperties(manifest), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + expect(filteredManifest.result.current).toEqual(manifest); + }); + + it('should hide individual fields from steps of template', () => { + mockFeatureFlagApi.isActive.mockReturnValue(false); + + const manifest: TemplateParameterSchema = { + title: 'Test Action template', + description: 'scaffolder v1beta3 template demo', + steps: [ + { + title: 'Fill in some steps', + schema: { + type: 'object', + properties: { + name: { + description: 'Unique name of the component', + title: 'Name', + type: 'string', + 'ui:autofocus': true, + }, + }, + }, + }, + { + title: 'Choose a location', + schema: { + type: 'object', + properties: { + repoUrl: { + type: 'string', + title: 'Repository Location', + 'ui:field': 'RepoUrlPicker', + 'backstage:featureFlag': 'experimental-feature', + }, + }, + }, + }, + ], + }; + + const filteredManifest = renderHook( + () => useFilteredSchemaProperties(manifest), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + const expectedManifest: TemplateParameterSchema = { + title: 'Test Action template', + description: 'scaffolder v1beta3 template demo', + steps: [ + { + title: 'Fill in some steps', + schema: { + type: 'object', + properties: { + name: { + description: 'Unique name of the component', + title: 'Name', + type: 'string', + 'ui:autofocus': true, + }, + }, + }, + }, + { + title: 'Choose a location', + schema: { + type: 'object', + properties: {}, + }, + }, + ], + }; + + expect(filteredManifest.result.current).toEqual(expectedManifest); + }); + + it('should hide "Fill in some steps" from steps of template', () => { + mockFeatureFlagApi.isActive.mockReturnValue(false); + + const manifest: TemplateParameterSchema = { + title: 'Test Action template', + description: 'scaffolder v1beta3 template demo', + steps: [ + { + title: 'Fill in some steps', + schema: { + type: 'object', + 'backstage:featureFlag': 'experimental-feature', + properties: { + name: { + description: 'Unique name of the component', + title: 'Name', + type: 'string', + 'ui:autofocus': true, + }, + }, + }, + }, + { + title: 'Choose a location', + schema: { + type: 'object', + properties: { + repoUrl: { + type: 'string', + title: 'Repository Location', + 'ui:field': 'RepoUrlPicker', + }, + }, + }, + }, + ], + }; + + const filteredManifest = renderHook( + () => useFilteredSchemaProperties(manifest), + { + wrapper: ({ children }) => ( + + {children} + + ), + }, + ); + + const expectedManifest: TemplateParameterSchema = { + title: 'Test Action template', + description: 'scaffolder v1beta3 template demo', + steps: [ + { + title: 'Choose a location', + schema: { + type: 'object', + properties: { + repoUrl: { + type: 'string', + title: 'Repository Location', + 'ui:field': 'RepoUrlPicker', + }, + }, + }, + }, + ], + }; + + expect(filteredManifest.result.current).toEqual(expectedManifest); + }); +}); diff --git a/plugins/scaffolder-react/src/next/hooks/useFilteredSchemaProperties.ts b/plugins/scaffolder-react/src/next/hooks/useFilteredSchemaProperties.ts new file mode 100644 index 0000000000..be6f581f87 --- /dev/null +++ b/plugins/scaffolder-react/src/next/hooks/useFilteredSchemaProperties.ts @@ -0,0 +1,76 @@ +/* + * Copyright 2023 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 cloneDeep from 'lodash/cloneDeep'; +import { useApi, featureFlagsApiRef } from '@backstage/core-plugin-api'; +import { TemplateParameterSchema } from '@backstage/plugin-scaffolder-react'; + +/** + * Returns manifest of software templates with steps without a featureFlag tag. + * @alpha + */ + +export const useFilteredSchemaProperties = ( + manifest: TemplateParameterSchema | undefined, +): TemplateParameterSchema | undefined => { + const featureFlagKey = 'backstage:featureFlag'; + const featureFlagApi = useApi(featureFlagsApiRef); + + if (!manifest) { + return undefined; + } + + const filteredSteps = manifest?.steps + .filter(step => { + const featureFlag = step.schema[featureFlagKey]; + return ( + typeof featureFlag !== 'string' || featureFlagApi.isActive(featureFlag) + ); + }) + .map(step => { + const filteredStep = cloneDeep(step); + const removedPropertyKeys: Array = []; + if (filteredStep.schema.properties) { + filteredStep.schema.properties = Object.fromEntries( + Object.entries(filteredStep.schema.properties).filter( + ([key, value]) => { + if (value[featureFlagKey]) { + if (featureFlagApi.isActive(value[featureFlagKey])) { + return true; + } + + removedPropertyKeys.push(key); + return false; + } + return true; + }, + ), + ); + + // remove the feature flag property key from required if they are not active + filteredStep.schema.required = Array.isArray( + filteredStep.schema.required, + ) + ? filteredStep.schema.required?.filter( + r => !removedPropertyKeys.includes(r as string), + ) + : filteredStep.schema.required; + } + + return filteredStep; + }); + + return { ...manifest, steps: filteredSteps }; +};