Prompt existing users about analytics (#3278)

* Prompt existing users about analytics

* Fix the tests

* Set hasPromptedAnalytics to true when going through the onboarding flow

* Add comments

* Show the analytics prompt after migrating if needed as well

* test: add a few more tests for analytics activity

Co-authored-by: Opender Singh <opender.singh@konghq.com>
This commit is contained in:
David Marby 2021-04-15 16:33:21 +02:00 committed by GitHub
parent 81e16a1c97
commit c69433fecc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 214 additions and 70 deletions

View File

@ -1,4 +1,5 @@
import {
ACTIVITY_ANALYTICS,
ACTIVITY_DEBUG,
ACTIVITY_HOME,
ACTIVITY_MIGRATION,
@ -46,6 +47,7 @@ describe('isWorkspaceActivity', () => {
expect(isWorkspaceActivity(ACTIVITY_HOME)).toBe(false);
expect(isWorkspaceActivity(ACTIVITY_ONBOARDING)).toBe(false);
expect(isWorkspaceActivity(ACTIVITY_MIGRATION)).toBe(false);
expect(isWorkspaceActivity(ACTIVITY_ANALYTICS)).toBe(false);
});
});
@ -57,6 +59,7 @@ describe('isValidActivity', () => {
expect(isValidActivity(ACTIVITY_HOME)).toBe(true);
expect(isValidActivity(ACTIVITY_ONBOARDING)).toBe(true);
expect(isValidActivity(ACTIVITY_MIGRATION)).toBe(true);
expect(isValidActivity(ACTIVITY_ANALYTICS)).toBe(true);
});
it('should return false', () => {

View File

@ -184,13 +184,21 @@ export const MIN_EDITOR_FONT_SIZE = 8;
export const MAX_EDITOR_FONT_SIZE = 24;
// Activities
export type GlobalActivity = 'spec' | 'debug' | 'unittest' | 'home' | 'migration' | 'onboarding';
export type GlobalActivity =
| 'spec'
| 'debug'
| 'unittest'
| 'home'
| 'migration'
| 'onboarding'
| 'analytics';
export const ACTIVITY_SPEC: GlobalActivity = 'spec';
export const ACTIVITY_DEBUG: GlobalActivity = 'debug';
export const ACTIVITY_UNIT_TEST: GlobalActivity = 'unittest';
export const ACTIVITY_HOME: GlobalActivity = 'home';
export const ACTIVITY_ONBOARDING: GlobalActivity = 'onboarding';
export const ACTIVITY_MIGRATION: GlobalActivity = 'migration';
export const ACTIVITY_ANALYTICS: GlobalActivity = 'analytics';
export const DEPRECATED_ACTIVITY_INSOMNIA = 'insomnia';
export const isWorkspaceActivity = (activity: GlobalActivity): boolean => {
@ -202,6 +210,7 @@ export const isWorkspaceActivity = (activity: GlobalActivity): boolean => {
case ACTIVITY_HOME:
case ACTIVITY_ONBOARDING:
case ACTIVITY_MIGRATION:
case ACTIVITY_ANALYTICS:
default:
return false;
}
@ -215,6 +224,7 @@ export const isValidActivity = (activity: GlobalActivity): boolean => {
case ACTIVITY_HOME:
case ACTIVITY_ONBOARDING:
case ACTIVITY_MIGRATION:
case ACTIVITY_ANALYTICS:
return true;
default:
return false;

View File

@ -150,7 +150,11 @@ async function _createModelInstances() {
async function _updateFlags({ launches }: Stats) {
const firstLaunch = launches === 1;
if (firstLaunch) {
await models.settings.patch({ hasPromptedOnboarding: false });
await models.settings.patch({
hasPromptedOnboarding: false,
// Don't show the analytics preferences prompt as it is part of the onboarding flow
hasPromptedAnalytics: true,
});
}
}

View File

@ -66,6 +66,7 @@ type BaseSettings = {
validateSSL: boolean,
hasPromptedToMigrateFromDesigner: boolean,
hasPromptedOnboarding: boolean,
hasPromptedAnalytics: boolean,
};
export type Settings = BaseModel & BaseSettings;
@ -129,6 +130,11 @@ export function init(): BaseSettings {
// older version should not see it, so by default this flag is set to true, and is toggled
// to false during initialization
hasPromptedOnboarding: true,
// Only existing users updating from an older version should see the analytics prompt
// So by default this flag is set to false, and is toggled to true during initialization
// for new users
hasPromptedAnalytics: false,
};
}

View File

@ -0,0 +1,65 @@
// @flow
import * as React from 'react';
import { autoBindMethodsForReact } from 'class-autobind-decorator';
import { Button } from 'insomnia-components';
import * as models from '../../models';
import { AUTOBIND_CFG, getAppLongName } from '../../common/constants';
import type { WrapperProps } from './wrapper';
import chartSrc from '../images/chart.svg';
type Props = {
wrapperProps: WrapperProps,
handleDone: Function,
};
@autoBindMethodsForReact(AUTOBIND_CFG)
class Analytics extends React.PureComponent<Props, State> {
async _handleAnalyticsSetting(enableAnalytics: boolean) {
const { settings } = this.props.wrapperProps;
// Update settings with analytics preferences
await models.settings.update(settings, { enableAnalytics });
this.props.handleDone();
}
async _handleClickEnableAnalytics(e: SyntheticEvent<HTMLButtonElement>) {
this._handleAnalyticsSetting(true);
}
async _handleClickDisableAnalytics(e: SyntheticEvent<HTMLButtonElement>) {
this._handleAnalyticsSetting(false);
}
render() {
return (
<React.Fragment>
<p>
<strong>Share Usage Analytics with Kong Inc</strong>
</p>
<img src={chartSrc} alt="Demonstration chart" />
<p>
Help us understand how <strong>you</strong> use {getAppLongName()} so we can make it
better.
</p>
<Button
key="enable"
bg="surprise"
radius="3px"
size="medium"
variant="contained"
onClick={this._handleClickEnableAnalytics}>
Share Usage Analytics
</Button>
<button
key="disable"
className="btn btn--super-compact"
onClick={this._handleClickDisableAnalytics}>
Don't share usage analytics
</button>
</React.Fragment>
);
}
}
export default Analytics;

View File

@ -565,6 +565,9 @@ class General extends React.PureComponent<Props, State> {
<div className="form-row pad-top-sm">
{this.renderBooleanSetting('Has seen onboarding experience', 'hasPromptedOnboarding')}
</div>
<div className="form-row pad-top-sm">
{this.renderBooleanSetting('Has seen analytics prompt', 'hasPromptedAnalytics')}
</div>
</>
)}
</div>

View File

@ -0,0 +1,31 @@
// @flow
import * as React from 'react';
import type { WrapperProps } from './wrapper';
import OnboardingContainer from './onboarding-container';
import Analytics from './analytics';
import { useDispatch } from 'react-redux';
import { setActiveActivity } from '../redux/modules/global';
import { getAppLongName, getAppSynopsis, ACTIVITY_HOME } from '../../common/constants';
type Props = {|
wrapperProps: WrapperProps,
|};
const WrapperAnalytics = ({ wrapperProps }: Props) => {
const reduxDispatch = useDispatch();
const navigateHome = React.useCallback(() => {
reduxDispatch(setActiveActivity(ACTIVITY_HOME));
}, [reduxDispatch]);
return (
<OnboardingContainer
wrapperProps={wrapperProps}
header={'Welcome to ' + getAppLongName()}
subHeader={getAppSynopsis()}>
<Analytics wrapperProps={wrapperProps} handleDone={navigateHome} />
</OnboardingContainer>
);
};
export default WrapperAnalytics;

View File

@ -2,17 +2,16 @@
import * as React from 'react';
import { autoBindMethodsForReact } from 'class-autobind-decorator';
import 'swagger-ui-react/swagger-ui.css';
import { Button } from 'insomnia-components';
import { showPrompt } from './modals';
import type { BaseModel } from '../../models';
import * as models from '../../models';
import { AUTOBIND_CFG, getAppLongName, getAppName, getAppSynopsis } from '../../common/constants';
import type { HandleImportFileCallback, HandleImportUriCallback, WrapperProps } from './wrapper';
import * as db from '../../common/database';
import chartSrc from '../images/chart.svg';
import { ForceToWorkspaceKeys } from '../redux/modules/helpers';
import OnboardingContainer from './onboarding-container';
import { WorkspaceScopeKeys } from '../../models/workspace';
import Analytics from './analytics';
type Props = {|
wrapperProps: WrapperProps,
@ -61,23 +60,10 @@ class WrapperOnboarding extends React.PureComponent<Props, State> {
this.setState(state => ({ step: state.step - 1 }));
}
async _handleCompleteAnalyticsStep(enableAnalytics: boolean) {
const { settings } = this.props.wrapperProps;
// Update settings with analytics preferences
await models.settings.update(settings, { enableAnalytics });
async _handleCompleteAnalyticsStep() {
this.setState(state => ({ step: state.step + 1 }));
}
async _handleClickEnableAnalytics(e: SyntheticEvent<HTMLButtonElement>) {
this._handleCompleteAnalyticsStep(true);
}
async _handleClickDisableAnalytics(e: SyntheticEvent<HTMLButtonElement>) {
this._handleCompleteAnalyticsStep(false);
}
_handleImportFile() {
const { handleImportFile } = this.props;
handleImportFile({
@ -120,31 +106,10 @@ class WrapperOnboarding extends React.PureComponent<Props, State> {
renderStep1() {
return (
<React.Fragment>
<p>
<strong>Share Usage Analytics with Kong Inc</strong>
</p>
<img src={chartSrc} alt="Demonstration chart" />
<p>
Help us understand how <strong>you</strong> use {getAppLongName()} so we can make it
better.
</p>
<Button
key="enable"
bg="surprise"
radius="3px"
size="medium"
variant="contained"
onClick={this._handleClickEnableAnalytics}>
Share Usage Analytics
</Button>
<button
key="disable"
className="btn btn--super-compact"
onClick={this._handleClickDisableAnalytics}>
Don't share usage analytics
</button>
</React.Fragment>
<Analytics
wrapperProps={this.props.wrapperProps}
handleDone={this._handleCompleteAnalyticsStep}
/>
);
}

View File

@ -24,6 +24,7 @@ import {
SortOrder,
ACTIVITY_MIGRATION,
ACTIVITY_ONBOARDING,
ACTIVITY_ANALYTICS,
} from '../../common/constants';
import { registerModal, showModal } from './modals/index';
import AlertModal from './modals/alert-modal';
@ -95,6 +96,7 @@ import ProtoFilesModal from './modals/proto-files-modal';
import { GrpcDispatchModalWrapper } from '../context/grpc';
import WrapperMigration from './wrapper-migration';
import type { ImportOptions } from '../redux/modules/global';
import WrapperAnalytics from './wrapper-analytics';
const spectral = new Spectral();
@ -868,6 +870,8 @@ class Wrapper extends React.PureComponent<WrapperProps, State> {
{activity === ACTIVITY_MIGRATION && <WrapperMigration wrapperProps={this.props} />}
{activity === ACTIVITY_ANALYTICS && <WrapperAnalytics wrapperProps={this.props} />}
{(activity === ACTIVITY_ONBOARDING || activity === null) && (
<WrapperOnboarding
wrapperProps={this.props}

View File

@ -2,7 +2,17 @@
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import { globalBeforeEach } from '../../../../__jest__/before-each';
import type { GlobalActivity } from '../../../../common/constants';
import {
ACTIVITY_ANALYTICS,
GlobalActivity,
ACTIVITY_DEBUG,
ACTIVITY_HOME,
ACTIVITY_MIGRATION,
ACTIVITY_ONBOARDING,
ACTIVITY_SPEC,
ACTIVITY_UNIT_TEST,
DEPRECATED_ACTIVITY_INSOMNIA,
} from '../../../../common/constants';
import { trackEvent } from '../../../../common/analytics';
import {
goToNextActivity,
@ -14,15 +24,7 @@ import {
setActiveActivity,
setActiveWorkspace,
} from '../global';
import {
ACTIVITY_DEBUG,
ACTIVITY_HOME,
ACTIVITY_MIGRATION,
ACTIVITY_ONBOARDING,
ACTIVITY_SPEC,
ACTIVITY_UNIT_TEST,
DEPRECATED_ACTIVITY_INSOMNIA,
} from '../../../../common/constants';
import * as models from '../../../../models';
import fs from 'fs';
import { getDesignerDataDir } from '../../../../common/misc';
@ -32,10 +34,15 @@ jest.mock('../../../../common/analytics');
const middlewares = [thunk];
const mockStore = configureMockStore(middlewares);
const createSettings = (hasPromptedMigration: boolean, hasPromptedOnboarding: boolean) => {
const createSettings = (
hasPromptedMigration: boolean,
hasPromptedOnboarding: boolean,
hasPromptedAnalytics: boolean,
) => {
const settings = models.settings.init();
settings.hasPromptedOnboarding = hasPromptedOnboarding;
settings.hasPromptedToMigrateFromDesigner = hasPromptedMigration;
settings.hasPromptedAnalytics = hasPromptedAnalytics;
return settings;
};
@ -114,7 +121,7 @@ describe('global', () => {
describe('goToNextActivity', () => {
it('should go from onboarding to home', async () => {
const settings = createSettings(false, false);
const settings = createSettings(false, false, true);
const activeActivity = ACTIVITY_ONBOARDING;
const store = mockStore({ global: { activeActivity }, entities: { settings: [settings] } });
@ -124,7 +131,7 @@ describe('global', () => {
});
it('should go from migration to onboarding', async () => {
const settings = createSettings(false, false);
const settings = createSettings(false, false, true);
const activeActivity = ACTIVITY_MIGRATION;
const store = mockStore({ global: { activeActivity }, entities: { settings: [settings] } });
@ -135,8 +142,20 @@ describe('global', () => {
]);
});
it('should go from migration to analytics', async () => {
const settings = createSettings(false, true, false);
const activeActivity = ACTIVITY_MIGRATION;
const store = mockStore({ global: { activeActivity }, entities: { settings: [settings] } });
await store.dispatch(goToNextActivity());
expect(store.getActions()).toEqual([
{ type: SET_ACTIVE_ACTIVITY, activity: ACTIVITY_ANALYTICS },
]);
});
it('should go from migration to home', async () => {
const settings = createSettings(true, true);
const settings = createSettings(true, true, true);
const activeActivity = ACTIVITY_MIGRATION;
const store = mockStore({ global: { activeActivity }, entities: { settings: [settings] } });
@ -148,7 +167,7 @@ describe('global', () => {
it.each([ACTIVITY_SPEC, ACTIVITY_DEBUG, ACTIVITY_UNIT_TEST, ACTIVITY_HOME])(
'should not change activity from: %s',
async (activeActivity: GlobalActivity) => {
const settings = createSettings(false, true);
const settings = createSettings(false, true, true);
const store = mockStore({ global: { activeActivity }, entities: { settings: [settings] } });
@ -178,8 +197,9 @@ describe('global', () => {
ACTIVITY_UNIT_TEST,
ACTIVITY_HOME,
ACTIVITY_ONBOARDING,
ACTIVITY_ANALYTICS,
])('should initialize %s from local storage', async activity => {
const settings = createSettings(true, true);
const settings = createSettings(true, true, true);
const store = mockStore({ global: {}, entities: { settings: [settings] } });
@ -191,7 +211,7 @@ describe('global', () => {
});
it('should initialize from local storage and migrate deprecated activity', async () => {
const settings = createSettings(true, true);
const settings = createSettings(true, true, true);
const store = mockStore({ global: {}, entities: { settings: [settings] } });
@ -204,7 +224,7 @@ describe('global', () => {
});
it('should go to onboarding if initialized at migration', async () => {
const settings = createSettings(true, false);
const settings = createSettings(true, false, true);
const store = mockStore({ global: {}, entities: { settings: [settings] } });
@ -221,7 +241,7 @@ describe('global', () => {
null,
undefined,
)('should go to home if initialized with an unsupported value: %s', async activity => {
const settings = createSettings(true, true);
const settings = createSettings(true, true, true);
const store = mockStore({ global: {}, entities: { settings: [settings] } });
@ -233,7 +253,7 @@ describe('global', () => {
});
it('should go to home if local storage key not found', async () => {
const settings = createSettings(true, true);
const settings = createSettings(true, true, true);
const store = mockStore({ global: {}, entities: { settings: [settings] } });
@ -244,7 +264,7 @@ describe('global', () => {
});
it('should go to home if initialized at migration and onboarding seen', async () => {
const settings = createSettings(true, true);
const settings = createSettings(true, true, true);
const store = mockStore({ global: {}, entities: { settings: [settings] } });
@ -257,7 +277,7 @@ describe('global', () => {
});
it('should prompt to migrate', async () => {
const settings = createSettings(false, true);
const settings = createSettings(false, true, true);
fsExistsSyncSpy.mockReturnValue(true);
const store = mockStore({ global: {}, entities: { settings: [settings] } });
@ -272,7 +292,7 @@ describe('global', () => {
});
it('should not prompt to migrate if default directory not found', async () => {
const settings = createSettings(false, true);
const settings = createSettings(false, true, true);
fsExistsSyncSpy.mockReturnValue(false);
const store = mockStore({ global: {}, entities: { settings: [settings] } });
@ -287,7 +307,7 @@ describe('global', () => {
});
it('should prompt to onboard', async () => {
const settings = createSettings(true, false);
const settings = createSettings(true, false, true);
const store = mockStore({ global: {}, entities: { settings: [settings] } });
@ -298,5 +318,18 @@ describe('global', () => {
await store.dispatch(initActiveActivity());
expect(store.getActions()).toEqual([expectedEvent]);
});
it('should prompt to change analytics settings', async () => {
const settings = createSettings(true, true, false);
const store = mockStore({ global: {}, entities: { settings: [settings] } });
const activity = ACTIVITY_HOME;
global.localStorage.setItem(`${LOCALSTORAGE_PREFIX}::activity`, JSON.stringify(activity));
const expectedEvent = { type: SET_ACTIVE_ACTIVITY, activity: ACTIVITY_ANALYTICS };
await store.dispatch(initActiveActivity());
expect(store.getActions()).toEqual([expectedEvent]);
});
});
});

View File

@ -35,6 +35,7 @@ import {
ACTIVITY_HOME,
ACTIVITY_MIGRATION,
ACTIVITY_ONBOARDING,
ACTIVITY_ANALYTICS,
DEPRECATED_ACTIVITY_INSOMNIA,
isValidActivity,
} from '../../../common/constants';
@ -235,8 +236,18 @@ export function loadRequestStop(requestId) {
function _getNextActivity(settings: Settings, currentActivity: GlobalActivity): GlobalActivity {
switch (currentActivity) {
case ACTIVITY_MIGRATION:
// Has seen the onboarding step? Go to home, otherwise go to onboarding
return settings.hasPromptedOnboarding ? ACTIVITY_HOME : ACTIVITY_ONBOARDING;
// Has not seen the onboarding step? Go to onboarding
if (!settings.hasPromptedOnboarding) {
return ACTIVITY_ONBOARDING;
}
// Has not seen the analytics prompt? Go to it
if (!settings.hasPromptedAnalytics) {
return ACTIVITY_ANALYTICS;
}
// Otherwise, go to home
return ACTIVITY_HOME;
case ACTIVITY_ONBOARDING:
// Always go to home after onboarding
return ACTIVITY_HOME;
@ -276,7 +287,14 @@ export function setActiveActivity(activity: GlobalActivity) {
models.settings.patch({ hasPromptedToMigrateFromDesigner: true });
break;
case ACTIVITY_ONBOARDING:
models.settings.patch({ hasPromptedOnboarding: true });
models.settings.patch({
hasPromptedOnboarding: true,
// Don't show the analytics preferences prompt as it is part of the onboarding flow
hasPromptedAnalytics: true,
});
break;
case ACTIVITY_ANALYTICS:
models.settings.patch({ hasPromptedAnalytics: true });
break;
default:
break;
@ -728,6 +746,8 @@ export function initActiveActivity() {
overrideActivity = ACTIVITY_MIGRATION;
} else if (!settings.hasPromptedOnboarding) {
overrideActivity = ACTIVITY_ONBOARDING;
} else if (!settings.hasPromptedAnalytics) {
overrideActivity = ACTIVITY_ANALYTICS;
}
break;
}