diff --git a/.changeset/strong-lions-compete.md b/.changeset/strong-lions-compete.md new file mode 100644 index 0000000000..635991eb1d --- /dev/null +++ b/.changeset/strong-lions-compete.md @@ -0,0 +1,40 @@ +--- +'@backstage/plugin-scaffolder': patch +--- + +You can now hide sections or fields in your templates based on a feature flag. For example, take this template: + +```json +{ + title: 'my-schema', + steps: [ + { + title: 'Fill in some steps', + schema: { + title: 'Fill in some steps', + 'backstage:featureFlag': 'experimental-feature', + properties: { + name: { + title: 'Name', + type: 'string', + 'backstage:featureFlag': 'should-show-some-stuff-first-option', + }, + description: { + title: 'Description', + type: 'string', + description: 'A description for the component', + }, + owner: { + title: 'Owner', + type: 'string', + description: 'Owner of the component', + }, + }, + type: 'object', + }, + }, +} + +``` + +If you have a feature flag that is called `experimental-feature` then your first step would be shown if you that feature flag was not active then it wouldn't be shown. The same goes for the properties in the schema. Make sure to use the key `backstage:featureFlag` in your templates if you want to use this functionality. diff --git a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx index a9c8eaec1d..4d37eafee4 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -24,12 +24,17 @@ import { Stepper, Typography, } from '@material-ui/core'; -import { errorApiRef, useApi } from '@backstage/core-plugin-api'; +import { + errorApiRef, + useApi, + featureFlagsApiRef, +} from '@backstage/core-plugin-api'; import { FormProps, IChangeEvent, UiSchema, withTheme } from '@rjsf/core'; import { Theme as MuiTheme } from '@rjsf/material-ui'; import React, { useState } from 'react'; import { transformSchemaToProps } from './schema'; import { Content, StructuredMetadataTable } from '@backstage/core-components'; +import cloneDeep from 'lodash/cloneDeep'; import * as fieldOverrides from './FieldOverrides'; const Form = withTheme(MuiTheme); @@ -104,18 +109,50 @@ export function getReviewData(formData: Record, steps: Step[]) { return reviewData; } -export const MultistepJsonForm = ({ - steps, - formData, - onChange, - onReset, - onFinish, - fields, - widgets, -}: Props) => { +export const MultistepJsonForm = (props: Props) => { + const { formData, onChange, onReset, onFinish, fields, widgets } = props; const [activeStep, setActiveStep] = useState(0); const [disableButtons, setDisableButtons] = useState(false); const errorApi = useApi(errorApiRef); + const featureFlagApi = useApi(featureFlagsApiRef); + const featureFlagKey = 'backstage:featureFlag'; + const filterOutProperties = (step: Step): 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; + }; + + const steps = props.steps + .filter(step => { + const featureFlag = step.schema[featureFlagKey]; + return ( + typeof featureFlag !== 'string' || featureFlagApi.isActive(featureFlag) + ); + }) + .map(filterOutProperties); const handleReset = () => { setActiveStep(0); diff --git a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx index 7ca65fff25..48710b7086 100644 --- a/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx +++ b/plugins/scaffolder/src/components/TemplatePage/TemplatePage.test.tsx @@ -26,6 +26,10 @@ import { MemoryRouter, Route } from 'react-router'; import { ScaffolderApi, scaffolderApiRef } from '../../api'; import { rootRouteRef } from '../../routes'; import { TemplatePage } from './TemplatePage'; +import { + featureFlagsApiRef, + FeatureFlagsApi, +} from '@backstage/core-plugin-api'; import { ApiProvider } from '@backstage/core-app-api'; import { errorApiRef } from '@backstage/core-plugin-api'; @@ -48,11 +52,63 @@ const scaffolderApiMock: jest.Mocked = { listActions: jest.fn(), }; +const featureFlagsApiMock: jest.Mocked = { + isActive: jest.fn(), + registerFlag: jest.fn(), + getRegisteredFlags: jest.fn(), + save: jest.fn(), +}; + const errorApiMock = { post: jest.fn(), error$: jest.fn() }; +const schemaMockValue = { + title: 'my-schema', + steps: [ + { + title: 'Fill in some steps', + schema: { + title: 'Fill in some steps', + 'backstage:featureFlag': 'experimental-feature', + properties: { + name: { + title: 'Name', + type: 'string', + 'backstage:featureFlag': 'should-show-some-stuff-first-option', + }, + description: { + title: 'Description', + type: 'string', + description: 'A description for the component', + }, + owner: { + title: 'Owner', + type: 'string', + description: 'Owner of the component', + }, + }, + type: 'object', + }, + }, + { + title: 'Send data', + schema: { + title: 'Send data', + properties: { + user: { + title: 'User', + type: 'string', + }, + }, + type: 'object', + }, + }, + ], +}; + const apis = TestApiRegistry.from( [scaffolderApiRef, scaffolderApiMock], [errorApiRef, errorApiMock], + [featureFlagsApiRef, featureFlagsApiMock], ); describe('TemplatePage', () => { @@ -197,4 +253,32 @@ describe('TemplatePage', () => { fireEvent.click(await findByText('Next step')); expect(await findByText('Reset')).toBeInTheDocument(); }); + + it('should display a section or property based on a feature flag', async () => { + featureFlagsApiMock.isActive.mockImplementation(flag => { + if (flag === 'experimental-feature') { + return true; + } + return false; + }); + scaffolderApiMock.getTemplateParameterSchema.mockResolvedValue( + schemaMockValue, + ); + + const { queryByText } = await renderInTestApp( + + + , + { + mountedRoutes: { + '/create/actions': rootRouteRef, + }, + }, + ); + + expect(await queryByText('Name')).not.toBeInTheDocument(); + expect(await queryByText('Description')).toBeInTheDocument(); + expect(await queryByText('Owner')).toBeInTheDocument(); + expect(await queryByText('Send data')).toBeInTheDocument(); + }); });