Skip to content

Commit

Permalink
feat(protocol-designer): implement selective redux persistence (#2436)
Browse files Browse the repository at this point in the history
* introduce subscriber-based mechanism to sync selected redux substates to localStorage
* refactor PD analytics to use this persistence mechanism
  • Loading branch information
IanLondon authored Oct 8, 2018
1 parent d963cfc commit 6591104
Show file tree
Hide file tree
Showing 11 changed files with 258 additions and 150 deletions.
24 changes: 24 additions & 0 deletions protocol-designer/src/analytics/actions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// @flow
import {initializeAnalytics, shutdownAnalytics} from './integrations'

export type SetOptIn = {
type: 'SET_OPT_IN',
payload: boolean,
}

const _setOptIn = (payload: $PropertyType<SetOptIn, 'payload'>): SetOptIn => {
// side effects
if (payload) {
initializeAnalytics()
} else {
shutdownAnalytics()
}

return {
type: 'SET_OPT_IN',
payload,
}
}

export const optIn = () => _setOptIn(true)
export const optOut = () => _setOptIn(false)
56 changes: 10 additions & 46 deletions protocol-designer/src/analytics/index.js
Original file line number Diff line number Diff line change
@@ -1,50 +1,14 @@
/* eslint-disable */
// @flow
import * as actions from './actions'
import * as selectors from './selectors'
import {rootReducer, type RootState} from './reducers'

export const shutdownAnalytics = () => {
if (window[window['_fs_namespace']]) { window[window['_fs_namespace']].shutdown() }
delete window[window['_fs_namespace']]
export {
actions,
selectors,
rootReducer,
}

export const optIn = () => {
try {
window.localStorage.setItem('optedInToAnalytics', true)
} catch(e) {
console.error('attempted to persist analytics preference in localStorage, but failed with error: ', e)
return false
}
return true
}

export const optOut = () => {
try {
window.localStorage.setItem('optedInToAnalytics', false)
} catch(e) {
console.error('attempted to persist analytics preference in localStorage, but failed with error: ', e)
return false
}
return true
}

export const getHasOptedIn = () => (
JSON.parse(window.localStorage.getItem('optedInToAnalytics'))
)

// NOTE: this code snippet is distributed by FullStory and formatting has been maintained
window['_fs_debug'] = false;
window['_fs_host'] = 'fullstory.com';
window['_fs_org'] = process.env.OT_PD_FULLSTORY_ORG;
window['_fs_namespace'] = 'FS';

export const initializeAnalytics = () => {
(function(m,n,e,t,l,o,g,y){
if (e in m) {if(m.console && m.console.log) { m.console.log('FullStory namespace conflict. Please set window["_fs_namespace"].');} return;}
g=m[e]=function(a,b){g.q?g.q.push([a,b]):g._api(a,b);};g.q=[];
o=n.createElement(t);o.async=1;o.src='https://'+_fs_host+'/s/fs.js';
y=n.getElementsByTagName(t)[0];y.parentNode.insertBefore(o,y);
g.identify=function(i,v){g(l,{uid:i});if(v)g(l,v)};g.setUserVars=function(v){g(l,v)};g.event=function(i,v){g('event',{n:i,p:v})};
g.shutdown=function(){g("rec",!1)};g.restart=function(){g("rec",!0)};
g.consent=function(a){g("consent",!arguments.length||a)};
g.identifyAccount=function(i,v){o='account';v=v||{};v.acctId=i;g(o,v)};
g.clearUserCookie=function(){};
})(window,document,window['_fs_namespace'],'script','user');
export type {
RootState,
}
26 changes: 26 additions & 0 deletions protocol-designer/src/analytics/integrations.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/* eslint-disable */

export const shutdownAnalytics = () => {
if (window[window['_fs_namespace']]) { window[window['_fs_namespace']].shutdown() }
delete window[window['_fs_namespace']]
}

// NOTE: this code snippet is distributed by FullStory and formatting has been maintained
window['_fs_debug'] = false;
window['_fs_host'] = 'fullstory.com';
window['_fs_org'] = process.env.OT_PD_FULLSTORY_ORG;
window['_fs_namespace'] = 'FS';

export const initializeAnalytics = () => {
(function(m,n,e,t,l,o,g,y){
if (e in m) {if(m.console && m.console.log) { m.console.log('FullStory namespace conflict. Please set window["_fs_namespace"].');} return;}
g=m[e]=function(a,b){g.q?g.q.push([a,b]):g._api(a,b);};g.q=[];
o=n.createElement(t);o.async=1;o.src='https://'+_fs_host+'/s/fs.js';
y=n.getElementsByTagName(t)[0];y.parentNode.insertBefore(o,y);
g.identify=function(i,v){g(l,{uid:i});if(v)g(l,v)};g.setUserVars=function(v){g(l,v)};g.event=function(i,v){g('event',{n:i,p:v})};
g.shutdown=function(){g("rec",!1)};g.restart=function(){g("rec",!0)};
g.consent=function(a){g("consent",!arguments.length||a)};
g.identifyAccount=function(i,v){o='account';v=v||{};v.acctId=i;g(o,v)};
g.clearUserCookie=function(){};
})(window,document,window['_fs_namespace'],'script','user');
}
23 changes: 23 additions & 0 deletions protocol-designer/src/analytics/reducers.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
// @flow
import {combineReducers} from 'redux'
import {handleActions} from 'redux-actions'
import {rehydrate} from '../persist'

import type {SetOptIn} from './actions'

type OptInState = boolean | null
const optInInitialState = null
const hasOptedIn = handleActions({
SET_OPT_IN: (state: OptInState, action: SetOptIn): OptInState => action.payload,
REHYDRATE_PERSISTED: () => rehydrate('analytics.hasOptedIn', optInInitialState),
}, optInInitialState)

const _allReducers = {
hasOptedIn,
}

export type RootState = {
hasOptedIn: OptInState,
}

export const rootReducer = combineReducers(_allReducers)
4 changes: 4 additions & 0 deletions protocol-designer/src/analytics/selectors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// @flow
import type {BaseState} from '../types'

export const getHasOptedIn = (state: BaseState) => state.analytics.hasOptedIn
96 changes: 53 additions & 43 deletions protocol-designer/src/components/SettingsPage/Privacy.js
Original file line number Diff line number Diff line change
@@ -1,56 +1,66 @@
// @flow
import React from 'react'
// import {connect} from 'react-redux'
import {connect} from 'react-redux'
import i18n from '../../localization'
import {Card, ToggleButton} from '@opentrons/components'
// import type {BaseState} from '../types'
import styles from './SettingsPage.css'
import {
optIn,
optOut,
getHasOptedIn,
shutdownAnalytics,
initializeAnalytics,
actions as analyticsActions,
selectors as analyticsSelectors,
} from '../../analytics'
import type {BaseState} from '../../types'

type State = {optInToggleValue: boolean}
class Privacy extends React.Component<*, State> {
state: State = {optInToggleValue: getHasOptedIn()}
toggleAnalyticsOptInValue = () => {
const hasOptedIn = getHasOptedIn()
if (hasOptedIn) {
shutdownAnalytics()
if (optOut()) this.setState({optInToggleValue: false})
} else {
initializeAnalytics()
if (optIn()) this.setState({optInToggleValue: true})
}
return true
type Props = {
hasOptedIn: boolean | null,
toggleOptedIn: () => mixed,
}

type SP = {
hasOptedIn: $PropertyType<Props, 'hasOptedIn'>,
}

function Privacy (props: Props) {
const {hasOptedIn, toggleOptedIn} = props
return (
<div className={styles.card_wrapper}>
<Card title={i18n.t('card.title.privacy')}>
<div className={styles.toggle_row}>
<p className={styles.toggle_label}>{i18n.t('card.toggle.share_session')}</p>
<ToggleButton
className={styles.toggle_button}
toggledOn={Boolean(hasOptedIn)}
onClick={toggleOptedIn} />
</div>
<div className={styles.body_wrapper}>
<p className={styles.card_body}>{i18n.t('card.body.reason_for_collecting_data')}</p>
<ul className={styles.card_point_list}>
<li>{i18n.t('card.body.data_collected_is_internal')}</li>
{/* TODO: BC 2018-09-26 uncomment when only using fullstory <li>{i18n.t('card.body.data_only_from_pd')}</li> */}
<li>{i18n.t('card.body.opt_out_of_data_collection')}</li>
</ul>
</div>
</Card>
</div>
)
}

function mapStateToProps (state: BaseState): SP {
return {
hasOptedIn: analyticsSelectors.getHasOptedIn(state),
}
}

function mergeProps (stateProps: SP, dispatchProps: {dispatch: Dispatch<*>}): Props {
const {dispatch} = dispatchProps
const {hasOptedIn} = stateProps

render () {
return (
<div className={styles.card_wrapper}>
<Card title={i18n.t('card.title.privacy')}>
<div className={styles.toggle_row}>
<p className={styles.toggle_label}>{i18n.t('card.toggle.share_session')}</p>
<ToggleButton
className={styles.toggle_button}
toggledOn={this.state.optInToggleValue}
onClick={this.toggleAnalyticsOptInValue} />
</div>
<div className={styles.body_wrapper}>
<p className={styles.card_body}>{i18n.t('card.body.reason_for_collecting_data')}</p>
<ul className={styles.card_point_list}>
<li>{i18n.t('card.body.data_collected_is_internal')}</li>
{/* TODO: BC 2018-09-26 uncomment when only using fullstory <li>{i18n.t('card.body.data_only_from_pd')}</li> */}
<li>{i18n.t('card.body.opt_out_of_data_collection')}</li>
</ul>
</div>
</Card>
</div>
)
const _toggleOptedIn = hasOptedIn
? analyticsActions.optOut
: analyticsActions.optIn
return {
...stateProps,
toggleOptedIn: () => dispatch(_toggleOptedIn()),
}
}

export default Privacy
export default connect(mapStateToProps, null, mergeProps)(Privacy)
111 changes: 52 additions & 59 deletions protocol-designer/src/components/modals/AnalyticsModal.js
Original file line number Diff line number Diff line change
@@ -1,74 +1,67 @@
// @flow
import * as React from 'react'
import {connect} from 'react-redux'
import cx from 'classnames'
import { AlertModal } from '@opentrons/components'
import i18n from '../../localization'
import modalStyles from './modal.css'
import settingsStyles from '../SettingsPage/SettingsPage.css'
import {
initializeAnalytics,
shutdownAnalytics,
optIn,
optOut,
getHasOptedIn,
actions as analyticsActions,
selectors as analyticsSelectors,
} from '../../analytics'
import type {BaseState} from '../../types'

type State = {isAnalyticsModalOpen: boolean}
type Props = {
hasOptedIn: boolean | null,
optIn: () => mixed,
optOut: () => mixed,
}

class AnalyticsModal extends React.Component<*, State> {
constructor () {
super()
const hasOptedIn = getHasOptedIn()
let initialState = {isAnalyticsModalOpen: false}
if (!!process.env.OT_PD_FULLSTORY_ORG && hasOptedIn === null) { // NOTE: only null if never set and has env variable
initialState = {isAnalyticsModalOpen: true}
} else if (hasOptedIn === true) {
initializeAnalytics()
} else {
// sanity check: there shouldn't be an analytics session, but shutdown just in case if user opted out
shutdownAnalytics()
}
this.state = initialState
}
handleCloseAnalyticsModal = () => {
this.setState({isAnalyticsModalOpen: false})
}
render () {
if (!this.state.isAnalyticsModalOpen) return null
return (
<AlertModal
className={cx(modalStyles.modal)}
buttons={[
{
onClick: () => {
this.handleCloseAnalyticsModal()
optOut()
shutdownAnalytics() // sanity check, there shouldn't be an analytics instance yet
},
children: i18n.t('button.no'),
},
{
onClick: () => {
this.handleCloseAnalyticsModal()
optIn()
initializeAnalytics()
},
children: i18n.t('button.yes'),
},
]}>
<h3>{i18n.t('card.toggle.share_session')}</h3>
<div className={settingsStyles.body_wrapper}>
<p className={settingsStyles.card_body}>{i18n.t('card.body.reason_for_collecting_data')}</p>
<ul className={settingsStyles.card_point_list}>
<li>{i18n.t('card.body.data_collected_is_internal')}</li>
{/* TODO: BC 2018-09-26 uncomment when only using fullstory <li>{i18n.t('card.body.data_only_from_pd')}</li> */}
<li>{i18n.t('card.body.opt_out_of_data_collection')}</li>
</ul>
</div>
</AlertModal>
type SP = {
hasOptedIn: $PropertyType<Props, 'hasOptedIn'>,
}

type DP = $Diff<Props, SP>

function AnalyticsModal (props: Props) {
const {hasOptedIn, optIn, optOut} = props
if (hasOptedIn !== null) return null
return (
<AlertModal
className={cx(modalStyles.modal)}
buttons={[
{
onClick: optOut,
children: i18n.t('button.no'),
},
{
onClick: optIn,
children: i18n.t('button.yes'),
},
]}>
<h3>{i18n.t('card.toggle.share_session')}</h3>
<div className={settingsStyles.body_wrapper}>
<p className={settingsStyles.card_body}>{i18n.t('card.body.reason_for_collecting_data')}</p>
<ul className={settingsStyles.card_point_list}>
<li>{i18n.t('card.body.data_collected_is_internal')}</li>
{/* TODO: BC 2018-09-26 uncomment when only using fullstory <li>{i18n.t('card.body.data_only_from_pd')}</li> */}
<li>{i18n.t('card.body.opt_out_of_data_collection')}</li>
</ul>
</div>
</AlertModal>
)
}

function mapStateToProps (state: BaseState): SP {
return {hasOptedIn: analyticsSelectors.getHasOptedIn(state)}
}

)
function mapDispatchToProps (dispatch: Dispatch<*>): DP {
return {
optIn: () => dispatch(analyticsActions.optIn()),
optOut: () => dispatch(analyticsActions.optOut()),
}
}

export default AnalyticsModal
export default connect(mapStateToProps, mapDispatchToProps)(AnalyticsModal)
Loading

0 comments on commit 6591104

Please sign in to comment.