Skip to content

Commit

Permalink
feat: Adding feature flag infrastructure (#4428)
Browse files Browse the repository at this point in the history
* initial front end feature flag implementation

* Initial server implementation

* Converting state to object over array

* Cleaning up dev test changes

* created HOC to abstract feature flag checking for UI features. Changed feature flag toggle per new designs.

* Added feature flag value population for hidden feature flags via env variables

* Making PR changes: variable and file name changes, general clean up, simplifying types

* adding format message for feature flag strings

* moving ComposerFeature HOC to client workspace so it is implicitly aware of recoil state

* changed default feature flags to function for format message, created hook for filtered templates, replaced env variable featue flag with new feature flag

* adding selector for feature flag filtered template state, created feature flag hook for reuse, type and name fixes

* removing unneeded div

* Adding util functions and reusing hook in HOC

* Updating UI for electron and web view of feature flag toggle per design spec

* Added feature flag documentation

* removing unused imports and variables

* Updated unit tests, added build locale
 autogenerated file updates

Co-authored-by: Patrick Volum <pavolum@microsoft.com>
Co-authored-by: Chris Whitten <christopher.whitten@microsoft.com>
  • Loading branch information
3 people authored Oct 24, 2020
1 parent ff51727 commit b534797
Show file tree
Hide file tree
Showing 39 changed files with 555 additions and 49 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe('<AppSettings /> & <ElectronSettings />', () => {
// there are 2 onboarding texts
getAllByText('Onboarding');
getByText('Property editor preferences');
expect(() => getByText('Application Updates')).toThrow();
getByText('Application Updates');
});

it('should render the electron settings section', () => {
Expand All @@ -47,7 +47,6 @@ describe('<AppSettings /> & <ElectronSettings />', () => {
appLocale: 'en-US',
});
});
getByText('Application Updates');
getByText('Auto update');
getByText('Early adopters');
});
Expand Down
4 changes: 0 additions & 4 deletions Composer/packages/client/config/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,6 @@ const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPl
const getClientEnvironment = require('./env');
const paths = require('./paths');

new webpack.DefinePlugin({
'process.env.COMPOSER_ENABLE_FORMS': JSON.stringify(process.env.COMPOSER_ENABLE_FORMS),
});

// Source maps are resource heavy and can cause out of memory issue for large source files.
const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false';

Expand Down
5 changes: 5 additions & 0 deletions Composer/packages/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,15 @@ initializeIcons(undefined, { disableWarnings: true });

