Skip to content
This repository was archived by the owner on Jul 9, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 4 additions & 7 deletions Composer/packages/client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Fragment>
<Suspense fallback={<div />}>{!onboarding.complete && <Onboarding />}</Suspense>
<Suspense fallback={<div />}>{renderDataCollectionDialog && <DataCollectionDialog />}</Suspense>
<Suspense fallback={<div />}>{renderOnboarding && <Onboarding />}</Suspense>
<Suspense fallback={<div />}>{renderAppUpdater && <AppUpdater />}</Suspense>
</Fragment>
);
Expand Down
57 changes: 57 additions & 0 deletions Composer/packages/client/src/components/DataCollectionDialog.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Dialog
hidden={false}
modalProps={{
isBlocking: true,
}}
title={formatMessage('Help us improve?')}
onDismiss={handleDataCollectionChange(false)}
>
<p>
{formatMessage(
'Composer includes a telemetry feature that collects usage information. It is important that the Composer team understands how the tool is being used so that it can be improved.'
)}
</p>
<p>{formatMessage('You can turn data collection on or off at any time in the Application Settings.')}</p>
<p>
<Link
aria-label={formatMessage('Privacy statement')}
href={'https://privacy.microsoft.com/privacystatement'}
target={'_blank'}
>
{formatMessage('Privacy statement')}
</Link>
</p>
<DialogFooter>
<DefaultButton text={formatMessage('Not now')} onClick={handleDataCollectionChange(false)} />
<PrimaryButton text={formatMessage('Yes, collect data')} onClick={handleDataCollectionChange(true)} />
</DialogFooter>
</Dialog>
);
};

export default DataCollectionDialog;
export { DataCollectionDialog };
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -28,7 +28,8 @@ const ElectronSettings = lazy(() =>
const AppSettings: React.FC<RouteComponentProps> = () => {
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(
Expand All @@ -48,6 +49,14 @@ const AppSettings: React.FC<RouteComponentProps> = () => {
updateUserSettings({ appLocale });
};

const handleDataCollectionChange = (allowDataCollection) => {
updateServerSettings({
telemetry: {
allowDataCollection,
},
});
};

const renderElectronSettings = isElectron();

// temporarily commented out until some translation issues are resolved post Composer 1.2
Expand Down Expand Up @@ -184,6 +193,18 @@ const AppSettings: React.FC<RouteComponentProps> = () => {
<Suspense fallback={<div />}>{renderElectronSettings && <ElectronSettings />}</Suspense>
<PreviewFeatureToggle />
</section>
<section css={section}>
<h2>{formatMessage('Data Collection')}</h2>
<SettingToggle
checked={!!telemetry?.allowDataCollection}
description={formatMessage(
'Composer includes a telemetry feature that collects usage information. It is important that the Composer team understands how the tool is being used so that it can be improved.'
)}
id="dataCollectionToggle"
title={formatMessage('Data collection')}
onToggle={handleDataCollectionChange}
/>
</section>
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
11 changes: 10 additions & 1 deletion Composer/packages/client/src/recoilModel/atoms/appState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -237,3 +237,12 @@ export const pageElementState = atom<{ [page in PageMode]?: { [key: string]: any
qna: {},
},
});

export const ServerSettingsState = atom<ServerSettings>({
key: getFullyQualifiedKey('serverSettings'),
default: {
telemetry: {
allowDataCollection: false,
},
},
});
Original file line number Diff line number Diff line change
@@ -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);
});
});
2 changes: 2 additions & 0 deletions Composer/packages/client/src/recoilModel/dispatchers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -50,6 +51,7 @@ const createDispatchers = () => {
...botProjectFileDispatcher(),
...zoomDispatcher(),
...recognizerDispatcher(),
...serverSettingsDispatcher(),
};
};

Expand Down
Original file line number Diff line number Diff line change
@@ -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<ServerSettings>) => {
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,
};
};
Loading