diff --git a/Composer/packages/client/src/App.tsx b/Composer/packages/client/src/App.tsx index 37d20721e9..deba75ad70 100644 --- a/Composer/packages/client/src/App.tsx +++ b/Composer/packages/client/src/App.tsx @@ -16,19 +16,16 @@ initializeIcons(undefined, { disableWarnings: true }); export const App: React.FC = () => { const { appLocale } = useRecoilValue(userSettingsState); - const { fetchFeatureFlags } = useRecoilValue(dispatcherState); + const { fetchExtensions, fetchFeatureFlags, fetchServerSettings } = useRecoilValue(dispatcherState); + useEffect(() => { loadLocale(appLocale); }, [appLocale]); - useEffect(() => { - fetchFeatureFlags(); - }, []); - - const { fetchExtensions } = useRecoilValue(dispatcherState); - useEffect(() => { fetchExtensions(); + fetchFeatureFlags(); + fetchServerSettings(); }, []); return ( diff --git a/Composer/packages/client/src/components/AppComponents/Assistant.tsx b/Composer/packages/client/src/components/AppComponents/Assistant.tsx index a0cd5e1b63..6a5c438afe 100644 --- a/Composer/packages/client/src/components/AppComponents/Assistant.tsx +++ b/Composer/packages/client/src/components/AppComponents/Assistant.tsx @@ -7,17 +7,24 @@ import { Suspense, Fragment } from 'react'; import React from 'react'; import { isElectron } from './../../utils/electronUtil'; -import { onboardingState } from './../../recoilModel'; +import { ServerSettingsState, onboardingState } from './../../recoilModel'; const Onboarding = React.lazy(() => import('./../../Onboarding/Onboarding')); const AppUpdater = React.lazy(() => import('./../AppUpdater').then((module) => ({ default: module.AppUpdater }))); +const DataCollectionDialog = React.lazy(() => import('./../DataCollectionDialog')); export const Assistant = () => { + const { telemetry } = useRecoilValue(ServerSettingsState); const onboarding = useRecoilValue(onboardingState); const renderAppUpdater = isElectron(); + + const renderDataCollectionDialog = telemetry?.allowDataCollection === null; + const renderOnboarding = !renderDataCollectionDialog && !onboarding.complete; + return ( - }>{!onboarding.complete && } + }>{renderDataCollectionDialog && } + }>{renderOnboarding && } }>{renderAppUpdater && } ); diff --git a/Composer/packages/client/src/components/DataCollectionDialog.tsx b/Composer/packages/client/src/components/DataCollectionDialog.tsx new file mode 100644 index 0000000000..fb0f11fc7d --- /dev/null +++ b/Composer/packages/client/src/components/DataCollectionDialog.tsx @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import formatMessage from 'format-message'; +import { DefaultButton, PrimaryButton } from 'office-ui-fabric-react/lib/Button'; +import { Dialog, DialogFooter } from 'office-ui-fabric-react/lib/Dialog'; +import { Link } from 'office-ui-fabric-react/lib/Link'; +import React from 'react'; +import { useRecoilValue } from 'recoil'; + +import { dispatcherState } from '../recoilModel'; + +const DataCollectionDialog: React.FC = () => { + const { updateServerSettings } = useRecoilValue(dispatcherState); + + const handleDataCollectionChange = (allowDataCollection: boolean) => () => { + updateServerSettings({ + telemetry: { + allowDataCollection, + }, + }); + }; + + return ( + + ); +}; + +export default DataCollectionDialog; +export { DataCollectionDialog }; 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 170ee9ca99..82cbcc7d9d 100644 --- a/Composer/packages/client/src/pages/setting/app-settings/AppSettings.tsx +++ b/Composer/packages/client/src/pages/setting/app-settings/AppSettings.tsx @@ -13,7 +13,7 @@ import { RouteComponentProps } from '@reach/router'; import { useRecoilValue } from 'recoil'; import { isElectron } from '../../../utils/electronUtil'; -import { onboardingState, userSettingsState, dispatcherState } from '../../../recoilModel'; +import { onboardingState, userSettingsState, dispatcherState, ServerSettingsState } from '../../../recoilModel'; import { container, section } from './styles'; import { SettingToggle } from './SettingToggle'; @@ -28,7 +28,8 @@ const ElectronSettings = lazy(() => const AppSettings: React.FC = () => { const [calloutIsShown, showCallout] = useState(false); - const { onboardingSetComplete, updateUserSettings } = useRecoilValue(dispatcherState); + const { onboardingSetComplete, updateUserSettings, updateServerSettings } = useRecoilValue(dispatcherState); + const { telemetry } = useRecoilValue(ServerSettingsState); const userSettings = useRecoilValue(userSettingsState); const { complete } = useRecoilValue(onboardingState); const onOnboardingChange = useCallback( @@ -48,6 +49,14 @@ const AppSettings: React.FC = () => { updateUserSettings({ appLocale }); }; + const handleDataCollectionChange = (allowDataCollection) => { + updateServerSettings({ + telemetry: { + allowDataCollection, + }, + }); + }; + const renderElectronSettings = isElectron(); // temporarily commented out until some translation issues are resolved post Composer 1.2 @@ -184,6 +193,18 @@ const AppSettings: React.FC = () => { }>{renderElectronSettings && } +
+

{formatMessage('Data Collection')}

+ +
); }; 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 69c6acffd2..35e20ad97c 100644 --- a/Composer/packages/client/src/pages/setting/app-settings/SettingToggle.tsx +++ b/Composer/packages/client/src/pages/setting/app-settings/SettingToggle.tsx @@ -16,7 +16,7 @@ interface ISettingToggleProps { checked?: boolean; description: React.ReactChild; id?: string; - image: string; + image?: string; onToggle: (checked: boolean) => void; title: string; hideToggle?: boolean; diff --git a/Composer/packages/client/src/recoilModel/atoms/appState.ts b/Composer/packages/client/src/recoilModel/atoms/appState.ts index f783176cb5..2efaed6ceb 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, FeatureFlagMap, BotTemplate, UserSettings } from '@bfc/shared'; +import { FormDialogSchemaTemplate, FeatureFlagMap, BotTemplate, UserSettings, ServerSettings } from '@bfc/shared'; import { ExtensionMetadata } from '@bfc/extension-client'; import formatMessage from 'format-message'; @@ -237,3 +237,12 @@ export const pageElementState = atom<{ [page in PageMode]?: { [key: string]: any qna: {}, }, }); + +export const ServerSettingsState = atom({ + key: getFullyQualifiedKey('serverSettings'), + default: { + telemetry: { + allowDataCollection: false, + }, + }, +}); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/__tests__/serverSettings.test.tsx b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/serverSettings.test.tsx new file mode 100644 index 0000000000..252e911a42 --- /dev/null +++ b/Composer/packages/client/src/recoilModel/dispatchers/__tests__/serverSettings.test.tsx @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useRecoilValue } from 'recoil'; +import { act } from '@botframework-composer/test-utils/lib/hooks'; + +import { renderRecoilHook } from '../../../../__tests__/testUtils'; +import { ServerSettingsState } from '../../atoms'; +import { dispatcherState } from '../../../recoilModel/DispatcherWrapper'; +import { Dispatcher } from '..'; +import { serverSettingsDispatcher } from '../serverSettings'; +import httpClient from '../../../utils/httpUtil'; + +jest.mock('../../../utils/httpUtil'); + +describe('server setting dispatcher', () => { + let renderedComponent, dispatcher: Dispatcher; + beforeEach(() => { + const useRecoilTestHook = () => { + const serverSettings = useRecoilValue(ServerSettingsState); + const currentDispatcher = useRecoilValue(dispatcherState); + + return { + serverSettings, + currentDispatcher, + }; + }; + + const { result } = renderRecoilHook(useRecoilTestHook, { + states: [{ recoilState: ServerSettingsState, initialValue: {} }], + dispatcher: { + recoilState: dispatcherState, + initialValue: { + serverSettingsDispatcher, + }, + }, + }); + renderedComponent = result; + dispatcher = renderedComponent.current.currentDispatcher; + }); + + it('should set allowDataCollection to false', async () => { + await act(async () => { + await dispatcher.updateServerSettings({ + telemetry: { + allowDataCollection: false, + }, + }); + }); + + expect(renderedComponent.current.serverSettings.telemetry.allowDataCollection).toBe(false); + expect(httpClient.post).toBeCalledWith( + '/settings', + expect.objectContaining({ + settings: { + telemetry: { + allowDataCollection: false, + }, + }, + }) + ); + }); + + it('should set allowDataCollection to true', async () => { + (httpClient.post as jest.Mock).mockResolvedValue({}); + + await act(async () => { + await dispatcher.updateServerSettings({ + telemetry: { + allowDataCollection: true, + }, + }); + }); + + expect(renderedComponent.current.serverSettings.telemetry.allowDataCollection).toBe(true); + expect(httpClient.post).toBeCalledWith( + '/settings', + expect.objectContaining({ + settings: { + telemetry: { + allowDataCollection: true, + }, + }, + }) + ); + }); + + it('should fetch settings from server', async () => { + (httpClient.get as jest.Mock).mockResolvedValue({ + data: { + telemetry: { + allowDataCollection: null, + }, + }, + }); + + await act(async () => { + await dispatcher.fetchServerSettings(); + }); + + expect(renderedComponent.current.serverSettings.telemetry.allowDataCollection).toBe(null); + }); +}); diff --git a/Composer/packages/client/src/recoilModel/dispatchers/index.ts b/Composer/packages/client/src/recoilModel/dispatchers/index.ts index 4830bdfd3d..02109f832a 100644 --- a/Composer/packages/client/src/recoilModel/dispatchers/index.ts +++ b/Composer/packages/client/src/recoilModel/dispatchers/index.ts @@ -24,6 +24,7 @@ import { formDialogsDispatcher } from './formDialogs'; import { botProjectFileDispatcher } from './botProjectFile'; import { zoomDispatcher } from './zoom'; import { recognizerDispatcher } from './recognizers'; +import { serverSettingsDispatcher } from './serverSettings'; const createDispatchers = () => { return { @@ -50,6 +51,7 @@ const createDispatchers = () => { ...botProjectFileDispatcher(), ...zoomDispatcher(), ...recognizerDispatcher(), + ...serverSettingsDispatcher(), }; }; diff --git a/Composer/packages/client/src/recoilModel/dispatchers/serverSettings.tsx b/Composer/packages/client/src/recoilModel/dispatchers/serverSettings.tsx new file mode 100644 index 0000000000..a9e52995de --- /dev/null +++ b/Composer/packages/client/src/recoilModel/dispatchers/serverSettings.tsx @@ -0,0 +1,45 @@ +/* eslint-disable react-hooks/rules-of-hooks */ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ServerSettings } from '@bfc/shared'; +import { CallbackInterface, useRecoilCallback } from 'recoil'; +import merge from 'lodash/merge'; + +import httpClient from '../../utils/httpUtil'; +import { ServerSettingsState } from '../atoms/appState'; + +import { logMessage } from './shared'; + +export const serverSettingsDispatcher = () => { + const fetchServerSettings = useRecoilCallback((callbackHelpers: CallbackInterface) => async () => { + const { set } = callbackHelpers; + try { + const { data: settings } = await httpClient.get('/settings'); + + set(ServerSettingsState, settings); + } catch (error) { + logMessage(callbackHelpers, `Error fetching server settings: ${error}`); + } + }); + + const updateServerSettings = useRecoilCallback( + (callbackHelpers: CallbackInterface) => async (partialSettings: Partial) => { + const { set, snapshot } = callbackHelpers; + try { + const currentSettings = await snapshot.getPromise(ServerSettingsState); + const settings = merge({}, currentSettings, partialSettings); + + await httpClient.post('/settings', { settings }); + set(ServerSettingsState, settings); + } catch (error) { + logMessage(callbackHelpers, `Error updating server settings: ${error}`); + } + } + ); + + return { + fetchServerSettings, + updateServerSettings, + }; +}; diff --git a/Composer/packages/server/src/controllers/__tests__/settings.test.ts b/Composer/packages/server/src/controllers/__tests__/settings.test.ts new file mode 100644 index 0000000000..7acb7e400d --- /dev/null +++ b/Composer/packages/server/src/controllers/__tests__/settings.test.ts @@ -0,0 +1,52 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Request, Response } from 'express'; + +import { SettingsController } from '../settings'; +import { Store } from '../../store/store'; + +Store.set = jest.fn(); + +let mockRes: Response; + +beforeEach(() => { + mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + } as any; +}); + +describe('server settings', () => { + it('should update user settings', async () => { + const mockReq = { + params: {}, + query: {}, + body: { + settings: { + telemetry: {}, + }, + }, + } as Request; + await SettingsController.updateUserSettings(mockReq, mockRes); + expect(mockRes.json).toHaveBeenCalledWith({ telemetry: {} }); + }); + + it('should get user settings', async () => { + (Store.set as jest.Mock).mockReturnValue(null); + const mockReq = { + params: {}, + query: {}, + body: {}, + } as Request; + await SettingsController.getUserSettings(mockReq, mockRes); + expect(mockRes.json).toHaveBeenCalledWith( + expect.objectContaining({ + telemetry: expect.objectContaining({ + allowDataCollection: null, + }), + }) + ); + }); +}); diff --git a/Composer/packages/server/src/controllers/settings.ts b/Composer/packages/server/src/controllers/settings.ts new file mode 100644 index 0000000000..50a1124093 --- /dev/null +++ b/Composer/packages/server/src/controllers/settings.ts @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { Request, Response } from 'express'; + +import SettingsService from '../services/settings'; + +async function getUserSettings(req: Request, res: Response) { + try { + const settings = SettingsService.getSettings(); + return res.status(200).json(settings); + } catch (err) { + return res.status(500).json({ + message: err instanceof Error ? err.message : err, + }); + } +} + +async function updateUserSettings(req: Request, res: Response) { + try { + const { settings } = req.body; + const updatedSettings = SettingsService.setSettings(settings); + return res.status(200).json(updatedSettings); + } catch (err) { + return res.status(500).json({ + message: err instanceof Error ? err.message : err, + }); + } +} + +export const SettingsController = { + getUserSettings, + updateUserSettings, +}; diff --git a/Composer/packages/server/src/router/api.ts b/Composer/packages/server/src/router/api.ts index 03d2580a95..ba1fe38c65 100644 --- a/Composer/packages/server/src/router/api.ts +++ b/Composer/packages/server/src/router/api.ts @@ -17,6 +17,7 @@ import { AuthController } from '../controllers/auth'; import { csrfProtection } from '../middleware/csrfProtection'; import { ImportController } from '../controllers/import'; import { StatusController } from '../controllers/status'; +import { SettingsController } from '../controllers/settings'; import { UtilitiesController } from './../controllers/utilities'; @@ -107,6 +108,10 @@ router.post('/import/:source/authenticate', ImportController.authenticate); // Process status router.get('/status/:jobId', StatusController.getStatus); +// User Server Settings +router.get('/settings', SettingsController.getUserSettings); +router.post('/settings', SettingsController.updateUserSettings); + 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/settings.ts b/Composer/packages/server/src/services/settings.ts new file mode 100644 index 0000000000..29dfaea494 --- /dev/null +++ b/Composer/packages/server/src/services/settings.ts @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { ServerSettings } from '@bfc/shared'; + +import { Store } from '../store/store'; + +const KEY = 'settings'; + +const DEFAULT_SETTINGS: ServerSettings = { + telemetry: { + allowDataCollection: null, + }, +}; + +export class SettingsService { + public static getSettings(): ServerSettings { + return Store.get(KEY, DEFAULT_SETTINGS); + } + + public static setSettings(settings: ServerSettings): ServerSettings { + Store.set(KEY, settings); + return settings; + } +} + +export default SettingsService; diff --git a/Composer/packages/types/src/index.ts b/Composer/packages/types/src/index.ts index 4d842f9d64..479bd2a8a5 100644 --- a/Composer/packages/types/src/index.ts +++ b/Composer/packages/types/src/index.ts @@ -14,4 +14,5 @@ export * from './sdk'; export * from './server'; export * from './settings'; export * from './shell'; +export * from './telemetry'; export * from './user'; diff --git a/Composer/packages/types/src/telemetry.ts b/Composer/packages/types/src/telemetry.ts new file mode 100644 index 0000000000..ddce7477a8 --- /dev/null +++ b/Composer/packages/types/src/telemetry.ts @@ -0,0 +1,8 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +export type TelemetrySettings = { + allowDataCollection?: boolean | null; +}; + +export type ServerSettings = Partial<{ telemetry: TelemetrySettings }>;