export const App: React.FC = () => {
const { appLocale } = useRecoilValue(userSettingsState);
const { fetchFeatureFlags } = useRecoilValue(dispatcherState);
useEffect(() => {
loadLocale(appLocale);
}, [appLocale]);

useEffect(() => {
fetchFeatureFlags();
}, []);

const { fetchExtensions } = useRecoilValue(dispatcherState);

useEffect(() => {
Expand Down
16 changes: 16 additions & 0 deletions Composer/packages/client/src/components/ComposerFeature.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
import { FeatureFlagKey } from '@bfc/shared';
import React, { Fragment } from 'react';

import { useFeatureFlag } from '../utils/hooks';

type ComposerFeatureProps = {
featureFlagKey: FeatureFlagKey;
};

export const ComposerFeature: React.FC<ComposerFeatureProps> = (props) => {
const { featureFlagKey } = props;
const featureIsEnabled = useFeatureFlag(featureFlagKey);
return <Fragment>{featureIsEnabled ? props.children : null}</Fragment>;
};
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { Sticky, StickyPositionType } from 'office-ui-fabric-react/lib/Sticky';
import { ProjectTemplate } from '@bfc/shared';
import { DialogWrapper, DialogTypes } from '@bfc/ui-shared';
import { NeutralColors } from '@uifabric/fluent-theme';
import { RouteComponentProps } from '@reach/router';

import { DialogCreationCopy, EmptyBotTemplateId, QnABotTemplateId } from '../../constants';

Expand Down Expand Up @@ -103,14 +104,18 @@ const optionKeys = {
};

// -------------------- CreateOptions -------------------- //
type CreateOptionsProps = {
templates: ProjectTemplate[];
onDismiss: () => void;
onNext: (data: string) => void;
} & RouteComponentProps<{}>;

export function CreateOptions(props) {
export function CreateOptions(props: CreateOptionsProps) {
const [option, setOption] = useState(optionKeys.createFromScratch);
const [disabled, setDisabled] = useState(true);
const { templates, onDismiss, onNext } = props;
const [currentTemplate, setCurrentTemplate] = useState('');
const [emptyBotKey, setEmptyBotKey] = useState('');

const selection = useMemo(() => {
return new Selection({
onSelectionChanged: () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ import { CreationFlowStatus } from '../../constants';
import {
dispatcherState,
creationFlowStatusState,
templateProjectsState,
storagesState,
focusedStorageFolderState,
currentProjectIdState,
userSettingsState,
filteredTemplatesSelector,
} from '../../recoilModel';
import Home from '../../pages/home/Home';
import { useProjectIdCache } from '../../utils/hooks';
Expand Down Expand Up @@ -48,9 +48,9 @@ const CreationFlow: React.FC<CreationFlowProps> = () => {
fetchProjectById,
} = useRecoilValue(dispatcherState);

const templateProjects = useRecoilValue(filteredTemplatesSelector);
const creationFlowStatus = useRecoilValue(creationFlowStatusState);
const projectId = useRecoilValue(currentProjectIdState);
const templateProjects = useRecoilValue(templateProjectsState);
const storages = useRecoilValue(storagesState);
const focusedStorageFolder = useRecoilValue(focusedStorageFolderState);
const { appLocale } = useRecoilValue(userSettingsState);
Expand Down Expand Up @@ -152,7 +152,7 @@ const CreationFlow: React.FC<CreationFlowProps> = () => {
}
};

const handleCreateNext = async (data) => {
const handleCreateNext = async (data: string) => {
setCreationFlowStatus(CreationFlowStatus.NEW_FROM_TEMPLATE);
navigate(`./create/${data}`);
};
Expand Down
14 changes: 4 additions & 10 deletions Composer/packages/client/src/pages/home/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,8 @@ import { navigate } from '@reach/router';
import { useRecoilValue } from 'recoil';

import { CreationFlowStatus } from '../../constants';
import { dispatcherState, botDisplayNameState } from '../../recoilModel';
import {
recentProjectsState,
templateProjectsState,
templateIdState,
currentProjectIdState,
} from '../../recoilModel/atoms/appState';
import { dispatcherState, botDisplayNameState, filteredTemplatesSelector } from '../../recoilModel';
import { recentProjectsState, templateIdState, currentProjectIdState } from '../../recoilModel/atoms/appState';
import { Toolbar, IToolbarItem } from '../../components/Toolbar';

import * as home from './styles';
Expand Down Expand Up @@ -61,14 +56,14 @@ const tutorials = [
];

const Home: React.FC<RouteComponentProps> = () => {
const templateProjects = useRecoilValue(templateProjectsState);
const projectId = useRecoilValue(currentProjectIdState);
const botName = useRecoilValue(botDisplayNameState(projectId));
const recentProjects = useRecoilValue(recentProjectsState);
const templateId = useRecoilValue(templateIdState);
const { openProject, setCreationFlowStatus, onboardingAddCoachMarkRef, saveTemplateId } = useRecoilValue(
dispatcherState
);
const filteredTemplates = useRecoilValue(filteredTemplatesSelector);

const onItemChosen = async (item) => {
if (item && item.path) {
Expand Down Expand Up @@ -135,7 +130,6 @@ const Home: React.FC<RouteComponentProps> = () => {
disabled: botName ? false : true,
},
];

return (
<div css={home.outline}>
<Toolbar toolbarItems={toolbarItems} />
Expand Down Expand Up @@ -242,7 +236,7 @@ const Home: React.FC<RouteComponentProps> = () => {
"These examples bring together all of the best practices and supporting components we've identified through building of conversational experiences."
)}
</p>
<ExampleList examples={templateProjects} onClick={onClickTemplate} />
<ExampleList examples={filteredTemplates} onClick={onClickTemplate} />
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { container, section } from './styles';
import { SettingToggle } from './SettingToggle';
import { SettingDropdown } from './SettingDropdown';
import * as images from './images';
import { PreviewFeatureToggle } from './PreviewFeatureToggle';

const ElectronSettings = lazy(() =>
import('./electronSettings').then((module) => ({ default: module.ElectronSettings }))
Expand All @@ -30,7 +31,6 @@ const AppSettings: React.FC<RouteComponentProps> = () => {
const { onboardingSetComplete, updateUserSettings } = useRecoilValue(dispatcherState);
const userSettings = useRecoilValue(userSettingsState);
const { complete } = useRecoilValue(onboardingState);

const onOnboardingChange = useCallback(
(checked: boolean) => {
// on means its not complete
Expand Down Expand Up @@ -155,7 +155,11 @@ const AppSettings: React.FC<RouteComponentProps> = () => {
onChange={onLocaleChange}
/>
</section>
<Suspense fallback={<div />}>{renderElectronSettings && <ElectronSettings />}</Suspense>
<section css={section}>
<h2>{formatMessage('Application Updates')}</h2>
<Suspense fallback={<div />}>{renderElectronSettings && <ElectronSettings />}</Suspense>
<PreviewFeatureToggle />
</section>
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/** @jsx jsx */
import { jsx } from '@emotion/core';
import React from 'react';
import { Checkbox } from 'office-ui-fabric-react/lib/Checkbox';
import { FeatureFlagKey } from '@bfc/shared';

import * as styles from './styles';

type FeatureFlagCheckBoxProps = {
featureFlagKey: FeatureFlagKey;
featureFlagName: string;
description: string;
enabled: boolean;
toggleFeatureFlag: (FeatureFlagKey: FeatureFlagKey, enabled: boolean) => void;
};

const renderLabel = (featureName: string, description: string) => () => (
<span>
<span css={styles.featureFlagTitle}>{`${featureName}.`}</span>
{` ${description}`}
</span>
);

export const FeatureFlagCheckBox: React.FC<FeatureFlagCheckBoxProps> = (props) => {
return (
<Checkbox
checked={props.enabled}
css={styles.featureFlagContainer}
onChange={(e: any, checked?: boolean) => {
if (checked !== undefined) {
props.toggleFeatureFlag(props.featureFlagKey, checked);
}
}}
onRenderLabel={renderLabel(props.featureFlagName, props.description)}
/>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

/** @jsx jsx */
import { jsx } from '@emotion/core';
import { Fragment, useState } from 'react';
import formatMessage from 'format-message';
import { FeatureFlag, FeatureFlagKey } from '@bfc/shared';
import { useRecoilValue } from 'recoil';

import { dispatcherState, featureFlagsState } from '../../../recoilModel';

import { featureFlagGroupContainer } from './styles';
import { SettingToggle } from './SettingToggle';
import * as images from './images';
import { FeatureFlagCheckBox } from './FeatureFlagCheckBox';

export const PreviewFeatureToggle: React.FC = () => {
const featureFlags = useRecoilValue(featureFlagsState);
const { toggleFeatureFlag } = useRecoilValue(dispatcherState);
const [featureFlagVisible, showFeatureFlag] = useState(false);

const renderFeatureFlagOptions = () => {
const result: React.ReactNode[] = [];
Object.keys(featureFlags).forEach((key: string) => {
const featureFlag: FeatureFlag = featureFlags[key];
if (!featureFlag.isHidden) {
result.push(
<FeatureFlagCheckBox
key={key}
description={featureFlag.description}
enabled={featureFlag.enabled}
featureFlagKey={key as FeatureFlagKey}
featureFlagName={featureFlag.displayName}
toggleFeatureFlag={toggleFeatureFlag}
/>
);
}
});
return <div css={featureFlagGroupContainer}>{result}</div>;
};

return (
<Fragment>
<SettingToggle
hideToggle
checked={featureFlagVisible}
description={formatMessage(
'Try new features in preview and help us make Composer better. You can turn them on or off at any time.'
)}
image={images.previewFeatures}
title={formatMessage('Preview features')}
onToggle={(checked: boolean) => {
showFeatureFlag(checked);
}}
/>
{renderFeatureFlagOptions()}
</Fragment>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ interface ISettingToggleProps {
image: string;
onToggle: (checked: boolean) => void;
title: string;
hideToggle?: boolean;
}

const SettingToggle: React.FC<ISettingToggleProps> = (props) => {
const { id, title, description, image, checked, onToggle } = props;
const { id, title, description, image, checked, onToggle, hideToggle } = props;
const uniqueId = useId(kebabCase(title));

return (
Expand All @@ -36,7 +37,7 @@ const SettingToggle: React.FC<ISettingToggleProps> = (props) => {
</Label>
<p css={styles.settingsDescription}>{description}</p>
</div>
<div>
{!hideToggle && (
<Toggle
checked={!!checked}
data-testid={id}
Expand All @@ -45,7 +46,7 @@ const SettingToggle: React.FC<ISettingToggleProps> = (props) => {
onChange={(_e, checked) => onToggle(!!checked)}
onText={formatMessage('On')}
/>
</div>
)}
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@ import formatMessage from 'format-message';
import { Link } from 'office-ui-fabric-react/lib/Link';
import { RouteComponentProps } from '@reach/router';
import { useRecoilValue } from 'recoil';
import { Fragment } from 'react';

import { userSettingsState, dispatcherState } from '../../../recoilModel';

import { link, section } from './styles';
import { link } from './styles';
import { SettingToggle } from './SettingToggle';
import * as images from './images';

Expand All @@ -23,8 +24,7 @@ export const ElectronSettings: React.FC<RouteComponentProps> = () => {
};

return (
<section css={section}>
<h2>{formatMessage('Application Updates')}</h2>
<Fragment>
<SettingToggle
checked={userSettings.appUpdater.autoDownload}
description={formatMessage('Check for updates and install them automatically.')}
Expand Down Expand Up @@ -55,6 +55,6 @@ export const ElectronSettings: React.FC<RouteComponentProps> = () => {
title={formatMessage('Early adopters')}
onToggle={onAppUpdatesChange('useNightly')}
/>
</section>
</Fragment>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@ import wordWrap from './word-wrap.svg';
import autoUpdate from './auto-update.svg';
import earlyAdopters from './early-adopters.svg';
import language from './language.svg';
import previewFeatures from './preview-features.svg';

export { minimap, onboarding, lineNumbers, wordWrap, autoUpdate, earlyAdopters, language };
export { minimap, onboarding, lineNumbers, wordWrap, autoUpdate, earlyAdopters, language, previewFeatures };
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading

0 comments on commit b534797

Please sign in to comment.