Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for groups in Happychat #13520

Merged
merged 12 commits into from
Jun 9, 2017
17 changes: 15 additions & 2 deletions client/lib/happychat/connection.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const debug = require( 'debug' )( 'calypso:happychat:connection' );

class Connection extends EventEmitter {

open( user_id, token, locale ) {
open( signer_user_id, jwt, locale, groups ) {
if ( ! this.openSocket ) {
this.openSocket = new Promise( resolve => {
const url = config( 'happychat_url' );
Expand All @@ -25,7 +25,7 @@ class Connection extends EventEmitter {
resolve( socket );
} )
.on( 'token', handler => {
handler( { signer_user_id: user_id, jwt: token, locale } );
handler( { signer_user_id, jwt, locale, groups } );
} )
.on( 'unauthorized', () => {
socket.close();
Expand Down Expand Up @@ -81,6 +81,19 @@ class Connection extends EventEmitter {
);
}


/**
* Update chat preferences (locale and groups)
* @param {string} locale representing the user selected locale
* @param {array} groups of string happychat groups (wp.com, jpop) based on the site selected
*/
preferences( locale, groups ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

setPreferences seems more explicit to me — what do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Definitely ;)

this.openSocket.then(
socket => socket.emit( 'preferences', { locale, groups } ),
e => debug( 'failed to send preferences', e )
);
}

// This is a temporary measure — we want to start sending some events that are
// picked up by the staged HUD but not by the production HUD. The only way to do this
// now is to send a different event type, and make the staging HUD render this event.
Expand Down
35 changes: 35 additions & 0 deletions client/lib/happychat/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Internal dependencies
*/
import {
HAPPYCHAT_GROUP_WPCOM,
HAPPYCHAT_GROUP_JPOP
} from 'state/happychat/constants';
import { isJetpackSite } from 'state/sites/selectors';

/**
* Get happychat group for site id
* For now if the site is jetpack jpop will be set as the group otherwise WP.COM
* @param {object} state Current state
* @param {int} siteId The site id, if no siteId is present primary siteId will be used
* @returns {string} The happychat group for the selected site
*/
export const getGroupForSiteId = ( state, siteId ) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this function? It's not imported to any other modules and it seems like it does the same thing as getGroups without wrapping it in an array method. Maybe this is preparing in some way for more than just two Happychat groups? But it would seem more obvious to me to just have it all condensed to a single function right now.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, initially I thought it would make sense to separate them but for now there is no added value so I will just merge them into one.

switch ( true ) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mind using a standard if block here instead of this dark sorcery? :P

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure :)

case isJetpackSite( state, siteId ):
return HAPPYCHAT_GROUP_JPOP;

default:
return HAPPYCHAT_GROUP_WPCOM;
}
};

/**
* Grab the group or groups for happychat based on siteId
* @param {object} state Current state
* @param {int} siteId The site id, if no siteId is present primary siteId will be used
* @returns {array} of groups for site Id
*/
export const getGroups = ( state, siteId ) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function has the look and feel of a selector — any reason we can't put it in state/happychat/selectors.js instead?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense, I think in the first implementation it did not use the isJetPack selector so it was more like a helper method not an actual selector. Moved.

return [ getGroupForSiteId( state, siteId ) ];
};
54 changes: 54 additions & 0 deletions client/lib/happychat/test/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/**
* External dependencies
*/
import { expect } from 'chai';

/**
* Internal dependencies
*/
import {
HAPPYCHAT_GROUP_WPCOM,
HAPPYCHAT_GROUP_JPOP
} from 'state/happychat/constants';
import { getGroups } from 'lib/happychat';

describe( 'groups', () => {
describe( '#getGroups()', () => {
it( 'should return default group for no sites', () => {
const siteId = 1;
const state = {
sites: {
items: {}
}
};

expect( getGroups( state, siteId ) ).to.eql( [ HAPPYCHAT_GROUP_WPCOM ] );
} );

it( 'should return default group for no siteId', () => {
const siteId = undefined;
const state = {
sites: {
items: {
1: {}
}
}
};

expect( getGroups( state, siteId ) ).to.eql( [ HAPPYCHAT_GROUP_WPCOM ] );
} );

it( 'should return JPOP group for jetpack site', () => {
const siteId = 1;
const state = {
sites: {
items: {
[ siteId ]: { jetpack: true }
}
}
};

expect( getGroups( state, siteId ) ).to.eql( [ HAPPYCHAT_GROUP_JPOP ] );
} );
} );
} );
14 changes: 13 additions & 1 deletion client/me/help/help-contact-form/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ import FormButton from 'components/forms/form-button';
import SitesDropdown from 'components/sites-dropdown';
import siteList from 'lib/sites-list';
import ChatClosureNotice from '../chat-closure-notice';
import QuerySites from 'components/data/query-sites';
import { getSelectedSiteId } from 'state/ui/selectors';
import { selectSiteId } from 'state/help/actions';

/**
* Module variables
Expand Down Expand Up @@ -109,6 +111,7 @@ export const HelpContactForm = React.createClass( {
setSite( siteSlug ) {
const site = sites.getSite( siteSlug );
this.setState( { siteId: site.ID } );
this.props.onChangeSite( site.ID );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's some state-syncing bugs that could happen since we're storing selected site both in component state and Redux state. Namely:

  • Open form to "Site A", your primary site
  • Change to "Site B", reflected in both component and Redux state
  • Navigate away and then come back to the form
  • Component state will be "Site A" since this.state will be reset, Redux state will be "Site B" since it never got updated

So I think we should probably either drop this.state.siteId and go full Redux on this component, or we should also fire this.props.onChangeSite on componentDidMount. Or maybe there's another option?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I totally agree, but when I worked on this I had to use QuerySites because sites-list was not fully removed from Calypso yet, so I had to hack it using component state. That is not the case anymore and I switched to full redux.

},

trackClickStats( selectionName, selectedOption ) {
Expand Down Expand Up @@ -222,6 +225,7 @@ export const HelpContactForm = React.createClass( {

return (
<div className="help-contact-form">
<QuerySites allSites />
<ChatClosureNotice
reason="eoy-holidays"
from="2016-12-24T00:00:00Z"
Expand Down Expand Up @@ -279,4 +283,12 @@ const mapStateToProps = ( state ) => {
};
};

export default connect( mapStateToProps )( localize( HelpContactForm ) );
const mapDispatchToProps = ( dispatch ) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FYI there's a shorthand for straightforward dispatch maps like this:

const mapDispatchToProps = {
    onChangeSite: selectSiteId
};

The dispatch() and param-passing will be taken care of for you. 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cool, thanks.

return {
onChangeSite( siteId ) {
dispatch( selectSiteId( siteId ) );
}
};
};

export default connect( mapStateToProps, mapDispatchToProps )( localize( HelpContactForm ) );
1 change: 1 addition & 0 deletions client/state/action-types.js
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,7 @@ export const HAPPYCHAT_SET_MESSAGE = 'HAPPYCHAT_SET_MESSAGE';
export const HAPPYCHAT_TRANSCRIPT_RECEIVE = 'HAPPYCHAT_TRANSCRIPT_RECEIVE';
export const HAPPYCHAT_TRANSCRIPT_REQUEST = 'HAPPYCHAT_TRANSCRIPT_REQUEST';
export const HELP_COURSES_RECEIVE = 'HELP_COURSES_RECEIVE';
export const HELP_SELECTED_SITE = 'HELP_SELECTED_SITE';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer this action type to be more verbose like HELP_CONTACT_FORM_SITE_SELECT.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pinging again on this — I still think a more action-y name like ending with SITE_SELECT is clearer than SELECTED_SITE ("selected" is both a past tense verb and an adjective).

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Totally missed this, your suggestion makes more sense, thanks

export const HELP_TICKET_CONFIGURATION_DISMISS_ERROR = 'HELP_TICKET_CONFIGURATION_DISMISS_ERROR';
export const HELP_TICKET_CONFIGURATION_REQUEST = 'HELP_TICKET_CONFIGURATION_REQUEST';
export const HELP_TICKET_CONFIGURATION_REQUEST_FAILURE = 'HELP_TICKET_CONFIGURATION_REQUEST_FAILURE';
Expand Down
6 changes: 6 additions & 0 deletions client/state/happychat/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,9 @@ export const HAPPYCHAT_CONNECTION_ERROR_TRANSPORT_ERROR = 'transport error';

// Max number of messages to save between refreshes
export const HAPPYCHAT_MAX_STORED_MESSAGES = 30;

// Groups
export const HAPPYCHAT_GROUP_JPOP = 'jpop';
export const HAPPYCHAT_GROUP_WOO = 'woo';
export const HAPPYCHAT_GROUP_JPPHP = 'jpphp';
export const HAPPYCHAT_GROUP_WPCOM = 'WP.com';
18 changes: 17 additions & 1 deletion client/state/happychat/middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
HAPPYCHAT_SEND_MESSAGE,
HAPPYCHAT_SET_MESSAGE,
HAPPYCHAT_TRANSCRIPT_REQUEST,
HELP_SELECTED_SITE,
ROUTE_SET,

COMMENTS_CHANGE_STATUS_SUCESS,
Expand Down Expand Up @@ -62,6 +63,7 @@ import {
getCurrentUser,
getCurrentUserLocale,
} from 'state/current-user/selectors';
import { getGroups } from 'lib/happychat';

const debug = require( 'debug' )( 'calypso:happychat:actions' );

Expand Down Expand Up @@ -99,6 +101,7 @@ export const connectChat = ( connection, { getState, dispatch } ) => {

const user = getCurrentUser( state );
const locale = getCurrentUserLocale( state );
const groups = getGroups( state );

// Notify that a new connection is being established
dispatch( setConnecting() );
Expand Down Expand Up @@ -129,10 +132,19 @@ export const connectChat = ( connection, { getState, dispatch } ) => {

return sign( { user, session_id } );
} )
.then( ( { jwt } ) => connection.open( user.ID, jwt, locale ) )
.then( ( { jwt } ) => connection.open( user.ID, jwt, locale, groups ) )
.catch( e => debug( 'failed to start happychat session', e, e.stack ) );
};


export const updateChatPreferences = ( connection, { getState }, siteId ) => {
const state = getState();
const locale = getCurrentUserLocale( state );
const groups = getGroups( state, siteId );

connection.preferences( locale, groups );
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should check first if there's an open connection established. When there isn't, this error can occur:

screen shot 2017-06-05 at 6 57 06 pm

This is because the connection class doesn't set the openSocket Promise until open() is called — so if any of its member functions are called before open() this error happens.

We should figure out a long-term solution for all functions, but it's especially problematic in this PR because the HELP_SELECTED_SITE action can happen before Happychat forms a connection.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch. I guess we could use the withSocket approach that we have on the happychat side. For now I just checked for isHappychatClientConnected

};

export const requestTranscript = ( connection, { dispatch } ) => {
debug( 'requesting current session transcript' );

Expand Down Expand Up @@ -309,6 +321,10 @@ export default function( connection = null ) {
case HAPPYCHAT_INITIALIZE:
connectIfRecentlyActive( connection, store );
break;

case HELP_SELECTED_SITE:
updateChatPreferences( connection, store, action.siteId );
break;

case HAPPYCHAT_SEND_USER_INFO:
sendInfo( connection, store, action.siteUrl );
Expand Down
22 changes: 22 additions & 0 deletions client/state/happychat/test/middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import middleware, {
sendActionLogsAndEvents,
sendAnalyticsLogEvent,
sendRouteSetEventMessage,
updateChatPreferences,
sendInfo,
} from '../middleware';
import * as selectors from '../selectors';
Expand Down Expand Up @@ -244,6 +245,27 @@ describe( 'middleware', () => {
} );
} );

describe( 'HELP_SELECTED_SITE action', () => {
it( 'should send the locale and groups through the connection and send a preferences signal', () => {
const state = {
currentUser: {
locale: 'en',
},
sites: {
items: {
1: { ID: 1 }
}
}
};
const getState = () => state;
const connection = {
preferences: stub(),
};
updateChatPreferences( connection, { getState }, 1 );
expect( connection.preferences ).to.have.been.called;
} );
} );

describe( 'ROUTE_SET action', () => {
let connection;
const action = { path: '/me' };
Expand Down
8 changes: 8 additions & 0 deletions client/state/help/actions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Internal dependencies
*/
import { HELP_SELECTED_SITE } from 'state/action-types';

export const selectSiteId = ( siteId ) => ( {
type: HELP_SELECTED_SITE, siteId
} );
31 changes: 31 additions & 0 deletions client/state/help/reducer.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,44 @@
/**
* External dependencies
*/
import isEmpty from 'lodash/isEmpty';
import { combineReducers } from 'redux';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm guessing this is a regression since this PR has been sitting so long, but combineReducers is imported below this line now, and I think the one from state/utils is what we're supposed to be canonically using now. This is causing errors, so needs to be fixed before shipping.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, updated.


/**
* Internal dependencies
*/
import {
HELP_SELECTED_SITE,
SITES_RECEIVE,
} from 'state/action-types';
import courses from './courses/reducer';
import { combineReducers } from 'state/utils';
import directly from './directly/reducer';
import ticket from './ticket/reducer';
import { createReducer } from 'state/utils';

const selectFirstSite = ( sites ) => {
if ( ! isEmpty( sites ) ) {
return sites[ 0 ].ID;
}
return null;
};

/**
* Tracks the site id for the selected site in the help/contact form
*
* @param {Object} state Current state
* @param {Object} action Action payload
* @return {Object} Updated state
*/
export const selectedSiteId = createReducer( null, {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This state property is being created but it's never actually used (the Happychat middleware just grabs the site ID from the action itself). Are you sure we need to keep this data in Redux?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I went with using full redux so this is used now.

[ HELP_SELECTED_SITE ]: ( state, action ) => action.siteId,
[ SITES_RECEIVE ]: ( state, action ) => selectFirstSite( action.sites ),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we keep this state property — are you sure this is what we want? Seems like it's possible this value will change under our feet from unrelated UI requesting site data. Maybe we should only grab the first site from SITES_RECEIVE if the current value is null?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is correct, that was part of my initial hacky approach because sites-list was not fully deprecated. This is not the case anymore I removed the SITES_RECEIVE case.

} );

export default combineReducers( {
courses,
directly,
ticket,
selectedSiteId,
} );
8 changes: 8 additions & 0 deletions client/state/help/selectors.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* Internal dependencies
*/
import createSelector from 'lib/create-selector';

export const getHelpSelectedSiteId = createSelector(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This selector is also unused and seems expendable.

Copy link
Contributor Author

@unDemian unDemian Jun 7, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moving to redux flow makes this indispensable.

state => state.help.selectedSiteId
);
27 changes: 27 additions & 0 deletions client/state/help/test/actions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* External dependencies
*/
import { expect } from 'chai';

/**
* Internal dependencies
*/
import {
HELP_SELECTED_SITE
} from 'state/action-types';
import {
selectSiteId,
} from '../actions';

describe( 'actions', () => {
describe( '#selectSiteId()', () => {
it( 'should return an action object', () => {
const action = selectSiteId( 1 );

expect( action ).to.eql( {
type: HELP_SELECTED_SITE,
siteId: 1,
} );
} );
} );
} );
Loading