diff --git a/Composer/packages/client/__tests__/components/appSettings.test.tsx b/Composer/packages/client/__tests__/components/appSettings.test.tsx index 8bacba2443..36fdff0bbe 100644 --- a/Composer/packages/client/__tests__/components/appSettings.test.tsx +++ b/Composer/packages/client/__tests__/components/appSettings.test.tsx @@ -27,7 +27,7 @@ describe(' & ', () => { // 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', () => { @@ -47,7 +47,6 @@ describe(' & ', () => { appLocale: 'en-US', }); }); - getByText('Application Updates'); getByText('Auto update'); getByText('Early adopters'); }); diff --git a/Composer/packages/client/config/webpack.config.js b/Composer/packages/client/config/webpack.config.js index cf9abb2b02..ba4867bfd5 100644 --- a/Composer/packages/client/config/webpack.config.js +++ b/Composer/packages/client/config/webpack.config.js @@ -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'; diff --git a/Composer/packages/client/src/App.tsx b/Composer/packages/client/src/App.tsx index 9b69bd3586..37d20721e9 100644 --- a/Composer/packages/client/src/App.tsx +++ b/Composer/packages/client/src/App.tsx @@ -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(() => { diff --git a/Composer/packages/client/src/components/ComposerFeature.tsx b/Composer/packages/client/src/components/ComposerFeature.tsx new file mode 100644 index 0000000000..9b10bf6bd6 --- /dev/null +++ b/Composer/packages/client/src/components/ComposerFeature.tsx @@ -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 = (props) => { + const { featureFlagKey } = props; + const featureIsEnabled = useFeatureFlag(featureFlagKey); + return {featureIsEnabled ? props.children : null}; +}; diff --git a/Composer/packages/client/src/components/CreationFlow/CreateOptions.tsx b/Composer/packages/client/src/components/CreationFlow/CreateOptions.tsx index 208e693c9a..e4c6a586b2 100644 --- a/Composer/packages/client/src/components/CreationFlow/CreateOptions.tsx +++ b/Composer/packages/client/src/components/CreationFlow/CreateOptions.tsx @@ -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'; @@ -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: () => { diff --git a/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx b/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx index 49901cbf34..69a2080a64 100644 --- a/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx +++ b/Composer/packages/client/src/components/CreationFlow/CreationFlow.tsx @@ -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'; @@ -48,9 +48,9 @@ const CreationFlow: React.FC = () => { 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); @@ -152,7 +152,7 @@ const CreationFlow: React.FC = () => { } }; - const handleCreateNext = async (data) => { + const handleCreateNext = async (data: string) => { setCreationFlowStatus(CreationFlowStatus.NEW_FROM_TEMPLATE); navigate(`./create/${data}`); }; diff --git a/Composer/packages/client/src/pages/home/Home.tsx b/Composer/packages/client/src/pages/home/Home.tsx index 09c02ed024..d524cddfa7 100644 --- a/Composer/packages/client/src/pages/home/Home.tsx +++ b/Composer/packages/client/src/pages/home/Home.tsx @@ -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'; @@ -61,7 +56,6 @@ const tutorials = [ ]; const Home: React.FC = () => { - const templateProjects = useRecoilValue(templateProjectsState); const projectId = useRecoilValue(currentProjectIdState); const botName = useRecoilValue(botDisplayNameState(projectId)); const recentProjects = useRecoilValue(recentProjectsState); @@ -69,6 +63,7 @@ const Home: React.FC = () => { const { openProject, setCreationFlowStatus, onboardingAddCoachMarkRef, saveTemplateId } = useRecoilValue( dispatcherState ); + const filteredTemplates = useRecoilValue(filteredTemplatesSelector); const onItemChosen = async (item) => { if (item && item.path) { @@ -135,7 +130,6 @@ const Home: React.FC = () => { disabled: botName ? false : true, }, ]; - return (
@@ -242,7 +236,7 @@ const Home: React.FC = () => { "These examples bring together all of the best practices and supporting components we've identified through building of conversational experiences." )}

- +
diff --git a/Composer/packages/client/src/pages/setting/app-settings/AppSettings.tsx b/Composer/packages/client/src/pages/setting/app-settings/AppSettings.tsx index f94d35cbc2..48850b6b89 100644 --- a/Composer/packages/client/src/pages/setting/app-settings/AppSettings.tsx +++ b/Composer/packages/client/src/pages/setting/app-settings/AppSettings.tsx @@ -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 })) @@ -30,7 +31,6 @@ const AppSettings: React.FC = () => { const { onboardingSetComplete, updateUserSettings } = useRecoilValue(dispatcherState); const userSettings = useRecoilValue(userSettingsState); const { complete } = useRecoilValue(onboardingState); - const onOnboardingChange = useCallback( (checked: boolean) => { // on means its not complete @@ -155,7 +155,11 @@ const AppSettings: React.FC = () => { onChange={onLocaleChange} /> - }>{renderElectronSettings && } +
+

{formatMessage('Application Updates')}

+ }>{renderElectronSettings && } + +
); }; diff --git a/Composer/packages/client/src/pages/setting/app-settings/FeatureFlagCheckBox.tsx b/Composer/packages/client/src/pages/setting/app-settings/FeatureFlagCheckBox.tsx new file mode 100644 index 0000000000..ba6dabe9f1 --- /dev/null +++ b/Composer/packages/client/src/pages/setting/app-settings/FeatureFlagCheckBox.tsx @@ -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) => () => ( + + {`${featureName}.`} + {` ${description}`} + +); + +export const FeatureFlagCheckBox: React.FC = (props) => { + return ( + { + if (checked !== undefined) { + props.toggleFeatureFlag(props.featureFlagKey, checked); + } + }} + onRenderLabel={renderLabel(props.featureFlagName, props.description)} + /> + ); +}; diff --git a/Composer/packages/client/src/pages/setting/app-settings/PreviewFeatureToggle.tsx b/Composer/packages/client/src/pages/setting/app-settings/PreviewFeatureToggle.tsx new file mode 100644 index 0000000000..6c1af7b21a --- /dev/null +++ b/Composer/packages/client/src/pages/setting/app-settings/PreviewFeatureToggle.tsx @@ -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( + + ); + } + }); + return
{result}
; + }; + + return ( + + { + showFeatureFlag(checked); + }} + /> + {renderFeatureFlagOptions()} + + ); +}; diff --git a/Composer/packages/client/src/pages/setting/app-settings/SettingToggle.tsx b/Composer/packages/client/src/pages/setting/app-settings/SettingToggle.tsx index e125c0ae4d..69c6acffd2 100644 --- a/Composer/packages/client/src/pages/setting/app-settings/SettingToggle.tsx +++ b/Composer/packages/client/src/pages/setting/app-settings/SettingToggle.tsx @@ -19,10 +19,11 @@ interface ISettingToggleProps { image: string; onToggle: (checked: boolean) => void; title: string; + hideToggle?: boolean; } const SettingToggle: React.FC = (props) => { - const { id, title, description, image, checked, onToggle } = props; + const { id, title, description, image, checked, onToggle, hideToggle } = props; const uniqueId = useId(kebabCase(title)); return ( @@ -36,7 +37,7 @@ const SettingToggle: React.FC = (props) => {

{description}

-
+ {!hideToggle && ( = (props) => { onChange={(_e, checked) => onToggle(!!checked)} onText={formatMessage('On')} /> -
+ )} ); }; diff --git a/Composer/packages/client/src/pages/setting/app-settings/electronSettings.tsx b/Composer/packages/client/src/pages/setting/app-settings/electronSettings.tsx index b51774946f..427f0bb7e5 100644 --- a/Composer/packages/client/src/pages/setting/app-settings/electronSettings.tsx +++ b/Composer/packages/client/src/pages/setting/app-settings/electronSettings.tsx @@ -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'; @@ -23,8 +24,7 @@ export const ElectronSettings: React.FC = () => { }; return ( -
-

{formatMessage('Application Updates')}

+ = () => { title={formatMessage('Early adopters')} onToggle={onAppUpdatesChange('useNightly')} /> -
+ ); }; diff --git a/Composer/packages/client/src/pages/setting/app-settings/images/index.ts b/Composer/packages/client/src/pages/setting/app-settings/images/index.ts index 1b553cd1af..f1c069e3da 100644 --- a/Composer/packages/client/src/pages/setting/app-settings/images/index.ts +++ b/Composer/packages/client/src/pages/setting/app-settings/images/index.ts @@ -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 }; diff --git a/Composer/packages/client/src/pages/setting/app-settings/images/preview-features.svg b/Composer/packages/client/src/pages/setting/app-settings/images/preview-features.svg new file mode 100644 index 0000000000..49fb26c540 --- /dev/null +++ b/Composer/packages/client/src/pages/setting/app-settings/images/preview-features.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Composer/packages/client/src/pages/setting/app-settings/styles.ts b/Composer/packages/client/src/pages/setting/app-settings/styles.ts index 714ce428ef..ad31e5354f 100644 --- a/Composer/packages/client/src/pages/setting/app-settings/styles.ts +++ b/Composer/packages/client/src/pages/setting/app-settings/styles.ts @@ -57,3 +57,16 @@ export const settingsDescription = css` export const image = css` width: 86px; `; + +export const featureFlagGroupContainer = css` + margin-left: 166px; + font-size: ${FontSizes.size12}; +`; + +export const featureFlagContainer = css` + margin-bottom: 15px; +`; + +export const featureFlagTitle = css` + font-weight: ${FontWeights.semibold}; +`; diff --git a/Composer/packages/client/src/recoilModel/atoms/appState.ts b/Composer/packages/client/src/recoilModel/atoms/appState.ts index 22c585d39a..a409ccbd7b 100644 --- a/Composer/packages/client/src/recoilModel/atoms/appState.ts +++ b/Composer/packages/client/src/recoilModel/atoms/appState.ts @@ -2,7 +2,7 @@ // Licensed under the MIT License. import { atom, atomFamily } from 'recoil'; -import { FormDialogSchemaTemplate, ProjectTemplate, UserSettings } from '@bfc/shared'; +import { FormDialogSchemaTemplate, FeatureFlagMap, ProjectTemplate, UserSettings } from '@bfc/shared'; import { ExtensionMetadata } from '@bfc/extension-client'; import { @@ -113,6 +113,11 @@ export const userSettingsState = atom({ default: getUserSettings(), }); +export const featureFlagsState = atom({ + key: getFullyQualifiedKey('featureFlag'), + default: {} as FeatureFlagMap, +}); + export const announcementState = atom({ key: getFullyQualifiedKey('announcement'), default: '', diff --git a/Composer/packages/client/src/recoilModel/dispatchers/storage.ts b/Composer/packages/client/src/recoilModel/dispatchers/storage.ts index 2c8352e52c..305c29e6d9 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/storage.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/storage.ts @@ -4,6 +4,7 @@ import { useRecoilCallback, CallbackInterface } from 'recoil'; import isArray from 'lodash/isArray'; import formatMessage from 'format-message'; +import { FeatureFlagKey, FeatureFlagMap } from '@bfc/shared'; import httpClient from '../../utils/httpUtil'; import { @@ -13,6 +14,7 @@ import { applicationErrorState, templateProjectsState, runtimeTemplatesState, + featureFlagsState, } from '../atoms/appState'; import { FileTypes } from '../../constants'; import { getExtension } from '../../utils/fileUtil'; @@ -167,6 +169,30 @@ export const storageDispatcher = () => { } ); + const fetchFeatureFlags = useRecoilCallback<[], Promise>((callbackHelpers: CallbackInterface) => async () => { + const { set } = callbackHelpers; + try { + const response = await httpClient.get('/featureFlags'); + set(featureFlagsState, response.data); + } catch (ex) { + logMessage(callbackHelpers, `Error fetching feature flag data: ${ex}`); + } + }); + + const toggleFeatureFlag = useRecoilCallback( + ({ set }: CallbackInterface) => async (featureName: FeatureFlagKey, enabled: boolean) => { + let newFeatureFlags: FeatureFlagMap = {} as FeatureFlagMap; + // update local + set(featureFlagsState, (featureFlagsState) => { + newFeatureFlags = { ...featureFlagsState }; + newFeatureFlags[featureName] = { ...featureFlagsState[featureName], enabled: enabled }; + return newFeatureFlags; + }); + // update server + await httpClient.post(`/featureFlags`, { featureFlags: newFeatureFlags }); + } + ); + return { fetchStorages, updateCurrentPathForStorage, @@ -178,5 +204,7 @@ export const storageDispatcher = () => { updateFolder, fetchTemplates, fetchRuntimeTemplates, + fetchFeatureFlags, + toggleFeatureFlag, }; }; diff --git a/Composer/packages/client/src/recoilModel/selectors/index.ts b/Composer/packages/client/src/recoilModel/selectors/index.ts index 6e1afda06f..c6cdd3fa4f 100644 --- a/Composer/packages/client/src/recoilModel/selectors/index.ts +++ b/Composer/packages/client/src/recoilModel/selectors/index.ts @@ -6,3 +6,4 @@ export * from './eject'; export * from './extensions'; export * from './validatedDialogs'; export * from './dialogs'; +export * from './projectTemplates'; diff --git a/Composer/packages/client/src/recoilModel/selectors/projectTemplates.ts b/Composer/packages/client/src/recoilModel/selectors/projectTemplates.ts new file mode 100644 index 0000000000..d3b8a8124d --- /dev/null +++ b/Composer/packages/client/src/recoilModel/selectors/projectTemplates.ts @@ -0,0 +1,23 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { selector } from 'recoil'; + +import { featureFlagsState, templateProjectsState } from '../atoms/appState'; + +export const filteredTemplatesSelector = selector({ + key: 'filteredTemplatesSelector', + get: ({ get }) => { + const templates = get(templateProjectsState); + const featureFlags = get(featureFlagsState); + + const filteredTemplates = [...templates]; + if (!featureFlags?.VA_CREATION?.enabled) { + const vaTemplateIndex = filteredTemplates.findIndex((template) => template.id === 'va-core'); + if (vaTemplateIndex !== -1) { + filteredTemplates.splice(vaTemplateIndex, 1); + } + } + return filteredTemplates; + }, +}); diff --git a/Composer/packages/client/src/utils/hooks.ts b/Composer/packages/client/src/utils/hooks.ts index 2eaac47ea4..c1d442fd07 100644 --- a/Composer/packages/client/src/utils/hooks.ts +++ b/Composer/packages/client/src/utils/hooks.ts @@ -6,8 +6,9 @@ import { globalHistory } from '@reach/router'; import replace from 'lodash/replace'; import find from 'lodash/find'; import { useRecoilValue } from 'recoil'; +import { FeatureFlagKey } from '@bfc/shared'; -import { designPageLocationState, currentProjectIdState, pluginPagesSelector } from '../recoilModel'; +import { designPageLocationState, currentProjectIdState, pluginPagesSelector, featureFlagsState } from '../recoilModel'; import { bottomLinks, topLinks } from './pageLinks'; import routerCache from './routerCache'; @@ -22,15 +23,28 @@ export const useLocation = () => { return state; }; +export const useFeatureFlag = (featureFlagKey: FeatureFlagKey): boolean => { + const featureFlags = useRecoilValue(featureFlagsState); + const enabled = useMemo(() => { + if (featureFlags[featureFlagKey]) { + return featureFlags[featureFlagKey].enabled; + } + return false; + }, [featureFlags[featureFlagKey]]); + + return enabled; +}; + export const useLinks = () => { const projectId = useRecoilValue(currentProjectIdState); const designPageLocation = useRecoilValue(designPageLocationState(projectId)); const pluginPages = useRecoilValue(pluginPagesSelector); const openedDialogId = designPageLocation.dialogId || 'Main'; + const showFormDialog = useFeatureFlag('FORM_DIALOG'); const pageLinks = useMemo(() => { - return topLinks(projectId, openedDialogId, pluginPages); - }, [projectId, openedDialogId, pluginPages]); + return topLinks(projectId, openedDialogId, pluginPages, showFormDialog); + }, [projectId, openedDialogId, pluginPages, showFormDialog]); return { topLinks: pageLinks, bottomLinks }; }; diff --git a/Composer/packages/client/src/utils/pageLinks.ts b/Composer/packages/client/src/utils/pageLinks.ts index 7e9deb52e6..088108f95b 100644 --- a/Composer/packages/client/src/utils/pageLinks.ts +++ b/Composer/packages/client/src/utils/pageLinks.ts @@ -5,7 +5,12 @@ import { ExtensionPageContribution } from '@bfc/extension-client'; export type ExtensionPageConfig = ExtensionPageContribution & { id: string }; -export const topLinks = (projectId: string, openedDialogId: string, pluginPages: ExtensionPageConfig[]) => { +export const topLinks = ( + projectId: string, + openedDialogId: string, + pluginPages: ExtensionPageConfig[], + showFormDialog: boolean +) => { const botLoaded = !!projectId; let links = [ { @@ -64,7 +69,7 @@ export const topLinks = (projectId: string, openedDialogId: string, pluginPages: exact: true, disabled: !botLoaded, }, - ...(process.env.COMPOSER_ENABLE_FORMS + ...(showFormDialog ? [ { to: `/bot/${projectId}/forms`, diff --git a/Composer/packages/lib/shared/src/featureFlagUtils/index.ts b/Composer/packages/lib/shared/src/featureFlagUtils/index.ts new file mode 100644 index 0000000000..3f03bf3a67 --- /dev/null +++ b/Composer/packages/lib/shared/src/featureFlagUtils/index.ts @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import formatMessage from 'format-message'; + +export type FeatureFlag = { + // Name to be displayed for this features toggle UI in app settings page + displayName: string; + // Description to be displayed for this features toggle UI in app settings page + description: string; + // Indicates whether or not the feature flag toggle will be visible to the user through the settings page UI + // Hidden feature flags are intended for features not ready for public preview + isHidden: boolean; + enabled: boolean; +}; + +export type FeatureFlagKey = 'VA_CREATION' | 'FORM_DIALOG'; + +export type FeatureFlagMap = Record; + +export const getDefaultFeatureFlags = (): FeatureFlagMap => ({ + VA_CREATION: { + displayName: formatMessage('VA Creation'), + description: formatMessage('VA template made available in new bot flow.'), + isHidden: false, + enabled: false, + }, + FORM_DIALOG: { + displayName: formatMessage('Show Form Dialog'), + description: formatMessage('Show form dialog editor in the canvas'), + isHidden: false, + enabled: false, + }, +}); diff --git a/Composer/packages/lib/shared/src/index.ts b/Composer/packages/lib/shared/src/index.ts index 57d7843a58..cba6732d3f 100644 --- a/Composer/packages/lib/shared/src/index.ts +++ b/Composer/packages/lib/shared/src/index.ts @@ -25,4 +25,5 @@ export * from './schemaUtils'; export * from './viewUtils'; export * from './walkerUtils'; export * from './skillsUtils'; +export * from './featureFlagUtils'; export const DialogUtils = dialogUtils; diff --git a/Composer/packages/server/src/controllers/asset.ts b/Composer/packages/server/src/controllers/asset.ts index 8d989ae260..3959f21760 100644 --- a/Composer/packages/server/src/controllers/asset.ts +++ b/Composer/packages/server/src/controllers/asset.ts @@ -1,11 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -import AssectService from '../services/asset'; +import AssetService from '../services/asset'; async function getProjTemplates(req: any, res: any) { try { - const templates = await AssectService.manager.getProjectTemplates(); + const templates = await AssetService.manager.getProjectTemplates(); res.status(200).json(templates); } catch (error) { res.status(400).json({ diff --git a/Composer/packages/server/src/controllers/featureFlags.ts b/Composer/packages/server/src/controllers/featureFlags.ts new file mode 100644 index 0000000000..dc2272d307 --- /dev/null +++ b/Composer/packages/server/src/controllers/featureFlags.ts @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Request, Response } from 'express'; + +import { FeatureFlagService } from '../services/featureFlags'; + +async function getFeatureFlags(req: Request, res: Response) { + try { + const featureFlags = await FeatureFlagService.getFeatureFlags(); + return res.status(200).json(featureFlags); + } catch (err) { + return res.status(500).json({ + message: err instanceof Error ? err.message : err, + }); + } +} + +async function updateFeatureFlags(req: Request, res: Response) { + try { + if (!req.body.featureFlags) { + res.status(400).json({ + message: 'parameters not provided, require feature flags', + }); + return; + } + const featureFlags = req.body.featureFlags; + await FeatureFlagService.updateFeatureFlag(featureFlags); + res.status(200).json(featureFlags); + } catch (err) { + res.status(500).json({ + message: err instanceof Error ? err.message : err, + }); + } +} + +export const FeatureFlagController = { + getFeatureFlags, + updateFeatureFlags, +}; diff --git a/Composer/packages/server/src/locales/en-US.json b/Composer/packages/server/src/locales/en-US.json index ddd7ce6a80..5a3d1ba73d 100644 --- a/Composer/packages/server/src/locales/en-US.json +++ b/Composer/packages/server/src/locales/en-US.json @@ -953,6 +953,9 @@ "edit_property_dd6a1172": { "message": "Edit Property" }, + "edit_schema_a2ab5695": { + "message": "Edit schema" + }, "edit_source_45af68b4": { "message": "Edit source" }, @@ -1805,6 +1808,9 @@ "open_inline_editor_a5aabcfa": { "message": "Open inline editor" }, + "open_skills_page_for_configuration_details_a2a484ea": { + "message": "Open Skills page for configuration details" + }, "optional_221bcc9d": { "message": "Optional" }, @@ -1904,6 +1910,9 @@ "press_enter_to_add_this_name_and_advance_to_the_ne_6a2ae080": { "message": "press Enter to add this name and advance to the next row, or press Tab to advance to the value field" }, + "preview_features_e279bac5": { + "message": "Preview features" + }, "previous_bd2ac015": { "message": "Previous" }, @@ -2072,6 +2081,9 @@ "regex_intent_is_already_defined_df095c1f": { "message": "RegEx { intent } is already defined" }, + "regular_expression_855557bf": { + "message": "Regular Expression" + }, "regular_expression_recognizer_44664557": { "message": "Regular expression recognizer" }, @@ -2282,6 +2294,12 @@ "show_code_f3e9d1cc": { "message": "Show code" }, + "show_form_dialog_90aad050": { + "message": "Show Form Dialog" + }, + "show_form_dialog_editor_in_the_canvas_140492b2": { + "message": "Show form dialog editor in the canvas" + }, "show_keys_3072a5b8": { "message": "Show keys" }, @@ -2597,6 +2615,9 @@ "try_again_ad656c3c": { "message": "Try again" }, + "try_new_features_in_preview_and_help_us_make_compo_e8e58983": { + "message": "Try new features in preview and help us make Composer better. You can turn them on or off at any time." + }, "type_a_name_that_describes_this_content_d1a910b6": { "message": "Type a name that describes this content" }, @@ -2618,6 +2639,9 @@ "typing_activity_6b634ae": { "message": "Typing activity" }, + "unable_to_determine_recognizer_type_from_data_valu_2960f526": { + "message": "Unable to determine recognizer type from data: { value }" + }, "undo_a7be8fef": { "message": "Undo" }, @@ -2708,6 +2732,12 @@ "user_is_typing_790cb502": { "message": "User is typing" }, + "va_creation_6babf7db": { + "message": "VA Creation" + }, + "va_template_made_available_in_new_bot_flow_5f85baa4": { + "message": "VA template made available in new bot flow." + }, "validating_35b79a96": { "message": "Validating..." }, diff --git a/Composer/packages/server/src/router/api.ts b/Composer/packages/server/src/router/api.ts index 75833b9649..be2b7c4bec 100644 --- a/Composer/packages/server/src/router/api.ts +++ b/Composer/packages/server/src/router/api.ts @@ -12,6 +12,7 @@ import { AssetController } from '../controllers/asset'; import { EjectController } from '../controllers/eject'; import { FormDialogController } from '../controllers/formDialog'; import * as ExtensionsController from '../controllers/extensions'; +import { FeatureFlagController } from '../controllers/featureFlags'; import { UtilitiesController } from './../controllers/utilities'; @@ -82,6 +83,10 @@ router.get('/extensions/:id/:bundleId', ExtensionsController.getBundleForView); // proxy route for extensions (allows extension client code to make fetch calls using the Composer server as a proxy -- avoids browser blocking request due to CORS) router.post('/extensions/proxy/:url', ExtensionsController.performExtensionFetch); +//FeatureFlags +router.get('/featureFlags', FeatureFlagController.getFeatureFlags); +router.post('/featureFlags', FeatureFlagController.updateFeatureFlags); + const errorHandler = (handler: RequestHandler) => (req: Request, res: Response, next: NextFunction) => { Promise.resolve(handler(req, res, next)).catch(next); }; diff --git a/Composer/packages/server/src/services/featureFlags.ts b/Composer/packages/server/src/services/featureFlags.ts new file mode 100644 index 0000000000..392d738976 --- /dev/null +++ b/Composer/packages/server/src/services/featureFlags.ts @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { getDefaultFeatureFlags, FeatureFlagMap, FeatureFlagKey } from '@bfc/shared'; + +import { Store } from '../store/store'; + +const storeKey = 'featureFlags'; + +export class FeatureFlagService { + private static currentFeatureFlagMap: FeatureFlagMap = {} as FeatureFlagMap; + private static defaultFeatureFlags: FeatureFlagMap = {} as FeatureFlagMap; + + private static initialize() { + // Get users feature flag config from data.json and populate if it does not exist + FeatureFlagService.defaultFeatureFlags = getDefaultFeatureFlags(); + FeatureFlagService.currentFeatureFlagMap = Store.get(storeKey, FeatureFlagService.defaultFeatureFlags); + FeatureFlagService.updateFeatureFlags(); + } + + private static updateFeatureFlags = () => { + const currentFeatureFlagKeys = Object.keys(FeatureFlagService.currentFeatureFlagMap); + const defaultFeatureFlagKeys = Object.keys(FeatureFlagService.defaultFeatureFlags); + const keysToAdd: string[] = []; + const keysToUpdateHidden: string[] = []; + + defaultFeatureFlagKeys.forEach((key: string) => { + if (!currentFeatureFlagKeys.includes(key)) { + keysToAdd.push(key); + } else if ( + currentFeatureFlagKeys.includes(key) && + FeatureFlagService.currentFeatureFlagMap[key as FeatureFlagKey].isHidden !== + FeatureFlagService.defaultFeatureFlags[key as FeatureFlagKey].isHidden + ) { + keysToUpdateHidden.push(key); + } + }); + + const keysToRemove = currentFeatureFlagKeys.filter((key: string) => { + if (!defaultFeatureFlagKeys.includes(key)) { + return key; + } + }); + + keysToAdd.forEach((key: string) => { + FeatureFlagService.currentFeatureFlagMap[key] = FeatureFlagService.defaultFeatureFlags[key]; + }); + + keysToRemove.forEach((key: string) => { + delete FeatureFlagService.currentFeatureFlagMap[key]; + }); + + keysToUpdateHidden.forEach((key: string) => { + FeatureFlagService.currentFeatureFlagMap[key as FeatureFlagKey].isHidden = + FeatureFlagService.defaultFeatureFlags[key as FeatureFlagKey].isHidden; + }); + + const hiddenFeatureFlagUpdated = FeatureFlagService.updateHiddenFeatureFlags(); + + if ( + keysToRemove?.length > 0 || + keysToAdd?.length > 0 || + keysToUpdateHidden?.length > 0 || + hiddenFeatureFlagUpdated + ) { + Store.set(storeKey, FeatureFlagService.currentFeatureFlagMap); + } + }; + + private static updateHiddenFeatureFlags = (): boolean => { + const hiddenFeatureFlagKeys = Object.keys(FeatureFlagService.currentFeatureFlagMap).filter((key: string) => { + if (FeatureFlagService.currentFeatureFlagMap[key as FeatureFlagKey].isHidden) { + return key; + } + }); + + let result = false; + hiddenFeatureFlagKeys.forEach((key: string) => { + if (process.env[key] && process.env[key] !== FeatureFlagService.currentFeatureFlagMap[key]) { + FeatureFlagService.currentFeatureFlagMap[key as FeatureFlagKey].enabled = + process.env[key]?.toLowerCase() === 'true'; + result = true; + } + }); + return result; + }; + + public static getFeatureFlags(): FeatureFlagMap { + FeatureFlagService.initialize(); + return FeatureFlagService.currentFeatureFlagMap; + } + + public static updateFeatureFlag(newFeatureFlags: FeatureFlagMap) { + FeatureFlagService.currentFeatureFlagMap = newFeatureFlags; + Store.set(storeKey, newFeatureFlags); + } + + public static getFeatureFlagValue(featureFlagKey: FeatureFlagKey): boolean { + if (FeatureFlagService.currentFeatureFlagMap && FeatureFlagService.currentFeatureFlagMap[featureFlagKey]) { + return FeatureFlagService.currentFeatureFlagMap[featureFlagKey].enabled; + } + return false; + } +} diff --git a/Composer/packages/server/src/store/data.template.ts b/Composer/packages/server/src/store/data.template.ts index 172c9044c5..0fd17e74d6 100644 --- a/Composer/packages/server/src/store/data.template.ts +++ b/Composer/packages/server/src/store/data.template.ts @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +import { getDefaultFeatureFlags } from '@bfc/shared'; + import settings from '../settings'; export default { @@ -17,4 +19,5 @@ export default { ], recentBotProjects: [], projectLocationMap: {}, + featureFlags: getDefaultFeatureFlags(), }; diff --git a/Composer/packages/server/src/store/store.ts b/Composer/packages/server/src/store/store.ts index 457424861c..8b695e878f 100644 --- a/Composer/packages/server/src/store/store.ts +++ b/Composer/packages/server/src/store/store.ts @@ -33,7 +33,6 @@ class JsonStore implements KVStore { public get(key: string, defaultValue?: T): T { this.readStore(); - if (key in this.data) { return this.data[key]; } else if (defaultValue) { diff --git a/docs/Assets/Feature-flag-1.png b/docs/Assets/Feature-flag-1.png new file mode 100644 index 0000000000..2be5a9c357 Binary files /dev/null and b/docs/Assets/Feature-flag-1.png differ diff --git a/docs/Assets/Feature-flag-2.png b/docs/Assets/Feature-flag-2.png new file mode 100644 index 0000000000..2506d697d9 Binary files /dev/null and b/docs/Assets/Feature-flag-2.png differ diff --git a/docs/Assets/Feature-flag-3.png b/docs/Assets/Feature-flag-3.png new file mode 100644 index 0000000000..36ac243bd7 Binary files /dev/null and b/docs/Assets/Feature-flag-3.png differ diff --git a/docs/Assets/Feature-flag-4.png b/docs/Assets/Feature-flag-4.png new file mode 100644 index 0000000000..a197fb5079 Binary files /dev/null and b/docs/Assets/Feature-flag-4.png differ diff --git a/docs/Assets/Feature-flag-5.png b/docs/Assets/Feature-flag-5.png new file mode 100644 index 0000000000..ff9d0209fb Binary files /dev/null and b/docs/Assets/Feature-flag-5.png differ diff --git a/docs/internal/FeatureFlags.md b/docs/internal/FeatureFlags.md new file mode 100644 index 0000000000..6a89f0fc6d --- /dev/null +++ b/docs/internal/FeatureFlags.md @@ -0,0 +1,64 @@ +# Feature Flags +## Overview +The following documentation walks developers thorough feature flag usage and creation in Composer. There is also guidance on when to use certain feature flag types as well as how to toggle hidden and visible feature flags in different environments. + +## Feature Flag Creation +- Go to ~\Composer\packages\lib\shared\src\featureFlagUtils\index.ts + - Add a feature flag key to FeatureFlagKeys + - Add a feature flag object that uses that key to getDefaultFeatureFlags function. + - Add displayName and description values for your feature flag, these will appear in the UI where the user can toggle the feature on or off. + - Indicate whether you want your feature flag to be hidden or not + - Hidden feature flags are not changeable through the UI and intended for in development or private preview features. + - Visible feature flags are used for features in public preview that are mature enough for any end user to use. + + ![Feature flag creation](../Assets/Feature-flag-1.png) + +- Build @bfc/shared and run Composer +- Navigate to the app settings page and validate that any feature flags that you added that have hidden = false are now visible in the app settings page + ![Feature flag toggle](../Assets/Feature-flag-2.png) + +## Feature Flag Usage +Now that you have created your feature flag you have what you need to put your new feature code behind this flag. You can do this a few different ways depending on the type of feature you are implementing. + +### Front end React.node feature +If your feature is integrated into the Composer UI as a React node then you can pass that react node as a child to the higher order component. +For example, say your “feature” was the tutorials UI in home.tsx (denoted below as `
…)`, then the below addition of would tie that UI to the feature flag value you added in the previous step + +![Feature flag HOC usage](../Assets/Feature-flag-3.png) + + + +### Front end feature that is not integrated as a React.node +There are scenarios in which a feature is integrated with the front end through other means then jsx code. In those scenarios you can use the useFeatureFlag(key: FeatureFlagKey) hook to get the value of a given feature flag. +The below example uses the useFeatureFlag hook to determine whether a not a “feature” should be included in toolbar Items. + +![Feature flag non-HOC usage](../Assets/Feature-flag-4.png) + + +### Server-side Feature +Feature flag state is maintained at parity between the server and client workspaces. So if you have server side code that you want to put behind a feature flag it is possible. That being said most feature scenarios will likely gate off entry point in the client side such that server side feature code can’t be hit. + +Feature flag state on the server can be accessed through FeatureFlagService.currentFeatureFlagMap and if you need a specific value, FeatureFlagService. getFeatureFlagValue(featureFlagKey: FeatureFlagKey). For example the below code would conditionally add a step to the createProject server flow. + +![Feature flag server usage](../Assets/Feature-flag-5.png) + +### Extension feature +Feature flag state not yet surfaced to extensions. + +## Toggling hidden Feature Flags +Non hidden feature flags are toggleable through the app settings page in Composer. Hidden feature flags can be configured by the end user in a few different ways depending on your environment. + +### Electron app +Currently no way yet to set feature flags in downloaded electron app + +### Dev env +Feature flag values are stored in data.json. In a development environment where you have direct access to this file you can toggle the value for hidden and non hidden feature flags directly in data.json. +Next iteration task items: + +## Work items for R12 +Tracked [here](https://github.com/microsoft/BotFramework-Composer/issues/4513). +- Add UI and back end functionality to toggle all feature flags to false at once (per design spec) +- Add feature flag groups (per design spec) +- Feature flag values available to extensions +- Add ability to configure hidden feature flags when running electron app +- Crete unit tests for all feature flag scenarios diff --git a/extensions/azurePublish/yarn.lock b/extensions/azurePublish/yarn.lock index 7d58c71d80..bc45364ad0 100644 --- a/extensions/azurePublish/yarn.lock +++ b/extensions/azurePublish/yarn.lock @@ -964,6 +964,9 @@ buffer@^5.1.0, buffer@^5.5.0: version "5.6.0" resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.6.0.tgz#a31749dc7d81d84db08abf937b6b8c4033f62786" integrity sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw== + dependencies: + base64-js "^1.0.2" + ieee754 "^1.1.4" cardinal@^2.1.1: version "2.1.1" diff --git a/extensions/localPublish/yarn.lock b/extensions/localPublish/yarn.lock index e2b9dd013c..00ded2c015 100644 --- a/extensions/localPublish/yarn.lock +++ b/extensions/localPublish/yarn.lock @@ -212,6 +212,9 @@ buffer@^5.1.0, buffer@^5.5.0: version "5.6.0" resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.6.0.tgz#a31749dc7d81d84db08abf937b6b8c4033f62786" integrity sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw== + dependencies: + base64-js "^1.0.2" + ieee754 "^1.1.4" chownr@^2.0.0: version "2.0.0" diff --git a/extensions/vacore/src/index.ts b/extensions/vacore/src/index.ts index a30d1aa6e9..e96fc0c3f9 100644 --- a/extensions/vacore/src/index.ts +++ b/extensions/vacore/src/index.ts @@ -5,13 +5,11 @@ import path from 'path'; import formatMessage from 'format-message'; export default async (composer: any): Promise => { - if (process.env.VA_CREATION) { - // register the base template which will appear in the new bot modal - composer.addBotTemplate({ - id: 'va-core', - name: formatMessage('VA Core'), - description: formatMessage('The core of your new VA - ready to run, just add skills!'), - path: path.resolve(__dirname, '../template'), - }); - } + // register the base template which will appear in the new bot modal + composer.addBotTemplate({ + id: 'va-core', + name: formatMessage('VA Core'), + description: formatMessage('The core of your new VA - ready to run, just add skills!'), + path: path.resolve(__dirname, '../template'), + }); };