Merge pull request #9665 from djamaile/feature-flag-scaffolding

feat(scaffolder): remove sections or fields based on a feature flag
This commit is contained in:
Fredrik Adelöw
2022-02-21 21:53:40 +01:00
committed by GitHub
3 changed files with 171 additions and 10 deletions
+40
View File
@@ -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.
@@ -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<string, any>, 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<string> = [];
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);
@@ -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<ScaffolderApi> = {
listActions: jest.fn(),
};
const featureFlagsApiMock: jest.Mocked<FeatureFlagsApi> = {
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(
<ApiProvider apis={apis}>
<TemplatePage />
</ApiProvider>,
{
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();
});
});