diff --git a/protocol-designer/cypress/e2e/createNew.cy.ts b/protocol-designer/cypress/e2e/createNew.cy.ts index be578415bee..fb1c70470b3 100644 --- a/protocol-designer/cypress/e2e/createNew.cy.ts +++ b/protocol-designer/cypress/e2e/createNew.cy.ts @@ -12,6 +12,7 @@ describe('The Redesigned Create Protocol Landing Page', () => { }) it('content and step 1 flow works', () => { + cy.closeAnalyticsModal() cy.clickCreateNew() cy.verifyCreateNewHeader() verifyCreateProtocolPage() diff --git a/protocol-designer/cypress/e2e/import.cy.ts b/protocol-designer/cypress/e2e/import.cy.ts index 83ddaf0577d..8001f44f7d6 100644 --- a/protocol-designer/cypress/e2e/import.cy.ts +++ b/protocol-designer/cypress/e2e/import.cy.ts @@ -7,6 +7,7 @@ import { describe('The Import Page', () => { beforeEach(() => { cy.visit('/') + cy.closeAnalyticsModal() }) it('successfully loads a protocol exported on a previous version', () => { diff --git a/protocol-designer/cypress/e2e/migrations.cy.ts b/protocol-designer/cypress/e2e/migrations.cy.ts index 61470962bad..eb93db7ed51 100644 --- a/protocol-designer/cypress/e2e/migrations.cy.ts +++ b/protocol-designer/cypress/e2e/migrations.cy.ts @@ -6,6 +6,7 @@ import { TestFilePath } from '../support/testFiles' describe('Protocol fixtures migrate and match snapshots', () => { beforeEach(() => { cy.visit('/') + cy.closeAnalyticsModal() }) const testCases: MigrateTestCase[] = [ diff --git a/protocol-designer/cypress/e2e/settings.cy.ts b/protocol-designer/cypress/e2e/settings.cy.ts index 5ce896aa883..a9802484d89 100644 --- a/protocol-designer/cypress/e2e/settings.cy.ts +++ b/protocol-designer/cypress/e2e/settings.cy.ts @@ -1,6 +1,7 @@ describe('The Settings Page', () => { before(() => { cy.visit('/') + cy.closeAnalyticsModal() }) it('content and toggle state', () => { @@ -19,19 +20,19 @@ describe('The Settings Page', () => { cy.getByTestId('analyticsToggle') .should('exist') .should('be.visible') - .find('path[aria-roledescription="ot-toggle-input-off"]') + .find('path[aria-roledescription="ot-toggle-input-on"]') .should('exist') // Toggle the share sessions with Opentrons setting cy.getByTestId('analyticsToggle').click() cy.getByTestId('analyticsToggle') - .find('path[aria-roledescription="ot-toggle-input-on"]') + .find('path[aria-roledescription="ot-toggle-input-off"]') .should('exist') // Navigate away from the settings page // Then return to see privacy toggle remains toggled on cy.visit('/') cy.openSettingsPage() cy.getByTestId('analyticsToggle').find( - 'path[aria-roledescription="ot-toggle-input-on"]' + 'path[aria-roledescription="ot-toggle-input-off"]' ) // Toggle off editing timeline tips // Navigate away from the settings page diff --git a/protocol-designer/cypress/support/commands.ts b/protocol-designer/cypress/support/commands.ts index 3f9ffd8ddd8..5a40d7762cb 100644 --- a/protocol-designer/cypress/support/commands.ts +++ b/protocol-designer/cypress/support/commands.ts @@ -9,6 +9,7 @@ declare global { verifyFullHeader: () => Cypress.Chainable verifyCreateNewHeader: () => Cypress.Chainable clickCreateNew: () => Cypress.Chainable + closeAnalyticsModal: () => Cypress.Chainable closeAnnouncementModal: () => Cypress.Chainable verifyHomePage: () => Cypress.Chainable importProtocol: (protocolFile: string) => Cypress.Chainable @@ -61,6 +62,7 @@ export const locators = { eula: 'a[href="https://opentrons.com/eula"]', privacyToggle: 'Settings_hotKeys', analyticsToggleTestId: 'analyticsToggle', + confirm: 'Confirm', } // General Custom Commands @@ -111,6 +113,13 @@ Cypress.Commands.add('clickCreateNew', () => { cy.contains(locators.createProtocol).click() }) +Cypress.Commands.add('closeAnalyticsModal', () => { + cy.get('button') + .contains(locators.confirm) + .should('be.visible') + .click({ force: true }) +}) + // Header Import Cypress.Commands.add('importProtocol', (protocolFilePath: string) => { cy.contains(locators.import).click() diff --git a/protocol-designer/src/ProtocolRoutes.tsx b/protocol-designer/src/ProtocolRoutes.tsx index 7350aa0a8da..1f9c4864ed2 100644 --- a/protocol-designer/src/ProtocolRoutes.tsx +++ b/protocol-designer/src/ProtocolRoutes.tsx @@ -59,9 +59,6 @@ export function ProtocolRoutes(): JSX.Element { path: '/', } const allRoutes: RouteProps[] = [...pdRoutes, landingPage] - const showGateModal = - process.env.NODE_ENV === 'production' || process.env.OT_PD_SHOW_GATE - const navigate = useNavigate() const handleReset = (): void => { navigate('/', { replace: true }) @@ -75,7 +72,7 @@ export function ProtocolRoutes(): JSX.Element { - {showGateModal ? : null} + diff --git a/protocol-designer/src/analytics/actions.ts b/protocol-designer/src/analytics/actions.ts index a6fa4fc18cf..d1357dcfc05 100644 --- a/protocol-designer/src/analytics/actions.ts +++ b/protocol-designer/src/analytics/actions.ts @@ -1,9 +1,10 @@ +import { OLDEST_MIGRATEABLE_VERSION } from '../load-file/migration' import { setMixpanelTracking } from './mixpanel' import type { AnalyticsEvent } from './mixpanel' export interface SetOptIn { type: 'SET_OPT_IN' - payload: boolean + payload: { hasOptedIn: boolean; appVersion: string } } const _setOptIn = (payload: SetOptIn['payload']): SetOptIn => { @@ -16,12 +17,20 @@ const _setOptIn = (payload: SetOptIn['payload']): SetOptIn => { return { type: 'SET_OPT_IN', - payload, + payload: { hasOptedIn: payload.hasOptedIn, appVersion: payload.appVersion }, } } -export const optIn = (): SetOptIn => _setOptIn(true) -export const optOut = (): SetOptIn => _setOptIn(false) +export const optIn = (): SetOptIn => + _setOptIn({ + hasOptedIn: true, + appVersion: process.env.OT_PD_VERSION || OLDEST_MIGRATEABLE_VERSION, + }) +export const optOut = (): SetOptIn => + _setOptIn({ + hasOptedIn: false, + appVersion: process.env.OT_PD_VERSION || OLDEST_MIGRATEABLE_VERSION, + }) export interface AnalyticsEventAction { type: 'ANALYTICS_EVENT' payload: AnalyticsEvent diff --git a/protocol-designer/src/analytics/middleware.ts b/protocol-designer/src/analytics/middleware.ts index 6c798e353ff..29cc66f8a11 100644 --- a/protocol-designer/src/analytics/middleware.ts +++ b/protocol-designer/src/analytics/middleware.ts @@ -499,7 +499,7 @@ export const trackEventMiddleware: Middleware = ({ // NOTE: this is the Redux state AFTER the action has been fully dispatched const state = getState() - const optedIn = getHasOptedIn(state as BaseState) ?? false + const optedIn = getHasOptedIn(state as BaseState)?.hasOptedIn ?? false const event = reduxActionToAnalyticsEvent(state as BaseState, action) if (event != null) { diff --git a/protocol-designer/src/analytics/mixpanel.ts b/protocol-designer/src/analytics/mixpanel.ts index 6304753d8c7..c15c95a8f51 100644 --- a/protocol-designer/src/analytics/mixpanel.ts +++ b/protocol-designer/src/analytics/mixpanel.ts @@ -1,10 +1,8 @@ -// TODO(IL, 2020-09-09): reconcile with app/src/analytics/mixpanel.js, which this is derived from import mixpanel from 'mixpanel-browser' import { getIsProduction } from '../networking/opentronsWebApi' import { getHasOptedIn } from './selectors' import type { BaseState } from '../types' -// TODO(IL, 2020-09-09): AnalyticsEvent type copied from app/src/analytics/types.js, consider merging export type AnalyticsEvent = | { name: string @@ -19,18 +17,20 @@ const MIXPANEL_ID = getIsProduction() : process.env.OT_PD_MIXPANEL_DEV_ID const MIXPANEL_OPTS = { - // opt out by default opt_out_tracking_by_default: true, } export function initializeMixpanel(state: BaseState): void { - const optedIn = getHasOptedIn(state) ?? false + const optedIn = getHasOptedIn(state)?.hasOptedIn ?? false if (MIXPANEL_ID != null) { - console.debug('Initializing Mixpanel', { optedIn }) - - mixpanel.init(MIXPANEL_ID, MIXPANEL_OPTS) - setMixpanelTracking(optedIn) - trackEvent({ name: 'appOpen', properties: {} }, optedIn) // TODO IMMEDIATELY: do we want this? + try { + console.debug('Initializing Mixpanel', { optedIn }) + mixpanel.init(MIXPANEL_ID, MIXPANEL_OPTS) + setMixpanelTracking(optedIn) + trackEvent({ name: 'appOpen', properties: {} }, optedIn) + } catch (error) { + console.error('Error initializing Mixpanel:', error) + } } else { console.warn('MIXPANEL_ID not found; this is a bug if build is production') } @@ -40,32 +40,38 @@ export function initializeMixpanel(state: BaseState): void { export function trackEvent(event: AnalyticsEvent, optedIn: boolean): void { console.debug('Trackable event', { event, optedIn }) if (MIXPANEL_ID != null && optedIn) { - if ('superProperties' in event && event.superProperties != null) { - mixpanel.register(event.superProperties) - } - if ('name' in event && event.name != null) { - mixpanel.track(event.name, event.properties) + try { + if ('superProperties' in event && event.superProperties != null) { + mixpanel.register(event.superProperties) + } + if ('name' in event && event.name != null) { + mixpanel.track(event.name, event.properties) + } + } catch (error) { + console.error('Error tracking event:', error) } } } export function setMixpanelTracking(optedIn: boolean): void { if (MIXPANEL_ID != null) { - if (optedIn) { - console.debug('User has opted into analytics; tracking with Mixpanel') - mixpanel.opt_in_tracking() - // Register "super properties" which are included with all events - mixpanel.register({ - appVersion: process.env.OT_PD_VERSION, - // NOTE(IL, 2020): Since PD may be in the same Mixpanel project as other OT web apps, this 'appName' property is intended to distinguish it - appName: 'protocolDesigner', - }) - } else { - console.debug( - 'User has opted out of analytics; stopping Mixpanel tracking' - ) - mixpanel.opt_out_tracking() - mixpanel.reset() + try { + if (optedIn) { + console.debug('User has opted into analytics; tracking with Mixpanel') + mixpanel.opt_in_tracking() + mixpanel.register({ + appVersion: process.env.OT_PD_VERSION, + appName: 'protocolDesigner', + }) + } else { + console.debug( + 'User has opted out of analytics; stopping Mixpanel tracking' + ) + mixpanel.opt_out_tracking() + mixpanel.reset() + } + } catch (error) { + console.error('Error setting Mixpanel tracking:', error) } } } diff --git a/protocol-designer/src/analytics/reducers.ts b/protocol-designer/src/analytics/reducers.ts index 6d4f71ebaa6..9c2427f603a 100644 --- a/protocol-designer/src/analytics/reducers.ts +++ b/protocol-designer/src/analytics/reducers.ts @@ -4,8 +4,14 @@ import type { Reducer } from 'redux' import type { Action } from '../types' import type { SetOptIn } from './actions' import type { RehydratePersistedAction } from '../persist' -type OptInState = boolean | null -const optInInitialState = null +export interface OptInState { + hasOptedIn: boolean | null + appVersion?: string +} +const optInInitialState = { + hasOptedIn: null, +} + // @ts-expect-error(sb, 2021-6-17): cannot use string literals as action type // TODO IMMEDIATELY: refactor this to the old fashioned way if we cannot have type safety: https://github.com/redux-utilities/redux-actions/issues/282#issuecomment-595163081 const hasOptedIn: Reducer = handleActions( diff --git a/protocol-designer/src/analytics/selectors.ts b/protocol-designer/src/analytics/selectors.ts index 77a37bbfcb1..e98c11e3ab7 100644 --- a/protocol-designer/src/analytics/selectors.ts +++ b/protocol-designer/src/analytics/selectors.ts @@ -1,3 +1,4 @@ import type { BaseState } from '../types' -export const getHasOptedIn = (state: BaseState): boolean | null => +import type { OptInState } from './reducers' +export const getHasOptedIn = (state: BaseState): OptInState => state.analytics.hasOptedIn diff --git a/protocol-designer/src/assets/localization/en/shared.json b/protocol-designer/src/assets/localization/en/shared.json index 0d9342713c4..212ae62bf05 100644 --- a/protocol-designer/src/assets/localization/en/shared.json +++ b/protocol-designer/src/assets/localization/en/shared.json @@ -102,7 +102,7 @@ "only_tiprack": "Incompatible file type", "opentrons_flex": "Opentrons Flex", "opentrons": "Opentrons", - "opentrons_collects_data": "In order to improve our products, Opentrons would like to collect data related to your use of Protocol Designer. With your consent, Opentrons will collect and store analytics and session data, including through the use of cookies and similar technologies, solely for the purpose enhancing our products.", + "opentrons_collects_data": "In order to improve our products, Opentrons would like to collect data related to your use of Protocol Designer. Opentrons will collect and store analytics and session data, including through the use of cookies and similar technologies, solely for the purpose enhancing our products.", "ot2": "Opentrons OT-2", "overwrite_labware": "Overwrite labware", "overwrite": "Click Overwrite to replace the existing labware with the new labware.", diff --git a/protocol-designer/src/components/SettingsPage/SettingsApp.tsx b/protocol-designer/src/components/SettingsPage/SettingsApp.tsx index 20a31121010..f3433b9787c 100644 --- a/protocol-designer/src/components/SettingsPage/SettingsApp.tsx +++ b/protocol-designer/src/components/SettingsPage/SettingsApp.tsx @@ -20,13 +20,10 @@ import { FeatureFlagCard } from './FeatureFlagCard/FeatureFlagCard' export function SettingsApp(): JSX.Element { const dispatch = useDispatch() - const hasOptedIn = useSelector(analyticsSelectors.getHasOptedIn) + const { hasOptedIn } = useSelector(analyticsSelectors.getHasOptedIn) const canClearHintDismissals = useSelector( tutorialSelectors.getCanClearHintDismissals ) - const _toggleOptedIn = hasOptedIn - ? analyticsActions.optOut - : analyticsActions.optIn const { t } = useTranslation(['card', 'application', 'button']) return ( @@ -73,7 +70,13 @@ export function SettingsApp(): JSX.Element { dispatch(_toggleOptedIn())} + onClick={() => + dispatch( + hasOptedIn + ? analyticsActions.optOut() + : analyticsActions.optIn() + ) + } /> diff --git a/protocol-designer/src/organisms/GateModal/index.tsx b/protocol-designer/src/organisms/GateModal/index.tsx index cfe35b1b24a..e5f3c5f734c 100644 --- a/protocol-designer/src/organisms/GateModal/index.tsx +++ b/protocol-designer/src/organisms/GateModal/index.tsx @@ -9,7 +9,6 @@ import { Modal, PrimaryButton, SPACING, - SecondaryButton, StyledText, } from '@opentrons/components' import { @@ -22,10 +21,12 @@ const EULA_URL = 'https://opentrons.com/eula' export function GateModal(): JSX.Element | null { const { t } = useTranslation('shared') - const hasOptedIn = useSelector(analyticsSelectors.getHasOptedIn) + const { appVersion, hasOptedIn } = useSelector( + analyticsSelectors.getHasOptedIn + ) const dispatch = useDispatch() - if (hasOptedIn == null) { + if (appVersion == null || hasOptedIn == null) { return ( - dispatch(analyticsActions.optOut())} - > - - {t('reject')} - - dispatch(analyticsActions.optIn())}> - {t('agree')} + {t('confirm')} @@ -85,9 +79,6 @@ export function GateModal(): JSX.Element | null { }} /> - - {t('analytics_tracking')} - ) diff --git a/protocol-designer/src/pages/Landing/__tests__/Landing.test.tsx b/protocol-designer/src/pages/Landing/__tests__/Landing.test.tsx index 4ca8430796f..c064a66dfb2 100644 --- a/protocol-designer/src/pages/Landing/__tests__/Landing.test.tsx +++ b/protocol-designer/src/pages/Landing/__tests__/Landing.test.tsx @@ -35,7 +35,10 @@ const render = () => { describe('Landing', () => { beforeEach(() => { - vi.mocked(getHasOptedIn).mockReturnValue(false) + vi.mocked(getHasOptedIn).mockReturnValue({ + hasOptedIn: false, + appVersion: '8.2.1', + }) vi.mocked(getFileMetadata).mockReturnValue({}) vi.mocked(loadProtocolFile).mockReturnValue(vi.fn()) vi.mocked(useAnnouncements).mockReturnValue({} as any) diff --git a/protocol-designer/src/pages/Landing/index.tsx b/protocol-designer/src/pages/Landing/index.tsx index 3a9ea55bfd3..0e66adaa435 100644 --- a/protocol-designer/src/pages/Landing/index.tsx +++ b/protocol-designer/src/pages/Landing/index.tsx @@ -38,7 +38,7 @@ export function Landing(): JSX.Element { const [showAnnouncementModal, setShowAnnouncementModal] = useState( false ) - const hasOptedIn = useSelector(getHasOptedIn) + const { hasOptedIn } = useSelector(getHasOptedIn) const { bakeToast, eatToast } = useKitchen() const announcements = useAnnouncements() const lastAnnouncement = announcements[announcements.length - 1] diff --git a/protocol-designer/src/pages/Settings/__tests__/Settings.test.tsx b/protocol-designer/src/pages/Settings/__tests__/Settings.test.tsx index 8a1b948e953..671a31ccfc4 100644 --- a/protocol-designer/src/pages/Settings/__tests__/Settings.test.tsx +++ b/protocol-designer/src/pages/Settings/__tests__/Settings.test.tsx @@ -32,7 +32,10 @@ const render = () => { describe('Settings', () => { beforeEach(() => { - vi.mocked(getHasOptedIn).mockReturnValue(false) + vi.mocked(getHasOptedIn).mockReturnValue({ + hasOptedIn: false, + appVersion: '8.2.1', + }) vi.mocked(getFeatureFlagData).mockReturnValue({}) vi.mocked(getCanClearHintDismissals).mockReturnValue(true) }) diff --git a/protocol-designer/src/pages/Settings/index.tsx b/protocol-designer/src/pages/Settings/index.tsx index b678327adb8..7165b5d68a8 100644 --- a/protocol-designer/src/pages/Settings/index.tsx +++ b/protocol-designer/src/pages/Settings/index.tsx @@ -42,18 +42,16 @@ export function Settings(): JSX.Element { const [showAnnouncementModal, setShowAnnouncementModal] = useState( false ) - const hasOptedIn = useSelector(analyticsSelectors.getHasOptedIn) + const analytics = useSelector(analyticsSelectors.getHasOptedIn) const flags = useSelector(getFeatureFlagData) const canClearHintDismissals = useSelector( tutorialSelectors.getCanClearHintDismissals ) - const _toggleOptedIn = hasOptedIn - ? analyticsActions.optOut - : analyticsActions.optIn - const prereleaseModeEnabled = flags.PRERELEASE_MODE === true const pdVersion = process.env.OT_PD_VERSION + const prereleaseModeEnabled = flags.PRERELEASE_MODE === true + const allFlags = Object.keys(flags) as FlagTypes[] const getDescription = (flag: FlagTypes): string => { @@ -276,15 +274,23 @@ export function Settings(): JSX.Element { data-testid="analyticsToggle" size="2rem" css={ - Boolean(hasOptedIn) + Boolean(analytics.hasOptedIn) ? TOGGLE_ENABLED_STYLES : TOGGLE_DISABLED_STYLES } - onClick={() => dispatch(_toggleOptedIn())} + onClick={() => + dispatch( + analytics.hasOptedIn + ? analyticsActions.optOut() + : analyticsActions.optIn() + ) + } > diff --git a/protocol-designer/src/persist.ts b/protocol-designer/src/persist.ts index 693346e77b0..576ad937cc2 100644 --- a/protocol-designer/src/persist.ts +++ b/protocol-designer/src/persist.ts @@ -8,7 +8,10 @@ export interface RehydratePersistedAction { payload: { 'tutorial.dismissedHints'?: Record 'featureFlags.flags'?: Record - 'analytics.hasOptedIn'?: boolean | null + 'analytics.hasOptedIn'?: { + hasOptedIn: boolean | null + appVersion?: string + } } } export const getLocalStorageItem = (path: string): unknown => {