From 7831e66fc75b283bed2b19f30f4cf5cd90046ff3 Mon Sep 17 00:00:00 2001 From: Seth Foster Date: Mon, 2 Nov 2020 09:12:21 -0500 Subject: [PATCH] feat(app): Send intercom event on no-cal-block selected When the user selects and saves the trash surface as their tip length calibration target, send an event to intercom so that support can follow up about fulfilling a calibration block. This also brings back the intercom event epics and bindings removed in #6781, without the calibration check session end bindings. --- .../AskForCalibrationBlockModal.test.js | 6 +- app/src/support/__tests__/epic.test.js | 160 ++++++++++++++++++ .../support/__tests__/intercom-event.test.js | 62 +++++++ .../__tests__/system-info-profile.test.js | 2 +- app/src/support/constants.js | 1 + app/src/support/epic.js | 14 +- app/src/support/intercom-event.js | 32 ++++ app/src/support/types.js | 4 +- 8 files changed, 275 insertions(+), 6 deletions(-) create mode 100644 app/src/support/__tests__/epic.test.js create mode 100644 app/src/support/__tests__/intercom-event.test.js create mode 100644 app/src/support/intercom-event.js diff --git a/app/src/components/CalibrateTipLength/__tests__/AskForCalibrationBlockModal.test.js b/app/src/components/CalibrateTipLength/__tests__/AskForCalibrationBlockModal.test.js index 0f5610d3e7e..e81af9aa3d8 100644 --- a/app/src/components/CalibrateTipLength/__tests__/AskForCalibrationBlockModal.test.js +++ b/app/src/components/CalibrateTipLength/__tests__/AskForCalibrationBlockModal.test.js @@ -52,7 +52,8 @@ describe('AskForCalibrationBlockModal', () => { useTrash: false, }, { - it: 'no dispatch when trash is picked but not saved', + it: + 'no dispatch (but yes intercom event) when trash is picked but not saved', save: false, savedVal: null, useTrash: true, @@ -64,7 +65,8 @@ describe('AskForCalibrationBlockModal', () => { useTrash: false, }, { - it: 'dispatches config command when trash is picked and saved', + it: + 'dispatches config command and fires interocm event when trash is picked and saved', save: true, savedVal: false, useTrash: true, diff --git a/app/src/support/__tests__/epic.test.js b/app/src/support/__tests__/epic.test.js new file mode 100644 index 00000000000..da0e66bb687 --- /dev/null +++ b/app/src/support/__tests__/epic.test.js @@ -0,0 +1,160 @@ +// @flow +// support profile epic test +import { TestScheduler } from 'rxjs/testing' +import { configInitialized } from '../../config' +import * as Profile from '../profile' +import * as Event from '../intercom-event' +import { supportEpic } from '../epic' + +import type { Action, State } from '../../types' +import type { Config } from '../../config/types' +import type { + SupportConfig, + SupportProfileUpdate, + IntercomEvent, +} from '../types' + +jest.mock('../profile') +jest.mock('../intercom-event') + +const makeProfileUpdate: JestMockFn< + [Action, State], + SupportProfileUpdate | null +> = Profile.makeProfileUpdate + +const makeIntercomEvent: JestMockFn<[Action, State], IntercomEvent | null> = + Event.makeIntercomEvent + +const sendEvent: JestMockFn<[IntercomEvent], void> = Event.sendEvent + +const initializeProfile: JestMockFn<[SupportConfig], void> = + Profile.initializeProfile + +const updateProfile: JestMockFn<[SupportProfileUpdate], void> = + Profile.updateProfile + +const MOCK_ACTION: Action = ({ type: 'MOCK_ACTION' }: any) +const MOCK_PROFILE_STATE: $Shape<{| ...State, config: $Shape |}> = { + config: { + support: { userId: 'foo', createdAt: 42, name: 'bar', email: null }, + }, +} + +const MOCK_EVENT_STATE: $Shape<{| ...State |}> = {} + +describe('support profile epic', () => { + let testScheduler + + beforeEach(() => { + makeProfileUpdate.mockReturnValue(null) + + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected) + }) + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it('should initialize support profile on config:INITIALIZED', () => { + testScheduler.run(({ hot, expectObservable, flush }) => { + const action$ = hot('-a', { + a: configInitialized(MOCK_PROFILE_STATE.config), + }) + const state$ = hot('--') + const result$ = supportEpic(action$, state$) + + expectObservable(result$, '--') + flush() + + expect(initializeProfile).toHaveBeenCalledWith( + MOCK_PROFILE_STATE.config.support + ) + }) + }) + + it('should do nothing with actions that do not map to a profile update', () => { + testScheduler.run(({ hot, expectObservable, flush }) => { + const action$ = hot('-a', { a: MOCK_ACTION }) + const state$ = hot('s-', { s: MOCK_PROFILE_STATE }) + const result$ = supportEpic(action$, state$) + + expectObservable(result$, '--') + flush() + + expect(makeProfileUpdate).toHaveBeenCalledWith( + MOCK_ACTION, + MOCK_PROFILE_STATE + ) + }) + }) + + it('should call a profile update ', () => { + const profileUpdate = { someProp: 'value' } + makeProfileUpdate.mockReturnValueOnce(profileUpdate) + + testScheduler.run(({ hot, expectObservable, flush }) => { + const action$ = hot('-a', { a: MOCK_ACTION }) + const state$ = hot('s-', { s: MOCK_PROFILE_STATE }) + const result$ = supportEpic(action$, state$) + + expectObservable(result$) + flush() + + expect(updateProfile).toHaveBeenCalledWith(profileUpdate) + }) + }) +}) + +describe('support event epic', () => { + let testScheduler + + beforeEach(() => { + makeIntercomEvent.mockReturnValue(null) + + testScheduler = new TestScheduler((actual, expected) => { + expect(actual).toEqual(expected) + }) + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + it('should do nothing with actions that do not map to an event', () => { + testScheduler.run(({ hot, expectObservable, flush }) => { + const action$ = hot('-a', { a: MOCK_ACTION }) + const state$ = hot('s-', { s: MOCK_EVENT_STATE }) + const result$ = supportEpic(action$, state$) + + expectObservable(result$, '--') + flush() + + expect(makeIntercomEvent).toHaveBeenCalledWith( + MOCK_ACTION, + MOCK_EVENT_STATE + ) + expect(sendEvent).not.toHaveBeenCalled() + }) + }) + + it('should send an event', () => { + const eventPayload = { + eventName: 'completed-robot-calibration-check', + metadata: { someProp: 'value' }, + } + makeIntercomEvent.mockReturnValueOnce(eventPayload) + + testScheduler.run(({ hot, expectObservable, flush }) => { + const action$ = hot('-a', { a: MOCK_ACTION }) + const state$ = hot('s-', { s: MOCK_PROFILE_STATE }) + const result$ = supportEpic(action$, state$) + + expectObservable(result$) + flush() + + expect(sendEvent).toHaveBeenCalledWith(eventPayload) + }) + }) +}) diff --git a/app/src/support/__tests__/intercom-event.test.js b/app/src/support/__tests__/intercom-event.test.js new file mode 100644 index 00000000000..5a030875eb3 --- /dev/null +++ b/app/src/support/__tests__/intercom-event.test.js @@ -0,0 +1,62 @@ +// @flow + +import type { IntercomPayload } from '../types' +import type { State } from '../../types' +import * as Binding from '../intercom-binding' +import * as Calibration from '../../calibration' +import * as Config from '../../config' +import { makeIntercomEvent, sendEvent } from '../intercom-event' +import * as Constants from '../constants' + +jest.mock('../intercom-binding') +jest.mock('../../sessions/selectors') + +const sendIntercomEvent: JestMockFn<[string, IntercomPayload], void> = + Binding.sendIntercomEvent + +const MOCK_STATE: $Shape<{| ...State |}> = {} + +describe('support event tests', () => { + afterEach(() => { + jest.resetAllMocks() + }) + + it('makeIntercomEvent should ignore unhandled events', () => { + const built = makeIntercomEvent( + Config.toggleConfigValue('some-random-path'), + MOCK_STATE + ) + expect(built).toBeNull() + }) + + it('makeIntercomEvent should send an event for no cal block selected', () => { + expect(makeIntercomEvent( + Calibration.setUseTrashSurfaceForTipCal(true), + MOCK_STATE + )).toEqual({ + eventName: Constants.INTERCOM_EVENT_NO_CAL_BLOCK, + metadata: {} + }) + }) + it('makeIntercomEvent should not send an event for cal block present', () => { + expect(makeIntercomEvent( + Calibration.setUseTrashSurfaceForTipCal(false), + MOCK_STATE + )).toBe(null) + }) + + it('sendEvent should pass on its arguments', () => { + const props = { + eventName: Constants.INTERCOM_EVENT_NO_CAL_BLOCK, + metadata: { + someKey: true, + someOtherKey: 'hi', + }, + } + sendEvent(props) + expect(sendIntercomEvent).toHaveBeenCalledWith( + props.eventName, + props.metadata + ) + }) +}) diff --git a/app/src/support/__tests__/system-info-profile.test.js b/app/src/support/__tests__/system-info-profile.test.js index 516da9ed946..f543e854432 100644 --- a/app/src/support/__tests__/system-info-profile.test.js +++ b/app/src/support/__tests__/system-info-profile.test.js @@ -25,7 +25,7 @@ const MOCK_ANALYTICS_PROPS = { 'U2E IPv4 Address': '10.0.0.1', } -describe('system info support profile updates', () => { +describe('custom labware analytics events', () => { beforeEach(() => { getU2EDeviceAnalyticsProps.mockImplementation(state => { expect(state).toBe(MOCK_STATE) diff --git a/app/src/support/constants.js b/app/src/support/constants.js index 5d70f4cfb17..560a00c9cb7 100644 --- a/app/src/support/constants.js +++ b/app/src/support/constants.js @@ -17,3 +17,4 @@ export const PROFILE_FEATURE_FLAG = 'Robot FF' // supported event names export const INTERCOM_EVENT_CALCHECK_COMPLETE: 'completed-robot-calibration-check' = 'completed-robot-calibration-check' +export const INTERCOM_EVENT_NO_CAL_BLOCK: 'no-cal-block' = 'no-cal-block' diff --git a/app/src/support/epic.js b/app/src/support/epic.js index 3328e166bff..5462c894e68 100644 --- a/app/src/support/epic.js +++ b/app/src/support/epic.js @@ -6,6 +6,8 @@ import { tap, filter, withLatestFrom, ignoreElements } from 'rxjs/operators' import * as Cfg from '../config' import { initializeProfile, makeProfileUpdate, updateProfile } from './profile' +import { makeIntercomEvent, sendEvent } from './intercom-event' + import type { Epic } from '../types' import type { ConfigInitializedAction } from '../config/types' @@ -28,7 +30,17 @@ const updateProfileEpic: Epic = (action$, state$) => { ) } +const sendEventEpic: Epic = (action$, state$) => { + return action$.pipe( + withLatestFrom(state$, makeIntercomEvent), + filter(maybeSend => maybeSend !== null), + tap(sendEvent), + ignoreElements() + ) +} + export const supportEpic: Epic = combineEpics( initializeSupportEpic, - updateProfileEpic + updateProfileEpic, + sendEventEpic ) diff --git a/app/src/support/intercom-event.js b/app/src/support/intercom-event.js new file mode 100644 index 00000000000..15887d13d7a --- /dev/null +++ b/app/src/support/intercom-event.js @@ -0,0 +1,32 @@ +// @flow +// functions for sending events to intercom, both for enriching user profiles +// and for triggering contextual support conversations +import type { Action, State } from '../types' +import { sendIntercomEvent } from './intercom-binding' +import type { IntercomEvent } from './types' +import { INTERCOM_EVENT_CALCHECK_COMPLETE, INTERCOM_EVENT_NO_CAL_BLOCK } from './constants' +import * as Config from '../config' + +export function makeIntercomEvent( + action: Action, + state: State +): IntercomEvent | null { + switch (action.type) { + case Config.UPDATE_VALUE: { + const {path, value} = action.payload + if (path !== 'calibration.useTrashSurfaceForTipCal' + || value !== true) { + return null + } + return { + eventName: INTERCOM_EVENT_NO_CAL_BLOCK, + metadata: {} + } + } + } + return null +} + +export function sendEvent(event: IntercomEvent): void { + sendIntercomEvent(event.eventName, event?.metadata ?? {}) +} diff --git a/app/src/support/types.js b/app/src/support/types.js index cd6e4721389..969cd2dd343 100644 --- a/app/src/support/types.js +++ b/app/src/support/types.js @@ -2,9 +2,9 @@ import type { Config } from '../config/types' -import typeof { INTERCOM_EVENT_CALCHECK_COMPLETE } from './constants' +import typeof { INTERCOM_EVENT_CALCHECK_COMPLETE, INTERCOM_EVENT_NO_CAL_BLOCK } from './constants' -export type IntercomEventName = INTERCOM_EVENT_CALCHECK_COMPLETE +export type IntercomEventName = INTERCOM_EVENT_CALCHECK_COMPLETE | INTERCOM_EVENT_NO_CAL_BLOCK export type SupportConfig = $PropertyType