From 2f2543592c33ad9a65ca4b250e38ff4bc19e36ac Mon Sep 17 00:00:00 2001 From: djamaile Date: Fri, 18 Feb 2022 16:21:37 +0100 Subject: [PATCH] feat(scaffolder): remove sections or fields based on a feature flag Signed-off-by: djamaile --- .changeset/strong-lions-compete.md | 40 +++++++++ .../MultistepJsonForm/MultistepJsonForm.tsx | 50 ++++++++++- .../TemplatePage/TemplatePage.test.tsx | 84 +++++++++++++++++++ 3 files changed, 171 insertions(+), 3 deletions(-) create mode 100644 .changeset/strong-lions-compete.md 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..14e7c642f2 100644 --- a/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx +++ b/plugins/scaffolder/src/components/MultistepJsonForm/MultistepJsonForm.tsx @@ -24,7 +24,11 @@ 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'; @@ -116,13 +120,53 @@ export const MultistepJsonForm = ({ const [activeStep, setActiveStep] = useState(0); const [disableButtons, setDisableButtons] = useState(false); const errorApi = useApi(errorApiRef); + const featureFlagApi = useApi(featureFlagsApiRef); + const featureFlagKey = 'backstage:featureFlag'; + const filterOutProperties = (schema: JsonObject): JsonObject => { + const removedPropertiesKeys: Array = []; + if (schema.properties) { + schema.properties = Object.fromEntries( + Object.entries(schema.properties).filter(([key, value]) => { + if (value[featureFlagKey]) { + if (featureFlagApi.isActive(value[featureFlagKey])) { + return true; + } + removedPropertiesKeys.push(key); + return false; + } + return true; + }), + ); + + // remove the feature flag property keys from required if they are not active + if (Array.isArray(schema.required) && removedPropertiesKeys.length > 0) { + for (const property of removedPropertiesKeys) { + const index = schema.required.findIndex(r => r === property); + if (index > -1) { + schema.required.splice(index, 1); + } + } + } + } + return schema; + }; + + const updatedSteps = steps.filter(s => { + const featureFlag = s.schema[featureFlagKey] as string; + if (featureFlag && !featureFlagApi.isActive(featureFlag)) { + return null; + } + // filter out properties accordingly to the feature flag settings; + s.schema = filterOutProperties(s.schema); + return s; + }); const handleReset = () => { setActiveStep(0); onReset(); }; const handleNext = () => { - setActiveStep(Math.min(activeStep + 1, steps.length)); + setActiveStep(Math.min(activeStep + 1, updatedSteps.length)); }; const handleBack = () => setActiveStep(Math.max(activeStep - 1, 0)); const handleCreate = async () => { @@ -138,7 +182,7 @@ export const MultistepJsonForm = ({ return ( <> - {steps.map(({ title, schema, ...formProps }, index) => { + {updatedSteps.map(({ title, schema, ...formProps }, index) => { return ( = { 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(); + }); });