From 1556b70b73aa2c00bd17dec8b42f0a6320d410ef Mon Sep 17 00:00:00 2001 From: Joel Griffith Date: Mon, 18 Nov 2019 11:26:43 -0800 Subject: [PATCH] [Telemetry] Show opt-in changes for OSS users (#50831) (#50941) * WIP: Notice banner for OSS folks * Add telemetryNotifyUserAboutOptInDefault to injected vars * add userHasSeenNotice check * More WIP on banner notice component * Text changes on screens * make userHasSeenNotice flag work * Finalzed splash text + checking new flag * Consolidating banner calls and saving status of opt-in notice * Conditionally remove the banner and add some code docs * Fixing prior welcome tests * api integration test for user has seen opt in * change post method to put in ui * unit test for get_telemetry_notify_user_about_optin_default * Ignore TS woes * Adding new tests and snapshots for opt-in banner component * Notice banner test * Translation miss * More opt-in tests * increase types usage * roll back core server api change * update snapshot * Prop name change + snapshot updates --- .../__snapshots__/welcome.test.tsx.snap | 6 +- .../kibana/public/home/components/home.js | 10 +- .../kibana/public/home/components/home_app.js | 5 +- .../public/home/components/welcome.test.tsx | 15 ++- .../kibana/public/home/components/welcome.tsx | 8 +- src/legacy/core_plugins/telemetry/index.ts | 1 + .../core_plugins/telemetry/mappings.json | 3 + .../opted_in_notice_banner.test.tsx.snap | 51 +++++++++ .../opted_in_notice_banner.test.tsx | 43 ++++++++ .../components/opted_in_notice_banner.tsx | 79 +++++++++++++ .../hacks/welcome_banner/inject_banner.js | 10 +- .../welcome_banner/render_notice_banner.js | 42 +++++++ .../render_notice_banner.test.js | 42 +++++++ .../should_show_opt_in_banner.js | 30 +++++ .../public/services/telemetry_opt_in.test.js | 48 +++++++- .../services/telemetry_opt_in.test.mocks.js | 3 +- .../public/services/telemetry_opt_in.ts | 48 +++++++- .../telemetry/server/routes/index.ts | 2 + .../routes/telemetry_user_has_seen_notice.ts | 59 ++++++++++ ...ry_notify_user_about_optin_default.test.ts | 104 ++++++++++++++++++ ...lemetry_notify_user_about_optin_default.ts | 45 ++++++++ .../telemetry_config/replace_injected_vars.ts | 9 ++ .../server/telemetry_repository/index.ts | 1 + .../api_integration/apis/telemetry/index.js | 1 + .../telemetry/telemetry_optin_notice_seen.ts | 40 +++++++ 25 files changed, 686 insertions(+), 19 deletions(-) create mode 100644 src/legacy/core_plugins/telemetry/public/components/__snapshots__/opted_in_notice_banner.test.tsx.snap create mode 100644 src/legacy/core_plugins/telemetry/public/components/opted_in_notice_banner.test.tsx create mode 100644 src/legacy/core_plugins/telemetry/public/components/opted_in_notice_banner.tsx create mode 100644 src/legacy/core_plugins/telemetry/public/hacks/welcome_banner/render_notice_banner.js create mode 100644 src/legacy/core_plugins/telemetry/public/hacks/welcome_banner/render_notice_banner.test.js create mode 100644 src/legacy/core_plugins/telemetry/public/hacks/welcome_banner/should_show_opt_in_banner.js create mode 100644 src/legacy/core_plugins/telemetry/server/routes/telemetry_user_has_seen_notice.ts create mode 100644 src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.test.ts create mode 100644 src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.ts create mode 100644 x-pack/test/api_integration/apis/telemetry/telemetry_optin_notice_seen.ts diff --git a/src/legacy/core_plugins/kibana/public/home/components/__snapshots__/welcome.test.tsx.snap b/src/legacy/core_plugins/kibana/public/home/components/__snapshots__/welcome.test.tsx.snap index 5a6c6eba5c8db..2007a3bb773cf 100644 --- a/src/legacy/core_plugins/kibana/public/home/components/__snapshots__/welcome.test.tsx.snap +++ b/src/legacy/core_plugins/kibana/public/home/components/__snapshots__/welcome.test.tsx.snap @@ -157,13 +157,13 @@ exports[`should render a Welcome screen with the telemetry disclaimer 1`] = ` target="_blank" > @@ -171,7 +171,7 @@ exports[`should render a Welcome screen with the telemetry disclaimer 1`] = ` href="#/management/kibana/settings" > diff --git a/src/legacy/core_plugins/kibana/public/home/components/home.js b/src/legacy/core_plugins/kibana/public/home/components/home.js index 1ca7b5c773c56..7b887d0abd999 100644 --- a/src/legacy/core_plugins/kibana/public/home/components/home.js +++ b/src/legacy/core_plugins/kibana/public/home/components/home.js @@ -47,8 +47,12 @@ export class Home extends Component { constructor(props) { super(props); - const isWelcomeEnabled = !(chrome.getInjected('disableWelcomeScreen') || props.localStorage.getItem(KEY_ENABLE_WELCOME) === 'false'); - const showTelemetryDisclaimer = chrome.getInjected('allowChangingOptInStatus'); + const isWelcomeEnabled = !( + chrome.getInjected('disableWelcomeScreen') || + props.localStorage.getItem(KEY_ENABLE_WELCOME) === 'false' + ); + const showTelemetryDisclaimer = chrome.getInjected('telemetryNotifyUserAboutOptInDefault'); + this.state = { // If welcome is enabled, we wait for loading to complete // before rendering. This prevents an annoying flickering @@ -227,6 +231,7 @@ export class Home extends Component { onSkip={this.skipWelcome} urlBasePath={this.props.urlBasePath} showTelemetryDisclaimer={this.state.showTelemetryDisclaimer} + onOptInSeen={this.props.onOptInSeen} /> ); } @@ -265,4 +270,5 @@ Home.propTypes = { localStorage: PropTypes.object.isRequired, urlBasePath: PropTypes.string.isRequired, mlEnabled: PropTypes.bool.isRequired, + onOptInSeen: PropTypes.func.isRequired, }; diff --git a/src/legacy/core_plugins/kibana/public/home/components/home_app.js b/src/legacy/core_plugins/kibana/public/home/components/home_app.js index 36a3911110fbc..517c6500a3785 100644 --- a/src/legacy/core_plugins/kibana/public/home/components/home_app.js +++ b/src/legacy/core_plugins/kibana/public/home/components/home_app.js @@ -30,13 +30,14 @@ import { } from 'react-router-dom'; import { getTutorial } from '../load_tutorials'; import { replaceTemplateStrings } from './tutorial/replace_template_strings'; -import { shouldShowTelemetryOptIn } from '../kibana_services'; +import { telemetryOptInProvider } from '../kibana_services'; import chrome from 'ui/chrome'; export function HomeApp({ directories }) { const isCloudEnabled = chrome.getInjected('isCloudEnabled', false); const apmUiEnabled = chrome.getInjected('apmUiEnabled', true); const mlEnabled = chrome.getInjected('mlEnabled', false); + const { setOptInNoticeSeen } = telemetryOptInProvider; const savedObjectsClient = chrome.getSavedObjectsClient(); const renderTutorialDirectory = (props) => { @@ -92,7 +93,7 @@ export function HomeApp({ directories }) { find={savedObjectsClient.find} localStorage={localStorage} urlBasePath={chrome.getBasePath()} - shouldShowTelemetryOptIn={shouldShowTelemetryOptIn} + onOptInSeen={setOptInNoticeSeen} /> diff --git a/src/legacy/core_plugins/kibana/public/home/components/welcome.test.tsx b/src/legacy/core_plugins/kibana/public/home/components/welcome.test.tsx index 3da183066bcac..aee3239649aa0 100644 --- a/src/legacy/core_plugins/kibana/public/home/components/welcome.test.tsx +++ b/src/legacy/core_plugins/kibana/public/home/components/welcome.test.tsx @@ -33,7 +33,7 @@ jest.mock('../kibana_services', () => ({ test('should render a Welcome screen with the telemetry disclaimer', () => { const component = shallow( // @ts-ignore - {}} showTelemetryDisclaimer={true} /> + {}} showTelemetryDisclaimer={true} onOptInSeen={() => {}} /> ); expect(component).toMatchSnapshot(); @@ -43,8 +43,19 @@ test('should render a Welcome screen with no telemetry disclaimer', () => { // @ts-ignore const component = shallow( // @ts-ignore - {}} showTelemetryDisclaimer={false} /> + {}} showTelemetryDisclaimer={false} onOptInSeen={() => {}} /> ); expect(component).toMatchSnapshot(); }); + +test('fires opt-in seen when mounted', () => { + const seen = jest.fn(); + + shallow( + // @ts-ignore + {}} showTelemetryDisclaimer={true} onOptInSeen={seen} /> + ); + + expect(seen).toHaveBeenCalled(); +}); diff --git a/src/legacy/core_plugins/kibana/public/home/components/welcome.tsx b/src/legacy/core_plugins/kibana/public/home/components/welcome.tsx index 22385317c4a05..70d1f98b22a5d 100644 --- a/src/legacy/core_plugins/kibana/public/home/components/welcome.tsx +++ b/src/legacy/core_plugins/kibana/public/home/components/welcome.tsx @@ -47,6 +47,7 @@ import { trackUiMetric, METRIC_TYPE } from '../kibana_services'; interface Props { urlBasePath: string; onSkip: () => void; + onOptInSeen: () => any; showTelemetryDisclaimer: boolean; } @@ -77,6 +78,7 @@ export class Welcome extends React.Component { componentDidMount() { trackUiMetric(METRIC_TYPE.LOADED, 'welcomeScreenMount'); + this.props.onOptInSeen(); document.addEventListener('keydown', this.hideOnEsc); } @@ -134,17 +136,17 @@ export class Welcome extends React.Component { > diff --git a/src/legacy/core_plugins/telemetry/index.ts b/src/legacy/core_plugins/telemetry/index.ts index 9993f2dbf0b86..5ae0d5f127eed 100644 --- a/src/legacy/core_plugins/telemetry/index.ts +++ b/src/legacy/core_plugins/telemetry/index.ts @@ -112,6 +112,7 @@ const telemetry = (kibana: any) => { telemetryOptInStatusUrl: config.get('telemetry.optInStatusUrl'), allowChangingOptInStatus: config.get('telemetry.allowChangingOptInStatus'), telemetrySendUsageFrom: config.get('telemetry.sendUsageFrom'), + telemetryNotifyUserAboutOptInDefault: false, }; }, hacks: ['plugins/telemetry/hacks/telemetry_init', 'plugins/telemetry/hacks/telemetry_opt_in'], diff --git a/src/legacy/core_plugins/telemetry/mappings.json b/src/legacy/core_plugins/telemetry/mappings.json index 95c6ebfc7dc79..a88372a5578e8 100644 --- a/src/legacy/core_plugins/telemetry/mappings.json +++ b/src/legacy/core_plugins/telemetry/mappings.json @@ -14,6 +14,9 @@ "lastVersionChecked": { "ignore_above": 256, "type": "keyword" + }, + "userHasSeenNotice": { + "type": "boolean" } } } diff --git a/src/legacy/core_plugins/telemetry/public/components/__snapshots__/opted_in_notice_banner.test.tsx.snap b/src/legacy/core_plugins/telemetry/public/components/__snapshots__/opted_in_notice_banner.test.tsx.snap new file mode 100644 index 0000000000000..9c26909dc68f1 --- /dev/null +++ b/src/legacy/core_plugins/telemetry/public/components/__snapshots__/opted_in_notice_banner.test.tsx.snap @@ -0,0 +1,51 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`OptInDetailsComponent renders as expected 1`] = ` + + + + , + "privacyStatementLink": + + , + } + } + /> + + + + + +`; diff --git a/src/legacy/core_plugins/telemetry/public/components/opted_in_notice_banner.test.tsx b/src/legacy/core_plugins/telemetry/public/components/opted_in_notice_banner.test.tsx new file mode 100644 index 0000000000000..008603526be38 --- /dev/null +++ b/src/legacy/core_plugins/telemetry/public/components/opted_in_notice_banner.test.tsx @@ -0,0 +1,43 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { EuiButton } from '@elastic/eui'; +import { shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { OptedInBanner } from './opted_in_notice_banner'; + +describe('OptInDetailsComponent', () => { + it('renders as expected', () => { + expect(shallowWithIntl( {}} />)).toMatchSnapshot(); + }); + + it('fires the "onSeenBanner" prop when a link is clicked', () => { + const onLinkClick = jest.fn(); + const component = shallowWithIntl(); + + const button = component.findWhere(n => n.type() === EuiButton); + + if (!button) { + throw new Error(`Couldn't find any buttons in opt-in notice`); + } + + button.simulate('click'); + + expect(onLinkClick).toHaveBeenCalled(); + }); +}); diff --git a/src/legacy/core_plugins/telemetry/public/components/opted_in_notice_banner.tsx b/src/legacy/core_plugins/telemetry/public/components/opted_in_notice_banner.tsx new file mode 100644 index 0000000000000..a535835f7cb83 --- /dev/null +++ b/src/legacy/core_plugins/telemetry/public/components/opted_in_notice_banner.tsx @@ -0,0 +1,79 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/* eslint @elastic/eui/href-or-on-click:0 */ + +import * as React from 'react'; +import { EuiButton, EuiLink, EuiCallOut, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@kbn/i18n/react'; + +interface Props { + onSeenBanner: () => any; +} + +/** + * React component for displaying the Telemetry opt-in notice. + */ +export class OptedInBanner extends React.PureComponent { + onLinkClick = () => { + this.props.onSeenBanner(); + return; + }; + + render() { + return ( + + + + + ), + disableLink: ( + + + + ), + }} + /> + + + + + + ); + } +} diff --git a/src/legacy/core_plugins/telemetry/public/hacks/welcome_banner/inject_banner.js b/src/legacy/core_plugins/telemetry/public/hacks/welcome_banner/inject_banner.js index 13417a137e6c3..c4c5c3e9e0aa2 100644 --- a/src/legacy/core_plugins/telemetry/public/hacks/welcome_banner/inject_banner.js +++ b/src/legacy/core_plugins/telemetry/public/hacks/welcome_banner/inject_banner.js @@ -21,7 +21,9 @@ import chrome from 'ui/chrome'; import { fetchTelemetry } from '../fetch_telemetry'; import { renderBanner } from './render_banner'; +import { renderOptedInBanner } from './render_notice_banner'; import { shouldShowBanner } from './should_show_banner'; +import { shouldShowOptInBanner } from './should_show_opt_in_banner'; import { TelemetryOptInProvider, isUnauthenticated } from '../../services'; import { npStart } from 'ui/new_platform'; @@ -48,12 +50,16 @@ async function asyncInjectBanner($injector) { return; } + const $http = $injector.get('$http'); + // determine if the banner should be displayed if (await shouldShowBanner(telemetryOptInProvider, config)) { - const $http = $injector.get('$http'); - renderBanner(telemetryOptInProvider, () => fetchTelemetry($http, { unencrypted: true })); } + + if (await shouldShowOptInBanner(telemetryOptInProvider, config)) { + renderOptedInBanner(telemetryOptInProvider, () => fetchTelemetry($http, { unencrypted: true })); + } } /** diff --git a/src/legacy/core_plugins/telemetry/public/hacks/welcome_banner/render_notice_banner.js b/src/legacy/core_plugins/telemetry/public/hacks/welcome_banner/render_notice_banner.js new file mode 100644 index 0000000000000..9323e4ec34e80 --- /dev/null +++ b/src/legacy/core_plugins/telemetry/public/hacks/welcome_banner/render_notice_banner.js @@ -0,0 +1,42 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; + +import { banners } from 'ui/notify'; +import { OptedInBanner } from '../../components/opted_in_notice_banner'; + +/** + * Render the Telemetry Opt-in notice banner. + * + * @param {Object} telemetryOptInProvider The telemetry opt-in provider. + * @param {Object} _banners Banners singleton, which can be overridden for tests. + */ +export function renderOptedInBanner(telemetryOptInProvider, { _banners = banners } = {}) { + const bannerId = _banners.add({ + component: ( + + ), + priority: 10000 + }); + + telemetryOptInProvider.setOptInBannerNoticeId(bannerId); +} diff --git a/src/legacy/core_plugins/telemetry/public/hacks/welcome_banner/render_notice_banner.test.js b/src/legacy/core_plugins/telemetry/public/hacks/welcome_banner/render_notice_banner.test.js new file mode 100644 index 0000000000000..f9571e3c793eb --- /dev/null +++ b/src/legacy/core_plugins/telemetry/public/hacks/welcome_banner/render_notice_banner.test.js @@ -0,0 +1,42 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import '../../services/telemetry_opt_in.test.mocks'; +import { renderOptedInBanner } from './render_notice_banner'; + +describe('render_notice_banner', () => { + + it('adds a banner to banners with priority of 10000', () => { + const bannerID = 'brucer-wayne'; + + const telemetryOptInProvider = { setOptInBannerNoticeId: jest.fn() }; + const banners = { add: jest.fn().mockReturnValue(bannerID) }; + + renderOptedInBanner(telemetryOptInProvider, { _banners: banners }); + + expect(banners.add).toBeCalledTimes(1); + expect(telemetryOptInProvider.setOptInBannerNoticeId).toBeCalledWith(bannerID); + + const bannerConfig = banners.add.mock.calls[0][0]; + + expect(bannerConfig.component).not.toBe(undefined); + expect(bannerConfig.priority).toBe(10000); + }); + +}); diff --git a/src/legacy/core_plugins/telemetry/public/hacks/welcome_banner/should_show_opt_in_banner.js b/src/legacy/core_plugins/telemetry/public/hacks/welcome_banner/should_show_opt_in_banner.js new file mode 100644 index 0000000000000..45539c4eea46c --- /dev/null +++ b/src/legacy/core_plugins/telemetry/public/hacks/welcome_banner/should_show_opt_in_banner.js @@ -0,0 +1,30 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Determine if the notice banner should be displayed. + * + * This method can have side-effects related to deprecated config settings. + * + * @param {Object} telemetryOptInProvider The Telemetry opt-in provider singleton. + * @return {Boolean} {@code true} if the banner should be displayed. {@code false} otherwise. + */ +export async function shouldShowOptInBanner(telemetryOptInProvider) { + return telemetryOptInProvider.notifyUserAboutOptInDefault(); +} diff --git a/src/legacy/core_plugins/telemetry/public/services/telemetry_opt_in.test.js b/src/legacy/core_plugins/telemetry/public/services/telemetry_opt_in.test.js index b0ebb9e7382f6..a3103664b7be8 100644 --- a/src/legacy/core_plugins/telemetry/public/services/telemetry_opt_in.test.js +++ b/src/legacy/core_plugins/telemetry/public/services/telemetry_opt_in.test.js @@ -21,12 +21,17 @@ import { mockInjectedMetadata } from './telemetry_opt_in.test.mocks'; import { TelemetryOptInProvider } from './telemetry_opt_in'; describe('TelemetryOptInProvider', () => { - const setup = ({ optedIn, simulatePostError }) => { + const setup = ({ optedIn, simulatePostError, simulatePutError }) => { const mockHttp = { post: jest.fn(async () => { if (simulatePostError) { return Promise.reject('Something happened'); } + }), + put: jest.fn(async () => { + if (simulatePutError) { + return Promise.reject('Something happened'); + } }) }; @@ -34,7 +39,7 @@ describe('TelemetryOptInProvider', () => { addBasePath: (url) => url }; - mockInjectedMetadata({ telemetryOptedIn: optedIn, allowChangingOptInStatus: true }); + mockInjectedMetadata({ telemetryOptedIn: optedIn, allowChangingOptInStatus: true, telemetryNotifyUserAboutOptInDefault: true }); const mockInjector = { get: (key) => { @@ -98,4 +103,43 @@ describe('TelemetryOptInProvider', () => { provider.setBannerId(bannerId); expect(provider.getBannerId()).toEqual(bannerId); }); + + describe('Notice Banner', () => { + it('should return the current bannerId', () => { + const { provider } = setup({}); + const bannerId = 'bruce-wayne'; + provider.setOptInBannerNoticeId(bannerId); + + expect(provider.getOptInBannerNoticeId()).toEqual(bannerId); + expect(provider.getBannerId()).not.toEqual(bannerId); + }); + + it('should persist that a user has seen the notice', async () => { + const { provider, mockHttp } = setup({}); + await provider.setOptInNoticeSeen(); + + expect(mockHttp.put).toHaveBeenCalledWith(`/api/telemetry/v2/userHasSeenNotice`); + + expect(provider.notifyUserAboutOptInDefault()).toEqual(false); + }); + + it('should only call the API once', async () => { + const { provider, mockHttp } = setup({}); + await provider.setOptInNoticeSeen(); + await provider.setOptInNoticeSeen(); + + expect(mockHttp.put).toHaveBeenCalledTimes(1); + + expect(provider.notifyUserAboutOptInDefault()).toEqual(false); + }); + + it('should gracefully handle errors', async () => { + const { provider } = setup({ simulatePutError: true }); + + await provider.setOptInNoticeSeen(); + + // opt-in change should not be reflected + expect(provider.notifyUserAboutOptInDefault()).toEqual(true); + }); + }); }); diff --git a/src/legacy/core_plugins/telemetry/public/services/telemetry_opt_in.test.mocks.js b/src/legacy/core_plugins/telemetry/public/services/telemetry_opt_in.test.mocks.js index 012f8de640042..574aaefd4f1f7 100644 --- a/src/legacy/core_plugins/telemetry/public/services/telemetry_opt_in.test.mocks.js +++ b/src/legacy/core_plugins/telemetry/public/services/telemetry_opt_in.test.mocks.js @@ -24,11 +24,12 @@ import { } from '../../../../../core/public/mocks'; const injectedMetadataMock = injectedMetadataServiceMock.createStartContract(); -export function mockInjectedMetadata({ telemetryOptedIn, allowChangingOptInStatus }) { +export function mockInjectedMetadata({ telemetryOptedIn, allowChangingOptInStatus, telemetryNotifyUserAboutOptInDefault }) { const mockGetInjectedVar = jest.fn().mockImplementation((key) => { switch (key) { case 'telemetryOptedIn': return telemetryOptedIn; case 'allowChangingOptInStatus': return allowChangingOptInStatus; + case 'telemetryNotifyUserAboutOptInDefault': return telemetryNotifyUserAboutOptInDefault; default: throw new Error(`unexpected injectedVar ${key}`); } }); diff --git a/src/legacy/core_plugins/telemetry/public/services/telemetry_opt_in.ts b/src/legacy/core_plugins/telemetry/public/services/telemetry_opt_in.ts index 9b32f88df1218..ea7fe2ee5d9f2 100644 --- a/src/legacy/core_plugins/telemetry/public/services/telemetry_opt_in.ts +++ b/src/legacy/core_plugins/telemetry/public/services/telemetry_opt_in.ts @@ -19,12 +19,15 @@ import moment from 'moment'; import { setCanTrackUiMetrics } from 'ui/ui_metric'; -import { toastNotifications } from 'ui/notify'; +// @ts-ignore +import { banners, toastNotifications } from 'ui/notify'; import { npStart } from 'ui/new_platform'; import { i18n } from '@kbn/i18n'; let bannerId: string | null = null; +let optInBannerNoticeId: string | null = null; let currentOptInStatus = false; +let telemetryNotifyUserAboutOptInDefault = true; async function sendOptInStatus($injector: any, chrome: any, enabled: boolean) { const telemetryOptInStatusUrl = npStart.core.injectedMetadata.getInjectedVar( @@ -57,18 +60,58 @@ async function sendOptInStatus($injector: any, chrome: any, enabled: boolean) { } export function TelemetryOptInProvider($injector: any, chrome: any, sendOptInStatusChange = true) { currentOptInStatus = npStart.core.injectedMetadata.getInjectedVar('telemetryOptedIn') as boolean; + const allowChangingOptInStatus = npStart.core.injectedMetadata.getInjectedVar( 'allowChangingOptInStatus' ) as boolean; + telemetryNotifyUserAboutOptInDefault = npStart.core.injectedMetadata.getInjectedVar( + 'telemetryNotifyUserAboutOptInDefault' + ) as boolean; + setCanTrackUiMetrics(currentOptInStatus); + const provider = { getBannerId: () => bannerId, + getOptInBannerNoticeId: () => optInBannerNoticeId, getOptIn: () => currentOptInStatus, canChangeOptInStatus: () => allowChangingOptInStatus, + notifyUserAboutOptInDefault: () => telemetryNotifyUserAboutOptInDefault, setBannerId(id: string) { bannerId = id; }, + setOptInBannerNoticeId(id: string) { + optInBannerNoticeId = id; + }, + setOptInNoticeSeen: async () => { + const $http = $injector.get('$http'); + + // If they've seen the notice don't spam the API + if (!telemetryNotifyUserAboutOptInDefault) { + return telemetryNotifyUserAboutOptInDefault; + } + + if (optInBannerNoticeId) { + banners.remove(optInBannerNoticeId); + } + + try { + await $http.put(chrome.addBasePath('/api/telemetry/v2/userHasSeenNotice')); + telemetryNotifyUserAboutOptInDefault = false; + } catch (error) { + toastNotifications.addError(error, { + title: i18n.translate('telemetry.optInNoticeSeenErrorTitle', { + defaultMessage: 'Error', + }), + toastMessage: i18n.translate('telemetry.optInNoticeSeenErrorToastText', { + defaultMessage: 'An error occurred dismissing the notice', + }), + }); + telemetryNotifyUserAboutOptInDefault = true; + } + + return telemetryNotifyUserAboutOptInDefault; + }, setOptIn: async (enabled: boolean) => { if (!allowChangingOptInStatus) { return; @@ -88,7 +131,8 @@ export function TelemetryOptInProvider($injector: any, chrome: any, sendOptInSta defaultMessage: 'Error', }), toastMessage: i18n.translate('telemetry.optInErrorToastText', { - defaultMessage: 'An error occured while trying to set the usage statistics preference.', + defaultMessage: + 'An error occurred while trying to set the usage statistics preference.', }), }); return false; diff --git a/src/legacy/core_plugins/telemetry/server/routes/index.ts b/src/legacy/core_plugins/telemetry/server/routes/index.ts index 66a7b2c97f3ae..30c018ca7796d 100644 --- a/src/legacy/core_plugins/telemetry/server/routes/index.ts +++ b/src/legacy/core_plugins/telemetry/server/routes/index.ts @@ -21,6 +21,7 @@ import { CoreSetup } from 'src/core/server'; import { registerTelemetryOptInRoutes } from './telemetry_opt_in'; import { registerTelemetryUsageStatsRoutes } from './telemetry_usage_stats'; import { registerTelemetryOptInStatsRoutes } from './telemetry_opt_in_stats'; +import { registerTelemetryUserHasSeenNotice } from './telemetry_user_has_seen_notice'; interface RegisterRoutesParams { core: CoreSetup; @@ -31,4 +32,5 @@ export function registerRoutes({ core, currentKibanaVersion }: RegisterRoutesPar registerTelemetryOptInRoutes({ core, currentKibanaVersion }); registerTelemetryUsageStatsRoutes(core); registerTelemetryOptInStatsRoutes(core); + registerTelemetryUserHasSeenNotice(core); } diff --git a/src/legacy/core_plugins/telemetry/server/routes/telemetry_user_has_seen_notice.ts b/src/legacy/core_plugins/telemetry/server/routes/telemetry_user_has_seen_notice.ts new file mode 100644 index 0000000000000..93416058c3277 --- /dev/null +++ b/src/legacy/core_plugins/telemetry/server/routes/telemetry_user_has_seen_notice.ts @@ -0,0 +1,59 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Legacy } from 'kibana'; +import { Request } from 'hapi'; +import { CoreSetup } from 'src/core/server'; +import { + TelemetrySavedObject, + TelemetrySavedObjectAttributes, + getTelemetrySavedObject, + updateTelemetrySavedObject, +} from '../telemetry_repository'; + +const getInternalRepository = (server: Legacy.Server) => { + const { getSavedObjectsRepository } = server.savedObjects; + const { callWithInternalUser } = server.plugins.elasticsearch.getCluster('admin'); + const internalRepository = getSavedObjectsRepository(callWithInternalUser); + return internalRepository; +}; + +export function registerTelemetryUserHasSeenNotice(core: CoreSetup) { + const { server }: { server: Legacy.Server } = core.http as any; + + server.route({ + method: 'PUT', + path: '/api/telemetry/v2/userHasSeenNotice', + handler: async (req: Request): Promise => { + const internalRepository = getInternalRepository(server); + const telemetrySavedObject: TelemetrySavedObject = await getTelemetrySavedObject( + internalRepository + ); + + // update the object with a flag stating that the opt-in notice has been seen + const updatedAttributes: TelemetrySavedObjectAttributes = { + ...telemetrySavedObject, + userHasSeenNotice: true, + }; + await updateTelemetrySavedObject(internalRepository, updatedAttributes); + + return updatedAttributes; + }, + }); +} diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.test.ts b/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.test.ts new file mode 100644 index 0000000000000..af142973a535d --- /dev/null +++ b/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.test.ts @@ -0,0 +1,104 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { getNotifyUserAboutOptInDefault } from './get_telemetry_notify_user_about_optin_default'; + +describe('getNotifyUserAboutOptInDefault: get a flag that describes if the user must be notified about optin default', () => { + it('should return true when kibana has fresh defaults', () => { + expect( + getNotifyUserAboutOptInDefault({ + allowChangingOptInStatus: true, + telemetrySavedObject: { userHasSeenNotice: false }, + telemetryOptedIn: true, + configTelemetryOptIn: true, + }) + ).toBe(true); + }); + + it('should return false if allowChangingOptInStatus = false', () => { + expect( + getNotifyUserAboutOptInDefault({ + allowChangingOptInStatus: false, + telemetrySavedObject: null, + telemetryOptedIn: false, + configTelemetryOptIn: false, + }) + ).toBe(false); + + expect( + getNotifyUserAboutOptInDefault({ + allowChangingOptInStatus: false, + telemetrySavedObject: null, + telemetryOptedIn: true, + configTelemetryOptIn: true, + }) + ).toBe(false); + }); + + it('should return false if user has seen notice', () => { + expect( + getNotifyUserAboutOptInDefault({ + allowChangingOptInStatus: true, + telemetrySavedObject: { userHasSeenNotice: true }, + telemetryOptedIn: false, + configTelemetryOptIn: false, + }) + ).toBe(false); + + expect( + getNotifyUserAboutOptInDefault({ + allowChangingOptInStatus: true, + telemetrySavedObject: { userHasSeenNotice: true }, + telemetryOptedIn: true, + configTelemetryOptIn: true, + }) + ).toBe(false); + }); + + it('should return false if user is opted out', () => { + expect( + getNotifyUserAboutOptInDefault({ + allowChangingOptInStatus: true, + telemetrySavedObject: { userHasSeenNotice: false }, + telemetryOptedIn: false, + configTelemetryOptIn: true, + }) + ).toBe(false); + + expect( + getNotifyUserAboutOptInDefault({ + allowChangingOptInStatus: true, + telemetrySavedObject: { userHasSeenNotice: false }, + telemetryOptedIn: false, + configTelemetryOptIn: false, + }) + ).toBe(false); + }); + + it('should return false if kibana is opted out via config', () => { + expect( + getNotifyUserAboutOptInDefault({ + allowChangingOptInStatus: true, + telemetrySavedObject: { userHasSeenNotice: false }, + telemetryOptedIn: true, + configTelemetryOptIn: false, + }) + ).toBe(false); + }); +}); diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.ts b/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.ts new file mode 100644 index 0000000000000..8ef3bd8388ecb --- /dev/null +++ b/src/legacy/core_plugins/telemetry/server/telemetry_config/get_telemetry_notify_user_about_optin_default.ts @@ -0,0 +1,45 @@ +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { TelemetrySavedObject } from '../telemetry_repository/get_telemetry_saved_object'; + +interface NotifyOpts { + allowChangingOptInStatus: boolean; + telemetrySavedObject: TelemetrySavedObject; + telemetryOptedIn: boolean | null; + configTelemetryOptIn: boolean; +} + +export function getNotifyUserAboutOptInDefault({ + allowChangingOptInStatus, + telemetrySavedObject, + telemetryOptedIn, + configTelemetryOptIn, +}: NotifyOpts) { + if (allowChangingOptInStatus === false) { + return false; + } + + // determine if notice has been seen before + if (telemetrySavedObject && telemetrySavedObject.userHasSeenNotice === true) { + return false; + } + + return telemetryOptedIn === true && configTelemetryOptIn === true; +} diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_config/replace_injected_vars.ts b/src/legacy/core_plugins/telemetry/server/telemetry_config/replace_injected_vars.ts index 90d1f9cfdac65..02f9150a095d9 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_config/replace_injected_vars.ts +++ b/src/legacy/core_plugins/telemetry/server/telemetry_config/replace_injected_vars.ts @@ -21,6 +21,7 @@ import { getTelemetrySavedObject } from '../telemetry_repository'; import { getTelemetryOptIn } from './get_telemetry_opt_in'; import { getTelemetrySendUsageFrom } from './get_telemetry_send_usage_from'; import { getTelemetryAllowChangingOptInStatus } from './get_telemetry_allow_changing_opt_in_status'; +import { getNotifyUserAboutOptInDefault } from './get_telemetry_notify_user_about_optin_default'; export async function replaceTelemetryInjectedVars(request: any) { const config = request.server.config(); @@ -56,8 +57,16 @@ export async function replaceTelemetryInjectedVars(request: any) { telemetrySavedObject, }); + const telemetryNotifyUserAboutOptInDefault = getNotifyUserAboutOptInDefault({ + telemetrySavedObject, + allowChangingOptInStatus, + configTelemetryOptIn, + telemetryOptedIn, + }); + return { telemetryOptedIn, telemetrySendUsageFrom, + telemetryNotifyUserAboutOptInDefault, }; } diff --git a/src/legacy/core_plugins/telemetry/server/telemetry_repository/index.ts b/src/legacy/core_plugins/telemetry/server/telemetry_repository/index.ts index f3629abc1620c..b9ba2ce5573c3 100644 --- a/src/legacy/core_plugins/telemetry/server/telemetry_repository/index.ts +++ b/src/legacy/core_plugins/telemetry/server/telemetry_repository/index.ts @@ -26,4 +26,5 @@ export interface TelemetrySavedObjectAttributes { sendUsageFrom?: 'browser' | 'server'; lastReported?: number; telemetryAllowChangingOptInStatus?: boolean; + userHasSeenNotice?: boolean; } diff --git a/x-pack/test/api_integration/apis/telemetry/index.js b/x-pack/test/api_integration/apis/telemetry/index.js index 6f794d56ae713..5f13b0b728468 100644 --- a/x-pack/test/api_integration/apis/telemetry/index.js +++ b/x-pack/test/api_integration/apis/telemetry/index.js @@ -9,5 +9,6 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./telemetry')); loadTestFile(require.resolve('./telemetry_local')); loadTestFile(require.resolve('./opt_in')); + loadTestFile(require.resolve('./telemetry_optin_notice_seen')); }); } diff --git a/x-pack/test/api_integration/apis/telemetry/telemetry_optin_notice_seen.ts b/x-pack/test/api_integration/apis/telemetry/telemetry_optin_notice_seen.ts new file mode 100644 index 0000000000000..de03fff7edcf7 --- /dev/null +++ b/x-pack/test/api_integration/apis/telemetry/telemetry_optin_notice_seen.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import expect from '@kbn/expect'; +import { Client, DeleteDocumentParams, GetParams, GetResponse } from 'elasticsearch'; +import { TelemetrySavedObjectAttributes } from '../../../../../src/legacy/core_plugins/telemetry/server/telemetry_repository'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function optInTest({ getService }: FtrProviderContext) { + const client: Client = getService('es'); + const supertest = getService('supertest'); + + describe('/api/telemetry/v2/optIn API Telemetry User has seen OptIn Notice', () => { + it('should update telemetry setting field via PUT', async () => { + await client.delete({ + index: '.kibana', + id: 'telemetry:telemetry', + } as DeleteDocumentParams); + + await supertest + .put('/api/telemetry/v2/userHasSeenNotice') + .set('kbn-xsrf', 'xxx') + .expect(200); + + const { + _source: { telemetry }, + }: GetResponse<{ + telemetry: TelemetrySavedObjectAttributes; + }> = await client.get({ + index: '.kibana', + id: 'telemetry:telemetry', + } as GetParams); + + expect(telemetry.userHasSeenNotice).to.be(true); + }); + }); +}