From 8b67517449f31d4649e8716497eac93bb09bb284 Mon Sep 17 00:00:00 2001 From: Brandon Kraft Date: Fri, 13 Oct 2017 14:24:22 -0500 Subject: [PATCH 001/192] Press This: Remove legacy bookmarklet code WordPress 4.9 removes this functionality. --- .../my-sites/site-settings/press-this/link.jsx | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/client/my-sites/site-settings/press-this/link.jsx b/client/my-sites/site-settings/press-this/link.jsx index f8454864187a9..02a58823b617c 100644 --- a/client/my-sites/site-settings/press-this/link.jsx +++ b/client/my-sites/site-settings/press-this/link.jsx @@ -66,24 +66,6 @@ class PressThisLink extends React.Component { site: PropTypes.object.isRequired, }; - /** - * Legacy press-this pointing to wp-admin. This will be - * deprecated and removed soon. - * @return {string} javascript pseudo-protocol link - */ - pressThisWPAdmin() { - const site = this.props.site; - const adminURL = site && site.options && site.options.admin_url; - return [ - /* eslint-disable max-len */ - "javascript:var d=document,w=window,e=w.getSelection,k=d.getSelection,x=d.selection,s=(e?e():(k)?k():(x?x.createRange().text:0)),f='", // eslint-disable-line no-script-url - adminURL, - "press-this.php',l=d.location,e=encodeURIComponent,u=f+'?u='+e(l.href)+'&t='+e(d.title)+'&s='+e(s)+'&v=4';a=function(){", - "if(!w.open(u,'t','toolbar=0,resizable=1,scrollbars=1,status=1,width=720,height=570'))l.href=u;};if (/Firefox/.test(navigator.userAgent)) setTimeout(a, 0); else a();void(0)", - /* eslint-enable max-len */ - ].join( '' ); - } - /** * generate press-this link pointing to current environment * @return {string} javascript pseudo-protocol link From 29c269a8f44685ce0a28cbeb650323da45ca8dce Mon Sep 17 00:00:00 2001 From: Andrija Vucinic Date: Fri, 27 Oct 2017 17:32:35 +0300 Subject: [PATCH 002/192] Signup: Domains only flow fix Domains only flow was broken by https://github.com/Automattic/wp-calypso/pull/18557 Fix to make it unbroken. --- client/signup/steps/site-or-domain/index.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/client/signup/steps/site-or-domain/index.jsx b/client/signup/steps/site-or-domain/index.jsx index e345d85717643..487b5120cb886 100644 --- a/client/signup/steps/site-or-domain/index.jsx +++ b/client/signup/steps/site-or-domain/index.jsx @@ -38,12 +38,12 @@ class SiteOrDomain extends Component { } getDomainName() { - const { queryObject, step } = this.props; + const { initialContext: { query }, step } = this.props; let domain, isValidDomain = false; - if ( queryObject && queryObject.new ) { - domain = queryObject.new; + if ( query && query.new ) { + domain = query.new; } else if ( step && step.domainItem ) { domain = step.domainItem.meta; } From 976cdcd65731cd26f15ce9cedaa338f846b0baa4 Mon Sep 17 00:00:00 2001 From: Bartosz Budzanowski Date: Fri, 27 Oct 2017 17:04:23 +0200 Subject: [PATCH 003/192] Remove MailChimp campaign defaults step. (#19086) --- .../email/mailchimp/setup-mailchimp.js | 56 +++++++++++-------- .../state/sites/settings/email/selectors.js | 13 +++++ .../sites/settings/email/test/selectors.js | 25 +++++++++ 3 files changed, 72 insertions(+), 22 deletions(-) diff --git a/client/extensions/woocommerce/app/settings/email/mailchimp/setup-mailchimp.js b/client/extensions/woocommerce/app/settings/email/mailchimp/setup-mailchimp.js index dc7d41f7e114a..d8476c72efb99 100644 --- a/client/extensions/woocommerce/app/settings/email/mailchimp/setup-mailchimp.js +++ b/client/extensions/woocommerce/app/settings/email/mailchimp/setup-mailchimp.js @@ -10,14 +10,18 @@ import React from 'react'; /** * Internal dependencies */ -import CampaignDefaultsStep from './setup-steps/campaign-defaults.js'; import Dialog from 'components/dialog'; import { getSiteTitle } from 'state/sites/selectors'; import { getStoreLocation } from 'woocommerce/state/sites/settings/general/selectors'; import { getCurrencyWithEdits } from 'woocommerce/state/ui/payments/currency/selectors'; import { getCurrentUserEmail } from 'state/current-user/selectors'; import { getSiteTimezoneValue } from 'state/selectors'; -import { isSubmittingApiKey, isApiKeyCorrect } from 'woocommerce/state/sites/settings/email/selectors'; +import { + isSubmittingApiKey, + isApiKeyCorrect, + isSubmittingNewsletterSetting, + isSubmittingStoreInfo, + } from 'woocommerce/state/sites/settings/email/selectors'; import KeyInputStep from './setup-steps/key-input.js'; import LogIntoMailchimp from './setup-steps/log-into-mailchimp.js'; import NewsletterSettings from './setup-steps/newsletter-settings.js'; @@ -43,11 +47,15 @@ const steps = { [ LOG_INTO_MAILCHIMP_STEP ]: { number: 0, nextStep: KEY_INPUT_STEP }, [ KEY_INPUT_STEP ]: { number: 1, nextStep: STORE_INFO_STEP }, [ STORE_INFO_STEP ]: { number: 2, nextStep: CAMPAIGN_DEFAULTS_STEP }, - [ CAMPAIGN_DEFAULTS_STEP ]: { number: 3, nextStep: NEWSLETTER_SETTINGS_STEP }, - [ NEWSLETTER_SETTINGS_STEP ]: { number: 4, nextStep: STORE_SYNC }, - [ STORE_SYNC ]: { number: 5, nextStep: null }, + // CAMPAIGN_DEFAULTS_STEP is also number 2 because it happens silently in the + // background and the number here is used for UI purposes + [ CAMPAIGN_DEFAULTS_STEP ]: { number: 2, nextStep: NEWSLETTER_SETTINGS_STEP }, + [ NEWSLETTER_SETTINGS_STEP ]: { number: 3, nextStep: STORE_SYNC }, + [ STORE_SYNC ]: { number: 4, nextStep: null }, }; +const uiStepsCount = steps[ STORE_SYNC ].number + 1; + const storeSettingsRequiredFields = [ 'store_name', 'store_street', 'store_city', 'store_state', 'store_postal_code', 'store_country', 'store_phone', 'store_locale', 'store_timezone', 'store_currency_code', 'admin_email' ]; @@ -78,7 +86,14 @@ class MailChimpSetup extends React.Component { const { active_tab } = nextProps.settings; if ( steps[ this.state.step ].nextStep === active_tab ) { const settings = this.prepareDefaultValues( nextProps ); - this.setState( { step: active_tab, settings } ); + if ( active_tab === CAMPAIGN_DEFAULTS_STEP ) { + // quickly to the next step + // we want CAMPAIGN_DEFAULTS_STEP to happen in the background + // we already have all the required information + this.setState( { step: active_tab, settings }, this.next ); + } else { + this.setState( { step: active_tab, settings } ); + } if ( active_tab === STORE_SYNC ) { nextProps.onClose( 'wizard-completed' ); } @@ -227,20 +242,15 @@ class MailChimpSetup extends React.Component { apiKey={ this.state.api_key_input } isKeyCorrect={ keyCorrect } />; } - if ( STORE_INFO_STEP === step ) { + // we show the same UI view for two steps because the + // CAMPAIGN_DEFAULTS_STEP is executed silently in the background + if ( STORE_INFO_STEP === step || CAMPAIGN_DEFAULTS_STEP === step ) { return ; } - if ( CAMPAIGN_DEFAULTS_STEP === step ) { - return ; - } if ( NEWSLETTER_SETTINGS_STEP === step ) { return MailChimp
{ this.renderStep() } @@ -330,20 +340,22 @@ MailChimpSetup.propTypes = { }; export default localize( connect( - ( state, props ) => { - const isSaving = isSubmittingApiKey( state, props.siteId ); - const isKeyCorrect = isApiKeyCorrect( state, props.siteId ); + ( state, { siteId } ) => { + const isSavingApiKey = isSubmittingApiKey( state, siteId ); + const isSavingStoreInfo = isSubmittingStoreInfo( state, siteId ); + const isSavingNewsletterSettings = isSubmittingNewsletterSetting( state, siteId ); + const isKeyCorrect = isApiKeyCorrect( state, siteId ); const address = getStoreLocation( state ); const currency = getCurrencyWithEdits( state ); - const isBusy = isSaving; + const isBusy = isSavingApiKey || isSavingStoreInfo || isSavingNewsletterSettings; return { isBusy, address, currency, isKeyCorrect, - siteTitle: getSiteTitle( state, props.siteId ), + siteTitle: getSiteTitle( state, siteId ), currentUserEmail: getCurrentUserEmail( state ), - timezone: getSiteTimezoneValue( state, props.siteId ) + timezone: getSiteTimezoneValue( state, siteId ) }; }, { diff --git a/client/extensions/woocommerce/state/sites/settings/email/selectors.js b/client/extensions/woocommerce/state/sites/settings/email/selectors.js index 981516f5f7f03..8eb521b7add57 100644 --- a/client/extensions/woocommerce/state/sites/settings/email/selectors.js +++ b/client/extensions/woocommerce/state/sites/settings/email/selectors.js @@ -85,6 +85,19 @@ export const isSubmittingNewsletterSetting = ( state, siteId ) => { return get( state, path, false ); }; +/** + * Returns true if currently submitting MailChimp store information or false otherwise. + * + * @param {Object} state Global state tree + * @param {Number} siteId Site ID + * @return {Boolean} Whether store informations are being submitted + */ +export const isSubmittingStoreInfo = ( state, siteId ) => { + const path = [ ...basePath( siteId ), 'storeInfoSubmit' ]; + + return get( state, path, false ); +}; + /** * Returns newletter settings submit error object * diff --git a/client/extensions/woocommerce/state/sites/settings/email/test/selectors.js b/client/extensions/woocommerce/state/sites/settings/email/test/selectors.js index ea1a6fa4d4a09..659716d08cde2 100644 --- a/client/extensions/woocommerce/state/sites/settings/email/test/selectors.js +++ b/client/extensions/woocommerce/state/sites/settings/email/test/selectors.js @@ -14,6 +14,7 @@ import { isRequestingSettings, isSavingSettings, isSubmittingApiKey, + isSubmittingStoreInfo, mailChimpSettings, requestingSettingsError, } from '../selectors'; @@ -147,6 +148,16 @@ const mailChimpSaveSettings = Object.assign( {}, emailState, { }, } ); +const submitStoreInfoState = Object.assign( {}, emailState, { + extensions: { + woocommerce: { + sites: { + 123: { settings: { email: { storeInfoSubmit: true } } }, + }, + }, + }, +} ); + describe( 'selectors', () => { describe( '#isRequestingSettings', () => { test( 'should be false when woocommerce state is not available.', () => { @@ -237,4 +248,18 @@ describe( 'selectors', () => { expect( isSavingSettings( emailState, 123 ) ).to.be.false; } ); } ); + + describe( '#isSubmittingStoreInfo', () => { + test( 'should be false when woocommerce state is not available.', () => { + expect( isSubmittingStoreInfo( {}, 123 ) ).to.be.false; + } ); + + test( 'should be true when mailchimp has valid connection with server.', () => { + expect( isSubmittingStoreInfo( submitStoreInfoState, 123 ) ).to.be.true; + } ); + + test( 'should be false when store infor submit is not pending', () => { + expect( isSubmittingStoreInfo( emailState, 123 ) ).to.be.false; + } ); + } ); } ); From 38019c1ab4c595f2b749782a9af9c47bb9e38096 Mon Sep 17 00:00:00 2001 From: Bartosz Budzanowski Date: Fri, 27 Oct 2017 17:20:12 +0200 Subject: [PATCH 004/192] Store: MailChimp loading sync notice (#19213) --- .../app/settings/email/mailchimp/mailchimp_dashboard.js | 3 ++- .../woocommerce/app/settings/email/mailchimp/style.scss | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/client/extensions/woocommerce/app/settings/email/mailchimp/mailchimp_dashboard.js b/client/extensions/woocommerce/app/settings/email/mailchimp/mailchimp_dashboard.js index 04844b9d3c7c3..5344f0ad05d1e 100644 --- a/client/extensions/woocommerce/app/settings/email/mailchimp/mailchimp_dashboard.js +++ b/client/extensions/woocommerce/app/settings/email/mailchimp/mailchimp_dashboard.js @@ -58,7 +58,8 @@ const SyncTab = localize( ( { siteId, translate, syncState, resync, isRequesting const syncing = () => ( Date: Fri, 27 Oct 2017 17:35:44 +0200 Subject: [PATCH 005/192] Jetpack Disconnect: Add Tracks event to beginning of Flow (#19217) Track whenever a user first starts the disconnect flow (which right now only consists of the confirmation dialog) so we have a funnel to see what percentage actually follow thru and compare that to the [new flow](https://github.com/Automattic/wp-calypso/projects/49) we're currently implementing. --- .../disconnect-jetpack-button.jsx | 25 +++++++++++-------- .../disconnect-site-link.jsx | 20 +++++++++------ 2 files changed, 28 insertions(+), 17 deletions(-) diff --git a/client/my-sites/plugins/disconnect-jetpack/disconnect-jetpack-button.jsx b/client/my-sites/plugins/disconnect-jetpack/disconnect-jetpack-button.jsx index e6936197e9be3..aed8523a89b6f 100644 --- a/client/my-sites/plugins/disconnect-jetpack/disconnect-jetpack-button.jsx +++ b/client/my-sites/plugins/disconnect-jetpack/disconnect-jetpack-button.jsx @@ -16,29 +16,30 @@ import { connect } from 'react-redux'; import Button from 'components/button'; import DisconnectJetpackDialog from 'blocks/disconnect-jetpack/dialog'; import QuerySitePlans from 'components/data/query-site-plans'; -import { recordGoogleEvent } from 'state/analytics/actions'; +import { + recordGoogleEvent as recordGoogleEventAction, + recordTracksEvent as recordTracksEventAction, +} from 'state/analytics/actions'; class DisconnectJetpackButton extends Component { - constructor( props ) { - super( props ); - this.state = { dialogVisible: false }; - } + state = { dialogVisible: false }; handleClick = event => { event.preventDefault(); - const { isMock, recordGoogleEvent: recordGAEvent } = this.props; + const { isMock, recordGoogleEvent, recordTracksEvent } = this.props; if ( isMock ) { return; } this.setState( { dialogVisible: true } ); - recordGAEvent( 'Jetpack', 'Clicked To Open Disconnect Jetpack Dialog' ); + recordGoogleEvent( 'Jetpack', 'Clicked To Open Disconnect Jetpack Dialog' ); + recordTracksEvent( 'calypso_jetpack_disconnect_start' ); }; hideDialog = () => { - const { recordGoogleEvent: recordGAEvent } = this.props; + const { recordGoogleEvent } = this.props; this.setState( { dialogVisible: false } ); - recordGAEvent( 'Jetpack', 'Clicked To Cancel Disconnect Jetpack Dialog' ); + recordGoogleEvent( 'Jetpack', 'Clicked To Cancel Disconnect Jetpack Dialog' ); }; render() { @@ -92,10 +93,14 @@ DisconnectJetpackButton.propTypes = { isMock: PropTypes.bool, text: PropTypes.string, recordGoogleEvent: PropTypes.func.isRequired, + recordTracksEvent: PropTypes.func.isRequired, }; DisconnectJetpackButton.defaultProps = { linkDisplay: true, }; -export default connect( null, { recordGoogleEvent } )( localize( DisconnectJetpackButton ) ); +export default connect( null, { + recordGoogleEvent: recordGoogleEventAction, + recordTracksEvent: recordTracksEventAction, +} )( localize( DisconnectJetpackButton ) ); diff --git a/client/my-sites/site-settings/manage-connection/disconnect-site-link.jsx b/client/my-sites/site-settings/manage-connection/disconnect-site-link.jsx index 13577480ba60e..f195984b63399 100644 --- a/client/my-sites/site-settings/manage-connection/disconnect-site-link.jsx +++ b/client/my-sites/site-settings/manage-connection/disconnect-site-link.jsx @@ -16,6 +16,7 @@ import QuerySitePlans from 'components/data/query-site-plans'; import SiteToolsLink from 'my-sites/site-settings/site-tools/link'; import { getSelectedSiteId } from 'state/ui/selectors'; import { isSiteAutomatedTransfer } from 'state/selectors'; +import { recordTracksEvent } from 'state/analytics/actions'; class DisconnectSiteLink extends Component { state = { @@ -28,6 +29,8 @@ class DisconnectSiteLink extends Component { this.setState( { dialogVisible: true, } ); + + this.props.recordTracksEvent( 'calypso_jetpack_disconnect_start' ); }; handleHideDialog = () => { @@ -69,11 +72,14 @@ class DisconnectSiteLink extends Component { } } -export default connect( state => { - const siteId = getSelectedSiteId( state ); +export default connect( + state => { + const siteId = getSelectedSiteId( state ); - return { - isAutomatedTransfer: isSiteAutomatedTransfer( state, siteId ), - siteId, - }; -} )( localize( DisconnectSiteLink ) ); + return { + isAutomatedTransfer: isSiteAutomatedTransfer( state, siteId ), + siteId, + }; + }, + { recordTracksEvent } +)( localize( DisconnectSiteLink ) ); From b0b731c7d86be6ca3a0df4c5ef35a3d0dd8a6cc0 Mon Sep 17 00:00:00 2001 From: Chris R Date: Fri, 27 Oct 2017 17:08:44 +0100 Subject: [PATCH 006/192] Reader: add reducer for conversation follow and mute (#19180) * Add reducer basics * Replace blogId with siteId for consistency's sake * Complete reducer and schema * Add selector * Remove empty deps block * Remove schema check in READER_CONVERSATION_UPDATE_FOLLOW_STATUS * Add schema tests --- .../wpcom/read/sites/posts/follow/index.js | 8 +- .../read/sites/posts/follow/test/index.js | 8 +- .../wpcom/read/sites/posts/mute/index.js | 8 +- .../wpcom/read/sites/posts/mute/test/index.js | 8 +- client/state/reader/conversations/actions.js | 12 +-- client/state/reader/conversations/reducer.js | 57 ++++++++++++++ client/state/reader/conversations/schema.js | 26 +++++++ .../reader/conversations/test/actions.js | 12 +-- .../reader/conversations/test/reducer.js | 74 +++++++++++++++++++ client/state/reader/conversations/utils.js | 5 ++ .../get-reader-conversation-follow-status.js | 25 +++++++ client/state/selectors/index.js | 1 + .../get-reader-conversation-follow-status.js | 36 +++++++++ 13 files changed, 252 insertions(+), 28 deletions(-) create mode 100644 client/state/reader/conversations/reducer.js create mode 100644 client/state/reader/conversations/schema.js create mode 100644 client/state/reader/conversations/test/reducer.js create mode 100644 client/state/reader/conversations/utils.js create mode 100644 client/state/selectors/get-reader-conversation-follow-status.js create mode 100644 client/state/selectors/test/get-reader-conversation-follow-status.js diff --git a/client/state/data-layer/wpcom/read/sites/posts/follow/index.js b/client/state/data-layer/wpcom/read/sites/posts/follow/index.js index e2b3b7e3f2267..24e74ae95eab0 100644 --- a/client/state/data-layer/wpcom/read/sites/posts/follow/index.js +++ b/client/state/data-layer/wpcom/read/sites/posts/follow/index.js @@ -31,7 +31,7 @@ export function requestConversationFollow( { dispatch }, action ) { { method: 'POST', apiNamespace: 'wpcom/v2', - path: `/read/sites/${ action.payload.blogId }/posts/${ action.payload.postId }/follow`, + path: `/read/sites/${ action.payload.siteId }/posts/${ action.payload.postId }/follow`, body: {}, // have to have an empty body to make wpcom-http happy }, actionWithRevert @@ -56,7 +56,7 @@ export function receiveConversationFollow( store, action, response ) { export function receiveConversationFollowError( { dispatch }, - { payload: { blogId, postId }, meta: { previousState } } + { payload: { siteId, postId }, meta: { previousState } } ) { dispatch( errorNotice( @@ -67,8 +67,8 @@ export function receiveConversationFollowError( dispatch( bypassDataLayer( updateConversationFollowStatus( { - blogId: blogId, - postId: postId, + siteId, + postId, followStatus: previousState, } ) ) diff --git a/client/state/data-layer/wpcom/read/sites/posts/follow/test/index.js b/client/state/data-layer/wpcom/read/sites/posts/follow/test/index.js index 210c84fce9303..2a848aec3649f 100644 --- a/client/state/data-layer/wpcom/read/sites/posts/follow/test/index.js +++ b/client/state/data-layer/wpcom/read/sites/posts/follow/test/index.js @@ -21,7 +21,7 @@ describe( 'conversation-follow', () => { describe( 'requestConversationFollow', () => { test( 'should dispatch an http request', () => { const dispatch = jest.fn(); - const action = followConversation( { blogId: 123, postId: 456 } ); + const action = followConversation( { siteId: 123, postId: 456 } ); const actionWithRevert = merge( {}, action, { meta: { previousState: CONVERSATION_FOLLOW_STATUS_MUTING, @@ -48,7 +48,7 @@ describe( 'conversation-follow', () => { receiveConversationFollow( { dispatch }, { - payload: { blogId: 123, postId: 456 }, + payload: { siteId: 123, postId: 456 }, meta: { previousState: CONVERSATION_FOLLOW_STATUS_MUTING }, }, { success: true } @@ -67,7 +67,7 @@ describe( 'conversation-follow', () => { receiveConversationFollow( { dispatch }, { - payload: { blogId: 123, postId: 456 }, + payload: { siteId: 123, postId: 456 }, meta: { previousState: CONVERSATION_FOLLOW_STATUS_MUTING }, }, { @@ -84,7 +84,7 @@ describe( 'conversation-follow', () => { expect( dispatch ).toHaveBeenCalledWith( bypassDataLayer( updateConversationFollowStatus( { - blogId: 123, + siteId: 123, postId: 456, followStatus: CONVERSATION_FOLLOW_STATUS_MUTING, } ) diff --git a/client/state/data-layer/wpcom/read/sites/posts/mute/index.js b/client/state/data-layer/wpcom/read/sites/posts/mute/index.js index cba6840a881dd..2d0b15cf7c463 100644 --- a/client/state/data-layer/wpcom/read/sites/posts/mute/index.js +++ b/client/state/data-layer/wpcom/read/sites/posts/mute/index.js @@ -31,7 +31,7 @@ export function requestConversationMute( { dispatch }, action ) { { method: 'POST', apiNamespace: 'wpcom/v2', - path: `/read/sites/${ action.payload.blogId }/posts/${ action.payload.postId }/mute`, + path: `/read/sites/${ action.payload.siteId }/posts/${ action.payload.postId }/mute`, body: {}, // have to have an empty body to make wpcom-http happy }, actionWithRevert @@ -56,7 +56,7 @@ export function receiveConversationMute( store, action, response ) { export function receiveConversationMuteError( { dispatch }, - { payload: { blogId, postId }, meta: { previousState } } + { payload: { siteId, postId }, meta: { previousState } } ) { dispatch( errorNotice( @@ -67,8 +67,8 @@ export function receiveConversationMuteError( dispatch( bypassDataLayer( updateConversationFollowStatus( { - blogId: blogId, - postId: postId, + siteId, + postId, followStatus: previousState, } ) ) diff --git a/client/state/data-layer/wpcom/read/sites/posts/mute/test/index.js b/client/state/data-layer/wpcom/read/sites/posts/mute/test/index.js index bfba28d1b35b2..a79f9f7a71a49 100644 --- a/client/state/data-layer/wpcom/read/sites/posts/mute/test/index.js +++ b/client/state/data-layer/wpcom/read/sites/posts/mute/test/index.js @@ -21,7 +21,7 @@ describe( 'conversation-mute', () => { describe( 'requestConversationMute', () => { test( 'should dispatch an http request', () => { const dispatch = jest.fn(); - const action = muteConversation( { blogId: 123, postId: 456 } ); + const action = muteConversation( { siteId: 123, postId: 456 } ); const actionWithRevert = merge( {}, action, { meta: { previousState: CONVERSATION_FOLLOW_STATUS_FOLLOWING, @@ -48,7 +48,7 @@ describe( 'conversation-mute', () => { receiveConversationMute( { dispatch }, { - payload: { blogId: 123, postId: 456 }, + payload: { siteId: 123, postId: 456 }, meta: { previousState: CONVERSATION_FOLLOW_STATUS_FOLLOWING }, }, { success: true } @@ -67,7 +67,7 @@ describe( 'conversation-mute', () => { receiveConversationMute( { dispatch }, { - payload: { blogId: 123, postId: 456 }, + payload: { siteId: 123, postId: 456 }, meta: { previousState: CONVERSATION_FOLLOW_STATUS_FOLLOWING }, }, { @@ -84,7 +84,7 @@ describe( 'conversation-mute', () => { expect( dispatch ).toHaveBeenCalledWith( bypassDataLayer( updateConversationFollowStatus( { - blogId: 123, + siteId: 123, postId: 456, followStatus: CONVERSATION_FOLLOW_STATUS_FOLLOWING, } ) diff --git a/client/state/reader/conversations/actions.js b/client/state/reader/conversations/actions.js index be695aab7083c..59f0cd8c0738e 100644 --- a/client/state/reader/conversations/actions.js +++ b/client/state/reader/conversations/actions.js @@ -11,31 +11,31 @@ import { READER_CONVERSATION_UPDATE_FOLLOW_STATUS, } from 'state/action-types'; -export function followConversation( { blogId, postId } ) { +export function followConversation( { siteId, postId } ) { return { type: READER_CONVERSATION_FOLLOW, payload: { - blogId, + siteId, postId, }, }; } -export function muteConversation( { blogId, postId } ) { +export function muteConversation( { siteId, postId } ) { return { type: READER_CONVERSATION_MUTE, payload: { - blogId, + siteId, postId, }, }; } -export function updateConversationFollowStatus( { blogId, postId, followStatus } ) { +export function updateConversationFollowStatus( { siteId, postId, followStatus } ) { return { type: READER_CONVERSATION_UPDATE_FOLLOW_STATUS, payload: { - blogId, + siteId, postId, followStatus, }, diff --git a/client/state/reader/conversations/reducer.js b/client/state/reader/conversations/reducer.js new file mode 100644 index 0000000000000..8f8f114996647 --- /dev/null +++ b/client/state/reader/conversations/reducer.js @@ -0,0 +1,57 @@ +/** @format */ +/** + * External dependencies + */ +import { assign } from 'lodash'; + +/** + * Internal dependencies + */ +import { + READER_CONVERSATION_FOLLOW, + READER_CONVERSATION_MUTE, + READER_CONVERSATION_UPDATE_FOLLOW_STATUS, +} from 'state/action-types'; +import { + CONVERSATION_FOLLOW_STATUS_FOLLOWING, + CONVERSATION_FOLLOW_STATUS_MUTING, +} from './follow-status'; +import { combineReducers, createReducer } from 'state/utils'; +import { itemsSchema } from './schema'; +import { key } from './utils'; + +/** + * Tracks all known conversation following statuses. + */ +export const items = createReducer( + {}, + { + [ READER_CONVERSATION_FOLLOW ]: ( state, action ) => { + const newState = assign( {}, state, { + [ key( + action.payload.siteId, + action.payload.postId + ) ]: CONVERSATION_FOLLOW_STATUS_FOLLOWING, + } ); + return newState; + }, + [ READER_CONVERSATION_MUTE ]: ( state, action ) => { + const newState = assign( {}, state, { + [ key( action.payload.siteId, action.payload.postId ) ]: CONVERSATION_FOLLOW_STATUS_MUTING, + } ); + return newState; + }, + [ READER_CONVERSATION_UPDATE_FOLLOW_STATUS ]: ( state, action ) => { + const newState = assign( {}, state, { + [ key( action.payload.siteId, action.payload.postId ) ]: action.payload.followStatus, + } ); + + return newState; + }, + }, + itemsSchema +); + +export default combineReducers( { + items, +} ); diff --git a/client/state/reader/conversations/schema.js b/client/state/reader/conversations/schema.js new file mode 100644 index 0000000000000..6f65a081e4137 --- /dev/null +++ b/client/state/reader/conversations/schema.js @@ -0,0 +1,26 @@ +/** @format */ + +/** + * Internal dependencies + */ +import { + CONVERSATION_FOLLOW_STATUS_FOLLOWING, + CONVERSATION_FOLLOW_STATUS_NOT_FOLLOWING, + CONVERSATION_FOLLOW_STATUS_MUTING, +} from './follow-status'; + +/* eslint-disable quote-props */ +export const itemsSchema = { + type: 'object', + patternProperties: { + '^[0-9]+-[0-9]+$': { + enum: [ + CONVERSATION_FOLLOW_STATUS_FOLLOWING, + CONVERSATION_FOLLOW_STATUS_NOT_FOLLOWING, + CONVERSATION_FOLLOW_STATUS_MUTING, + ], + }, + }, + additionalProperties: false, +}; +/* eslint-enable quote-props */ diff --git a/client/state/reader/conversations/test/actions.js b/client/state/reader/conversations/test/actions.js index 2d805c6b6a623..25ed79caf6c7d 100644 --- a/client/state/reader/conversations/test/actions.js +++ b/client/state/reader/conversations/test/actions.js @@ -18,20 +18,20 @@ import { CONVERSATION_FOLLOW_STATUS_MUTING } from 'state/reader/conversations/fo describe( 'actions', () => { describe( '#followConversation', () => { test( 'should return an action when a conversation is followed', () => { - const action = followConversation( { blogId: 123, postId: 456 } ); + const action = followConversation( { siteId: 123, postId: 456 } ); expect( action ).toEqual( { type: READER_CONVERSATION_FOLLOW, - payload: { blogId: 123, postId: 456 }, + payload: { siteId: 123, postId: 456 }, } ); } ); } ); describe( '#muteConversation', () => { test( 'should return an action when a conversation is muted', () => { - const action = muteConversation( { blogId: 123, postId: 456 } ); + const action = muteConversation( { siteId: 123, postId: 456 } ); expect( action ).toEqual( { type: READER_CONVERSATION_MUTE, - payload: { blogId: 123, postId: 456 }, + payload: { siteId: 123, postId: 456 }, } ); } ); } ); @@ -39,13 +39,13 @@ describe( 'actions', () => { describe( '#updateConversationFollowStatus', () => { test( 'should return an action when a conversation follow status is updated', () => { const action = updateConversationFollowStatus( { - blogId: 123, + siteId: 123, postId: 456, followStatus: CONVERSATION_FOLLOW_STATUS_MUTING, } ); expect( action ).toEqual( { type: READER_CONVERSATION_UPDATE_FOLLOW_STATUS, - payload: { blogId: 123, postId: 456, followStatus: CONVERSATION_FOLLOW_STATUS_MUTING }, + payload: { siteId: 123, postId: 456, followStatus: CONVERSATION_FOLLOW_STATUS_MUTING }, } ); } ); } ); diff --git a/client/state/reader/conversations/test/reducer.js b/client/state/reader/conversations/test/reducer.js new file mode 100644 index 0000000000000..b787bf93cdc89 --- /dev/null +++ b/client/state/reader/conversations/test/reducer.js @@ -0,0 +1,74 @@ +/** @format */ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +import { items } from '../reducer'; +import { + READER_CONVERSATION_FOLLOW, + READER_CONVERSATION_MUTE, + READER_CONVERSATION_UPDATE_FOLLOW_STATUS, + SERIALIZE, + DESERIALIZE, +} from 'state/action-types'; + +describe( 'reducer', () => { + describe( '#items()', () => { + test( 'should default to an empty object', () => { + const state = items( undefined, {} ); + expect( state ).toEqual( {} ); + } ); + + test( 'should update for successful follow', () => { + const original = deepFreeze( {} ); + + const state = items( original, { + type: READER_CONVERSATION_FOLLOW, + payload: { siteId: 123, postId: 456 }, + } ); + + expect( state[ '123-456' ] ).toEqual( 'F' ); + } ); + + test( 'should update for successful mute', () => { + const original = deepFreeze( {} ); + + const state = items( original, { + type: READER_CONVERSATION_MUTE, + payload: { siteId: 123, postId: 456 }, + } ); + + expect( state[ '123-456' ] ).toEqual( 'M' ); + } ); + + test( 'should update when given a valid follow status', () => { + const original = deepFreeze( { '123-456': 'M' } ); + + const state = items( original, { + type: READER_CONVERSATION_UPDATE_FOLLOW_STATUS, + payload: { siteId: 123, postId: 456, followStatus: 'F' }, + } ); + + expect( state[ '123-456' ] ).toEqual( 'F' ); + } ); + + test( 'will deserialize valid state', () => { + const validState = { '123-456': 'M' }; + expect( items( validState, { type: DESERIALIZE } ) ).toEqual( validState ); + } ); + + test( 'will not deserialize invalid state', () => { + const invalidState = { '123-456': 'X' }; + expect( items( invalidState, { type: DESERIALIZE } ) ).toEqual( {} ); + } ); + + test( 'will serialize', () => { + const validState = { '123-456': 'M' }; + expect( items( validState, { type: SERIALIZE } ) ).toEqual( validState ); + } ); + } ); +} ); diff --git a/client/state/reader/conversations/utils.js b/client/state/reader/conversations/utils.js new file mode 100644 index 0000000000000..7fe92b36d7206 --- /dev/null +++ b/client/state/reader/conversations/utils.js @@ -0,0 +1,5 @@ +/** @format */ + +export function key( siteId, postId ) { + return `${ siteId }-${ postId }`; +} diff --git a/client/state/selectors/get-reader-conversation-follow-status.js b/client/state/selectors/get-reader-conversation-follow-status.js new file mode 100644 index 0000000000000..53004662a7ced --- /dev/null +++ b/client/state/selectors/get-reader-conversation-follow-status.js @@ -0,0 +1,25 @@ +/* + * @format + */ + +/** + * External dependencies + */ +import { get } from 'lodash'; + +/** + * Internal dependencies + */ +import { key } from 'state/reader/conversations/utils'; + +/* + * Get the follow status for a given post + * + * @param {Object} state Global state tree + * @param {Number} siteId + * @param {Number} postId + * @return {String} Follow status + */ +export default function getReaderConversationFollowStatus( state, { siteId, postId } ) { + return get( state, [ 'reader', 'conversations', 'items', key( siteId, postId ) ], null ); +} diff --git a/client/state/selectors/index.js b/client/state/selectors/index.js index 7d2ae3a73f7b6..9e2a4cf89f17d 100644 --- a/client/state/selectors/index.js +++ b/client/state/selectors/index.js @@ -88,6 +88,7 @@ export getPublicizeConnection from './get-publicize-connection'; export getPublicSites from './get-public-sites'; export getRawOffsets from './get-raw-offsets'; export getReaderAliasedFollowFeedUrl from './get-reader-aliased-follow-feed-url'; +export getReaderConversationFollowStatus from './get-reader-conversation-follow-status'; export getReaderFeedsCountForQuery from './get-reader-feeds-count-for-query'; export getReaderFeedsForQuery from './get-reader-feeds-for-query'; export getReaderFollowedTags from './get-reader-followed-tags'; diff --git a/client/state/selectors/test/get-reader-conversation-follow-status.js b/client/state/selectors/test/get-reader-conversation-follow-status.js new file mode 100644 index 0000000000000..d6ba50c71229e --- /dev/null +++ b/client/state/selectors/test/get-reader-conversation-follow-status.js @@ -0,0 +1,36 @@ +/** @format */ + +/** + * Internal dependencies + */ +import { getReaderConversationFollowStatus } from '../'; + +describe( 'getReaderConversationFollowStatus()', () => { + test( 'should return follow status for a known post', () => { + const prevState = { + reader: { + conversations: { + items: { + '123-456': 'F', + }, + }, + }, + }; + const nextState = getReaderConversationFollowStatus( prevState, { siteId: 123, postId: 456 } ); + expect( nextState ).toEqual( 'F' ); + } ); + + test( 'should return null for an unknown post', () => { + const prevState = { + reader: { + conversations: { + items: { + '123-456': 'F', + }, + }, + }, + }; + const nextState = getReaderConversationFollowStatus( prevState, { siteId: 234, postId: 456 } ); + expect( nextState ).toEqual( null ); + } ); +} ); From 3421066c90ee4ec3496ccfd5219dad0467ef655c Mon Sep 17 00:00:00 2001 From: Jake Date: Fri, 27 Oct 2017 11:16:13 -0500 Subject: [PATCH 007/192] Reader: add high watermark redux bits (#18769) * Reader: add high watermark * add config flag for hwm * add basic reducers/selector/tests/actions * hacustompersistence * teency cleanup of comment "ajv": "5.2.1", * add tests --- client/state/action-types.js | 1 + client/state/reader/watermarks/actions.js | 23 ++++++++ client/state/reader/watermarks/reducer.js | 27 +++++++++ .../state/reader/watermarks/test/reducer.js | 57 +++++++++++++++++++ .../reader/watermarks/watermark-schema.js | 6 ++ .../state/selectors/get-reader-watermark.js | 12 ++++ client/state/selectors/index.js | 1 + config/development.json | 1 + config/wpcalypso.json | 1 + 9 files changed, 129 insertions(+) create mode 100644 client/state/reader/watermarks/actions.js create mode 100644 client/state/reader/watermarks/reducer.js create mode 100644 client/state/reader/watermarks/test/reducer.js create mode 100644 client/state/reader/watermarks/watermark-schema.js create mode 100644 client/state/selectors/get-reader-watermark.js diff --git a/client/state/action-types.js b/client/state/action-types.js index 2eb09c6b12450..3b569975c49c6 100644 --- a/client/state/action-types.js +++ b/client/state/action-types.js @@ -687,6 +687,7 @@ export const READER_UNSUBSCRIBE_TO_NEW_COMMENT_EMAIL = 'READER_UNSUBSCRIBE_TO_NE export const READER_UNSUBSCRIBE_TO_NEW_POST_EMAIL = 'READER_UNSUBSCRIBE_TO_NEW_POST_EMAIL'; export const READER_UPDATE_NEW_POST_EMAIL_SUBSCRIPTION = 'READER_UPDATE_NEW_POST_EMAIL_SUBSCRIPTION'; +export const READER_VIEW_STREAM = 'READER_VIEW_STREAM'; export const RECEIPT_FETCH = 'RECEIPT_FETCH'; export const RECEIPT_FETCH_COMPLETED = 'RECEIPT_FETCH_COMPLETED'; export const RECEIPT_FETCH_FAILED = 'RECEIPT_FETCH_FAILED'; diff --git a/client/state/reader/watermarks/actions.js b/client/state/reader/watermarks/actions.js new file mode 100644 index 0000000000000..fb9dde895ac74 --- /dev/null +++ b/client/state/reader/watermarks/actions.js @@ -0,0 +1,23 @@ +/** @format */ + +/** + * Internal dependencies + */ +import { READER_VIEW_STREAM } from 'state/action-types'; + +/** + * this is a relatively generic action type for something very specific (marking up the watermark) + * My hope is that we'll be able to reuse this same action-type for many other functionalities. + * i.e. unexpanding all photos/videos when opening a stream. + * + * @param {Date} mark - date last viewed + * @param {String} streamId - stream being viewed + * @returns {Object} action object for dispatch + */ +export const viewStream = ( { mark, streamId } ) => { + return { + type: READER_VIEW_STREAM, + mark, + streamId, + }; +}; diff --git a/client/state/reader/watermarks/reducer.js b/client/state/reader/watermarks/reducer.js new file mode 100644 index 0000000000000..4a7dbe79f0729 --- /dev/null +++ b/client/state/reader/watermarks/reducer.js @@ -0,0 +1,27 @@ +/** @format */ + +/** + * External Dependencies + */ +import { max } from 'lodash'; + +/** + * Internal dependencies + */ +import { READER_VIEW_STREAM, DESERIALIZE } from 'state/action-types'; +import { createReducer, keyedReducer } from 'state/utils'; +import schema from './watermark-schema'; + +export const watermarks = keyedReducer( + 'streamId', + createReducer( + {}, + { + [ READER_VIEW_STREAM ]: ( state, action ) => max( [ +state, +action.mark ] ), + }, + schema + ), + [ DESERIALIZE ] +); + +watermarks.hasCustomPersistence = true; diff --git a/client/state/reader/watermarks/test/reducer.js b/client/state/reader/watermarks/test/reducer.js new file mode 100644 index 0000000000000..88b01bb9bdd85 --- /dev/null +++ b/client/state/reader/watermarks/test/reducer.js @@ -0,0 +1,57 @@ +/** @format */ + +/** + * Internal dependencies + */ +import { viewStream } from '../actions'; +import { watermarks } from '../reducer'; +import { DESERIALIZE, SERIALIZE } from 'state/action-types'; + +const streamId = 'special-chicken-stream'; +const mark = Date.now(); + +describe( '#watermarks', () => { + test( 'defaults to empty object', () => { + expect( watermarks( undefined, { type: 'INIT' } ) ).toEqual( {} ); + } ); + + test( 'can add a new stream to empty state', () => { + const action = viewStream( { streamId, mark } ); + expect( watermarks( {}, action ) ).toEqual( { + [ streamId ]: mark, + } ); + } ); + + test( 'can update an existing stream', () => { + const prevState = { [ streamId ]: mark }; + const newMark = mark + 2; + const action = viewStream( { streamId, mark: newMark } ); + expect( watermarks( prevState, action ) ).toEqual( { + [ streamId ]: newMark, + } ); + } ); + + test( 'will reject an attempt to update to an older mark', () => { + const prevState = { [ streamId ]: mark }; + const newMark = mark - 2; + const action = viewStream( { streamId, mark: newMark } ); + expect( watermarks( prevState, action ) ).toEqual( { + [ streamId ]: mark, + } ); + } ); + + test( 'will skip deserializing invalid marks', () => { + const invalidState = { [ streamId ]: 'invalid' }; + expect( watermarks( invalidState, { type: DESERIALIZE } ) ).toEqual( {} ); + } ); + + test( 'will deserialize valid mark', () => { + const validState = { [ streamId ]: 42 }; + expect( watermarks( validState, { type: DESERIALIZE } ) ).toEqual( validState ); + } ); + + test( 'will serialize', () => { + const validState = { [ streamId ]: 42 }; + expect( watermarks( validState, { type: SERIALIZE } ) ).toEqual( validState ); + } ); +} ); diff --git a/client/state/reader/watermarks/watermark-schema.js b/client/state/reader/watermarks/watermark-schema.js new file mode 100644 index 0000000000000..0b435c88a48db --- /dev/null +++ b/client/state/reader/watermarks/watermark-schema.js @@ -0,0 +1,6 @@ +/** @format */ + +export default { + type: 'number', + additionalProperties: false, +}; diff --git a/client/state/selectors/get-reader-watermark.js b/client/state/selectors/get-reader-watermark.js new file mode 100644 index 0000000000000..dcb73b821815a --- /dev/null +++ b/client/state/selectors/get-reader-watermark.js @@ -0,0 +1,12 @@ +/** @format */ + +/** + * Get the high watermark for a Reader stream + * + * @param {Object} state - + * @param {String} streamId - + * @returns {Number} date in number form + */ +const getReaderWatermark = ( state, streamId ) => state.reader.watermarks[ streamId ]; + +export default getReaderWatermark; diff --git a/client/state/selectors/index.js b/client/state/selectors/index.js index 9e2a4cf89f17d..4ce86ee800324 100644 --- a/client/state/selectors/index.js +++ b/client/state/selectors/index.js @@ -101,6 +101,7 @@ export getReaderRecommendedSites from './get-reader-recommended-sites'; export getReaderRecommendedSitesPagingOffset from './get-reader-recommended-sites-paging-offset'; export getReaderTags from './get-reader-tags'; export getReaderTeams from './get-reader-teams'; +export getReaderWatermark from './get-reader-watermark'; export getRegistrantWhois from './get-registrant-whois'; export getRequestedRewind from './get-requested-rewind'; export getRestoreError from './get-restore-error'; diff --git a/config/development.json b/config/development.json index 7c307dd8e5f36..85123f957fa16 100644 --- a/config/development.json +++ b/config/development.json @@ -145,6 +145,7 @@ "reader/recommendations/posts": true, "reader/related-posts": true, "reader/search": true, + "reader/high-watermark": true, "reader/tags-with-elasticsearch": false, "resume-editing": true, "republicize": true, diff --git a/config/wpcalypso.json b/config/wpcalypso.json index 97d89ea495212..1fd8b249fc299 100644 --- a/config/wpcalypso.json +++ b/config/wpcalypso.json @@ -109,6 +109,7 @@ "reader/recommendations/posts": true, "reader/related-posts": true, "reader/search": true, + "reader/high-watermark": true, "reader/tags-with-elasticsearch": false, "resume-editing": true, "republicize": true, From 2a8da4a65095ea97e196773eb8e061eb5d5313a1 Mon Sep 17 00:00:00 2001 From: Jacopo Tomasone Date: Fri, 27 Oct 2017 17:31:00 +0100 Subject: [PATCH 008/192] Comments: Remove error notices on single comment failed requests (#19101) The PR title says it all. The reason is that users can't do anything about it anyway. --- .../data-layer/wpcom/sites/comments/index.js | 29 +------------------ .../wpcom/sites/comments/test/index.js | 5 ++-- 2 files changed, 3 insertions(+), 31 deletions(-) diff --git a/client/state/data-layer/wpcom/sites/comments/index.js b/client/state/data-layer/wpcom/sites/comments/index.js index c93ae0e197b21..65acfc6473588 100644 --- a/client/state/data-layer/wpcom/sites/comments/index.js +++ b/client/state/data-layer/wpcom/sites/comments/index.js @@ -26,8 +26,6 @@ import likes from './likes'; import { errorNotice, removeNotice } from 'state/notices/actions'; import { getRawSite } from 'state/sites/selectors'; import { getSiteComment } from 'state/selectors'; -import { getSiteName as getReaderSiteName } from 'reader/get-helpers'; -import { getSite as getReaderSite } from 'state/reader/sites/selectors'; const changeCommentStatus = ( { dispatch, getState }, action ) => { const { siteId, commentId, status } = action; @@ -111,37 +109,12 @@ export const receiveCommentSuccess = ( store, action, response ) => { } ); }; -export const receiveCommentError = ( { dispatch, getState }, { siteId, commentId } ) => { - const site = getReaderSite( getState(), siteId ); - const siteName = getReaderSiteName( { site } ); - - if ( siteName ) { - dispatch( - errorNotice( - translate( 'Failed to retrieve comment for site “%(siteName)s”', { - args: { siteName }, - } ), - { id: `request-comment-error-${ siteId }` } - ) - ); - } else { - const rawSite = getRawSite( getState(), siteId ); - const error = - rawSite && rawSite.name - ? translate( 'Failed to retrieve comment for site “%(siteName)s”', { - args: { siteName: rawSite.name }, - } ) - : translate( 'Failed to retrieve comment for your site' ); - - dispatch( errorNotice( error, { id: `request-comment-error-${ siteId }` } ) ); - } - +export const receiveCommentError = ( { dispatch }, { siteId, commentId } ) => dispatch( { type: COMMENTS_RECEIVE_ERROR, siteId, commentId, } ); -}; // @see https://developer.wordpress.com/docs/api/1.1/get/sites/%24site/comments/ export const fetchCommentsList = ( { dispatch }, action ) => { diff --git a/client/state/data-layer/wpcom/sites/comments/test/index.js b/client/state/data-layer/wpcom/sites/comments/test/index.js index 03f84775804ba..72f66eb476f5d 100644 --- a/client/state/data-layer/wpcom/sites/comments/test/index.js +++ b/client/state/data-layer/wpcom/sites/comments/test/index.js @@ -193,9 +193,8 @@ describe( '#receiveCommentError', () => { receiveCommentError( { dispatch, getState }, action, response ); expect( dispatch ).to.have.been.calledWithMatch( { - notice: { - text: 'Failed to retrieve comment for site “sqeeeeee!”', - }, + siteId, + commentId, } ); } ); } ); From cc0ca7c715ab6a0e7839e8e04ba364057f4c49ad Mon Sep 17 00:00:00 2001 From: Kelly Dwan Date: Fri, 27 Oct 2017 12:49:00 -0400 Subject: [PATCH 009/192] Store Orders: Add fees to an existing order (#19141) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add “+ Product” and “+ Fee” buttons when editing an order * Add the dialog for adding fees * Only allow the fee to be saved if there is a name & value * Add currency formatting to the fee value, to ensure whole-cent values * Disable negative fees * Address PR feedback * Update fee value setter to allow the user to delete the `0` * Ensure the inputs have valid values before enabling the save button * Don’t trim the name while typing * Use removeTemporaryIds to enable saving of new fees --- .../app/order/order-details/add-items.js | 45 ++++++ .../app/order/order-details/fee-dialog.js | 153 ++++++++++++++++++ .../app/order/order-details/style.scss | 22 +++ .../app/order/order-details/table.js | 2 + .../woocommerce/state/sites/orders/actions.js | 4 +- 5 files changed, 225 insertions(+), 1 deletion(-) create mode 100644 client/extensions/woocommerce/app/order/order-details/add-items.js create mode 100644 client/extensions/woocommerce/app/order/order-details/fee-dialog.js diff --git a/client/extensions/woocommerce/app/order/order-details/add-items.js b/client/extensions/woocommerce/app/order/order-details/add-items.js new file mode 100644 index 0000000000000..c560357623506 --- /dev/null +++ b/client/extensions/woocommerce/app/order/order-details/add-items.js @@ -0,0 +1,45 @@ +/** @format */ +/** + * External dependencies + */ +import React, { Component } from 'react'; +import { localize } from 'i18n-calypso'; +import Gridicon from 'gridicons'; + +/** + * Internal dependencies + */ +import Button from 'components/button'; +import OrderFeeDialog from './fee-dialog'; + +class OrderAddItems extends Component { + state = { + showDialog: false, + }; + + toggleDialog = type => () => { + this.setState( { showDialog: type } ); + }; + + render() { + const { translate } = this.props; + return ( +
+ + + +
+ ); + } +} + +export default localize( OrderAddItems ); diff --git a/client/extensions/woocommerce/app/order/order-details/fee-dialog.js b/client/extensions/woocommerce/app/order/order-details/fee-dialog.js new file mode 100644 index 0000000000000..62239232a23f0 --- /dev/null +++ b/client/extensions/woocommerce/app/order/order-details/fee-dialog.js @@ -0,0 +1,153 @@ +/** @format */ +/** + * External dependencies + */ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; +import { localize } from 'i18n-calypso'; +import { trim, uniqueId } from 'lodash'; + +/** + * Internal dependencies + */ +import Button from 'components/button'; +import Dialog from 'components/dialog'; +import { editOrder } from 'woocommerce/state/ui/orders/actions'; +import FormLabel from 'components/forms/form-label'; +import FormTextInput from 'components/forms/form-text-input'; +import { getCurrencyFormatDecimal } from 'woocommerce/lib/currency'; +import { getOrderWithEdits } from 'woocommerce/state/ui/orders/selectors'; +import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; +import PriceInput from 'woocommerce/components/price-input'; + +class OrderFeeDialog extends Component { + static propTypes = { + isVisible: PropTypes.bool.isRequired, + editOrder: PropTypes.func.isRequired, + order: PropTypes.shape( { + currency: PropTypes.string.isRequired, + id: PropTypes.number.isRequired, + } ), + siteId: PropTypes.number.isRequired, + translate: PropTypes.func.isRequired, + }; + + state = { + name: '', + total: 0, + }; + + componentWillUpdate( nextProps ) { + // Dialog is being closed, clear the state + if ( this.props.isVisible && ! nextProps.isVisible ) { + this.setState( { + name: '', + total: 0, + } ); + } + } + + handleChange = event => { + const value = event.target.value; + switch ( event.target.name ) { + case 'new_fee_name': + this.setState( { name: value } ); + break; + case 'new_fee_total': + // If value is a positive number, we can use it + if ( ! isNaN( parseFloat( value ) ) && parseFloat( value ) >= 0 ) { + this.setState( { total: value } ); + } else { + this.setState( { total: '' } ); + } + break; + } + }; + + formatCurrencyInput = () => { + const { currency } = this.props.order; + this.setState( prevState => ( { + total: getCurrencyFormatDecimal( prevState.total, currency ), + } ) ); + }; + + handleFeeSave = () => { + const { siteId, order } = this.props; + if ( siteId ) { + const { name, total } = this.state; + const feeLines = order.fee_lines || []; + const tempId = uniqueId( 'fee_' ); + this.props.editOrder( siteId, { + id: order.id, + fee_lines: [ ...feeLines, { id: tempId, name, total } ], + } ); + } + this.props.closeDialog(); + }; + + hasValidValues = () => { + const { currency } = this.props.order; + + const hasName = !! trim( this.state.name ); + const hasValue = getCurrencyFormatDecimal( this.state.total, currency ) > 0; + return hasName && hasValue; + }; + + render() { + const { closeDialog, isVisible, order, translate } = this.props; + const dialogClass = 'woocommerce order-details__dialog'; // eslint/css specificity hack + + const canSave = this.hasValidValues(); + + const dialogButtons = [ + , + , + ]; + + return ( + +

{ translate( 'Add a fee' ) }

+ { translate( 'Name' ) } + + { translate( 'Value' ) } + +
+ ); + } +} + +export default connect( + state => { + const site = getSelectedSiteWithFallback( state ); + const siteId = site ? site.ID : false; + const order = getOrderWithEdits( state ); + + return { + siteId, + order, + }; + }, + dispatch => bindActionCreators( { editOrder }, dispatch ) +)( localize( OrderFeeDialog ) ); diff --git a/client/extensions/woocommerce/app/order/order-details/style.scss b/client/extensions/woocommerce/app/order/order-details/style.scss index c4fa52b290d09..601d1659df6e4 100644 --- a/client/extensions/woocommerce/app/order/order-details/style.scss +++ b/client/extensions/woocommerce/app/order/order-details/style.scss @@ -45,6 +45,28 @@ text-align: right; } +.order-details__actions { + margin: 0 -24px; + padding: 8px 78px 8px 24px; + border-bottom: 1px solid lighten($gray, 30%); + text-align: right; + + .button + .button { + margin-left: 24px; + } + + .button { + color: $blue-medium; + + &:hover, + &:focus, + &:active { + color: $link-highlight; + } + } +} + + // Duplicating `.table` for increased specificity .order-details__totals.table.table { margin: 16px -24px; diff --git a/client/extensions/woocommerce/app/order/order-details/table.js b/client/extensions/woocommerce/app/order/order-details/table.js index 77cb48474a8c3..ebb01b74de264 100644 --- a/client/extensions/woocommerce/app/order/order-details/table.js +++ b/client/extensions/woocommerce/app/order/order-details/table.js @@ -29,6 +29,7 @@ import { getOrderRefundTotal, getOrderTotal, } from 'woocommerce/lib/order-values/totals'; +import OrderAddItems from './add-items'; import OrderTotalRow from './row-total'; import ScreenReaderText from 'components/screen-reader-text'; import Table from 'woocommerce/components/table'; @@ -270,6 +271,7 @@ class OrderDetailsTable extends Component { { order.line_items.map( this.renderOrderItem ) } { order.fee_lines.map( this.renderOrderFee ) } + { isEditing && } ( dispatch, siteId = getSelectedSiteId( state ); } + order = removeTemporaryIds( order ); + const updateAction = { type: WOOCOMMERCE_ORDER_UPDATE, siteId, From 8d9b3797df1162196090275ae9f6dae1cbcaf1d1 Mon Sep 17 00:00:00 2001 From: Bernie Reiter Date: Fri, 27 Oct 2017 19:29:28 +0200 Subject: [PATCH 010/192] JP Disconnect Survey: Add Debug Link (#19224) --- .../disconnect-site/troubleshoot.jsx | 35 ++++++++++++++----- 1 file changed, 26 insertions(+), 9 deletions(-) diff --git a/client/my-sites/site-settings/disconnect-site/troubleshoot.jsx b/client/my-sites/site-settings/disconnect-site/troubleshoot.jsx index 3ce889520ee5a..506051c9b750e 100644 --- a/client/my-sites/site-settings/disconnect-site/troubleshoot.jsx +++ b/client/my-sites/site-settings/disconnect-site/troubleshoot.jsx @@ -9,12 +9,23 @@ import { localize } from 'i18n-calypso'; /** * Internal dependencies */ -import JetpackConnectHappychatButton from 'jetpack-connect/happychat-button'; import HelpButton from 'jetpack-connect/help-button'; +import JetpackConnectHappychatButton from 'jetpack-connect/happychat-button'; +import LoggedOutFormLinkItem from 'components/logged-out-form/link-item'; +import LoggedOutFormLinks from 'components/logged-out-form/links'; +import addQueryArgs from 'lib/route/add-query-args'; import { recordTracksEvent, withAnalytics } from 'state/analytics/actions'; +import { getSiteUrl } from 'state/selectors'; +import { getSelectedSiteId } from 'state/ui/selectors'; -const Troubleshoot = ( { trackSupportClick, translate } ) => ( -
+const Troubleshoot = ( { siteUrl, trackDebugClick, trackSupportClick, translate } ) => ( + + + { translate( 'Diagnose a connection problem' ) } + ( onClick={ trackSupportClick } /> -
+ ); -export default connect( null, { - trackSupportClick: withAnalytics( - recordTracksEvent( 'calypso_jetpack_disconnect_support_click' ) - ), -} )( localize( Troubleshoot ) ); +export default connect( + state => ( { + siteUrl: getSiteUrl( state, getSelectedSiteId( state ) ), + } ), + { + trackDebugClick: withAnalytics( recordTracksEvent( 'calypso_jetpack_disconnect_debug_click' ) ), + trackSupportClick: withAnalytics( + recordTracksEvent( 'calypso_jetpack_disconnect_support_click' ) + ), + } +)( localize( Troubleshoot ) ); From aaafa9b4413b5d47a1ccf3c8792724e6faffa6d7 Mon Sep 17 00:00:00 2001 From: Takashi Irie Date: Fri, 27 Oct 2017 19:31:39 +0100 Subject: [PATCH 011/192] Simple Payment: Vertical alignment fix for Simaple Payment button in editor --- .../plugins/wpcom-view/views/simple-payments/style.scss | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/client/components/tinymce/plugins/wpcom-view/views/simple-payments/style.scss b/client/components/tinymce/plugins/wpcom-view/views/simple-payments/style.scss index 66bf22fca8f15..5f29c7e5824b0 100644 --- a/client/components/tinymce/plugins/wpcom-view/views/simple-payments/style.scss +++ b/client/components/tinymce/plugins/wpcom-view/views/simple-payments/style.scss @@ -116,14 +116,15 @@ position: relative; text-align: left; text-shadow: rgb(204, 204, 204) 0px -1px; - top: -3px; + top: 50%; width: 43px; - perspective-origin: 21.125px 6px; - font-family: "Helvetica Neue",Helvetica, Arial, sans-serif; + font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 10px; font-style: normal; font-weight: 500; line-height: normal; + transform: translateY(-50%); + vertical-align: top; } .wpview-type-simple-payments_paypal-logo { From 1f9acb7b11fa1098e8df051adc5c064ba8b584f8 Mon Sep 17 00:00:00 2001 From: Rich Collier Date: Fri, 27 Oct 2017 16:14:39 -0400 Subject: [PATCH 012/192] Add credentials interface to Settings/Security (#18962) * First commit of credentials interface in a working state * Remove change to rewindStatusSchema that is introduced in a different PR * Updates from pair-programming Major changes: - Rearranges some code in the data layer to fit file path pattern - Removed some requesting state tracking - Simplified some redundant logic - Connected noticies by id * Remove unused methods * Clean up in preparation for extracting credentials form * Continue inlineing and simplifying UI code * Further reorganization of backups and credentials components. Created stylesheets for each component, fixed classnames, and other small fixes. * - Create selector for getAutoConfigStatus - Use a keyed reducer for credentials items and updateRequesting * Use existing values to populate form fields on first render * Move selectors to the proper place * Put setup flow into its own component, along with a few other fixes * Some more updates: - add private key field, remove public key field - make sure form has a submitting state when credentials already exist - add cancel button to TOS step * Add abspath field to interface * More changes - Make requestCredentials required in QueryJetpackCredentials - Remove componentWillReceiveProps from CredentialsForm, not needed any more - Refactor form validation in CredentialsForm to make it simpler with less code - Refactor goToNextStep in CredentialsSetupFlow - Refactor CredentialsForm to draw input fields using a class method to reduce code * Make sure credentials form only shows in dev & staging environments * More fixes, along with reverting change to credentials form that added renderInputField() function * Move setup footer into CredentialsSetupFlow component * Extract more JSX into new components * Need to perform a new request in QueryJetpackCredentials when a new siteId is received * Change some CSS selectors to conform to namespace guidelines * One last round of fixes - Do not pass translate() to subcomponents, instead, localize() them - Use function syntax for popover refs - Remove lodash get() from setup-footer.jsx, not needed --- assets/stylesheets/_components.scss | 4 + .../data/query-jetpack-credentials/index.jsx | 40 +++ .../credentials-configured/index.jsx | 105 ++++++++ .../credentials-configured/style.scss | 31 +++ .../credentials-form/index.jsx | 239 ++++++++++++++++++ .../credentials-form/style.scss | 32 +++ .../credentials-setup-flow/index.jsx | 83 ++++++ .../credentials-setup-flow/setup-footer.jsx | 54 ++++ .../credentials-setup-flow/setup-form.jsx | 31 +++ .../credentials-setup-flow/setup-start.jsx | 25 ++ .../credentials-setup-flow/setup-tos.jsx | 44 ++++ .../credentials-setup-flow/style.scss | 57 +++++ .../jetpack-credentials/index.jsx | 109 ++++++++ .../jetpack-credentials/style.scss | 22 ++ .../site-settings/settings-security/main.jsx | 3 + client/state/action-types.js | 10 + .../activity-log/get-credentials/index.js | 56 ++++ .../data-layer/wpcom/activity-log/index.js | 4 +- .../activity-log/rewind/activate/index.js | 63 +++++ .../wpcom/activity-log/rewind/index.js | 3 +- .../activity-log/update-credentials/index.js | 80 ++++++ client/state/jetpack/credentials/actions.js | 24 ++ client/state/jetpack/credentials/reducer.js | 39 +++ client/state/jetpack/credentials/schema.js | 14 + client/state/jetpack/reducer.js | 2 + .../get-credentials-auto-config-status.js | 8 + .../selectors/get-jetpack-credentials.js | 8 + .../state/selectors/has-main-credentials.js | 8 + client/state/selectors/index.js | 5 + client/state/selectors/is-site-pressable.js | 8 + .../is-updating-jetpack-credentials.js | 8 + config/development.json | 1 + config/stage.json | 1 + 33 files changed, 1219 insertions(+), 2 deletions(-) create mode 100644 client/components/data/query-jetpack-credentials/index.jsx create mode 100644 client/my-sites/site-settings/jetpack-credentials/credentials-configured/index.jsx create mode 100644 client/my-sites/site-settings/jetpack-credentials/credentials-configured/style.scss create mode 100644 client/my-sites/site-settings/jetpack-credentials/credentials-form/index.jsx create mode 100644 client/my-sites/site-settings/jetpack-credentials/credentials-form/style.scss create mode 100644 client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/index.jsx create mode 100644 client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/setup-footer.jsx create mode 100644 client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/setup-form.jsx create mode 100644 client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/setup-start.jsx create mode 100644 client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/setup-tos.jsx create mode 100644 client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/style.scss create mode 100644 client/my-sites/site-settings/jetpack-credentials/index.jsx create mode 100644 client/my-sites/site-settings/jetpack-credentials/style.scss create mode 100644 client/state/data-layer/wpcom/activity-log/get-credentials/index.js create mode 100644 client/state/data-layer/wpcom/activity-log/rewind/activate/index.js create mode 100644 client/state/data-layer/wpcom/activity-log/update-credentials/index.js create mode 100644 client/state/jetpack/credentials/actions.js create mode 100644 client/state/jetpack/credentials/reducer.js create mode 100644 client/state/jetpack/credentials/schema.js create mode 100644 client/state/selectors/get-credentials-auto-config-status.js create mode 100644 client/state/selectors/get-jetpack-credentials.js create mode 100644 client/state/selectors/has-main-credentials.js create mode 100644 client/state/selectors/is-site-pressable.js create mode 100644 client/state/selectors/is-updating-jetpack-credentials.js diff --git a/assets/stylesheets/_components.scss b/assets/stylesheets/_components.scss index 11068deeb5f62..ed66b04d9ced9 100644 --- a/assets/stylesheets/_components.scss +++ b/assets/stylesheets/_components.scss @@ -386,6 +386,10 @@ @import 'my-sites/sidebar/style'; @import 'my-sites/sidebar-navigation/style'; @import 'my-sites/site-indicator/style'; +@import 'my-sites/site-settings/jetpack-credentials/style'; +@import 'my-sites/site-settings/jetpack-credentials/credentials-configured/style'; +@import 'my-sites/site-settings/jetpack-credentials/credentials-form/style'; +@import 'my-sites/site-settings/jetpack-credentials/credentials-setup-flow/style'; @import 'my-sites/importer/style'; @import 'blocks/site/style'; @import 'my-sites/sites/style'; diff --git a/client/components/data/query-jetpack-credentials/index.jsx b/client/components/data/query-jetpack-credentials/index.jsx new file mode 100644 index 0000000000000..fba843e93418c --- /dev/null +++ b/client/components/data/query-jetpack-credentials/index.jsx @@ -0,0 +1,40 @@ +/** + * External dependencies + */ +import PropTypes from 'prop-types'; +import { Component } from 'react'; +import { connect } from 'react-redux'; + +/** + * Internal dependencies + */ +import { requestCredentials } from 'state/jetpack/credentials/actions'; + +class QueryJetpackCredentials extends Component { + componentWillMount() { + this.request( this.props ); + } + + componentWillReceiveProps( nextProps ) { + if ( this.props.siteId === nextProps.siteId ) { + return; + } + + this.request( nextProps ); + } + + request( props ) { + this.props.requestCredentials( props.siteId ); + } + + render() { + return null; + } +} + +QueryJetpackCredentials.propTypes = { + requestCredentials: PropTypes.func.isRequired, + siteId: PropTypes.number.isRequired +}; + +export default connect( null, { requestCredentials } )( QueryJetpackCredentials ); diff --git a/client/my-sites/site-settings/jetpack-credentials/credentials-configured/index.jsx b/client/my-sites/site-settings/jetpack-credentials/credentials-configured/index.jsx new file mode 100644 index 0000000000000..80dda6c2f4bd9 --- /dev/null +++ b/client/my-sites/site-settings/jetpack-credentials/credentials-configured/index.jsx @@ -0,0 +1,105 @@ +/** @format */ +/** + * External dependencies + */ +import React, { Component } from 'react'; +import { localize } from 'i18n-calypso'; +import { get } from 'lodash'; + +/** + * Internal dependencies + */ +import Gridicon from 'gridicons'; +import FoldableCard from 'components/foldable-card'; +import CompactCard from 'components/card/compact'; +import CredentialsForm from '../credentials-form/index'; + +class CredentialsConfigured extends Component { + getProtocolDescription = protocol => { + const { translate } = this.props; + + switch ( protocol ) { + case 'SSH': + return translate( 'Secure Shell, the most complete and secure way to access your site.' ); + case 'SFTP': + return translate( 'Secure File Transfer Protocol, a secure way to access your files.' ); + case 'FTP': + return translate( 'File Transfer Protocol, a way to access your files.' ); + case 'PRESSABLE-SSH': + return translate( 'A special Secure Shell connection to Pressable.' ); + } + + return ''; + }; + + render() { + const { + isPressable, + credentialsUpdating, + mainCredentials, + formIsSubmitting, + siteId, + updateCredentials, + translate, + } = this.props; + + const protocol = get( this.props.mainCredentials, 'protocol', 'SSH' ).toUpperCase(); + const protocolDescription = this.getProtocolDescription( protocol ); + + if ( isPressable ) { + return ( + + +
+ { translate( + "You're all set! Your credentials have been " + + 'automatically configured and your site is connected. ' + + 'Backups and restores should work seamlessly.' + ) } +
+
+ ); + } + + const header = ( +
+ +
+

{ protocol }

+

{ protocolDescription }

+
+
+ ); + + return ( + + + + ); + } +} + +export default localize( CredentialsConfigured ); diff --git a/client/my-sites/site-settings/jetpack-credentials/credentials-configured/style.scss b/client/my-sites/site-settings/jetpack-credentials/credentials-configured/style.scss new file mode 100644 index 0000000000000..4e96ad4b8c44c --- /dev/null +++ b/client/my-sites/site-settings/jetpack-credentials/credentials-configured/style.scss @@ -0,0 +1,31 @@ +.credentials-configured__header { + width: 85%; +} + +.credentials-configured__header-gridicon { + display: inline-block; + vertical-align: middle; + margin-right: 10px; +} + +.credentials-configured__header-configured-text, +.credentials-configured__header-text { + display: inline-block; + vertical-align: middle; + width: 85%; +} + +.credentials-configured__header-gridicon { + color: $alert-green; +} + +.credentials-configured__header-protocol { + font-weight: bold; + font-size: 1.5rem; +} + +.credentials-configured__header-description { + width: 500px; + color: $gray; + font-size: 1.3rem; +} diff --git a/client/my-sites/site-settings/jetpack-credentials/credentials-form/index.jsx b/client/my-sites/site-settings/jetpack-credentials/credentials-form/index.jsx new file mode 100644 index 0000000000000..d188c3f23c3e7 --- /dev/null +++ b/client/my-sites/site-settings/jetpack-credentials/credentials-form/index.jsx @@ -0,0 +1,239 @@ +/** @format */ +/* eslint-disable */ +/** + * External dependendies + */ +import React, { Component } from 'react'; +import { get, isEmpty } from 'lodash'; +import { localize } from 'i18n-calypso'; + +/** + * Internal dependencies + */ +import Button from 'components/button'; +import FormFieldset from 'components/forms/form-fieldset'; +import FormSelect from 'components/forms/form-select'; +import FormTextInput from 'components/forms/form-text-input'; +import FormLabel from 'components/forms/form-label'; +import FormTextArea from 'components/forms/form-textarea'; +import FormInputValidation from 'components/forms/form-input-validation'; +import FormPasswordInput from 'components/forms/form-password-input'; + +export class CredentialsForm extends Component { + state = { + showPrivateKeyField: false, + form: { + protocol: this.props.protocol, + host: this.props.host, + port: this.props.port, + user: this.props.user, + pass: this.props.pass, + abspath: this.props.abspath, + kpri: this.props.kpri, + }, + formErrors: { + host: false, + port: false, + user: false, + pass: false, + abspath: false, + }, + }; + + handleFieldChange = event => { + this.setState( { + form: { ...this.state.form, [ event.target.name ]: event.target.value }, + formErrors: { ...this.state.formErrors, [ event.target.name ]: false }, + } ); + }; + + handleSubmit = () => { + const { siteId, updateCredentials, translate } = this.props; + + const payload = { + ...this.state.form, + role: 'main', + }; + + const errors = Object.assign( + ! payload.host && { host: translate( 'Please enter a valid server address.' ) }, + ! payload.port && { port: translate( 'Please enter a valid server port.' ) }, + isNaN( payload.port ) && { port: translate( 'Port number must be numeric.' ) }, + ! payload.user && { user: translate( 'Please enter your server username.' ) }, + ! payload.pass && { pass: translate( 'Please enter your server password.' ) }, + ! payload.abspath && { abspath: translate( 'Please enter a valid upload path.' ) } + ); + + return isEmpty( errors ) + ? updateCredentials( siteId, payload ) + : this.setState( { formErrors: errors } ); + }; + + togglePrivateKeyField = () => + this.setState( { showPrivateKeyField: ! this.state.showPrivateKeyField } ); + + render() { + const { formIsSubmitting, onCancel, translate } = this.props; + + const { showPrivateKeyField, formErrors } = this.state; + + return ( + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
{ translate( 'Credential Type' ) }
+ + + + + +
+
+ +
{ translate( 'Server Address' ) }
+ + { formErrors.host && ( + + ) } +
+
+ +
{ translate( 'Port Number' ) }
+ + { formErrors.port && ( + + ) } +
+
+ +
{ translate( 'Username' ) }
+ + { formErrors.user && ( + + ) } +
+
+ +
{ translate( 'Password' ) }
+ + { formErrors.pass && ( + + ) } +
+
+ +
{ translate( 'Upload Path' ) }
+ + { formErrors.abspath && ( + + ) } +
+
+ +
{ translate( 'Private Key' ) }
+ + { showPrivateKeyField && ( +
+ +

+ This field is only required if your host uses key based authentication. +

+
+ ) } +
+
+ + { this.props.showCancelButton && ( + + ) } +
+ + ); + } +} + +export default localize( CredentialsForm ); diff --git a/client/my-sites/site-settings/jetpack-credentials/credentials-form/style.scss b/client/my-sites/site-settings/jetpack-credentials/credentials-form/style.scss new file mode 100644 index 0000000000000..7fb442b4e2db6 --- /dev/null +++ b/client/my-sites/site-settings/jetpack-credentials/credentials-form/style.scss @@ -0,0 +1,32 @@ +.credentials-form .form-label { + margin-bottom: 15px; +} + +.credentials-form__host-field { + width: 70%; +} + +.credentials-form__port-field { + width: 25%; + padding-left: 5%; +} + +.credentials-form__port-field .form-input-validation { + padding: 0; + margin-top: 5px; +} + +.credentials-form__port-field .form-input-validation .gridicon { + display: none; +} + +.credentials-form__cancel-button { + float: right; + margin-right: 15px; +} + +.credentials-form__private-key-description { + font-weight: normal; + font-style: italic; + color: $gray; +} \ No newline at end of file diff --git a/client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/index.jsx b/client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/index.jsx new file mode 100644 index 0000000000000..c290d72b76a2a --- /dev/null +++ b/client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/index.jsx @@ -0,0 +1,83 @@ +/** + * External dependencies + */ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { localize } from 'i18n-calypso'; +import { get } from 'lodash'; + +/** + * Internal dependencies + */ +import SetupStart from './setup-start'; +import SetupTos from './setup-tos'; +import SetupForm from './setup-form'; +import SetupFooter from './setup-footer'; + +class CredentialsSetupFlow extends Component { + static propTypes = { + isPressable: PropTypes.bool, + formIsSubmitting: PropTypes.bool, + siteId: PropTypes.number, + updateCredentials: PropTypes.func, + autoConfigCredentials: PropTypes.func, + autoConfigStatus: PropTypes.string + }; + + componentWillMount() { + this.setState( { currentStep: 'start', showPopover: false } ); + } + + togglePopover = () => this.setState( { showPopover: ! this.state.showPopover } ); + + reset = () => this.setState( { currentStep: 'start' } ); + + getNextStep = step => get( { + start: 'tos', + tos: 'form', + }, step, step ); + + goToNextStep = () => this.setState( { + currentStep: this.getNextStep( this.state.currentStep ) + } ); + + autoConfigure = () => this.props.autoConfigCredentials( this.props.siteId ); + + render() { + const { + isPressable, + formIsSubmitting, + updateCredentials, + siteId + } = this.props; + + return ( +
+ { 'start' === this.state.currentStep && ( + + ) } + { 'tos' === this.state.currentStep && ( + + ) } + { 'form' === this.state.currentStep && ( + + ) } + +
+ ); + } +} + +export default localize( CredentialsSetupFlow ); diff --git a/client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/setup-footer.jsx b/client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/setup-footer.jsx new file mode 100644 index 0000000000000..94e27345d30fe --- /dev/null +++ b/client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/setup-footer.jsx @@ -0,0 +1,54 @@ +/** + * External dependencies + */ +import React, { Component } from 'react'; +import { localize } from 'i18n-calypso'; + +/** + * Internal dependencies + */ +import CompactCard from 'components/card/compact'; +import Gridicon from 'gridicons'; +import Popover from 'components/popover'; + +class SetupFooter extends Component { + componentWillMount() { + this.setState( { showPopover: false } ); + } + + togglePopover = () => this.setState( { showPopover: ! this.state.showPopover } ); + + storePopoverLink = ref => this.popoverLink = ref; + + render() { + const { translate } = this.props; + + return ( + + + + + { translate( 'Why do I need this?' ) } + + + { translate( + 'These credentials are used to perform automatic actions ' + + 'on your server including backups and restores.' + ) } + + + ); + } +} + +export default localize( SetupFooter ); diff --git a/client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/setup-form.jsx b/client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/setup-form.jsx new file mode 100644 index 0000000000000..4a4bd6edb0445 --- /dev/null +++ b/client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/setup-form.jsx @@ -0,0 +1,31 @@ +/** + * External dependencies + */ +import React from 'react'; + +/** + * Internal dependencies + */ +import CompactCard from 'components/card/compact'; +import CredentialsForm from '../credentials-form/index'; + +const SetupForm = ( { formIsSubmitting, reset, siteId, updateCredentials } ) => ( + + + +); + +export default SetupForm; diff --git a/client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/setup-start.jsx b/client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/setup-start.jsx new file mode 100644 index 0000000000000..4ef013dac7071 --- /dev/null +++ b/client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/setup-start.jsx @@ -0,0 +1,25 @@ +/** + * External dependencies + */ +import React from 'react'; +import { localize } from 'i18n-calypso'; + +/** + * Internal dependencies + */ +import CompactCard from 'components/card/compact'; +import Gridicon from 'gridicons'; + +const SetupStart = ( { goToNextStep, translate } ) => ( + + +
+

{ translate( 'Add site credentials' ) }

+

+ { translate( 'Used to perform automatic actions on your server including backing up and restoring.' ) } +

+
+
+); + +export default localize( SetupStart ); diff --git a/client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/setup-tos.jsx b/client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/setup-tos.jsx new file mode 100644 index 0000000000000..793ff3a998f36 --- /dev/null +++ b/client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/setup-tos.jsx @@ -0,0 +1,44 @@ +/** + * External dependencies + */ +import React from 'react'; +import { localize } from 'i18n-calypso'; + +/** + * Internal dependencies + */ +import CompactCard from 'components/card/compact'; +import Gridicon from 'gridicons'; +import Button from 'components/button'; + +const SetupTos = ( { autoConfigure, isPressable, reset, translate, goToNextStep } ) => ( + + +
+ { + isPressable + ? translate( 'WordPress.com can obtain the credentials from your ' + + 'current host which are necessary to perform site backups and ' + + 'restores. Do you want to give WordPress.com access to your ' + + 'host\'s server?' ) + : translate( 'By adding your site credentials, you are giving ' + + 'WordPress.com access to perform automatic actions on your ' + + 'server including backing up your site, restoring your site, ' + + 'as well as manually accessing your site in case of an emergency.' ) + } +
+
+ + { + isPressable + ? + : + } +
+
+); + +export default localize( SetupTos ); diff --git a/client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/style.scss b/client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/style.scss new file mode 100644 index 0000000000000..ec0a4de8bd92b --- /dev/null +++ b/client/my-sites/site-settings/jetpack-credentials/credentials-setup-flow/style.scss @@ -0,0 +1,57 @@ +.credentials-setup-flow { + cursor: pointer; +} + +.credentials-setup-flow__header-gridicon { + display: inline-block; + vertical-align: middle; + margin-right: 10px; + color: $gray; +} + +.credentials-setup-flow__header-text { + display: inline-block; + vertical-align: middle; + width: 85%; +} + +.credentials-setup-flow__header-text-title { + font-size: 1.6rem; +} + +.credentials-setup-flow__header-text-description { + font-size: 1.3rem; + font-style: italic; +} + +.credentials-setup-flow__tos-gridicon { + display: inline-block; + vertical-align: top; + margin-right: 10px; + color: $blue-wordpress; +} + +.credentials-setup-flow__tos-text { + display: inline-block; + vertical-align: top; + width: 85%; +} + +.credentials-setup-flow__tos-buttons { + display: block; + text-align: right; +} + +.credentials-setup-flow__tos-buttons .is-borderless { + margin-right: 15px; +} + +.credentials-setup-flow__footer-popover-link { + cursor: pointer; +} + +.credentials-setup-flow__footer-popover-icon { + position: relative; + top: 3px; + left: -6px; +} diff --git a/client/my-sites/site-settings/jetpack-credentials/index.jsx b/client/my-sites/site-settings/jetpack-credentials/index.jsx new file mode 100644 index 0000000000000..e0aa451748623 --- /dev/null +++ b/client/my-sites/site-settings/jetpack-credentials/index.jsx @@ -0,0 +1,109 @@ +/** + * External dependencies + */ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { localize } from 'i18n-calypso'; +import { connect } from 'react-redux'; + +/** + * Internal dependencies + */ +import CompactCard from 'components/card/compact'; +import CredentialsSetupFlow from './credentials-setup-flow/index'; +import CredentialsConfigured from './credentials-configured/index'; +import Gridicon from 'gridicons'; +import QueryRewindStatus from 'components/data/query-rewind-status'; +import QueryJetpackCredentials from 'components/data/query-jetpack-credentials'; +import { isRewindActive } from 'state/selectors'; +import { getSelectedSiteId } from 'state/ui/selectors'; +import { + getJetpackCredentials, + isUpdatingJetpackCredentials, + hasMainCredentials, + isSitePressable, + getCredentialsAutoConfigStatus +} from 'state/selectors'; +import { + updateCredentials as updateCredentialsAction, + autoConfigCredentials as autoConfigCredentialsAction +} from 'state/jetpack/credentials/actions'; + +class Backups extends Component { + static propTypes = { + autoConfigStatus: PropTypes.string, + formIsSubmitting: PropTypes.bool, + hasMainCredentials: PropTypes.bool, + mainCredentials: PropTypes.object, + isPressable: PropTypes.bool, + isRewindActive: PropTypes.bool, + siteId: PropTypes.number.isRequired + }; + + render() { + const { + autoConfigStatus, + hasMainCredentials, // eslint-disable-line no-shadow + isPressable, + isRewindActive, // eslint-disable-line no-shadow + translate, + formIsSubmitting, + updateCredentials, + siteId, + autoConfigCredentials + } = this.props; + + return ( +
+ + + { isRewindActive && ( + + { translate( 'Backups and restores' ) } + { hasMainCredentials && ( + + + { translate( 'Connected' ) } + + ) } + + ) } + + { isRewindActive && ! hasMainCredentials && ( + + ) } + + { isRewindActive && hasMainCredentials && ( + + ) } +
+ ); + } +} + +export default connect( + ( state ) => { + const siteId = getSelectedSiteId( state ); + const credentials = getJetpackCredentials( state, siteId, 'main' ); + + return { + autoConfigStatus: getCredentialsAutoConfigStatus( state, siteId ), + formIsSubmitting: isUpdatingJetpackCredentials( state, siteId ), + hasMainCredentials: hasMainCredentials( state, siteId ), + mainCredentials: credentials, + isPressable: isSitePressable( state, siteId ), + isRewindActive: isRewindActive( state, siteId ), + siteId, + }; + }, { + autoConfigCredentials: autoConfigCredentialsAction, + updateCredentials: updateCredentialsAction, + } +)( localize( Backups ) ); diff --git a/client/my-sites/site-settings/jetpack-credentials/style.scss b/client/my-sites/site-settings/jetpack-credentials/style.scss new file mode 100644 index 0000000000000..400aff9f52e03 --- /dev/null +++ b/client/my-sites/site-settings/jetpack-credentials/style.scss @@ -0,0 +1,22 @@ +.jetpack-credentials { + margin-bottom: 15px; +} + +.jetpack-credentials .foldable-card.card.is-expanded { + margin: 0; +} + +.jetpack-credentials__connected { + float: right; + background: $alert-green; + padding: 0 8px; + margin: -4px; + font-size: 1.3rem; + color: $white; +} + +.jetpack-credentials__connected-checkmark { + position: relative; + top: 3px; + margin-right: 3px; +} diff --git a/client/my-sites/site-settings/settings-security/main.jsx b/client/my-sites/site-settings/settings-security/main.jsx index 05794387d32df..8b29e044dee2a 100644 --- a/client/my-sites/site-settings/settings-security/main.jsx +++ b/client/my-sites/site-settings/settings-security/main.jsx @@ -12,6 +12,7 @@ import { connect } from 'react-redux'; /** * Internal dependencies */ +import config from 'config'; import Main from 'components/main'; import DocumentHead from 'components/data/document-head'; import SidebarNavigation from 'my-sites/sidebar-navigation'; @@ -23,6 +24,7 @@ import JetpackDevModeNotice from 'my-sites/site-settings/jetpack-dev-mode-notice import JetpackMonitor from 'my-sites/site-settings/form-jetpack-monitor'; import JetpackManageErrorPage from 'my-sites/jetpack-manage-error-page'; import Placeholder from 'my-sites/site-settings/placeholder'; +import Backups from 'my-sites/site-settings/jetpack-credentials'; const SiteSettingsSecurity = ( { site, siteId, siteIsJetpack, translate } ) => { if ( ! site ) { @@ -64,6 +66,7 @@ const SiteSettingsSecurity = ( { site, siteId, siteIsJetpack, translate } ) => { + { config.isEnabled( 'jetpack/credentials' ) && } diff --git a/client/state/action-types.js b/client/state/action-types.js index 3b569975c49c6..7407cebc313b1 100644 --- a/client/state/action-types.js +++ b/client/state/action-types.js @@ -294,6 +294,16 @@ export const JETPACK_CONNECT_SSO_VALIDATION_REQUEST = 'JETPACK_CONNECT_SSO_VALID export const JETPACK_CONNECT_SSO_VALIDATION_SUCCESS = 'JETPACK_CONNECT_SSO_VALIDATION_SUCCESS'; export const JETPACK_CONNECT_STORE_SESSION = 'JETPACK_CONNECT_STORE_SESSION'; export const JETPACK_CONNECT_USER_ALREADY_CONNECTED = 'JETPACK_CONNECT_USER_ALREADY_CONNECTED'; +export const JETPACK_CREDENTIALS_AUTOCONFIGURE = 'JETPACK_CREDENTIALS_AUTOCONFIGURE'; +export const JETPACK_CREDENTIALS_AUTOCONFIGURE_FAILURE = + 'JETPACK_CREDENTIALS_AUTOCONFIGURE_FAILURE'; +export const JETPACK_CREDENTIALS_AUTOCONFIGURE_SUCCESS = + 'JETPACK_CREDENTIALS_AUTOCONFIGURE_SUCCESS'; +export const JETPACK_CREDENTIALS_REQUEST = 'JETPACK_CREDENTIALS_REQUEST'; +export const JETPACK_CREDENTIALS_STORE = 'JETPACK_CREDENTIALS_STORE'; +export const JETPACK_CREDENTIALS_UPDATE = 'JETPACK_CREDENTIALS_UPDATE'; +export const JETPACK_CREDENTIALS_UPDATE_SUCCESS = 'JETPACK_CREDENTIALS_UPDATE_SUCCESS'; +export const JETPACK_CREDENTIALS_UPDATE_FAILURE = 'JETPACK_CREDENTIALS_UPDATE_FAILURE'; export const JETPACK_DISCONNECT_RECEIVE = 'JETPACK_DISCONNECT_RECEIVE'; export const JETPACK_DISCONNECT_REQUEST = 'JETPACK_DISCONNECT_REQUEST'; export const JETPACK_DISCONNECT_REQUEST_FAILURE = 'JETPACK_DISCONNECT_REQUEST_FAILURE'; diff --git a/client/state/data-layer/wpcom/activity-log/get-credentials/index.js b/client/state/data-layer/wpcom/activity-log/get-credentials/index.js new file mode 100644 index 0000000000000..b9f3bbf76d35e --- /dev/null +++ b/client/state/data-layer/wpcom/activity-log/get-credentials/index.js @@ -0,0 +1,56 @@ +/** @format */ +/** + * External dependencies + */ +import i18n from 'i18n-calypso'; + +/** + * Internal dependencies + */ +import { http } from 'state/data-layer/wpcom-http/actions'; +import { dispatchRequest } from 'state/data-layer/wpcom-http/utils'; +import { JETPACK_CREDENTIALS_REQUEST, JETPACK_CREDENTIALS_STORE } from 'state/action-types'; +import { errorNotice } from 'state/notices/actions'; + +export const fetch = ( { dispatch }, action ) => + dispatch( + http( + { + apiVersion: '1.1', + method: 'GET', + path: `/activity-log/${ action.siteId }/get-credentials`, + }, + action + ) + ); + +export const store = ( { dispatch }, action, credentials ) => + dispatch( { + type: JETPACK_CREDENTIALS_STORE, + credentials, + siteId: action.siteId, + } ); + +export const announceFailure = ( { dispatch } ) => + dispatch( + errorNotice( i18n.translate( 'Unexpected problem retrieving credentials. Please try again.' ) ) + ); + +const fromApi = response => { + if ( response.ok ) { + return response.credentials; + } + + // this is an API goof - we get a false value for `ok` instead of an empty list + if ( ! response.ok && response.error === 'No credentials found for this site.' ) { + return {}; + } + + throw new Error( 'Could not obtain credentials' ); +}; + +export default { + [ JETPACK_CREDENTIALS_REQUEST ]: [ + dispatchRequest( fetch, store, announceFailure, { fromApi } ), + ], +}; diff --git a/client/state/data-layer/wpcom/activity-log/index.js b/client/state/data-layer/wpcom/activity-log/index.js index fe1105d024512..21291534e785f 100644 --- a/client/state/data-layer/wpcom/activity-log/index.js +++ b/client/state/data-layer/wpcom/activity-log/index.js @@ -7,6 +7,8 @@ import { mergeHandlers } from 'state/action-watchers/utils'; import activate from './activate'; import deactivate from './deactivate'; +import getCredentials from './get-credentials'; import rewind from './rewind'; +import updateCredentials from './update-credentials'; -export default mergeHandlers( activate, deactivate, rewind ); +export default mergeHandlers( activate, deactivate, getCredentials, rewind, updateCredentials ); diff --git a/client/state/data-layer/wpcom/activity-log/rewind/activate/index.js b/client/state/data-layer/wpcom/activity-log/rewind/activate/index.js new file mode 100644 index 0000000000000..e6aa815f7d4e8 --- /dev/null +++ b/client/state/data-layer/wpcom/activity-log/rewind/activate/index.js @@ -0,0 +1,63 @@ +/** + * External dependencies + * + * @format + */ + +import i18n from 'i18n-calypso'; + +/** + * Internal dependencies + */ +import { http } from 'state/data-layer/wpcom-http/actions'; +import { dispatchRequest } from 'state/data-layer/wpcom-http/utils'; +import { JETPACK_CREDENTIALS_AUTOCONFIGURE, JETPACK_CREDENTIALS_STORE } from 'state/action-types'; +import { successNotice, errorNotice } from 'state/notices/actions'; + +export const fetch = ( { dispatch }, action ) => { + const notice = successNotice( i18n.translate( 'Obtaining your credentials…' ) ); + const { notice: { noticeId } } = notice; + + dispatch( notice ); + + dispatch( + http( + { + apiVersion: '1.1', + method: 'POST', + path: `/activity-log/${ action.siteId }/rewind/activate`, + }, + { ...action, noticeId } + ) + ); +}; + +export const storeAndAnnounce = ( { dispatch }, { siteId, noticeId } ) => { + dispatch( { + type: JETPACK_CREDENTIALS_STORE, + credentials: { main: { type: 'auto' } }, // fake for now until data actually comes through + siteId + } ); + + dispatch( + successNotice( i18n.translate( 'Your credentials have been auto configured.' ), { + duration: 4000, + id: noticeId, + } ) + ); +}; + +export const announceFailure = ( { dispatch }, { noticeId } ) => { + dispatch( + errorNotice( i18n.translate( 'Error auto configuring your credentials.' ), { + duration: 4000, + id: noticeId, + } ) + ); +}; + +export default { + [ JETPACK_CREDENTIALS_AUTOCONFIGURE ]: [ + dispatchRequest( fetch, storeAndAnnounce, announceFailure ), + ], +}; diff --git a/client/state/data-layer/wpcom/activity-log/rewind/index.js b/client/state/data-layer/wpcom/activity-log/rewind/index.js index ad8580a66c360..e94a3b1cff765 100644 --- a/client/state/data-layer/wpcom/activity-log/rewind/index.js +++ b/client/state/data-layer/wpcom/activity-log/rewind/index.js @@ -10,6 +10,7 @@ import { pick } from 'lodash'; * Internal dependencies */ import { mergeHandlers } from 'state/action-watchers/utils'; +import activate from './activate'; import restoreHandler from './to'; import restoreStatusHandler from './restore-status'; import { REWIND_STATUS_REQUEST } from 'state/action-types'; @@ -51,4 +52,4 @@ const statusHandler = { ], }; -export default mergeHandlers( restoreHandler, restoreStatusHandler, statusHandler ); +export default mergeHandlers( activate, restoreHandler, restoreStatusHandler, statusHandler ); diff --git a/client/state/data-layer/wpcom/activity-log/update-credentials/index.js b/client/state/data-layer/wpcom/activity-log/update-credentials/index.js new file mode 100644 index 0000000000000..bf4219168e7ff --- /dev/null +++ b/client/state/data-layer/wpcom/activity-log/update-credentials/index.js @@ -0,0 +1,80 @@ +/** + * External dependencies + * + * @format + */ + +import i18n from 'i18n-calypso'; + +/** + * Internal dependencies + */ +import { http } from 'state/data-layer/wpcom-http/actions'; +import { dispatchRequest } from 'state/data-layer/wpcom-http/utils'; +import { + JETPACK_CREDENTIALS_UPDATE, + JETPACK_CREDENTIALS_UPDATE_SUCCESS, + JETPACK_CREDENTIALS_UPDATE_FAILURE, + JETPACK_CREDENTIALS_STORE, +} from 'state/action-types'; +import { successNotice, errorNotice } from 'state/notices/actions'; + +export const request = ( { dispatch }, action ) => { + const notice = successNotice( i18n.translate( 'Testing connection…' ), { duration: 4000 } ); + const { notice: { noticeId } } = notice; + + dispatch( notice ); + + dispatch( + http( + { + apiVersion: '1.1', + method: 'POST', + path: `/activity-log/${ action.siteId }/update-credentials`, + body: { credentials: action.credentials }, + }, + { ...action, noticeId } + ) + ); +}; + +export const success = ( { dispatch }, action ) => { + dispatch( { + type: JETPACK_CREDENTIALS_UPDATE_SUCCESS, + siteId: action.siteId, + } ); + + dispatch( { + type: JETPACK_CREDENTIALS_STORE, + credentials: { + main: action.credentials, + }, + siteId: action.siteId, + } ); + + dispatch( + successNotice( i18n.translate( 'Your site is now connected.' ), { + duration: 4000, + id: action.noticeId, + } ) + ); +}; + +export const failure = ( { dispatch }, action, error ) => { + dispatch( { + type: JETPACK_CREDENTIALS_UPDATE_FAILURE, + error, + siteId: action.siteId, + } ); + + dispatch( + errorNotice( i18n.translate( 'Error saving. Please check your credentials and try again.' ), { + duration: 4000, + id: action.noticeId, + } ) + ); +}; + +export default { + [ JETPACK_CREDENTIALS_UPDATE ]: [ dispatchRequest( request, success, failure ) ], +}; diff --git a/client/state/jetpack/credentials/actions.js b/client/state/jetpack/credentials/actions.js new file mode 100644 index 0000000000000..8c7eefc3d6932 --- /dev/null +++ b/client/state/jetpack/credentials/actions.js @@ -0,0 +1,24 @@ +/** + * Internal dependencies + */ +import { + JETPACK_CREDENTIALS_AUTOCONFIGURE, + JETPACK_CREDENTIALS_REQUEST, + JETPACK_CREDENTIALS_UPDATE, +} from 'state/action-types'; + +export const requestCredentials = ( siteId ) => ( { + type: JETPACK_CREDENTIALS_REQUEST, + siteId +} ); + +export const updateCredentials = ( siteId, credentials ) => ( { + type: JETPACK_CREDENTIALS_UPDATE, + siteId, + credentials +} ); + +export const autoConfigCredentials = ( siteId ) => ( { + type: JETPACK_CREDENTIALS_AUTOCONFIGURE, + siteId +} ); diff --git a/client/state/jetpack/credentials/reducer.js b/client/state/jetpack/credentials/reducer.js new file mode 100644 index 0000000000000..f5e1d323c919d --- /dev/null +++ b/client/state/jetpack/credentials/reducer.js @@ -0,0 +1,39 @@ +/** @format */ +/** + * Internal dependencies + */ +import { + JETPACK_CREDENTIALS_STORE, + JETPACK_CREDENTIALS_UPDATE, + JETPACK_CREDENTIALS_UPDATE_SUCCESS, + JETPACK_CREDENTIALS_UPDATE_FAILURE, +} from 'state/action-types'; +import { combineReducers, keyedReducer } from 'state/utils'; +import { itemsSchema } from './schema'; + +export const items = keyedReducer( 'siteId', ( state, { type, credentials } ) => { + if ( JETPACK_CREDENTIALS_STORE === type ) { + return 'object' === typeof credentials ? credentials : {}; + } + + return state; +} ); +items.schema = itemsSchema; + +export const updateRequesting = keyedReducer( 'siteId', ( state, { type } ) => { + switch ( type ) { + case JETPACK_CREDENTIALS_UPDATE: + return true; + + case JETPACK_CREDENTIALS_UPDATE_SUCCESS: + case JETPACK_CREDENTIALS_UPDATE_FAILURE: + return false; + } + + return state; +} ); + +export const reducer = combineReducers( { + items, + updateRequesting, +} ); diff --git a/client/state/jetpack/credentials/schema.js b/client/state/jetpack/credentials/schema.js new file mode 100644 index 0000000000000..07792b7ea9b4f --- /dev/null +++ b/client/state/jetpack/credentials/schema.js @@ -0,0 +1,14 @@ +export const itemsSchema = { + type: 'object', + items: { + type: 'object', + properties: { + abspath: { type: 'string' }, + host: { type: 'string' }, + port: { type: 'number' }, + protocol: { type: 'string' }, + pass: { type: 'bool' }, + user: { type: 'string' }, + }, + }, +}; diff --git a/client/state/jetpack/reducer.js b/client/state/jetpack/reducer.js index a60e986e456ad..9397296118b10 100644 --- a/client/state/jetpack/reducer.js +++ b/client/state/jetpack/reducer.js @@ -6,12 +6,14 @@ import { reducer as connection } from './connection/reducer'; import { combineReducers } from 'state/utils'; +import { reducer as credentials } from './credentials/reducer'; import { reducer as jumpstart } from './jumpstart/reducer'; import { reducer as modules } from './modules/reducer'; import { reducer as settings } from './settings/reducer'; export default combineReducers( { connection, + credentials, jumpstart, modules, settings, diff --git a/client/state/selectors/get-credentials-auto-config-status.js b/client/state/selectors/get-credentials-auto-config-status.js new file mode 100644 index 0000000000000..40ec16445407d --- /dev/null +++ b/client/state/selectors/get-credentials-auto-config-status.js @@ -0,0 +1,8 @@ +/** + * External Dependencies + */ +import { get } from 'lodash'; + +export default function getCredentialsAutoConfigStatus( state, siteId ) { + return get( state, [ 'jetpack', 'credentials', 'items', siteId, 'main' ], false ) ? 'requesting' : 'success'; +} diff --git a/client/state/selectors/get-jetpack-credentials.js b/client/state/selectors/get-jetpack-credentials.js new file mode 100644 index 0000000000000..c5886dbd492da --- /dev/null +++ b/client/state/selectors/get-jetpack-credentials.js @@ -0,0 +1,8 @@ +/** + * External Dependencies + */ +import { get } from 'lodash'; + +export default function getJetpackCredentials( state, siteId, role ) { + return get( state, [ 'jetpack', 'credentials', 'items', siteId, role ], {} ); +} diff --git a/client/state/selectors/has-main-credentials.js b/client/state/selectors/has-main-credentials.js new file mode 100644 index 0000000000000..db611418f3c36 --- /dev/null +++ b/client/state/selectors/has-main-credentials.js @@ -0,0 +1,8 @@ +/** + * External Dependencies + */ +import { get } from 'lodash'; + +export default function hasMainCredentials( state, siteId ) { + return !! get( state, [ 'jetpack', 'credentials', 'items', siteId, 'main' ], false ); +} diff --git a/client/state/selectors/index.js b/client/state/selectors/index.js index 4ce86ee800324..44878daa3bb6d 100644 --- a/client/state/selectors/index.js +++ b/client/state/selectors/index.js @@ -37,12 +37,14 @@ export getBlockedSites from './get-blocked-sites'; export getBlogStickers from './get-blog-stickers'; export getContactDetailsCache from './get-contact-details-cache'; export getContactDetailsExtraCache from './get-contact-details-extra-cache'; +export getCredentialsAutoConfigStatus from './get-credentials-auto-config-status'; export getCurrentLocaleSlug from './get-current-locale-slug'; export getCurrentPlanPurchaseId from './get-current-plan-purchase-id'; export getCurrentUserPaymentMethods from './get-current-user-payment-methods'; export getImageEditorIsGreaterThanMinimumDimensions from './get-image-editor-is-greater-than-minimum-dimensions'; export getImageEditorOriginalAspectRatio from './get-image-editor-original-aspect-ratio'; export getJetpackConnectionStatus from './get-jetpack-connection-status'; +export getJetpackCredentials from './get-jetpack-credentials'; export getJetpackJumpstartStatus from './get-jetpack-jumpstart-status'; export getJetpackModule from './get-jetpack-module'; export getJetpackModules from './get-jetpack-modules'; @@ -161,6 +163,7 @@ export hasBrokenSiteUserConnection from './has-broken-site-user-connection'; export hasInitializedSites from './has-initialized-sites'; export hasJetpackSites from './has-jetpack-sites'; export hasSitePendingAutomatedTransfer from './has-site-pending-automated-transfer'; +export hasMainCredentials from './has-main-credentials'; export hasUnsavedUserSettings from './has-unsaved-user-settings'; export hasUserAskedADirectlyQuestion from './has-user-asked-a-directly-question'; export hasUserSettings from './has-user-settings'; @@ -244,12 +247,14 @@ export isSiteAutomatedTransfer from './is-site-automated-transfer'; export isSiteBlocked from './is-site-blocked'; export isSiteOnFreePlan from './is-site-on-free-plan'; export isSiteOnPaidPlan from './is-site-on-paid-plan'; +export isSitePressable from './is-site-pressable'; export isSiteSupportingImageEditor from './is-site-supporting-image-editor'; export isSiteUpgradeable from './is-site-upgradeable'; export isTracking from './is-tracking'; export isTransientMedia from './is-transient-media'; export isTwoStepEnabled from './is-two-step-enabled'; export isTwoStepSmsEnabled from './is-two-step-sms-enabled'; +export isUpdatingJetpackCredentials from './is-updating-jetpack-credentials'; export isUpdatingJetpackSettings from './is-updating-jetpack-settings'; export isUpdatingSiteMonitorSettings from './is-updating-site-monitor-settings'; export isUserRegistrationDaysWithinRange from './is-user-registration-days-within-range'; diff --git a/client/state/selectors/is-site-pressable.js b/client/state/selectors/is-site-pressable.js new file mode 100644 index 0000000000000..7b4a4649e6af9 --- /dev/null +++ b/client/state/selectors/is-site-pressable.js @@ -0,0 +1,8 @@ +/** + * External Dependencies + */ +import { get } from 'lodash'; + +export default function isSitePressable( state, siteId ) { + return get( state.activityLog.rewindStatus, [ siteId, 'isPressable' ], null ); +} diff --git a/client/state/selectors/is-updating-jetpack-credentials.js b/client/state/selectors/is-updating-jetpack-credentials.js new file mode 100644 index 0000000000000..5410ee94d0f21 --- /dev/null +++ b/client/state/selectors/is-updating-jetpack-credentials.js @@ -0,0 +1,8 @@ +/** + * External Dependencies + */ +import { get } from 'lodash'; + +export default function isUpdatingJetpackCredentials( state, siteId ) { + return get( state, [ 'jetpack', 'credentials', 'updateRequesting', siteId ], false ); +} diff --git a/config/development.json b/config/development.json index 85123f957fa16..45c2c229def9c 100644 --- a/config/development.json +++ b/config/development.json @@ -64,6 +64,7 @@ "jetpack/activity-log": true, "jetpack/activity-log/rewind": true, "jetpack/api-cache": true, + "jetpack/credentials": true, "jetpack/happychat": true, "jetpack/google-analytics-anonymize-ip": true, "jetpack/google-analytics-for-stores": true, diff --git a/config/stage.json b/config/stage.json index c6daf587ac946..968d63f828534 100644 --- a/config/stage.json +++ b/config/stage.json @@ -37,6 +37,7 @@ "jetpack/activity-log": true, "jetpack/activity-log/rewind": true, "jetpack/api-cache": true, + "jetpack/credentials": true, "jetpack/happychat": true, "jetpack/google-analytics-anonymize-ip": true, "jetpack/google-analytics-for-stores": true, From ba366e2b3e748687302aa249dd5e9fbeb6055feb Mon Sep 17 00:00:00 2001 From: Elio Rivero Date: Fri, 27 Oct 2017 18:27:27 -0300 Subject: [PATCH 013/192] Activity Log: don't display the progress bar when it's queued (#19198) --- .../my-sites/stats/activity-log-banner/progress-banner.jsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/client/my-sites/stats/activity-log-banner/progress-banner.jsx b/client/my-sites/stats/activity-log-banner/progress-banner.jsx index a3c4a60b248da..c1264e46fb4eb 100644 --- a/client/my-sites/stats/activity-log-banner/progress-banner.jsx +++ b/client/my-sites/stats/activity-log-banner/progress-banner.jsx @@ -48,11 +48,7 @@ function ProgressBanner( {
{ restoreStatusDescription } - + { 'running' === status && }
); From b718bbe6f74ce9ba2ee2cb1738f7c44c5a800ecb Mon Sep 17 00:00:00 2001 From: Paul Dechov Date: Fri, 27 Oct 2017 18:36:27 -0400 Subject: [PATCH 014/192] Store: fixed email settings breadcrumb link (#19247) --- .../woocommerce/app/settings/email/index.js | 22 ++++++++++--------- .../app/settings/payments/index.js | 1 + .../woocommerce/app/settings/taxes/index.js | 1 + 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/client/extensions/woocommerce/app/settings/email/index.js b/client/extensions/woocommerce/app/settings/email/index.js index c22080bdf7477..d8890eb2b0ceb 100644 --- a/client/extensions/woocommerce/app/settings/email/index.js +++ b/client/extensions/woocommerce/app/settings/email/index.js @@ -17,13 +17,13 @@ import MailChimp from './mailchimp'; import ActionHeader from 'woocommerce/components/action-header'; import SettingsNavigation from '../navigation'; import { getLink } from 'woocommerce/lib/nav-utils'; -import { getSelectedSiteId } from 'state/ui/selectors'; +import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; import { mailChimpSaveSettings } from 'woocommerce/state/sites/settings/email/actions'; import { isSavingSettings } from 'woocommerce/state/sites/settings/email/selectors'; -const SettingsEmail = ( { site, siteId, translate, className, params, isSaving, mailChimpSaveSettings: saveSettings } ) => { +const SettingsEmail = ( { site, translate, className, params, isSaving, mailChimpSaveSettings: saveSettings } ) => { const breadcrumbs = [ - ( { translate( 'Settings' ) } ), + ( { translate( 'Settings' ) } ), ( { translate( 'Email' ) } ), ]; @@ -31,7 +31,7 @@ const SettingsEmail = ( { site, siteId, translate, className, params, isSaving, const startWizard = 'wizard' === setup; const onSave = () => ( - saveSettings( siteId ) + saveSettings( site.ID ) ); return ( @@ -42,23 +42,25 @@ const SettingsEmail = ( { site, siteId, translate, className, params, isSaving, - + ); }; SettingsEmail.propTypes = { className: PropTypes.string, - siteId: PropTypes.number, - site: PropTypes.object, + site: PropTypes.shape( { + slug: PropTypes.string, + ID: PropTypes.number, + } ), mailChimpSaveSettings: PropTypes.func.isRequired, }; function mapStateToProps( state ) { - const siteId = getSelectedSiteId( state ); + const site = getSelectedSiteWithFallback( state ); return { - siteId, - isSaving: isSavingSettings( state, siteId ), + site, + isSaving: isSavingSettings( state, site.ID ), }; } diff --git a/client/extensions/woocommerce/app/settings/payments/index.js b/client/extensions/woocommerce/app/settings/payments/index.js index 5dc217c73e12c..4d3317f2444f0 100644 --- a/client/extensions/woocommerce/app/settings/payments/index.js +++ b/client/extensions/woocommerce/app/settings/payments/index.js @@ -36,6 +36,7 @@ class SettingsPayments extends Component { isSaving: PropTypes.bool, site: PropTypes.shape( { slug: PropTypes.string, + ID: PropTypes.number, } ), className: PropTypes.string, }; diff --git a/client/extensions/woocommerce/app/settings/taxes/index.js b/client/extensions/woocommerce/app/settings/taxes/index.js index 4dc785002ea89..200828f607c4a 100644 --- a/client/extensions/woocommerce/app/settings/taxes/index.js +++ b/client/extensions/woocommerce/app/settings/taxes/index.js @@ -54,6 +54,7 @@ class SettingsTaxes extends Component { static propTypes = { site: PropTypes.shape( { slug: PropTypes.string, + ID: PropTypes.number, } ), className: PropTypes.string, }; From 14056dd16119b93a7be302aee774ed6173e2b015 Mon Sep 17 00:00:00 2001 From: Dennis Snell Date: Sun, 29 Oct 2017 21:47:51 -0400 Subject: [PATCH 015/192] Data Layer: Add docs for `fromApi()` in data layer guide (#19146) --- client/state/data-layer/wpcom-http/README.md | 38 ++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/client/state/data-layer/wpcom-http/README.md b/client/state/data-layer/wpcom-http/README.md index 6c843889fca26..9d5b205a0c68b 100644 --- a/client/state/data-layer/wpcom-http/README.md +++ b/client/state/data-layer/wpcom-http/README.md @@ -286,6 +286,8 @@ export default { }; ``` +#### Parsing the response + It's important that perform the mapping stage when handling API request responses to go _from_ the API's native data format _to_ Calypso's native data format. These middleware functions are the perfect place to perform this mapping because it will leave API-specific code separated into specific places that are relatively easy to find. @@ -329,3 +331,39 @@ export default { ) ] } ``` + +Of course, not every response is very complicated or warrants a full-blown parser. +Sometimes we just want to determine the failure or success based off of a simple value in the response. +Let's put this together for a fictitious two-factor authentication process. + + +```js +const fromApi = response => { + if ( ! response.hasOwnProperty( 'auth_granted' ) ) { + throw new ValueError( 'Could not understand API response' ); + } + + if ( ! response.auth_granted ) { + throw 'authorization-denied'; + } + + return { + token: response.auth_token, + expiresAt: response.auth_valid_until, + }; +} + +dispatchRequest( + fetch2Auth, + approveAuth, + announceAppropriateFailureMessage, + { fromApi }, +) +``` + +In this case we're not only validating the _schema_ of the response data but also the values and deciding +to mark a response as a failure not only when we don't recognize it, but plainly too when the value of the +response indicates that our actual need was a failure. Here we can see that an auth request is a failure +not only if we can't recognize the response, but also if the response indicates that the attempt was a failure. +By incorporating this into the response parser we can keep the logic of Calypso data further separated from +the act of handling network activity. From 0f46594ec8e65bc55aa79afe82e5247b88a2edc1 Mon Sep 17 00:00:00 2001 From: Alister Scott Date: Sun, 29 Oct 2017 22:32:21 -0700 Subject: [PATCH 016/192] Signup: Add data attribute for e2e testing (#19264) --- client/signup/steps/site-or-domain/choice.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/signup/steps/site-or-domain/choice.jsx b/client/signup/steps/site-or-domain/choice.jsx index 322ae06f9c5bb..a061b01243c5a 100644 --- a/client/signup/steps/site-or-domain/choice.jsx +++ b/client/signup/steps/site-or-domain/choice.jsx @@ -40,7 +40,7 @@ export default class SiteOrDomainChoice extends Component { } return ( -
+
{ choice.image } From 45db750ac3b34c1530a9dea7d094895119c570a4 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 30 Oct 2017 10:03:51 +0100 Subject: [PATCH 017/192] Jetpack connect: Separate reducers (#19210) * Add initialState snapshot test * Move Jetpack Connnect reducer into subdirectory * Move jetpackConnectSite reducer to independent file * Move jetpackSSO reducer to independent file * Move jetpackAuthAttempts reducer to independent file * Move jetpackConnectSessions reducer to separate file * Separate jetpackConnectSelectedPlans reducer * Separate jetpackConnectAuthorize reducer and tests * Check that `isFetched` is not set as test describes --- client/state/jetpack-connect/reducer.js | 303 ------- client/state/jetpack-connect/reducer/index.js | 20 + .../reducer/jetpack-auth-attempts.js | 31 + .../reducer/jetpack-connect-authorize.js | 127 +++ .../reducer/jetpack-connect-selected-plans.js | 25 + .../reducer/jetpack-connect-sessions.js | 41 + .../reducer/jetpack-connect-site.js | 58 ++ .../jetpack-connect/reducer/jetpack-sso.js | 48 ++ .../jetpack-connect/{ => reducer}/schema.js | 0 .../reducer/test/__snapshots__/index.js.snap | 12 + .../jetpack-connect/reducer/test/index.js | 20 + .../reducer/test/jetpack-auth-attempts.js | 63 ++ .../reducer/test/jetpack-connect-authorize.js | 329 +++++++ .../reducer/test/jetpack-connect-sessions.js | 157 ++++ .../reducer/test/jetpack-connect-site.js | 149 ++++ .../reducer/test/jetpack-sso.js | 145 ++++ client/state/jetpack-connect/test/reducer.js | 811 ------------------ 17 files changed, 1225 insertions(+), 1114 deletions(-) delete mode 100644 client/state/jetpack-connect/reducer.js create mode 100644 client/state/jetpack-connect/reducer/index.js create mode 100644 client/state/jetpack-connect/reducer/jetpack-auth-attempts.js create mode 100644 client/state/jetpack-connect/reducer/jetpack-connect-authorize.js create mode 100644 client/state/jetpack-connect/reducer/jetpack-connect-selected-plans.js create mode 100644 client/state/jetpack-connect/reducer/jetpack-connect-sessions.js create mode 100644 client/state/jetpack-connect/reducer/jetpack-connect-site.js create mode 100644 client/state/jetpack-connect/reducer/jetpack-sso.js rename client/state/jetpack-connect/{ => reducer}/schema.js (100%) create mode 100644 client/state/jetpack-connect/reducer/test/__snapshots__/index.js.snap create mode 100644 client/state/jetpack-connect/reducer/test/index.js create mode 100644 client/state/jetpack-connect/reducer/test/jetpack-auth-attempts.js create mode 100644 client/state/jetpack-connect/reducer/test/jetpack-connect-authorize.js create mode 100644 client/state/jetpack-connect/reducer/test/jetpack-connect-sessions.js create mode 100644 client/state/jetpack-connect/reducer/test/jetpack-connect-site.js create mode 100644 client/state/jetpack-connect/reducer/test/jetpack-sso.js delete mode 100644 client/state/jetpack-connect/test/reducer.js diff --git a/client/state/jetpack-connect/reducer.js b/client/state/jetpack-connect/reducer.js deleted file mode 100644 index 353f952838dcd..0000000000000 --- a/client/state/jetpack-connect/reducer.js +++ /dev/null @@ -1,303 +0,0 @@ -/** - * External dependencis - * - * @format - */ - -import { isEmpty, omit, pickBy } from 'lodash'; -/** - * Internal dependencies - */ -import { - JETPACK_CONNECT_CHECK_URL, - JETPACK_CONNECT_CHECK_URL_RECEIVE, - JETPACK_CONNECT_DISMISS_URL_STATUS, - JETPACK_CONNECT_CONFIRM_JETPACK_STATUS, - JETPACK_CONNECT_COMPLETE_FLOW, - JETPACK_CONNECT_QUERY_SET, - JETPACK_CONNECT_AUTHORIZE, - JETPACK_CONNECT_AUTHORIZE_LOGIN_COMPLETE, - JETPACK_CONNECT_AUTHORIZE_RECEIVE, - JETPACK_CONNECT_AUTHORIZE_RECEIVE_SITE_LIST, - JETPACK_CONNECT_CREATE_ACCOUNT, - JETPACK_CONNECT_CREATE_ACCOUNT_RECEIVE, - JETPACK_CONNECT_REDIRECT, - JETPACK_CONNECT_REDIRECT_WP_ADMIN, - JETPACK_CONNECT_REDIRECT_XMLRPC_ERROR_FALLBACK_URL, - JETPACK_CONNECT_RETRY_AUTH, - JETPACK_CONNECT_SELECT_PLAN_IN_ADVANCE, - JETPACK_CONNECT_SSO_AUTHORIZE_REQUEST, - JETPACK_CONNECT_SSO_AUTHORIZE_SUCCESS, - JETPACK_CONNECT_SSO_AUTHORIZE_ERROR, - JETPACK_CONNECT_SSO_VALIDATION_REQUEST, - JETPACK_CONNECT_SSO_VALIDATION_SUCCESS, - JETPACK_CONNECT_SSO_VALIDATION_ERROR, - JETPACK_CONNECT_USER_ALREADY_CONNECTED, - SITE_REQUEST_FAILURE, - SERIALIZE, - DESERIALIZE, -} from 'state/action-types'; -import { combineReducers, isValidStateWithSchema } from 'state/utils'; -import { - jetpackConnectSessionsSchema, - jetpackAuthAttemptsSchema, - jetpackConnectSelectedPlansSchema, -} from './schema'; -import { isStale } from './utils'; -import { JETPACK_CONNECT_AUTHORIZE_TTL, AUTH_ATTEMPS_TTL } from './constants'; -import { urlToSlug } from 'lib/url'; - -function buildDefaultAuthorizeState() { - return { - queryObject: {}, - isAuthorizing: false, - authorizeSuccess: false, - authorizeError: false, - timestamp: Date.now(), - userAlreadyConnected: false, - }; -} - -function buildUrlSessionObj( url, flowType ) { - const slug = urlToSlug( url ); - const sessionValue = { - timestamp: Date.now(), - flowType: flowType || '', - }; - return { [ slug ]: sessionValue }; -} - -export function jetpackConnectSessions( state = {}, action ) { - switch ( action.type ) { - case JETPACK_CONNECT_CHECK_URL: - return Object.assign( {}, state, buildUrlSessionObj( action.url, action.flowType ) ); - case DESERIALIZE: - if ( isValidStateWithSchema( state, jetpackConnectSessionsSchema ) ) { - return pickBy( state, session => { - return ! isStale( session.timestamp ); - } ); - } - return {}; - case SERIALIZE: - return state; - } - return state; -} -jetpackConnectSessions.hasCustomPersistence = true; - -export function jetpackConnectSite( state = {}, action ) { - const defaultState = { - url: null, - isFetching: false, - isFetched: false, - isDismissed: false, - installConfirmedByUser: null, - data: {}, - }; - switch ( action.type ) { - case JETPACK_CONNECT_CHECK_URL: - return Object.assign( {}, defaultState, { - url: action.url, - isFetching: true, - isFetched: false, - isDismissed: false, - installConfirmedByUser: null, - data: {}, - } ); - case JETPACK_CONNECT_CHECK_URL_RECEIVE: - if ( action.url === state.url ) { - return Object.assign( {}, state, { - isFetching: false, - isFetched: true, - data: action.data, - } ); - } - return state; - case JETPACK_CONNECT_DISMISS_URL_STATUS: - if ( action.url === state.url ) { - return Object.assign( {}, state, { installConfirmedByUser: null, isDismissed: true } ); - } - return state; - case JETPACK_CONNECT_REDIRECT: - if ( action.url === state.url ) { - return Object.assign( {}, state, { isRedirecting: true } ); - } - return state; - case JETPACK_CONNECT_CONFIRM_JETPACK_STATUS: - return Object.assign( {}, state, { installConfirmedByUser: action.status } ); - case JETPACK_CONNECT_COMPLETE_FLOW: - return {}; - } - return state; -} - -export function jetpackConnectAuthorize( state = {}, action ) { - switch ( action.type ) { - case JETPACK_CONNECT_AUTHORIZE: - return Object.assign( {}, omit( state, 'userData', 'bearerToken' ), { - isAuthorizing: true, - authorizeSuccess: false, - authorizeError: false, - isRedirectingToWpAdmin: false, - autoAuthorize: false, - } ); - case JETPACK_CONNECT_AUTHORIZE_RECEIVE: - if ( isEmpty( action.error ) && action.data ) { - const { plans_url } = action.data; - return Object.assign( {}, state, { - authorizeError: false, - authorizeSuccess: true, - autoAuthorize: false, - plansUrl: plans_url, - siteReceived: false, - } ); - } - return Object.assign( {}, state, { - isAuthorizing: false, - authorizeError: action.error, - authorizeSuccess: false, - autoAuthorize: false, - } ); - case JETPACK_CONNECT_AUTHORIZE_LOGIN_COMPLETE: - return Object.assign( {}, state, { authorizationCode: action.data.code } ); - case JETPACK_CONNECT_AUTHORIZE_RECEIVE_SITE_LIST: - const updateQueryObject = omit( state.queryObject, '_wp_nonce', 'secret', 'scope' ); - return Object.assign( {}, omit( state, 'queryObject' ), { - siteReceived: true, - isAuthorizing: false, - queryObject: updateQueryObject, - } ); - case JETPACK_CONNECT_QUERY_SET: - const queryObject = Object.assign( {}, action.queryObject ); - return Object.assign( {}, buildDefaultAuthorizeState(), { queryObject: queryObject } ); - case JETPACK_CONNECT_CREATE_ACCOUNT: - return Object.assign( {}, state, { - isAuthorizing: true, - authorizeSuccess: false, - authorizeError: false, - autoAuthorize: true, - } ); - case JETPACK_CONNECT_CREATE_ACCOUNT_RECEIVE: - if ( ! isEmpty( action.error ) ) { - return Object.assign( {}, state, { - isAuthorizing: false, - authorizeSuccess: false, - authorizeError: true, - autoAuthorize: false, - } ); - } - return Object.assign( {}, state, { - isAuthorizing: true, - authorizeSuccess: false, - authorizeError: false, - autoAuthorize: true, - userData: action.userData, - bearerToken: action.data.bearer_token, - } ); - case SITE_REQUEST_FAILURE: - if ( - state.queryObject && - state.queryObject.client_id && - parseInt( state.queryObject.client_id ) === action.siteId - ) { - return Object.assign( {}, state, { clientNotResponding: true } ); - } - return state; - case JETPACK_CONNECT_USER_ALREADY_CONNECTED: - return Object.assign( {}, state, { userAlreadyConnected: true } ); - case JETPACK_CONNECT_REDIRECT_XMLRPC_ERROR_FALLBACK_URL: - return Object.assign( {}, state, { isRedirectingToWpAdmin: true } ); - case JETPACK_CONNECT_REDIRECT_WP_ADMIN: - return Object.assign( {}, state, { isRedirectingToWpAdmin: true } ); - case JETPACK_CONNECT_COMPLETE_FLOW: - return {}; - case DESERIALIZE: - return ! isStale( state.timestamp, JETPACK_CONNECT_AUTHORIZE_TTL ) ? state : {}; - case SERIALIZE: - return state; - } - return state; -} -jetpackConnectAuthorize.hasCustomPersistence = true; - -export function jetpackAuthAttempts( state = {}, action ) { - switch ( action.type ) { - case JETPACK_CONNECT_RETRY_AUTH: - const slug = action.slug; - let currentTimestamp = state[ slug ] ? state[ slug ].timestamp || Date.now() : Date.now(); - let attemptNumber = action.attemptNumber; - if ( attemptNumber > 0 ) { - const now = Date.now(); - if ( isStale( currentTimestamp, AUTH_ATTEMPS_TTL ) ) { - currentTimestamp = now; - attemptNumber = 0; - } - } - return Object.assign( {}, state, { - [ slug ]: { attempt: attemptNumber, timestamp: currentTimestamp }, - } ); - case JETPACK_CONNECT_COMPLETE_FLOW: - return {}; - } - return state; -} -jetpackAuthAttempts.schema = jetpackAuthAttemptsSchema; - -export function jetpackSSO( state = {}, action ) { - switch ( action.type ) { - case JETPACK_CONNECT_SSO_VALIDATION_REQUEST: - return Object.assign( {}, state, { isValidating: true } ); - case JETPACK_CONNECT_SSO_VALIDATION_SUCCESS: - return Object.assign( {}, state, { - isValidating: false, - validationError: false, - nonceValid: action.success, - blogDetails: action.blogDetails, - sharedDetails: action.sharedDetails, - } ); - case JETPACK_CONNECT_SSO_VALIDATION_ERROR: - return Object.assign( {}, state, { - isValidating: false, - validationError: action.error, - nonceValid: false, - } ); - case JETPACK_CONNECT_SSO_AUTHORIZE_REQUEST: - return Object.assign( {}, state, { isAuthorizing: true } ); - case JETPACK_CONNECT_SSO_AUTHORIZE_SUCCESS: - return Object.assign( {}, state, { - isAuthorizing: false, - authorizationError: false, - ssoUrl: action.ssoUrl, - } ); - case JETPACK_CONNECT_SSO_AUTHORIZE_ERROR: - return Object.assign( {}, state, { - isAuthorizing: false, - authorizationError: action.error, - ssoUrl: false, - } ); - } - return state; -} - -export function jetpackConnectSelectedPlans( state = {}, action ) { - switch ( action.type ) { - case JETPACK_CONNECT_SELECT_PLAN_IN_ADVANCE: - const siteSlug = urlToSlug( action.site ); - return Object.assign( {}, state, { [ siteSlug ]: action.plan } ); - case JETPACK_CONNECT_CHECK_URL: - return { '*': state[ '*' ] }; - case JETPACK_CONNECT_COMPLETE_FLOW: - return {}; - } - return state; -} -jetpackConnectSelectedPlans.schema = jetpackConnectSelectedPlansSchema; - -export default combineReducers( { - jetpackConnectSite, - jetpackSSO, - jetpackConnectAuthorize, - jetpackConnectSessions, - jetpackConnectSelectedPlans, - jetpackAuthAttempts, -} ); diff --git a/client/state/jetpack-connect/reducer/index.js b/client/state/jetpack-connect/reducer/index.js new file mode 100644 index 0000000000000..d61f6c4acb546 --- /dev/null +++ b/client/state/jetpack-connect/reducer/index.js @@ -0,0 +1,20 @@ +/** @format */ +/** + * Internal dependencies + */ +import jetpackAuthAttempts from './jetpack-auth-attempts'; +import jetpackConnectAuthorize from './jetpack-connect-authorize'; +import jetpackConnectSelectedPlans from './jetpack-connect-selected-plans'; +import jetpackConnectSessions from './jetpack-connect-site'; +import jetpackConnectSite from './jetpack-connect-site'; +import jetpackSSO from './jetpack-sso'; +import { combineReducers } from 'state/utils'; + +export default combineReducers( { + jetpackConnectSite, + jetpackSSO, + jetpackConnectAuthorize, + jetpackConnectSessions, + jetpackConnectSelectedPlans, + jetpackAuthAttempts, +} ); diff --git a/client/state/jetpack-connect/reducer/jetpack-auth-attempts.js b/client/state/jetpack-connect/reducer/jetpack-auth-attempts.js new file mode 100644 index 0000000000000..226fbe0a0b24f --- /dev/null +++ b/client/state/jetpack-connect/reducer/jetpack-auth-attempts.js @@ -0,0 +1,31 @@ +/** @format */ +/** + * Internal dependencies + */ +import { AUTH_ATTEMPS_TTL } from '../constants'; +import { isStale } from '../utils'; +import { JETPACK_CONNECT_COMPLETE_FLOW, JETPACK_CONNECT_RETRY_AUTH } from 'state/action-types'; +import { jetpackAuthAttemptsSchema } from './schema'; + +export default function jetpackAuthAttempts( state = {}, action ) { + switch ( action.type ) { + case JETPACK_CONNECT_RETRY_AUTH: + const slug = action.slug; + let currentTimestamp = state[ slug ] ? state[ slug ].timestamp || Date.now() : Date.now(); + let attemptNumber = action.attemptNumber; + if ( attemptNumber > 0 ) { + const now = Date.now(); + if ( isStale( currentTimestamp, AUTH_ATTEMPS_TTL ) ) { + currentTimestamp = now; + attemptNumber = 0; + } + } + return Object.assign( {}, state, { + [ slug ]: { attempt: attemptNumber, timestamp: currentTimestamp }, + } ); + case JETPACK_CONNECT_COMPLETE_FLOW: + return {}; + } + return state; +} +jetpackAuthAttempts.schema = jetpackAuthAttemptsSchema; diff --git a/client/state/jetpack-connect/reducer/jetpack-connect-authorize.js b/client/state/jetpack-connect/reducer/jetpack-connect-authorize.js new file mode 100644 index 0000000000000..f6fcfc7aca53a --- /dev/null +++ b/client/state/jetpack-connect/reducer/jetpack-connect-authorize.js @@ -0,0 +1,127 @@ +/** @format */ +/** + * External dependencis + */ +import { isEmpty, omit } from 'lodash'; + +/** + * Internal dependencies + */ +import { + JETPACK_CONNECT_COMPLETE_FLOW, + JETPACK_CONNECT_QUERY_SET, + JETPACK_CONNECT_AUTHORIZE, + JETPACK_CONNECT_AUTHORIZE_LOGIN_COMPLETE, + JETPACK_CONNECT_AUTHORIZE_RECEIVE, + JETPACK_CONNECT_AUTHORIZE_RECEIVE_SITE_LIST, + JETPACK_CONNECT_CREATE_ACCOUNT, + JETPACK_CONNECT_CREATE_ACCOUNT_RECEIVE, + JETPACK_CONNECT_REDIRECT_WP_ADMIN, + JETPACK_CONNECT_REDIRECT_XMLRPC_ERROR_FALLBACK_URL, + JETPACK_CONNECT_USER_ALREADY_CONNECTED, + SITE_REQUEST_FAILURE, + SERIALIZE, + DESERIALIZE, +} from 'state/action-types'; +import { isStale } from '../utils'; +import { JETPACK_CONNECT_AUTHORIZE_TTL } from '../constants'; + +function buildDefaultAuthorizeState() { + return { + queryObject: {}, + isAuthorizing: false, + authorizeSuccess: false, + authorizeError: false, + timestamp: Date.now(), + userAlreadyConnected: false, + }; +} + +export default function jetpackConnectAuthorize( state = {}, action ) { + switch ( action.type ) { + case JETPACK_CONNECT_AUTHORIZE: + return Object.assign( {}, omit( state, 'userData', 'bearerToken' ), { + isAuthorizing: true, + authorizeSuccess: false, + authorizeError: false, + isRedirectingToWpAdmin: false, + autoAuthorize: false, + } ); + case JETPACK_CONNECT_AUTHORIZE_RECEIVE: + if ( isEmpty( action.error ) && action.data ) { + const { plans_url } = action.data; + return Object.assign( {}, state, { + authorizeError: false, + authorizeSuccess: true, + autoAuthorize: false, + plansUrl: plans_url, + siteReceived: false, + } ); + } + return Object.assign( {}, state, { + isAuthorizing: false, + authorizeError: action.error, + authorizeSuccess: false, + autoAuthorize: false, + } ); + case JETPACK_CONNECT_AUTHORIZE_LOGIN_COMPLETE: + return Object.assign( {}, state, { authorizationCode: action.data.code } ); + case JETPACK_CONNECT_AUTHORIZE_RECEIVE_SITE_LIST: + const updateQueryObject = omit( state.queryObject, '_wp_nonce', 'secret', 'scope' ); + return Object.assign( {}, omit( state, 'queryObject' ), { + siteReceived: true, + isAuthorizing: false, + queryObject: updateQueryObject, + } ); + case JETPACK_CONNECT_QUERY_SET: + const queryObject = Object.assign( {}, action.queryObject ); + return Object.assign( {}, buildDefaultAuthorizeState(), { queryObject: queryObject } ); + case JETPACK_CONNECT_CREATE_ACCOUNT: + return Object.assign( {}, state, { + isAuthorizing: true, + authorizeSuccess: false, + authorizeError: false, + autoAuthorize: true, + } ); + case JETPACK_CONNECT_CREATE_ACCOUNT_RECEIVE: + if ( ! isEmpty( action.error ) ) { + return Object.assign( {}, state, { + isAuthorizing: false, + authorizeSuccess: false, + authorizeError: true, + autoAuthorize: false, + } ); + } + return Object.assign( {}, state, { + isAuthorizing: true, + authorizeSuccess: false, + authorizeError: false, + autoAuthorize: true, + userData: action.userData, + bearerToken: action.data.bearer_token, + } ); + case SITE_REQUEST_FAILURE: + if ( + state.queryObject && + state.queryObject.client_id && + parseInt( state.queryObject.client_id ) === action.siteId + ) { + return Object.assign( {}, state, { clientNotResponding: true } ); + } + return state; + case JETPACK_CONNECT_USER_ALREADY_CONNECTED: + return Object.assign( {}, state, { userAlreadyConnected: true } ); + case JETPACK_CONNECT_REDIRECT_XMLRPC_ERROR_FALLBACK_URL: + return Object.assign( {}, state, { isRedirectingToWpAdmin: true } ); + case JETPACK_CONNECT_REDIRECT_WP_ADMIN: + return Object.assign( {}, state, { isRedirectingToWpAdmin: true } ); + case JETPACK_CONNECT_COMPLETE_FLOW: + return {}; + case DESERIALIZE: + return ! isStale( state.timestamp, JETPACK_CONNECT_AUTHORIZE_TTL ) ? state : {}; + case SERIALIZE: + return state; + } + return state; +} +jetpackConnectAuthorize.hasCustomPersistence = true; diff --git a/client/state/jetpack-connect/reducer/jetpack-connect-selected-plans.js b/client/state/jetpack-connect/reducer/jetpack-connect-selected-plans.js new file mode 100644 index 0000000000000..bdae541171ff8 --- /dev/null +++ b/client/state/jetpack-connect/reducer/jetpack-connect-selected-plans.js @@ -0,0 +1,25 @@ +/** @format */ +/** + * Internal dependencies + */ +import { + JETPACK_CONNECT_CHECK_URL, + JETPACK_CONNECT_COMPLETE_FLOW, + JETPACK_CONNECT_SELECT_PLAN_IN_ADVANCE, +} from 'state/action-types'; +import { jetpackConnectSelectedPlansSchema } from './schema'; +import { urlToSlug } from 'lib/url'; + +export default function jetpackConnectSelectedPlans( state = {}, action ) { + switch ( action.type ) { + case JETPACK_CONNECT_SELECT_PLAN_IN_ADVANCE: + const siteSlug = urlToSlug( action.site ); + return Object.assign( {}, state, { [ siteSlug ]: action.plan } ); + case JETPACK_CONNECT_CHECK_URL: + return { '*': state[ '*' ] }; + case JETPACK_CONNECT_COMPLETE_FLOW: + return {}; + } + return state; +} +jetpackConnectSelectedPlans.schema = jetpackConnectSelectedPlansSchema; diff --git a/client/state/jetpack-connect/reducer/jetpack-connect-sessions.js b/client/state/jetpack-connect/reducer/jetpack-connect-sessions.js new file mode 100644 index 0000000000000..147060aace7b3 --- /dev/null +++ b/client/state/jetpack-connect/reducer/jetpack-connect-sessions.js @@ -0,0 +1,41 @@ +/** @format */ +/** + * External dependencis + */ +import { pickBy } from 'lodash'; + +/** + * Internal dependencies + */ +import { DESERIALIZE, JETPACK_CONNECT_CHECK_URL, SERIALIZE } from 'state/action-types'; +import { isValidStateWithSchema } from 'state/utils'; +import { jetpackConnectSessionsSchema } from './schema'; +import { isStale } from '../utils'; +import { urlToSlug } from 'lib/url'; + +function buildUrlSessionObj( url, flowType ) { + const slug = urlToSlug( url ); + const sessionValue = { + timestamp: Date.now(), + flowType: flowType || '', + }; + return { [ slug ]: sessionValue }; +} + +export default function jetpackConnectSessions( state = {}, action ) { + switch ( action.type ) { + case JETPACK_CONNECT_CHECK_URL: + return Object.assign( {}, state, buildUrlSessionObj( action.url, action.flowType ) ); + case DESERIALIZE: + if ( isValidStateWithSchema( state, jetpackConnectSessionsSchema ) ) { + return pickBy( state, session => { + return ! isStale( session.timestamp ); + } ); + } + return {}; + case SERIALIZE: + return state; + } + return state; +} +jetpackConnectSessions.hasCustomPersistence = true; diff --git a/client/state/jetpack-connect/reducer/jetpack-connect-site.js b/client/state/jetpack-connect/reducer/jetpack-connect-site.js new file mode 100644 index 0000000000000..b1664a68230e3 --- /dev/null +++ b/client/state/jetpack-connect/reducer/jetpack-connect-site.js @@ -0,0 +1,58 @@ +/** @format */ +/** + * Internal dependencies + */ +import { + JETPACK_CONNECT_CHECK_URL, + JETPACK_CONNECT_CHECK_URL_RECEIVE, + JETPACK_CONNECT_DISMISS_URL_STATUS, + JETPACK_CONNECT_CONFIRM_JETPACK_STATUS, + JETPACK_CONNECT_COMPLETE_FLOW, + JETPACK_CONNECT_REDIRECT, +} from 'state/action-types'; + +export default function jetpackConnectSite( state = {}, action ) { + const defaultState = { + url: null, + isFetching: false, + isFetched: false, + isDismissed: false, + installConfirmedByUser: null, + data: {}, + }; + switch ( action.type ) { + case JETPACK_CONNECT_CHECK_URL: + return Object.assign( {}, defaultState, { + url: action.url, + isFetching: true, + isFetched: false, + isDismissed: false, + installConfirmedByUser: null, + data: {}, + } ); + case JETPACK_CONNECT_CHECK_URL_RECEIVE: + if ( action.url === state.url ) { + return Object.assign( {}, state, { + isFetching: false, + isFetched: true, + data: action.data, + } ); + } + return state; + case JETPACK_CONNECT_DISMISS_URL_STATUS: + if ( action.url === state.url ) { + return Object.assign( {}, state, { installConfirmedByUser: null, isDismissed: true } ); + } + return state; + case JETPACK_CONNECT_REDIRECT: + if ( action.url === state.url ) { + return Object.assign( {}, state, { isRedirecting: true } ); + } + return state; + case JETPACK_CONNECT_CONFIRM_JETPACK_STATUS: + return Object.assign( {}, state, { installConfirmedByUser: action.status } ); + case JETPACK_CONNECT_COMPLETE_FLOW: + return {}; + } + return state; +} diff --git a/client/state/jetpack-connect/reducer/jetpack-sso.js b/client/state/jetpack-connect/reducer/jetpack-sso.js new file mode 100644 index 0000000000000..9837cbae3ff1d --- /dev/null +++ b/client/state/jetpack-connect/reducer/jetpack-sso.js @@ -0,0 +1,48 @@ +/** @format */ +/** + * Internal dependencies + */ +import { + JETPACK_CONNECT_SSO_AUTHORIZE_REQUEST, + JETPACK_CONNECT_SSO_AUTHORIZE_SUCCESS, + JETPACK_CONNECT_SSO_AUTHORIZE_ERROR, + JETPACK_CONNECT_SSO_VALIDATION_REQUEST, + JETPACK_CONNECT_SSO_VALIDATION_SUCCESS, + JETPACK_CONNECT_SSO_VALIDATION_ERROR, +} from 'state/action-types'; + +export default function jetpackSSO( state = {}, action ) { + switch ( action.type ) { + case JETPACK_CONNECT_SSO_VALIDATION_REQUEST: + return Object.assign( {}, state, { isValidating: true } ); + case JETPACK_CONNECT_SSO_VALIDATION_SUCCESS: + return Object.assign( {}, state, { + isValidating: false, + validationError: false, + nonceValid: action.success, + blogDetails: action.blogDetails, + sharedDetails: action.sharedDetails, + } ); + case JETPACK_CONNECT_SSO_VALIDATION_ERROR: + return Object.assign( {}, state, { + isValidating: false, + validationError: action.error, + nonceValid: false, + } ); + case JETPACK_CONNECT_SSO_AUTHORIZE_REQUEST: + return Object.assign( {}, state, { isAuthorizing: true } ); + case JETPACK_CONNECT_SSO_AUTHORIZE_SUCCESS: + return Object.assign( {}, state, { + isAuthorizing: false, + authorizationError: false, + ssoUrl: action.ssoUrl, + } ); + case JETPACK_CONNECT_SSO_AUTHORIZE_ERROR: + return Object.assign( {}, state, { + isAuthorizing: false, + authorizationError: action.error, + ssoUrl: false, + } ); + } + return state; +} diff --git a/client/state/jetpack-connect/schema.js b/client/state/jetpack-connect/reducer/schema.js similarity index 100% rename from client/state/jetpack-connect/schema.js rename to client/state/jetpack-connect/reducer/schema.js diff --git a/client/state/jetpack-connect/reducer/test/__snapshots__/index.js.snap b/client/state/jetpack-connect/reducer/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..c0ba449f285c9 --- /dev/null +++ b/client/state/jetpack-connect/reducer/test/__snapshots__/index.js.snap @@ -0,0 +1,12 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`reducer should export expected reducer keys 1`] = ` +Object { + "jetpackAuthAttempts": Object {}, + "jetpackConnectAuthorize": Object {}, + "jetpackConnectSelectedPlans": Object {}, + "jetpackConnectSessions": Object {}, + "jetpackConnectSite": Object {}, + "jetpackSSO": Object {}, +} +`; diff --git a/client/state/jetpack-connect/reducer/test/index.js b/client/state/jetpack-connect/reducer/test/index.js new file mode 100644 index 0000000000000..c8ab8db7e385b --- /dev/null +++ b/client/state/jetpack-connect/reducer/test/index.js @@ -0,0 +1,20 @@ +/** @format */ +/** + * Internal dependencies + */ +import reducer from '..'; + +describe( 'reducer', () => { + test( 'should export expected reducer keys', () => { + const initialState = reducer( undefined, {} ); + expect( initialState ).toMatchObject( { + jetpackConnectSite: expect.anything(), + jetpackConnectAuthorize: expect.anything(), + jetpackConnectSessions: expect.anything(), + jetpackSSO: expect.anything(), + jetpackConnectSelectedPlans: expect.anything(), + jetpackAuthAttempts: expect.anything(), + } ); + expect( initialState ).toMatchSnapshot(); + } ); +} ); diff --git a/client/state/jetpack-connect/reducer/test/jetpack-auth-attempts.js b/client/state/jetpack-connect/reducer/test/jetpack-auth-attempts.js new file mode 100644 index 0000000000000..791c6875b48fb --- /dev/null +++ b/client/state/jetpack-connect/reducer/test/jetpack-auth-attempts.js @@ -0,0 +1,63 @@ +/** @format */ +/** + * External dependencies + */ +import { expect } from 'chai'; + +/** + * Internal dependencies + */ +import jetpackAuthAttempts from '../jetpack-auth-attempts'; +import { JETPACK_CONNECT_RETRY_AUTH } from 'state/action-types'; + +describe( '#jetpackAuthAttempts()', () => { + test( 'should default to an empty object', () => { + const state = jetpackAuthAttempts( undefined, {} ); + expect( state ).to.eql( {} ); + } ); + + test( 'should update the timestamp when adding an existent slug with stale timestamp', () => { + const nowTime = Date.now(); + const state = jetpackAuthAttempts( + { 'example.com': { timestamp: 1, attempt: 1 } }, + { + type: JETPACK_CONNECT_RETRY_AUTH, + slug: 'example.com', + attemptNumber: 2, + } + ); + expect( state[ 'example.com' ] ) + .to.have.property( 'timestamp' ) + .to.be.at.least( nowTime ); + } ); + + test( 'should reset the attempt number to 0 when adding an existent slug with stale timestamp', () => { + const state = jetpackAuthAttempts( + { 'example.com': { timestamp: 1, attempt: 1 } }, + { + type: JETPACK_CONNECT_RETRY_AUTH, + slug: 'example.com', + attemptNumber: 2, + } + ); + + expect( state[ 'example.com' ] ) + .to.have.property( 'attempt' ) + .to.equals( 0 ); + } ); + + test( 'should store the attempt number when adding an existent slug with non-stale timestamp', () => { + const state = jetpackAuthAttempts( + { 'example.com': { timestamp: Date.now(), attempt: 1 } }, + { + type: JETPACK_CONNECT_RETRY_AUTH, + slug: 'example.com', + attemptNumber: 2, + } + ); + + expect( state[ 'example.com' ] ) + .to.have.property( 'attempt' ) + .to.equals( 2 ); + } ); +} ); diff --git a/client/state/jetpack-connect/reducer/test/jetpack-connect-authorize.js b/client/state/jetpack-connect/reducer/test/jetpack-connect-authorize.js new file mode 100644 index 0000000000000..cb5c8f13522ec --- /dev/null +++ b/client/state/jetpack-connect/reducer/test/jetpack-connect-authorize.js @@ -0,0 +1,329 @@ +/** @format */ +/** + * External dependencies + */ +import { expect } from 'chai'; +import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +import jetpackConnectAuthorize from '../jetpack-connect-authorize.js'; +import { + DESERIALIZE, + JETPACK_CONNECT_AUTHORIZE, + JETPACK_CONNECT_AUTHORIZE_LOGIN_COMPLETE, + JETPACK_CONNECT_AUTHORIZE_RECEIVE, + JETPACK_CONNECT_AUTHORIZE_RECEIVE_SITE_LIST, + JETPACK_CONNECT_CREATE_ACCOUNT, + JETPACK_CONNECT_CREATE_ACCOUNT_RECEIVE, + JETPACK_CONNECT_QUERY_SET, + JETPACK_CONNECT_REDIRECT_WP_ADMIN, + JETPACK_CONNECT_REDIRECT_XMLRPC_ERROR_FALLBACK_URL, + SERIALIZE, + SITE_REQUEST_FAILURE, +} from 'state/action-types'; + +import { useSandbox } from 'test/helpers/use-sinon'; + +describe( '#jetpackConnectAuthorize()', () => { + useSandbox( sandbox => { + sandbox.stub( console, 'warn' ); + } ); + + test( 'should default to an empty object', () => { + const state = jetpackConnectAuthorize( undefined, {} ); + expect( state ).to.eql( {} ); + } ); + + test( 'should set isAuthorizing to true when starting authorization', () => { + const state = jetpackConnectAuthorize( undefined, { + type: JETPACK_CONNECT_AUTHORIZE, + } ); + + expect( state ).to.have.property( 'isAuthorizing' ).to.be.true; + expect( state ).to.have.property( 'authorizeSuccess' ).to.be.false; + expect( state ).to.have.property( 'authorizeError' ).to.be.false; + expect( state ).to.have.property( 'isRedirectingToWpAdmin' ).to.be.false; + expect( state ).to.have.property( 'autoAuthorize' ).to.be.false; + } ); + + test( 'should omit userData and bearerToken when starting authorization', () => { + const state = jetpackConnectAuthorize( + { + userData: { + ID: 123, + email: 'example@example.com', + }, + bearerToken: 'abcd1234', + }, + { + type: JETPACK_CONNECT_AUTHORIZE, + } + ); + + expect( state ).to.not.have.property( 'userData' ); + expect( state ).to.not.have.property( 'bearerToken' ); + } ); + + test( 'should set authorizeSuccess to true when completed authorization successfully', () => { + const data = { + plans_url: 'https://wordpress.com/jetpack/connect/plans/', + }; + const state = jetpackConnectAuthorize( undefined, { + type: JETPACK_CONNECT_AUTHORIZE_RECEIVE, + data, + } ); + + expect( state ).to.have.property( 'authorizeError' ).to.be.false; + expect( state ).to.have.property( 'authorizeSuccess' ).to.be.true; + expect( state ).to.have.property( 'autoAuthorize' ).to.be.false; + expect( state ) + .to.have.property( 'plansUrl' ) + .to.eql( data.plans_url ); + expect( state ).to.have.property( 'siteReceived' ).to.be.false; + } ); + + test( 'should set authorizeSuccess to false when an error occurred during authorization', () => { + const error = 'You need to stay logged in to your WordPress blog while you authorize Jetpack.'; + const state = jetpackConnectAuthorize( undefined, { + type: JETPACK_CONNECT_AUTHORIZE_RECEIVE, + error, + } ); + + expect( state ).to.have.property( 'isAuthorizing' ).to.be.false; + expect( state ) + .to.have.property( 'authorizeError' ) + .to.eql( error ); + expect( state ).to.have.property( 'authorizeSuccess' ).to.be.false; + expect( state ).to.have.property( 'autoAuthorize' ).to.be.false; + } ); + + test( 'should set authorization code when login is completed', () => { + const code = 'abcd1234efgh5678'; + const state = jetpackConnectAuthorize( undefined, { + type: JETPACK_CONNECT_AUTHORIZE_LOGIN_COMPLETE, + data: { + code, + }, + } ); + + expect( state ) + .to.have.property( 'authorizationCode' ) + .to.eql( code ); + } ); + + test( 'should set siteReceived to true and omit some query object properties when received site list', () => { + const state = jetpackConnectAuthorize( + { + queryObject: { + _wp_nonce: 'testnonce', + client_id: 'example.com', + redirect_uri: 'https://example.com/', + scope: 'auth', + secret: 'abcd1234', + site: 'https://example.com/', + state: 1234567890, + }, + }, + { + type: JETPACK_CONNECT_AUTHORIZE_RECEIVE_SITE_LIST, + } + ); + + expect( state ).to.have.property( 'siteReceived' ).to.be.true; + expect( state ).to.have.property( 'isAuthorizing' ).to.be.false; + expect( state ) + .to.have.property( 'queryObject' ) + .to.eql( { + client_id: 'example.com', + redirect_uri: 'https://example.com/', + site: 'https://example.com/', + state: 1234567890, + } ); + } ); + + test( 'should use default authorize state when setting an empty connect query', () => { + const state = jetpackConnectAuthorize( undefined, { + type: JETPACK_CONNECT_QUERY_SET, + } ); + + expect( state ) + .to.have.property( 'queryObject' ) + .to.eql( {} ); + expect( state ).to.have.property( 'isAuthorizing' ).to.be.false; + expect( state ).to.have.property( 'authorizeSuccess' ).to.be.false; + expect( state ).to.have.property( 'authorizeError' ).to.be.false; + } ); + + test( 'should use new query object over default authorize state when setting a connect query', () => { + const queryObject = { + redirect_uri: 'https://example.wordpress.com', + }; + const state = jetpackConnectAuthorize( undefined, { + type: JETPACK_CONNECT_QUERY_SET, + queryObject, + } ); + + expect( state ) + .to.have.property( 'queryObject' ) + .to.eql( queryObject ); + expect( state ).to.have.property( 'isAuthorizing' ).to.be.false; + expect( state ).to.have.property( 'authorizeSuccess' ).to.be.false; + expect( state ).to.have.property( 'authorizeError' ).to.be.false; + } ); + + test( 'should set isAuthorizing and autoAuthorize to true when initiating an account creation', () => { + const state = jetpackConnectAuthorize( undefined, { + type: JETPACK_CONNECT_CREATE_ACCOUNT, + } ); + + expect( state ).to.have.property( 'isAuthorizing' ).to.be.true; + expect( state ).to.have.property( 'authorizeSuccess' ).to.be.false; + expect( state ).to.have.property( 'authorizeError' ).to.be.false; + expect( state ).to.have.property( 'autoAuthorize' ).to.be.true; + } ); + + test( 'should receive userData and bearerToken on successful account creation', () => { + const userData = { + ID: 123, + email: 'example@example.com', + }; + const bearer_token = 'abcd1234'; + const state = jetpackConnectAuthorize( undefined, { + type: JETPACK_CONNECT_CREATE_ACCOUNT_RECEIVE, + userData, + data: { + bearer_token, + }, + } ); + + expect( state ).to.have.property( 'isAuthorizing' ).to.be.true; + expect( state ).to.have.property( 'authorizeSuccess' ).to.be.false; + expect( state ).to.have.property( 'authorizeError' ).to.be.false; + expect( state ).to.have.property( 'autoAuthorize' ).to.be.true; + expect( state ) + .to.have.property( 'userData' ) + .to.eql( userData ); + expect( state ) + .to.have.property( 'bearerToken' ) + .to.eql( bearer_token ); + } ); + + test( 'should mark authorizeError as true on unsuccessful account creation', () => { + const error = 'Sorry, that username already exists!'; + const state = jetpackConnectAuthorize( undefined, { + type: JETPACK_CONNECT_CREATE_ACCOUNT_RECEIVE, + error, + } ); + + expect( state ).to.have.property( 'isAuthorizing' ).to.be.false; + expect( state ).to.have.property( 'authorizeSuccess' ).to.be.false; + expect( state ).to.have.property( 'authorizeError' ).to.be.true; + expect( state ).to.have.property( 'autoAuthorize' ).to.be.false; + } ); + + test( 'should set isRedirectingToWpAdmin to true when an xmlrpc error occurs', () => { + const state = jetpackConnectAuthorize( undefined, { + type: JETPACK_CONNECT_REDIRECT_XMLRPC_ERROR_FALLBACK_URL, + } ); + + expect( state ).to.have.property( 'isRedirectingToWpAdmin' ).to.be.true; + } ); + + test( 'should set isRedirectingToWpAdmin to true when a redirect to wp-admin is triggered', () => { + const state = jetpackConnectAuthorize( undefined, { + type: JETPACK_CONNECT_REDIRECT_WP_ADMIN, + } ); + + expect( state ).to.have.property( 'isRedirectingToWpAdmin' ).to.be.true; + } ); + + test( 'should set clientNotResponding when a site request to current client fails', () => { + const state = jetpackConnectAuthorize( + { queryObject: { client_id: '123' } }, + { type: SITE_REQUEST_FAILURE, siteId: 123 } + ); + expect( state ).to.have.property( 'clientNotResponding' ).to.be.true; + } ); + + test( 'should return the given state when a site request fails on a different site', () => { + const originalState = { queryObject: { client_id: '123' } }; + const state = jetpackConnectAuthorize( originalState, { + type: SITE_REQUEST_FAILURE, + siteId: 234, + } ); + expect( state ).to.eql( originalState ); + } ); + + test( 'should return the given state when a site request fails and no client id is set', () => { + const originalState = { queryObject: { jetpack_version: '4.0' } }; + const state = jetpackConnectAuthorize( originalState, { + type: SITE_REQUEST_FAILURE, + siteId: 123, + } ); + expect( state ).to.eql( originalState ); + } ); + + test( 'should return the given state when a site request fails and no query object is set', () => { + const originalState = { isAuthorizing: false }; + const state = jetpackConnectAuthorize( originalState, { + type: SITE_REQUEST_FAILURE, + siteId: 123, + } ); + expect( state ).to.eql( originalState ); + } ); + + test( 'should persist state when a site request to a different client fails', () => { + const state = jetpackConnectAuthorize( + { queryObject: { client_id: '123' } }, + { type: SITE_REQUEST_FAILURE, siteId: 456 } + ); + expect( state ).to.eql( { queryObject: { client_id: '123' } } ); + } ); + + test( 'should persist state', () => { + const originalState = deepFreeze( { + queryObject: { + client_id: 'example.com', + redirect_uri: 'https://example.com/', + }, + timestamp: Date.now(), + } ); + const state = jetpackConnectAuthorize( originalState, { + type: SERIALIZE, + } ); + + expect( state ).to.be.eql( originalState ); + } ); + + test( 'should load valid persisted state', () => { + const originalState = deepFreeze( { + queryObject: { + client_id: 'example.com', + redirect_uri: 'https://example.com/', + }, + timestamp: Date.now(), + } ); + const state = jetpackConnectAuthorize( originalState, { + type: DESERIALIZE, + } ); + + expect( state ).to.be.eql( originalState ); + } ); + + test( 'should not load stale state', () => { + const originalState = deepFreeze( { + queryObject: { + client_id: 'example.com', + redirect_uri: 'https://example.com/', + }, + timestamp: 1, + } ); + const state = jetpackConnectAuthorize( originalState, { + type: DESERIALIZE, + } ); + + expect( state ).to.be.eql( {} ); + } ); +} ); diff --git a/client/state/jetpack-connect/reducer/test/jetpack-connect-sessions.js b/client/state/jetpack-connect/reducer/test/jetpack-connect-sessions.js new file mode 100644 index 0000000000000..1d8578e0e431c --- /dev/null +++ b/client/state/jetpack-connect/reducer/test/jetpack-connect-sessions.js @@ -0,0 +1,157 @@ +/** @format */ +/** + * External dependencies + */ +import { expect } from 'chai'; + +/** + * Internal dependencies + */ +import jetpackConnectSessions from '../jetpack-connect-sessions'; +import { DESERIALIZE, JETPACK_CONNECT_CHECK_URL } from 'state/action-types'; + +import { useSandbox } from 'test/helpers/use-sinon'; + +describe( '#jetpackConnectSessions()', () => { + useSandbox( sandbox => { + sandbox.stub( console, 'warn' ); + } ); + + test( 'should default to an empty object', () => { + const state = jetpackConnectSessions( undefined, {} ); + expect( state ).to.eql( {} ); + } ); + + test( 'should add the url slug as a new property when checking a new url', () => { + const state = jetpackConnectSessions( undefined, { + type: JETPACK_CONNECT_CHECK_URL, + url: 'https://example.wordpress.com', + } ); + + expect( state ) + .to.have.property( 'example.wordpress.com' ) + .to.be.a( 'object' ); + } ); + + test( 'should convert forward slashes to double colon when checking a new url', () => { + const state = jetpackConnectSessions( undefined, { + type: JETPACK_CONNECT_CHECK_URL, + url: 'https://example.wordpress.com/example123', + } ); + + expect( state ) + .to.have.property( 'example.wordpress.com::example123' ) + .to.be.a( 'object' ); + } ); + + test( 'should store a timestamp when checking a new url', () => { + const nowTime = Date.now(); + const state = jetpackConnectSessions( undefined, { + type: JETPACK_CONNECT_CHECK_URL, + url: 'https://example.wordpress.com', + } ); + + expect( state[ 'example.wordpress.com' ] ) + .to.have.property( 'timestamp' ) + .to.be.at.least( nowTime ); + } ); + + test( 'should update the timestamp when checking an existent url', () => { + const nowTime = Date.now(); + const state = jetpackConnectSessions( + { 'example.wordpress.com': { timestamp: 1 } }, + { + type: JETPACK_CONNECT_CHECK_URL, + url: 'https://example.wordpress.com', + } + ); + + expect( state[ 'example.wordpress.com' ] ) + .to.have.property( 'timestamp' ) + .to.be.at.least( nowTime ); + } ); + + test( 'should not restore a state with a property without a timestamp', () => { + const state = jetpackConnectSessions( + { 'example.wordpress.com': {} }, + { + type: DESERIALIZE, + } + ); + + expect( state ).to.be.eql( {} ); + } ); + + test( 'should not restore a state with a property with a non-integer timestamp', () => { + const state = jetpackConnectSessions( + { 'example.wordpress.com': { timestamp: '1' } }, + { + type: DESERIALIZE, + } + ); + + expect( state ).to.be.eql( {} ); + } ); + + test( 'should not restore a state with a property with a stale timestamp', () => { + const state = jetpackConnectSessions( + { 'example.wordpress.com': { timestamp: 1 } }, + { + type: DESERIALIZE, + } + ); + + expect( state ).to.be.eql( {} ); + } ); + + test( 'should not restore a state with a session stored with extra properties', () => { + const timestamp = Date.now(); + const state = jetpackConnectSessions( + { 'example.wordpress.com': { timestamp, foo: 'bar' } }, + { + type: DESERIALIZE, + } + ); + + expect( state ).to.be.eql( {} ); + } ); + + test( 'should restore a valid state', () => { + const timestamp = Date.now(); + const state = jetpackConnectSessions( + { 'example.wordpress.com': { timestamp } }, + { + type: DESERIALIZE, + } + ); + + expect( state ).to.be.eql( { 'example.wordpress.com': { timestamp } } ); + } ); + + test( 'should restore a valid state including dashes, slashes and semicolons', () => { + const timestamp = Date.now(); + const state = jetpackConnectSessions( + { 'https://example.wordpress.com:3000/test-one': { timestamp } }, + { + type: DESERIALIZE, + } + ); + + expect( state ).to.be.eql( { 'https://example.wordpress.com:3000/test-one': { timestamp } } ); + } ); + + test( 'should restore only sites with non-stale timestamps', () => { + const timestamp = Date.now(); + const state = jetpackConnectSessions( + { + 'example.wordpress.com': { timestamp: 1 }, + 'automattic.wordpress.com': { timestamp }, + }, + { + type: DESERIALIZE, + } + ); + + expect( state ).to.be.eql( { 'automattic.wordpress.com': { timestamp } } ); + } ); +} ); diff --git a/client/state/jetpack-connect/reducer/test/jetpack-connect-site.js b/client/state/jetpack-connect/reducer/test/jetpack-connect-site.js new file mode 100644 index 0000000000000..85d6ca9f9ae5b --- /dev/null +++ b/client/state/jetpack-connect/reducer/test/jetpack-connect-site.js @@ -0,0 +1,149 @@ +/** @format */ +/** + * External dependencies + */ +import { expect } from 'chai'; + +/** + * Internal dependencies + */ +import jetpackConnectSite from '../jetpack-connect-site'; +import { + JETPACK_CONNECT_CHECK_URL, + JETPACK_CONNECT_CHECK_URL_RECEIVE, + JETPACK_CONNECT_CONFIRM_JETPACK_STATUS, + JETPACK_CONNECT_DISMISS_URL_STATUS, + JETPACK_CONNECT_REDIRECT, +} from 'state/action-types'; + +describe( '#jetpackConnectSite()', () => { + test( 'should default to an empty object', () => { + const state = jetpackConnectSite( undefined, {} ); + + expect( state ).to.eql( {} ); + } ); + + test( 'should add the url and mark it as currently fetching', () => { + const state = jetpackConnectSite( undefined, { + type: JETPACK_CONNECT_CHECK_URL, + url: 'https://example.wordpress.com', + } ); + + expect( state ) + .to.have.property( 'url' ) + .to.eql( 'https://example.wordpress.com' ); + expect( state ).to.have.property( 'isFetching' ).to.be.true; + expect( state ).to.have.property( 'isFetched' ).to.be.false; + expect( state ).to.have.property( 'isDismissed' ).to.be.false; + expect( state ).to.have.property( 'installConfirmedByUser' ).to.be.null; + expect( state ) + .to.have.property( 'data' ) + .to.eql( {} ); + } ); + + test( 'should mark the url as fetched if it is the current one', () => { + const data = { + exists: true, + isWordPress: true, + hasJetpack: true, + isJetpackActive: true, + isWordPressDotCom: false, + }; + const state = jetpackConnectSite( + { url: 'https://example.wordpress.com' }, + { + type: JETPACK_CONNECT_CHECK_URL_RECEIVE, + url: 'https://example.wordpress.com', + data: data, + } + ); + + expect( state ).to.have.property( 'isFetching' ).to.be.false; + expect( state ).to.have.property( 'isFetched' ).to.be.true; + expect( state ) + .to.have.property( 'data' ) + .to.eql( data ); + } ); + + test( 'should not mark the url as fetched if it is not the current one', () => { + const data = { + exists: true, + isWordPress: true, + hasJetpack: true, + isJetpackActive: true, + isWordPressDotCom: false, + }; + const state = jetpackConnectSite( + { url: 'https://automattic.com' }, + { + type: JETPACK_CONNECT_CHECK_URL_RECEIVE, + url: 'https://example.wordpress.com', + data: data, + } + ); + + expect( state ).to.eql( { url: 'https://automattic.com' } ); + expect( state ).to.not.have.property( 'isFetched' ); + } ); + + test( 'should mark the url as dismissed if it is the current one', () => { + const state = jetpackConnectSite( + { url: 'https://example.wordpress.com' }, + { + type: JETPACK_CONNECT_DISMISS_URL_STATUS, + url: 'https://example.wordpress.com', + } + ); + + expect( state ).to.have.property( 'installConfirmedByUser' ).to.be.null; + expect( state ).to.have.property( 'isDismissed' ).to.be.true; + } ); + + test( 'should not mark the url as dismissed if it is not the current one', () => { + const state = jetpackConnectSite( + { url: 'https://automattic.com' }, + { + type: JETPACK_CONNECT_DISMISS_URL_STATUS, + url: 'https://example.wordpress.com', + } + ); + + expect( state ).to.eql( { url: 'https://automattic.com' } ); + } ); + + test( 'should schedule a redirect to the url if it is the current one', () => { + const state = jetpackConnectSite( + { url: 'https://example.wordpress.com' }, + { + type: JETPACK_CONNECT_REDIRECT, + url: 'https://example.wordpress.com', + } + ); + + expect( state ).to.have.property( 'isRedirecting' ).to.be.true; + } ); + + test( 'should not schedule a redirect to the url if it is not the current one', () => { + const state = jetpackConnectSite( + { url: 'https://automattic.com' }, + { + type: JETPACK_CONNECT_REDIRECT, + url: 'https://example.wordpress.com', + } + ); + + expect( state ).to.eql( { url: 'https://automattic.com' } ); + } ); + + test( 'should set the jetpack confirmed status to the new one', () => { + const state = jetpackConnectSite( + { url: 'https://example.wordpress.com' }, + { + type: JETPACK_CONNECT_CONFIRM_JETPACK_STATUS, + status: true, + } + ); + + expect( state ).to.have.property( 'installConfirmedByUser' ).to.be.true; + } ); +} ); diff --git a/client/state/jetpack-connect/reducer/test/jetpack-sso.js b/client/state/jetpack-connect/reducer/test/jetpack-sso.js new file mode 100644 index 0000000000000..f3284733a7870 --- /dev/null +++ b/client/state/jetpack-connect/reducer/test/jetpack-sso.js @@ -0,0 +1,145 @@ +/** @format */ +/** + * External dependencies + */ +import deepFreeze from 'deep-freeze'; +import { expect } from 'chai'; + +/** + * Internal dependencies + */ +import jetpackSSO from '../jetpack-sso'; +import { + JETPACK_CONNECT_SSO_AUTHORIZE_ERROR, + JETPACK_CONNECT_SSO_AUTHORIZE_REQUEST, + JETPACK_CONNECT_SSO_AUTHORIZE_SUCCESS, + JETPACK_CONNECT_SSO_VALIDATION_ERROR, + JETPACK_CONNECT_SSO_VALIDATION_REQUEST, + JETPACK_CONNECT_SSO_VALIDATION_SUCCESS, +} from 'state/action-types'; + +const successfulSSOValidation = { + type: JETPACK_CONNECT_SSO_VALIDATION_SUCCESS, + success: true, + blogDetails: { + domain: 'example.wordpress.com', + title: 'My BBQ Site', + icon: { + img: '', + ico: '', + }, + URL: 'https://example.wordpress.com', + admin_url: 'https://example.wordpress.com/wp-admin', + }, + sharedDetails: { + ID: 0, + login: 'bbquser', + email: 'ieatbbq@example.wordpress.com', + url: 'https://example.wordpress.com', + first_name: 'Lou', + last_name: 'Bucket', + display_name: 'bestbbqtester', + description: 'I like BBQ, a lot.', + two_step_enabled: 0, + external_user_id: 1, + }, +}; + +const falseSSOValidation = Object.assign( successfulSSOValidation, { success: false } ); + +describe( '#jetpackSSO()', () => { + test( 'should default to an empty object', () => { + const state = jetpackSSO( undefined, {} ); + expect( state ).to.eql( {} ); + } ); + + test( 'should set isValidating to true when validating', () => { + const state = jetpackSSO( undefined, { + type: JETPACK_CONNECT_SSO_VALIDATION_REQUEST, + } ); + + expect( state ).to.have.property( 'isValidating', true ); + } ); + + test( 'should set isAuthorizing to true when authorizing', () => { + const state = jetpackSSO( undefined, { + type: JETPACK_CONNECT_SSO_AUTHORIZE_REQUEST, + } ); + + expect( state ).to.have.property( 'isAuthorizing', true ); + } ); + + test( 'should set isValidating to false after validation', () => { + const actions = [ + successfulSSOValidation, + { + type: JETPACK_CONNECT_SSO_VALIDATION_ERROR, + error: { + statusCode: 400, + }, + }, + ]; + + actions.forEach( action => { + const state = jetpackSSO( undefined, action ); + expect( state ).to.have.property( 'isValidating', false ); + } ); + } ); + + test( 'should store boolean nonceValid after validation', () => { + const actions = [ successfulSSOValidation, falseSSOValidation ]; + + actions.forEach( action => { + const originalAction = deepFreeze( action ); + const state = jetpackSSO( undefined, originalAction ); + expect( state ).to.have.property( 'nonceValid', originalAction.success ); + } ); + } ); + + test( 'should store blog details after validation', () => { + const successState = jetpackSSO( undefined, successfulSSOValidation ); + expect( successState ) + .to.have.property( 'blogDetails' ) + .to.be.eql( successfulSSOValidation.blogDetails ); + } ); + + test( 'should store shared details after validation', () => { + const successState = jetpackSSO( undefined, successfulSSOValidation ); + expect( successState ) + .to.have.property( 'sharedDetails' ) + .to.be.eql( successfulSSOValidation.sharedDetails ); + } ); + + test( 'should set isAuthorizing to false after authorization', () => { + const actions = [ + { + type: JETPACK_CONNECT_SSO_AUTHORIZE_SUCCESS, + ssoUrl: 'http://example.wordpress.com', + siteUrl: 'http://example.wordpress.com', + }, + { + type: JETPACK_CONNECT_SSO_AUTHORIZE_ERROR, + error: { + statusCode: 400, + }, + }, + ]; + + actions.forEach( action => { + const state = jetpackSSO( undefined, action ); + expect( state ).to.have.property( 'isAuthorizing', false ); + } ); + } ); + + test( 'should store sso_url after authorization', () => { + const action = deepFreeze( { + type: JETPACK_CONNECT_SSO_AUTHORIZE_SUCCESS, + ssoUrl: 'http://example.wordpress.com', + siteUrl: 'http://example.wordpress.com', + } ); + + const state = jetpackSSO( undefined, action ); + + expect( state ).to.have.property( 'ssoUrl', action.ssoUrl ); + } ); +} ); diff --git a/client/state/jetpack-connect/test/reducer.js b/client/state/jetpack-connect/test/reducer.js deleted file mode 100644 index 3fb61f9cf6681..0000000000000 --- a/client/state/jetpack-connect/test/reducer.js +++ /dev/null @@ -1,811 +0,0 @@ -/** @format */ - -/** - * External dependencies - */ -import { expect } from 'chai'; -import deepFreeze from 'deep-freeze'; - -/** - * Internal dependencies - */ -import reducer, { - jetpackConnectAuthorize, - jetpackSSO, - jetpackConnectSessions, - jetpackConnectSite, - jetpackAuthAttempts, -} from '../reducer'; -import { - JETPACK_CONNECT_SSO_AUTHORIZE_REQUEST, - JETPACK_CONNECT_SSO_AUTHORIZE_SUCCESS, - JETPACK_CONNECT_SSO_AUTHORIZE_ERROR, - JETPACK_CONNECT_SSO_VALIDATION_REQUEST, - JETPACK_CONNECT_SSO_VALIDATION_SUCCESS, - JETPACK_CONNECT_SSO_VALIDATION_ERROR, - JETPACK_CONNECT_CHECK_URL, - JETPACK_CONNECT_CHECK_URL_RECEIVE, - JETPACK_CONNECT_DISMISS_URL_STATUS, - JETPACK_CONNECT_REDIRECT, - JETPACK_CONNECT_CONFIRM_JETPACK_STATUS, - JETPACK_CONNECT_AUTHORIZE, - JETPACK_CONNECT_AUTHORIZE_RECEIVE, - JETPACK_CONNECT_AUTHORIZE_LOGIN_COMPLETE, - JETPACK_CONNECT_AUTHORIZE_RECEIVE_SITE_LIST, - JETPACK_CONNECT_QUERY_SET, - JETPACK_CONNECT_CREATE_ACCOUNT, - JETPACK_CONNECT_CREATE_ACCOUNT_RECEIVE, - JETPACK_CONNECT_REDIRECT_XMLRPC_ERROR_FALLBACK_URL, - JETPACK_CONNECT_REDIRECT_WP_ADMIN, - JETPACK_CONNECT_RETRY_AUTH, - SITE_REQUEST_FAILURE, - SERIALIZE, - DESERIALIZE, -} from 'state/action-types'; - -import { useSandbox } from 'test/helpers/use-sinon'; - -const successfulSSOValidation = { - type: JETPACK_CONNECT_SSO_VALIDATION_SUCCESS, - success: true, - blogDetails: { - domain: 'example.wordpress.com', - title: 'My BBQ Site', - icon: { - img: '', - ico: '', - }, - URL: 'https://example.wordpress.com', - admin_url: 'https://example.wordpress.com/wp-admin', - }, - sharedDetails: { - ID: 0, - login: 'bbquser', - email: 'ieatbbq@example.wordpress.com', - url: 'https://example.wordpress.com', - first_name: 'Lou', - last_name: 'Bucket', - display_name: 'bestbbqtester', - description: 'I like BBQ, a lot.', - two_step_enabled: 0, - external_user_id: 1, - }, -}; - -const falseSSOValidation = Object.assign( successfulSSOValidation, { success: false } ); - -describe( 'reducer', () => { - useSandbox( sandbox => { - sandbox.stub( console, 'warn' ); - } ); - - test( 'should export expected reducer keys', () => { - expect( reducer( undefined, {} ) ).to.have.keys( [ - 'jetpackConnectSite', - 'jetpackConnectAuthorize', - 'jetpackConnectSessions', - 'jetpackSSO', - 'jetpackConnectSelectedPlans', - 'jetpackAuthAttempts', - ] ); - } ); - - describe( '#jetpackConnectSessions()', () => { - test( 'should default to an empty object', () => { - const state = jetpackConnectSessions( undefined, {} ); - expect( state ).to.eql( {} ); - } ); - - test( 'should add the url slug as a new property when checking a new url', () => { - const state = jetpackConnectSessions( undefined, { - type: JETPACK_CONNECT_CHECK_URL, - url: 'https://example.wordpress.com', - } ); - - expect( state ) - .to.have.property( 'example.wordpress.com' ) - .to.be.a( 'object' ); - } ); - - test( 'should convert forward slashes to double colon when checking a new url', () => { - const state = jetpackConnectSessions( undefined, { - type: JETPACK_CONNECT_CHECK_URL, - url: 'https://example.wordpress.com/example123', - } ); - - expect( state ) - .to.have.property( 'example.wordpress.com::example123' ) - .to.be.a( 'object' ); - } ); - - test( 'should store a timestamp when checking a new url', () => { - const nowTime = Date.now(); - const state = jetpackConnectSessions( undefined, { - type: JETPACK_CONNECT_CHECK_URL, - url: 'https://example.wordpress.com', - } ); - - expect( state[ 'example.wordpress.com' ] ) - .to.have.property( 'timestamp' ) - .to.be.at.least( nowTime ); - } ); - - test( 'should update the timestamp when checking an existent url', () => { - const nowTime = Date.now(); - const state = jetpackConnectSessions( - { 'example.wordpress.com': { timestamp: 1 } }, - { - type: JETPACK_CONNECT_CHECK_URL, - url: 'https://example.wordpress.com', - } - ); - - expect( state[ 'example.wordpress.com' ] ) - .to.have.property( 'timestamp' ) - .to.be.at.least( nowTime ); - } ); - - test( 'should not restore a state with a property without a timestamp', () => { - const state = jetpackConnectSessions( - { 'example.wordpress.com': {} }, - { - type: DESERIALIZE, - } - ); - - expect( state ).to.be.eql( {} ); - } ); - - test( 'should not restore a state with a property with a non-integer timestamp', () => { - const state = jetpackConnectSessions( - { 'example.wordpress.com': { timestamp: '1' } }, - { - type: DESERIALIZE, - } - ); - - expect( state ).to.be.eql( {} ); - } ); - - test( 'should not restore a state with a property with a stale timestamp', () => { - const state = jetpackConnectSessions( - { 'example.wordpress.com': { timestamp: 1 } }, - { - type: DESERIALIZE, - } - ); - - expect( state ).to.be.eql( {} ); - } ); - - test( 'should not restore a state with a session stored with extra properties', () => { - const timestamp = Date.now(); - const state = jetpackConnectSessions( - { 'example.wordpress.com': { timestamp, foo: 'bar' } }, - { - type: DESERIALIZE, - } - ); - - expect( state ).to.be.eql( {} ); - } ); - - test( 'should restore a valid state', () => { - const timestamp = Date.now(); - const state = jetpackConnectSessions( - { 'example.wordpress.com': { timestamp } }, - { - type: DESERIALIZE, - } - ); - - expect( state ).to.be.eql( { 'example.wordpress.com': { timestamp } } ); - } ); - - test( 'should restore a valid state including dashes, slashes and semicolons', () => { - const timestamp = Date.now(); - const state = jetpackConnectSessions( - { 'https://example.wordpress.com:3000/test-one': { timestamp } }, - { - type: DESERIALIZE, - } - ); - - expect( state ).to.be.eql( { 'https://example.wordpress.com:3000/test-one': { timestamp } } ); - } ); - - test( 'should restore only sites with non-stale timestamps', () => { - const timestamp = Date.now(); - const state = jetpackConnectSessions( - { - 'example.wordpress.com': { timestamp: 1 }, - 'automattic.wordpress.com': { timestamp }, - }, - { - type: DESERIALIZE, - } - ); - - expect( state ).to.be.eql( { 'automattic.wordpress.com': { timestamp } } ); - } ); - } ); - - describe( '#jetpackConnectSite()', () => { - test( 'should default to an empty object', () => { - const state = jetpackConnectSite( undefined, {} ); - - expect( state ).to.eql( {} ); - } ); - - test( 'should add the url and mark it as currently fetching', () => { - const state = jetpackConnectSite( undefined, { - type: JETPACK_CONNECT_CHECK_URL, - url: 'https://example.wordpress.com', - } ); - - expect( state ) - .to.have.property( 'url' ) - .to.eql( 'https://example.wordpress.com' ); - expect( state ).to.have.property( 'isFetching' ).to.be.true; - expect( state ).to.have.property( 'isFetched' ).to.be.false; - expect( state ).to.have.property( 'isDismissed' ).to.be.false; - expect( state ).to.have.property( 'installConfirmedByUser' ).to.be.null; - expect( state ) - .to.have.property( 'data' ) - .to.eql( {} ); - } ); - - test( 'should mark the url as fetched if it is the current one', () => { - const data = { - exists: true, - isWordPress: true, - hasJetpack: true, - isJetpackActive: true, - isWordPressDotCom: false, - }; - const state = jetpackConnectSite( - { url: 'https://example.wordpress.com' }, - { - type: JETPACK_CONNECT_CHECK_URL_RECEIVE, - url: 'https://example.wordpress.com', - data: data, - } - ); - - expect( state ).to.have.property( 'isFetching' ).to.be.false; - expect( state ).to.have.property( 'isFetched' ).to.be.true; - expect( state ) - .to.have.property( 'data' ) - .to.eql( data ); - } ); - - test( 'should not mark the url as fetched if it is not the current one', () => { - const data = { - exists: true, - isWordPress: true, - hasJetpack: true, - isJetpackActive: true, - isWordPressDotCom: false, - }; - const state = jetpackConnectSite( - { url: 'https://automattic.com' }, - { - type: JETPACK_CONNECT_CHECK_URL_RECEIVE, - url: 'https://example.wordpress.com', - data: data, - } - ); - - expect( state ).to.eql( { url: 'https://automattic.com' } ); - } ); - - test( 'should mark the url as dismissed if it is the current one', () => { - const state = jetpackConnectSite( - { url: 'https://example.wordpress.com' }, - { - type: JETPACK_CONNECT_DISMISS_URL_STATUS, - url: 'https://example.wordpress.com', - } - ); - - expect( state ).to.have.property( 'installConfirmedByUser' ).to.be.null; - expect( state ).to.have.property( 'isDismissed' ).to.be.true; - } ); - - test( 'should not mark the url as dismissed if it is not the current one', () => { - const state = jetpackConnectSite( - { url: 'https://automattic.com' }, - { - type: JETPACK_CONNECT_DISMISS_URL_STATUS, - url: 'https://example.wordpress.com', - } - ); - - expect( state ).to.eql( { url: 'https://automattic.com' } ); - } ); - - test( 'should schedule a redirect to the url if it is the current one', () => { - const state = jetpackConnectSite( - { url: 'https://example.wordpress.com' }, - { - type: JETPACK_CONNECT_REDIRECT, - url: 'https://example.wordpress.com', - } - ); - - expect( state ).to.have.property( 'isRedirecting' ).to.be.true; - } ); - - test( 'should not schedule a redirect to the url if it is not the current one', () => { - const state = jetpackConnectSite( - { url: 'https://automattic.com' }, - { - type: JETPACK_CONNECT_REDIRECT, - url: 'https://example.wordpress.com', - } - ); - - expect( state ).to.eql( { url: 'https://automattic.com' } ); - } ); - - test( 'should set the jetpack confirmed status to the new one', () => { - const state = jetpackConnectSite( - { url: 'https://example.wordpress.com' }, - { - type: JETPACK_CONNECT_CONFIRM_JETPACK_STATUS, - status: true, - } - ); - - expect( state ).to.have.property( 'installConfirmedByUser' ).to.be.true; - } ); - } ); - - describe( '#jetpackConnectAuthorize()', () => { - test( 'should default to an empty object', () => { - const state = jetpackConnectAuthorize( undefined, {} ); - expect( state ).to.eql( {} ); - } ); - - test( 'should set isAuthorizing to true when starting authorization', () => { - const state = jetpackConnectAuthorize( undefined, { - type: JETPACK_CONNECT_AUTHORIZE, - } ); - - expect( state ).to.have.property( 'isAuthorizing' ).to.be.true; - expect( state ).to.have.property( 'authorizeSuccess' ).to.be.false; - expect( state ).to.have.property( 'authorizeError' ).to.be.false; - expect( state ).to.have.property( 'isRedirectingToWpAdmin' ).to.be.false; - expect( state ).to.have.property( 'autoAuthorize' ).to.be.false; - } ); - - test( 'should omit userData and bearerToken when starting authorization', () => { - const state = jetpackConnectAuthorize( - { - userData: { - ID: 123, - email: 'example@example.com', - }, - bearerToken: 'abcd1234', - }, - { - type: JETPACK_CONNECT_AUTHORIZE, - } - ); - - expect( state ).to.not.have.property( 'userData' ); - expect( state ).to.not.have.property( 'bearerToken' ); - } ); - - test( 'should set authorizeSuccess to true when completed authorization successfully', () => { - const data = { - plans_url: 'https://wordpress.com/jetpack/connect/plans/', - }; - const state = jetpackConnectAuthorize( undefined, { - type: JETPACK_CONNECT_AUTHORIZE_RECEIVE, - data, - } ); - - expect( state ).to.have.property( 'authorizeError' ).to.be.false; - expect( state ).to.have.property( 'authorizeSuccess' ).to.be.true; - expect( state ).to.have.property( 'autoAuthorize' ).to.be.false; - expect( state ) - .to.have.property( 'plansUrl' ) - .to.eql( data.plans_url ); - expect( state ).to.have.property( 'siteReceived' ).to.be.false; - } ); - - test( 'should set authorizeSuccess to false when an error occurred during authorization', () => { - const error = - 'You need to stay logged in to your WordPress blog while you authorize Jetpack.'; - const state = jetpackConnectAuthorize( undefined, { - type: JETPACK_CONNECT_AUTHORIZE_RECEIVE, - error, - } ); - - expect( state ).to.have.property( 'isAuthorizing' ).to.be.false; - expect( state ) - .to.have.property( 'authorizeError' ) - .to.eql( error ); - expect( state ).to.have.property( 'authorizeSuccess' ).to.be.false; - expect( state ).to.have.property( 'autoAuthorize' ).to.be.false; - } ); - - test( 'should set authorization code when login is completed', () => { - const code = 'abcd1234efgh5678'; - const state = jetpackConnectAuthorize( undefined, { - type: JETPACK_CONNECT_AUTHORIZE_LOGIN_COMPLETE, - data: { - code, - }, - } ); - - expect( state ) - .to.have.property( 'authorizationCode' ) - .to.eql( code ); - } ); - - test( 'should set siteReceived to true and omit some query object properties when received site list', () => { - const state = jetpackConnectAuthorize( - { - queryObject: { - _wp_nonce: 'testnonce', - client_id: 'example.com', - redirect_uri: 'https://example.com/', - scope: 'auth', - secret: 'abcd1234', - site: 'https://example.com/', - state: 1234567890, - }, - }, - { - type: JETPACK_CONNECT_AUTHORIZE_RECEIVE_SITE_LIST, - } - ); - - expect( state ).to.have.property( 'siteReceived' ).to.be.true; - expect( state ).to.have.property( 'isAuthorizing' ).to.be.false; - expect( state ) - .to.have.property( 'queryObject' ) - .to.eql( { - client_id: 'example.com', - redirect_uri: 'https://example.com/', - site: 'https://example.com/', - state: 1234567890, - } ); - } ); - - test( 'should use default authorize state when setting an empty connect query', () => { - const state = jetpackConnectAuthorize( undefined, { - type: JETPACK_CONNECT_QUERY_SET, - } ); - - expect( state ) - .to.have.property( 'queryObject' ) - .to.eql( {} ); - expect( state ).to.have.property( 'isAuthorizing' ).to.be.false; - expect( state ).to.have.property( 'authorizeSuccess' ).to.be.false; - expect( state ).to.have.property( 'authorizeError' ).to.be.false; - } ); - - test( 'should use new query object over default authorize state when setting a connect query', () => { - const queryObject = { - redirect_uri: 'https://example.wordpress.com', - }; - const state = jetpackConnectAuthorize( undefined, { - type: JETPACK_CONNECT_QUERY_SET, - queryObject, - } ); - - expect( state ) - .to.have.property( 'queryObject' ) - .to.eql( queryObject ); - expect( state ).to.have.property( 'isAuthorizing' ).to.be.false; - expect( state ).to.have.property( 'authorizeSuccess' ).to.be.false; - expect( state ).to.have.property( 'authorizeError' ).to.be.false; - } ); - - test( 'should set isAuthorizing and autoAuthorize to true when initiating an account creation', () => { - const state = jetpackConnectAuthorize( undefined, { - type: JETPACK_CONNECT_CREATE_ACCOUNT, - } ); - - expect( state ).to.have.property( 'isAuthorizing' ).to.be.true; - expect( state ).to.have.property( 'authorizeSuccess' ).to.be.false; - expect( state ).to.have.property( 'authorizeError' ).to.be.false; - expect( state ).to.have.property( 'autoAuthorize' ).to.be.true; - } ); - - test( 'should receive userData and bearerToken on successful account creation', () => { - const userData = { - ID: 123, - email: 'example@example.com', - }; - const bearer_token = 'abcd1234'; - const state = jetpackConnectAuthorize( undefined, { - type: JETPACK_CONNECT_CREATE_ACCOUNT_RECEIVE, - userData, - data: { - bearer_token, - }, - } ); - - expect( state ).to.have.property( 'isAuthorizing' ).to.be.true; - expect( state ).to.have.property( 'authorizeSuccess' ).to.be.false; - expect( state ).to.have.property( 'authorizeError' ).to.be.false; - expect( state ).to.have.property( 'autoAuthorize' ).to.be.true; - expect( state ) - .to.have.property( 'userData' ) - .to.eql( userData ); - expect( state ) - .to.have.property( 'bearerToken' ) - .to.eql( bearer_token ); - } ); - - test( 'should mark authorizeError as true on unsuccessful account creation', () => { - const error = 'Sorry, that username already exists!'; - const state = jetpackConnectAuthorize( undefined, { - type: JETPACK_CONNECT_CREATE_ACCOUNT_RECEIVE, - error, - } ); - - expect( state ).to.have.property( 'isAuthorizing' ).to.be.false; - expect( state ).to.have.property( 'authorizeSuccess' ).to.be.false; - expect( state ).to.have.property( 'authorizeError' ).to.be.true; - expect( state ).to.have.property( 'autoAuthorize' ).to.be.false; - } ); - - test( 'should set isRedirectingToWpAdmin to true when an xmlrpc error occurs', () => { - const state = jetpackConnectAuthorize( undefined, { - type: JETPACK_CONNECT_REDIRECT_XMLRPC_ERROR_FALLBACK_URL, - } ); - - expect( state ).to.have.property( 'isRedirectingToWpAdmin' ).to.be.true; - } ); - - test( 'should set isRedirectingToWpAdmin to true when a redirect to wp-admin is triggered', () => { - const state = jetpackConnectAuthorize( undefined, { - type: JETPACK_CONNECT_REDIRECT_WP_ADMIN, - } ); - - expect( state ).to.have.property( 'isRedirectingToWpAdmin' ).to.be.true; - } ); - - test( 'should set clientNotResponding when a site request to current client fails', () => { - const state = jetpackConnectAuthorize( - { queryObject: { client_id: '123' } }, - { type: SITE_REQUEST_FAILURE, siteId: 123 } - ); - expect( state ).to.have.property( 'clientNotResponding' ).to.be.true; - } ); - - test( 'should return the given state when a site request fails on a different site', () => { - const originalState = { queryObject: { client_id: '123' } }; - const state = jetpackConnectAuthorize( originalState, { - type: SITE_REQUEST_FAILURE, - siteId: 234, - } ); - expect( state ).to.eql( originalState ); - } ); - - test( 'should return the given state when a site request fails and no client id is set', () => { - const originalState = { queryObject: { jetpack_version: '4.0' } }; - const state = jetpackConnectAuthorize( originalState, { - type: SITE_REQUEST_FAILURE, - siteId: 123, - } ); - expect( state ).to.eql( originalState ); - } ); - - test( 'should return the given state when a site request fails and no query object is set', () => { - const originalState = { isAuthorizing: false }; - const state = jetpackConnectAuthorize( originalState, { - type: SITE_REQUEST_FAILURE, - siteId: 123, - } ); - expect( state ).to.eql( originalState ); - } ); - - test( 'should persist state when a site request to a different client fails', () => { - const state = jetpackConnectAuthorize( - { queryObject: { client_id: '123' } }, - { type: SITE_REQUEST_FAILURE, siteId: 456 } - ); - expect( state ).to.eql( { queryObject: { client_id: '123' } } ); - } ); - - test( 'should persist state', () => { - const originalState = deepFreeze( { - queryObject: { - client_id: 'example.com', - redirect_uri: 'https://example.com/', - }, - timestamp: Date.now(), - } ); - const state = jetpackConnectAuthorize( originalState, { - type: SERIALIZE, - } ); - - expect( state ).to.be.eql( originalState ); - } ); - - test( 'should load valid persisted state', () => { - const originalState = deepFreeze( { - queryObject: { - client_id: 'example.com', - redirect_uri: 'https://example.com/', - }, - timestamp: Date.now(), - } ); - const state = jetpackConnectAuthorize( originalState, { - type: DESERIALIZE, - } ); - - expect( state ).to.be.eql( originalState ); - } ); - - test( 'should not load stale state', () => { - const originalState = deepFreeze( { - queryObject: { - client_id: 'example.com', - redirect_uri: 'https://example.com/', - }, - timestamp: 1, - } ); - const state = jetpackConnectAuthorize( originalState, { - type: DESERIALIZE, - } ); - - expect( state ).to.be.eql( {} ); - } ); - } ); - - describe( '#jetpackSSO()', () => { - test( 'should default to an empty object', () => { - const state = jetpackSSO( undefined, {} ); - expect( state ).to.eql( {} ); - } ); - - test( 'should set isValidating to true when validating', () => { - const state = jetpackSSO( undefined, { - type: JETPACK_CONNECT_SSO_VALIDATION_REQUEST, - } ); - - expect( state ).to.have.property( 'isValidating', true ); - } ); - - test( 'should set isAuthorizing to true when authorizing', () => { - const state = jetpackSSO( undefined, { - type: JETPACK_CONNECT_SSO_AUTHORIZE_REQUEST, - } ); - - expect( state ).to.have.property( 'isAuthorizing', true ); - } ); - - test( 'should set isValidating to false after validation', () => { - const actions = [ - successfulSSOValidation, - { - type: JETPACK_CONNECT_SSO_VALIDATION_ERROR, - error: { - statusCode: 400, - }, - }, - ]; - - actions.forEach( action => { - const state = jetpackSSO( undefined, action ); - expect( state ).to.have.property( 'isValidating', false ); - } ); - } ); - - test( 'should store boolean nonceValid after validation', () => { - const actions = [ successfulSSOValidation, falseSSOValidation ]; - - actions.forEach( action => { - const originalAction = deepFreeze( action ); - const state = jetpackSSO( undefined, originalAction ); - expect( state ).to.have.property( 'nonceValid', originalAction.success ); - } ); - } ); - - test( 'should store blog details after validation', () => { - const successState = jetpackSSO( undefined, successfulSSOValidation ); - expect( successState ) - .to.have.property( 'blogDetails' ) - .to.be.eql( successfulSSOValidation.blogDetails ); - } ); - - test( 'should store shared details after validation', () => { - const successState = jetpackSSO( undefined, successfulSSOValidation ); - expect( successState ) - .to.have.property( 'sharedDetails' ) - .to.be.eql( successfulSSOValidation.sharedDetails ); - } ); - - test( 'should set isAuthorizing to false after authorization', () => { - const actions = [ - { - type: JETPACK_CONNECT_SSO_AUTHORIZE_SUCCESS, - ssoUrl: 'http://example.wordpress.com', - siteUrl: 'http://example.wordpress.com', - }, - { - type: JETPACK_CONNECT_SSO_AUTHORIZE_ERROR, - error: { - statusCode: 400, - }, - }, - ]; - - actions.forEach( action => { - const state = jetpackSSO( undefined, action ); - expect( state ).to.have.property( 'isAuthorizing', false ); - } ); - } ); - - test( 'should store sso_url after authorization', () => { - const action = deepFreeze( { - type: JETPACK_CONNECT_SSO_AUTHORIZE_SUCCESS, - ssoUrl: 'http://example.wordpress.com', - siteUrl: 'http://example.wordpress.com', - } ); - - const state = jetpackSSO( undefined, action ); - - expect( state ).to.have.property( 'ssoUrl', action.ssoUrl ); - } ); - } ); - - describe( '#jetpackAuthAttempts()', () => { - test( 'should default to an empty object', () => { - const state = jetpackAuthAttempts( undefined, {} ); - expect( state ).to.eql( {} ); - } ); - - test( 'should update the timestamp when adding an existent slug with stale timestamp', () => { - const nowTime = Date.now(); - const state = jetpackAuthAttempts( - { 'example.com': { timestamp: 1, attempt: 1 } }, - { - type: JETPACK_CONNECT_RETRY_AUTH, - slug: 'example.com', - attemptNumber: 2, - } - ); - expect( state[ 'example.com' ] ) - .to.have.property( 'timestamp' ) - .to.be.at.least( nowTime ); - } ); - - test( 'should reset the attempt number to 0 when adding an existent slug with stale timestamp', () => { - const state = jetpackAuthAttempts( - { 'example.com': { timestamp: 1, attempt: 1 } }, - { - type: JETPACK_CONNECT_RETRY_AUTH, - slug: 'example.com', - attemptNumber: 2, - } - ); - - expect( state[ 'example.com' ] ) - .to.have.property( 'attempt' ) - .to.equals( 0 ); - } ); - - test( 'should store the attempt number when adding an existent slug with non-stale timestamp', () => { - const state = jetpackAuthAttempts( - { 'example.com': { timestamp: Date.now(), attempt: 1 } }, - { - type: JETPACK_CONNECT_RETRY_AUTH, - slug: 'example.com', - attemptNumber: 2, - } - ); - - expect( state[ 'example.com' ] ) - .to.have.property( 'attempt' ) - .to.equals( 2 ); - } ); - } ); -} ); From 3552f31677048116a6ce3d68cfc14dfdb5b25d84 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 30 Oct 2017 10:05:12 +0100 Subject: [PATCH 018/192] Jetpack connect plans: Remove props spread (#19218) * Explicitly pass isLanding to PlansGrid isLanding is always undefined in Plans component. * Explicitly pass showFirst prop to PlansGrid * Explicitly pass interval prop to PlansGrid * Explicitly pass selectedSite to PlansGrid Also simplifies conditional QueryComponent render logic * Remove props passing spread * Update snapshot to reflect explicitly passed props --- client/jetpack-connect/plans-grid.jsx | 3 + client/jetpack-connect/plans.jsx | 23 +- .../test/__snapshots__/plans.js.snap | 2124 +---------------- 3 files changed, 57 insertions(+), 2093 deletions(-) diff --git a/client/jetpack-connect/plans-grid.jsx b/client/jetpack-connect/plans-grid.jsx index f33570582fca5..fdb6556adc913 100644 --- a/client/jetpack-connect/plans-grid.jsx +++ b/client/jetpack-connect/plans-grid.jsx @@ -27,6 +27,9 @@ class JetpackPlansGrid extends Component { onSelect: PropTypes.func, selectedSite: PropTypes.object, showFirst: PropTypes.bool, + + // Connected + translate: PropTypes.func.isRequired, }; renderConnectHeader() { diff --git a/client/jetpack-connect/plans.jsx b/client/jetpack-connect/plans.jsx index 84affeedaf08e..7b4b27d795de2 100644 --- a/client/jetpack-connect/plans.jsx +++ b/client/jetpack-connect/plans.jsx @@ -255,13 +255,13 @@ class Plans extends Component { }; render() { - const { isRtlLayout, translate } = this.props; + const { interval, isRtlLayout, selectedSite, showFirst, translate } = this.props; if ( this.redirecting || this.hasPreSelectedPlan() || - ( ! this.props.showFirst && ! this.props.canPurchasePlans ) || - ( ! this.props.showFirst && this.props.hasPlan ) + ( ! showFirst && ! this.props.canPurchasePlans ) || + ( ! showFirst && this.props.hasPlan ) ) { return ; } @@ -271,18 +271,15 @@ class Plans extends Component { return (
- { this.props.selectedSite ? ( - - ) : null } + { selectedSite && } diff --git a/client/jetpack-connect/test/__snapshots__/plans.js.snap b/client/jetpack-connect/test/__snapshots__/plans.js.snap index 64c5a40e84906..84469700350b3 100644 --- a/client/jetpack-connect/test/__snapshots__/plans.js.snap +++ b/client/jetpack-connect/test/__snapshots__/plans.js.snap @@ -1235,74 +1235,10 @@ ShallowWrapper { /> - - - - - - - -
, - "nodes": Array [ -
- - - + + + + + + +
, + "nodes": Array [ +
+ + + Date: Mon, 30 Oct 2017 11:15:50 +0100 Subject: [PATCH 019/192] JP Disconnect Survey: Remove obsolete styling (#19246) --- client/my-sites/site-settings/disconnect-site/style.scss | 8 -------- 1 file changed, 8 deletions(-) diff --git a/client/my-sites/site-settings/disconnect-site/style.scss b/client/my-sites/site-settings/disconnect-site/style.scss index e2d8bb8987e1e..e0c4bb0c9312d 100644 --- a/client/my-sites/site-settings/disconnect-site/style.scss +++ b/client/my-sites/site-settings/disconnect-site/style.scss @@ -29,11 +29,3 @@ float: right; } } - -// 'Troubleshooting' section -.disconnect-site__troubleshooting { - .jetpack-connect__happychat-button, - .jetpack-connect__help-button { - text-align: center; - } -} From e3d5a722cf1e6d303d70f2f37f601028164f3a86 Mon Sep 17 00:00:00 2001 From: Andrija Vucinic Date: Mon, 30 Oct 2017 12:25:27 +0200 Subject: [PATCH 020/192] Domains: Incoming Domain Transfers - Domains: Add a domain suggestion for transfer/map - Update all links to point to the transfer option - Add transfer domain component and path in controller - Add controller for transfer domain - add working domain transfer step - Add checkout screen for domains to be transferred - Add pre checkout validation form for transfers - add text for multiple transfers :) - add checkout thank you for domain transfer - Add transfers to domain list and add notices/flags - hide the transfers behind a feature flag --- assets/stylesheets/_components.scss | 2 + .../domains/domain-search-results/index.jsx | 63 ++++- .../domains/domain-search-results/style.scss | 2 +- .../domains/domain-suggestion/index.jsx | 7 +- .../domain-transfer-suggestion/index.jsx | 52 ++++ .../domain-transfer-suggestion/style.scss | 21 ++ .../domains/map-domain-step/index.jsx | 6 +- .../domains/register-domain-step/index.jsx | 52 +++- .../domains/transfer-domain-step/index.jsx | 260 ++++++++++++++++++ .../domains/transfer-domain-step/style.scss | 68 +++++ client/lib/cart-values/cart-items.js | 200 +++++++++----- client/lib/domains/assembler.js | 3 +- client/lib/domains/constants.js | 14 + client/lib/domains/index.js | 17 ++ client/lib/domains/test/assembler.js | 3 +- client/lib/domains/utils.js | 33 ++- client/lib/products-values/index.js | 87 +++--- .../wpcom-undocumented/lib/undocumented.js | 18 ++ .../checkout-thank-you/features-header.jsx | 4 +- .../checkout/checkout-thank-you/header.jsx | 32 ++- .../checkout/checkout-thank-you/index.jsx | 3 + .../my-sites/checkout/checkout/checkout.jsx | 38 ++- client/my-sites/checkout/checkout/style.scss | 4 +- .../checkout/transfer-domain-precheck.jsx | 185 +++++++++++++ .../components/domain-warnings/index.jsx | 77 +++++- client/my-sites/domains/controller.jsx | 70 +++-- .../components/domain/transfer-flag.jsx | 51 ++++ .../edit/card/header/index.jsx | 2 + .../edit/card/subscription-settings/index.jsx | 1 + .../domains/domain-management/edit/index.jsx | 4 + .../domain-management/edit/transfer.jsx | 85 ++++++ .../domains/domain-management/list/item.jsx | 5 + .../domains/domain-management/style.scss | 2 +- .../domains/domain-search/domain-search.jsx | 8 +- client/my-sites/domains/index.js | 17 ++ .../domains/transfer-domain/index.jsx | 136 +++++++++ client/signup/steps/domains/index.jsx | 2 +- client/state/current-user/constants.js | 1 + 38 files changed, 1448 insertions(+), 187 deletions(-) create mode 100644 client/components/domains/domain-transfer-suggestion/index.jsx create mode 100644 client/components/domains/domain-transfer-suggestion/style.scss create mode 100644 client/components/domains/transfer-domain-step/index.jsx create mode 100644 client/components/domains/transfer-domain-step/style.scss create mode 100644 client/my-sites/checkout/checkout/transfer-domain-precheck.jsx create mode 100644 client/my-sites/domains/domain-management/components/domain/transfer-flag.jsx create mode 100644 client/my-sites/domains/domain-management/edit/transfer.jsx create mode 100644 client/my-sites/domains/transfer-domain/index.jsx diff --git a/assets/stylesheets/_components.scss b/assets/stylesheets/_components.scss index ed66b04d9ced9..4f7f169b8faad 100644 --- a/assets/stylesheets/_components.scss +++ b/assets/stylesheets/_components.scss @@ -97,8 +97,10 @@ @import 'components/domains/domain-product-price/style'; @import 'components/domains/domain-search-results/style'; @import 'components/domains/domain-suggestion/style'; +@import 'components/domains/domain-transfer-suggestion/style'; @import 'components/domains/example-domain-suggestions/style'; @import 'components/domains/map-domain-step/style'; +@import 'components/domains/transfer-domain-step/style'; @import 'components/domains/register-domain-step/style'; @import 'components/domains/registrant-extra-info/style'; @import 'components/drop-zone/style'; diff --git a/client/components/domains/domain-search-results/index.jsx b/client/components/domains/domain-search-results/index.jsx index 9219871bccf84..ddb90e9713f56 100644 --- a/client/components/domains/domain-search-results/index.jsx +++ b/client/components/domains/domain-search-results/index.jsx @@ -17,12 +17,15 @@ import { endsWith, includes, times } from 'lodash'; * Internal dependencies */ import DomainRegistrationSuggestion from 'components/domains/domain-registration-suggestion'; +import DomainTransferSuggestion from 'components/domains/domain-transfer-suggestion'; import DomainMappingSuggestion from 'components/domains/domain-mapping-suggestion'; import DomainSuggestion from 'components/domains/domain-suggestion'; import { isNextDomainFree } from 'lib/cart-values/cart-items'; import Notice from 'components/notice'; import { getTld } from 'lib/domains'; import { domainAvailability } from 'lib/domains/constants'; +import { currentUserHasFlag } from 'state/current-user/selectors'; +import { TRANSFER_IN } from 'state/current-user/constants'; class DomainSearchResults extends React.Component { static propTypes = { @@ -37,10 +40,12 @@ class DomainSearchResults extends React.Component { placeholderQuantity: PropTypes.number.isRequired, buttonLabel: PropTypes.string, mappingSuggestionLabel: PropTypes.string, - offerMappingOption: PropTypes.bool, + offerUnavailableOption: PropTypes.bool, onClickResult: PropTypes.func.isRequired, onAddMapping: PropTypes.func, + onAddTransfer: PropTypes.func, onClickMapping: PropTypes.func, + onClickTransfer: PropTypes.func, isSignupStep: PropTypes.bool, railcarSeed: PropTypes.string, fetchAlgo: PropTypes.string, @@ -55,7 +60,7 @@ class DomainSearchResults extends React.Component { const suggestions = this.props.suggestions || []; const { MAPPABLE, UNKNOWN } = domainAvailability; - let availabilityElement, domainSuggestionElement, mappingOffer; + let availabilityElement, domainSuggestionElement, offer; if ( availableDomain ) { // should use real notice component or custom class @@ -82,25 +87,47 @@ class DomainSearchResults extends React.Component { includes( [ MAPPABLE, UNKNOWN ], lastDomainStatus ) && this.props.products.domain_map ) { - const components = { a: , small: }; + let components = { a: , small: }; if ( isNextDomainFree( this.props.cart ) ) { - mappingOffer = translate( + offer = translate( '{{small}}If you purchased %(domain)s elsewhere, you can {{a}}map it{{/a}} for free.{{/small}}', { args: { domain }, components } ); } else if ( ! this.props.domainsWithPlansOnly || this.props.isSiteOnPaidPlan ) { - mappingOffer = translate( + offer = translate( '{{small}}If you purchased %(domain)s elsewhere, you can {{a}}map it{{/a}} for %(cost)s.{{/small}}', { args: { domain, cost: this.props.products.domain_map.cost_display }, components } ); } else { - mappingOffer = translate( + offer = translate( '{{small}}If you purchased %(domain)s elsewhere, you can {{a}}map it{{/a}} with WordPress.com Premium.{{/small}}', { args: { domain }, components } ); } + if ( this.props.transferInAllowed ) { + components = { a: , small: }; + + if ( isNextDomainFree( this.props.cart ) ) { + offer = translate( + '{{small}}If you purchased %(domain)s elsewhere, you can {{a}}transfer it{{/a}} for free.{{/small}}', + { args: { domain }, components } + ); + } else if ( ! this.props.domainsWithPlansOnly || this.props.isSiteOnPaidPlan ) { + offer = translate( + '{{small}}If you purchased %(domain)s elsewhere, you can {{a}}transfer it{{/a}}.{{/small}}', + { args: { domain }, components } + ); + } else { + offer = translate( + '{{small}}If you purchased %(domain)s elsewhere, you can {{a}}transfer it{{/a}} ' + + 'with WordPress.com Premium.{{/small}}', + { args: { domain }, components } + ); + } + } + const domainUnavailableMessage = lastDomainStatus === UNKNOWN ? translate( '.%(tld)s domains are not offered on WordPress.com.', { @@ -108,10 +135,10 @@ class DomainSearchResults extends React.Component { } ) : translate( '%(domain)s is taken.', { args: { domain } } ); - if ( this.props.offerMappingOption ) { + if ( this.props.offerUnavailableOption ) { availabilityElement = ( - { domainUnavailableMessage } { mappingOffer } + { domainUnavailableMessage } { offer } ); } @@ -131,6 +158,10 @@ class DomainSearchResults extends React.Component { this.props.onAddMapping( this.props.lastDomainSearched ); }; + handleAddTransfer = () => { + this.props.onAddTransfer( this.props.lastDomainSearched ); + }; + renderPlaceholders() { return times( this.props.placeholderQuantity, function( n ) { return ; @@ -138,7 +169,8 @@ class DomainSearchResults extends React.Component { } renderDomainSuggestions() { - let suggestionElements, mappingOffer; + let suggestionElements; + let unavailableOffer; if ( this.props.suggestions.length ) { suggestionElements = this.props.suggestions.map( function( suggestion, i ) { @@ -165,8 +197,8 @@ class DomainSearchResults extends React.Component { ); }, this ); - if ( this.props.offerMappingOption ) { - mappingOffer = ( + if ( this.props.offerUnavailableOption ) { + unavailableOffer = ( ); + + if ( this.props.transferInAllowed ) { + unavailableOffer = ( + + ); + } } } else { suggestionElements = this.renderPlaceholders(); @@ -184,7 +222,7 @@ class DomainSearchResults extends React.Component { return (
{ suggestionElements } - { mappingOffer } + { unavailableOffer }
); } @@ -203,6 +241,7 @@ const mapStateToProps = state => { const selectedSiteId = getSelectedSiteId( state ); return { isSiteOnPaidPlan: isSiteOnPaidPlan( state, selectedSiteId ), + transferInAllowed: currentUserHasFlag( state, TRANSFER_IN ), }; }; diff --git a/client/components/domains/domain-search-results/style.scss b/client/components/domains/domain-search-results/style.scss index 78be9036d4315..69acbee11bcc1 100644 --- a/client/components/domains/domain-search-results/style.scss +++ b/client/components/domains/domain-search-results/style.scss @@ -1,4 +1,4 @@ -.domain-search-results__domain-availability { +.domain-search-results__domain-availability, .transfer-domain-step__domain-availability { .notice.is-success { margin: 0; } diff --git a/client/components/domains/domain-suggestion/index.jsx b/client/components/domains/domain-suggestion/index.jsx index b62e786195b1c..d33f086c3a710 100644 --- a/client/components/domains/domain-suggestion/index.jsx +++ b/client/components/domains/domain-suggestion/index.jsx @@ -20,13 +20,14 @@ class DomainSuggestion extends React.Component { buttonClasses: PropTypes.string, extraClasses: PropTypes.string, onButtonClick: PropTypes.func.isRequired, - priceRule: PropTypes.string.isRequired, + priceRule: PropTypes.string, price: PropTypes.string, domain: PropTypes.string, + hidePrice: PropTypes.bool, }; render() { - const { price, isAdded, extraClasses, children, priceRule } = this.props; + const { hidePrice, price, isAdded, extraClasses, children, priceRule } = this.props; const classes = classNames( 'domain-suggestion', 'card', @@ -47,7 +48,7 @@ class DomainSuggestion extends React.Component { >
{ children } - + { ! hidePrice && }
{ this.props.buttonContent }
diff --git a/client/components/domains/domain-transfer-suggestion/index.jsx b/client/components/domains/domain-transfer-suggestion/index.jsx new file mode 100644 index 0000000000000..15a8eb3049bc9 --- /dev/null +++ b/client/components/domains/domain-transfer-suggestion/index.jsx @@ -0,0 +1,52 @@ +/** + * External dependencies + * + * @format + */ +import PropTypes from 'prop-types'; +import React from 'react'; +import { localize } from 'i18n-calypso'; + +/** + * Internal dependencies + */ +import DomainSuggestion from 'components/domains/domain-suggestion'; + +class DomainTransferSuggestion extends React.Component { + static propTypes = { + onButtonClick: PropTypes.func.isRequired, + }; + + render() { + const { translate } = this.props; + const buttonContent = translate( 'Use a domain I own', { + context: 'Domain transfer or mapping suggestion button', + } ); + + return ( + +
+

+ { translate( 'Already own a domain?', { + context: 'Upgrades: Register domain header', + comment: 'Asks if you already own a domain name.', + } ) } +

+

+ { translate( "Transfer or map it to use it as your site's address.", { + context: 'Upgrades: Register domain description', + comment: 'Explains how you could use an existing domain name with your site.', + } ) } +

+
+
+ ); + } +} + +export default localize( DomainTransferSuggestion ); diff --git a/client/components/domains/domain-transfer-suggestion/style.scss b/client/components/domains/domain-transfer-suggestion/style.scss new file mode 100644 index 0000000000000..ccbe9d3d48749 --- /dev/null +++ b/client/components/domains/domain-transfer-suggestion/style.scss @@ -0,0 +1,21 @@ +.domain-transfer-suggestion { + display: flex; +} + +.domain-transfer-suggestion__domain-description { + @include breakpoint( ">660px" ) { + width: 75%; + } + + > p { + color: $gray-dark; + font-size: 12px; + font-weight: 600; + margin-bottom: 0; + opacity: 0.7; + + @include breakpoint( ">960px" ) { + margin-bottom: 8px; + } + } +} diff --git a/client/components/domains/map-domain-step/index.jsx b/client/components/domains/map-domain-step/index.jsx index 87e3b0417a328..b3a1b5ce2b1e1 100644 --- a/client/components/domains/map-domain-step/index.jsx +++ b/client/components/domains/map-domain-step/index.jsx @@ -78,9 +78,9 @@ class MapDomainStep extends React.Component { render() { const suggestion = this.props.products.domain_map ? { - cost: this.props.products.domain_map.cost_display, - product_slug: this.props.products.domain_map.product_slug, - } + cost: this.props.products.domain_map.cost_display, + product_slug: this.props.products.domain_map.product_slug, + } : { cost: null, product_slug: '' }; const { translate } = this.props; diff --git a/client/components/domains/register-domain-step/index.jsx b/client/components/domains/register-domain-step/index.jsx index 4b7e4514b71b2..76c3c4eee4bbe 100644 --- a/client/components/domains/register-domain-step/index.jsx +++ b/client/components/domains/register-domain-step/index.jsx @@ -36,6 +36,7 @@ import { getAvailabilityNotice } from 'lib/domains/registration/availability-mes import SearchCard from 'components/search-card'; import DomainRegistrationSuggestion from 'components/domains/domain-registration-suggestion'; import DomainMappingSuggestion from 'components/domains/domain-mapping-suggestion'; +import DomainTransferSuggestion from 'components/domains/domain-transfer-suggestion'; import DomainSuggestion from 'components/domains/domain-suggestion'; import DomainSearchResults from 'components/domains/domain-search-results'; import ExampleDomainSuggestions from 'components/domains/example-domain-suggestions'; @@ -47,6 +48,8 @@ import { getDomainsSuggestionsError, } from 'state/domains/suggestions/selectors'; import { composeAnalytics, recordGoogleEvent, recordTracksEvent } from 'state/analytics/actions'; +import { currentUserHasFlag } from 'state/current-user/selectors'; +import { TRANSFER_IN } from 'state/current-user/constants'; const domains = wpcom.domains(); @@ -133,6 +136,7 @@ class RegisterDomainStep extends React.Component { onSave: PropTypes.func, onAddMapping: PropTypes.func, onAddDomain: PropTypes.func, + onAddTransfer: PropTypes.func, designType: PropTypes.string, }; @@ -608,7 +612,7 @@ class RegisterDomainStep extends React.Component { initialSuggestions() { let domainRegistrationSuggestions; - let domainMappingSuggestion; + let domainUnavailableSuggestion; let suggestions; if ( this.isLoadingSuggestions() || isEmpty( this.props.products ) ) { @@ -633,7 +637,7 @@ class RegisterDomainStep extends React.Component { ); }, this ); - domainMappingSuggestion = ( + domainUnavailableSuggestion = ( ); + + if ( this.props.transferInAllowed ) { + domainUnavailableSuggestion = ( + + ); + } } return ( @@ -651,7 +661,7 @@ class RegisterDomainStep extends React.Component { className="register-domain-step__domain-suggestions" > { domainRegistrationSuggestions } - { domainMappingSuggestion } + { domainUnavailableSuggestion }
); } @@ -700,10 +710,12 @@ class RegisterDomainStep extends React.Component { onAddMapping={ onAddMapping } onClickResult={ this.props.onAddDomain } onClickMapping={ this.goToMapDomainStep } + onAddTransfer={ this.props.onAddTransfer } + onClickTransfer={ this.goToTransferDomainStep } suggestions={ suggestions } products={ this.props.products } selectedSite={ this.props.selectedSite } - offerMappingOption={ this.props.offerMappingOption } + offerUnavailableOption={ this.props.offerUnavailableOption } placeholderQuantity={ SUGGESTION_QUANTITY } isSignupStep={ this.props.isSignupStep } railcarSeed={ this.state.railcarSeed } @@ -729,6 +741,22 @@ class RegisterDomainStep extends React.Component { return mapDomainUrl; } + getTransferDomainUrl() { + let transferDomainUrl; + + if ( this.props.transferDomainUrl ) { + transferDomainUrl = this.props.transferDomainUrl; + } else { + const query = qs.stringify( { initialQuery: this.state.lastQuery.trim() } ); + transferDomainUrl = `${ this.props.basePath }/transfer`; + if ( this.props.selectedSite ) { + transferDomainUrl += `/${ this.props.selectedSite.slug }?${ query }`; + } + } + + return transferDomainUrl; + } + goToMapDomainStep = event => { event.preventDefault(); @@ -737,6 +765,14 @@ class RegisterDomainStep extends React.Component { page( this.getMapDomainUrl() ); }; + goToTransferDomainStep = event => { + event.preventDefault(); + + this.props.recordTransferDomainButtonClick( this.props.analyticsSection ); + + page( this.getTransferDomainUrl() ); + }; + showValidationErrorMessage( domain, error ) { const { message, severity } = getAvailabilityNotice( domain, error ); this.setState( { notice: message, noticeSeverity: severity } ); @@ -749,6 +785,12 @@ const recordMapDomainButtonClick = section => recordTracksEvent( 'calypso_domain_search_results_mapping_button_click', { section } ) ); +const recordTransferDomainButtonClick = section => + composeAnalytics( + recordGoogleEvent( 'Domain Search', 'Clicked "Use a Domain I own" Button' ), + recordTracksEvent( 'calypso_domain_search_results_transfer_button_click', { section } ) + ); + const recordSearchFormSubmit = ( searchBoxValue, section, timeDiffFromLastSearch, count, vendor ) => composeAnalytics( recordGoogleEvent( @@ -818,6 +860,7 @@ export default connect( currentUser: getCurrentUser( state ), defaultSuggestions: getDomainsSuggestions( state, queryObject ), defaultSuggestionsError: getDomainsSuggestionsError( state, queryObject ), + transferInAllowed: currentUserHasFlag( state, TRANSFER_IN ), }; }, { @@ -826,5 +869,6 @@ export default connect( recordSearchFormSubmit, recordSearchFormView, recordSearchResultsReceive, + recordTransferDomainButtonClick, } )( localize( RegisterDomainStep ) ); diff --git a/client/components/domains/transfer-domain-step/index.jsx b/client/components/domains/transfer-domain-step/index.jsx new file mode 100644 index 0000000000000..8df150ecaa455 --- /dev/null +++ b/client/components/domains/transfer-domain-step/index.jsx @@ -0,0 +1,260 @@ +/** + * External dependencies + * + * @format + */ +import PropTypes from 'prop-types'; +import React from 'react'; +import { connect } from 'react-redux'; +import { localize } from 'i18n-calypso'; +import { endsWith, get, noop } from 'lodash'; +import Gridicon from 'gridicons'; +import page from 'page'; +import qs from 'qs'; + +/** + * Internal dependencies + */ +import { getFixedDomainSearch, checkDomainAvailability } from 'lib/domains'; +import { domainAvailability } from 'lib/domains/constants'; +import { getAvailabilityNotice } from 'lib/domains/registration/availability-messages'; +import DomainRegistrationSuggestion from 'components/domains/domain-registration-suggestion'; +import { getCurrentUser } from 'state/current-user/selectors'; +import { + recordAddDomainButtonClickInMapDomain, + recordFormSubmitInMapDomain, + recordInputFocusInMapDomain, + recordGoButtonClickInMapDomain, +} from 'state/domains/actions'; +import Notice from 'components/notice'; +import { composeAnalytics, recordGoogleEvent, recordTracksEvent } from 'state/analytics/actions'; +import { getSelectedSite } from 'state/ui/selectors'; + +class TransferDomainStep extends React.Component { + static propTypes = { + products: PropTypes.object.isRequired, + cart: PropTypes.object, + selectedSite: PropTypes.oneOfType( [ PropTypes.object, PropTypes.bool ] ), + initialQuery: PropTypes.string, + analyticsSection: PropTypes.string.isRequired, + domainsWithPlansOnly: PropTypes.bool.isRequired, + onRegisterDomain: PropTypes.func.isRequired, + onTransferDomain: PropTypes.func.isRequired, + onSave: PropTypes.func, + }; + + static defaultProps = { + onSave: noop, + analyticsSection: 'domains', + }; + + state = this.getDefaultState(); + + getDefaultState() { + return { + searchQuery: this.props.initialQuery || '', + }; + } + + componentWillMount() { + if ( this.props.initialState ) { + this.setState( Object.assign( {}, this.props.initialState, this.getDefaultState() ) ); + } + } + + componentWillUnmount() { + this.props.onSave( this.state ); + } + + notice() { + if ( this.state.notice ) { + return ( + + ); + } + } + + getMapDomainUrl() { + const { basePath, selectedSite } = this.props; + let mapDomainUrl; + + const basePathForMapping = endsWith( basePath, '/transfer' ) + ? basePath.substring( 0, basePath.length - 9 ) + : basePath; + + const query = qs.stringify( { initialQuery: this.state.searchQuery.trim() } ); + mapDomainUrl = `${ basePathForMapping }/mapping`; + if ( selectedSite ) { + mapDomainUrl += `/${ selectedSite.slug }?${ query }`; + } + + return mapDomainUrl; + } + + goToMapDomainStep = event => { + event.preventDefault(); + + this.props.recordMapDomainButtonClick( this.props.analyticsSection ); + + page( this.getMapDomainUrl() ); + }; + + render() { + const cost = this.props.products.domain_map + ? this.props.products.domain_map.cost_display + : null; + const { translate } = this.props; + + return ( + + ); + } + + domainRegistrationUpsell() { + const { suggestion } = this.state; + if ( ! suggestion ) { + return; + } + + return ( +
+ + { this.props.translate( '%(domain)s is available!', { + args: { domain: suggestion.domain_name }, + } ) } + + +
+ ); + } + + registerSuggestedDomain = () => { + this.props.recordAddDomainButtonClickInMapDomain( + this.state.suggestion.domain_name, + this.props.analyticsSection + ); + + return this.props.onRegisterDomain( this.state.suggestion ); + }; + + recordInputFocus = () => { + this.props.recordInputFocusInMapDomain( this.state.searchQuery ); + }; + + recordGoButtonClick = () => { + this.props.recordGoButtonClickInMapDomain( + this.state.searchQuery, + this.props.analyticsSection + ); + }; + + setSearchQuery = event => { + this.setState( { searchQuery: event.target.value } ); + }; + + handleFormSubmit = event => { + event.preventDefault(); + + const domain = getFixedDomainSearch( this.state.searchQuery ); + this.props.recordFormSubmitInMapDomain( this.state.searchQuery ); + this.setState( { suggestion: null, notice: null } ); + + checkDomainAvailability( domain, ( error, result ) => { + const status = get( result, 'status', error ); + switch ( status ) { + case domainAvailability.MAPPABLE: + case domainAvailability.UNKNOWN: + this.props.onTransferDomain( domain ); + return; + + case domainAvailability.AVAILABLE: + this.setState( { suggestion: result } ); + return; + + default: + const { message, severity } = getAvailabilityNotice( domain, status ); + this.setState( { notice: message, noticeSeverity: severity } ); + return; + } + } ); + }; +} + +const recordMapDomainButtonClick = section => + composeAnalytics( + recordGoogleEvent( 'Domain Search', 'Clicked "Map it" Button' ), + recordTracksEvent( 'calypso_domain_search_results_mapping_button_click', { section } ) + ); + +export default connect( + state => ( { + currentUser: getCurrentUser( state ), + selectedSite: getSelectedSite( state ), + } ), + { + recordAddDomainButtonClickInMapDomain, + recordFormSubmitInMapDomain, + recordInputFocusInMapDomain, + recordGoButtonClickInMapDomain, + recordMapDomainButtonClick, + } +)( localize( TransferDomainStep ) ); diff --git a/client/components/domains/transfer-domain-step/style.scss b/client/components/domains/transfer-domain-step/style.scss new file mode 100644 index 0000000000000..055c28a3dcaa9 --- /dev/null +++ b/client/components/domains/transfer-domain-step/style.scss @@ -0,0 +1,68 @@ +.transfer-domain-step { + padding: 0; + + form.transfer-domain-step__form { + padding: 20px; + margin-bottom: 9px; + } + + p { + color: $gray-dark; + font-size: 13px; + font-weight: 600; + margin-bottom: 0; + opacity: 0.7; + } + + .domain-product-price { + float: left; + margin-bottom: 20px; + min-width: 0; + + @include breakpoint( ">960px" ) { + float: right; + margin-bottom: 0; + } + } + + .notice { + margin-top: 25px; + } + + .transfer-domain-step__domain-availability { + margin-top: 30px; + } +} + +.transfer-domain-step__domain-description { + @include breakpoint( ">960px" ) { + max-width: 75%; + float: left; + margin-bottom: 20px; + } +} + +.transfer-domain-step__add-domain { + display: flex; + flex-flow: column; + width: 100%; + + @include breakpoint( ">660px" ) { + flex-flow: row; + } +} + +input.transfer-domain-step__external-domain { + flex-grow: 1; + width: auto; +} + +.transfer-domain-step__go { + flex-grow: 1; + margin: 10px 0 0 0; + + @include breakpoint( ">660px" ) { + flex-grow: 0; + margin: 0 0 0 10px; + } +} diff --git a/client/lib/cart-values/cart-items.js b/client/lib/cart-values/cart-items.js index b4d374e10f88d..115b968e48aa1 100644 --- a/client/lib/cart-values/cart-items.js +++ b/client/lib/cart-values/cart-items.js @@ -50,6 +50,7 @@ import { } from 'lib/products-values'; import sortProducts from 'lib/products-values/sort'; import { PLAN_PERSONAL } from 'lib/plans/constants'; +import { domainProductSlugs } from 'lib/domains/constants'; import { PLAN_FREE, @@ -67,13 +68,11 @@ import { * @param {Object} newCartItem - new item as `CartItemValue` object * @returns {Function} the function that adds the item to a shopping cart */ -function add( newCartItem ) { +export function add( newCartItem ) { function appendItem( products ) { - var isDuplicate; - products = products || []; - isDuplicate = products.some( function( existingCartItem ) { + const isDuplicate = products.some( function( existingCartItem ) { return isEqual( newCartItem, existingCartItem ); } ); @@ -99,7 +98,7 @@ function add( newCartItem ) { * @param {Object} cart - the existing shopping cart * @returns {Boolean} whether or not the item should replace the cart */ -function cartItemShouldReplaceCart( cartItem, cart ) { +export function cartItemShouldReplaceCart( cartItem, cart ) { if ( isRenewal( cartItem ) && ! isPrivacyProtection( cartItem ) && @@ -134,7 +133,7 @@ function cartItemShouldReplaceCart( cartItem, cart ) { * @param {Object} cartItemToRemove - item as `CartItemValue` object * @returns {Function} the function that removes the item from a shopping cart */ -function remove( cartItemToRemove ) { +export function remove( cartItemToRemove ) { function rejectItem( products ) { return reject( products, function( existingCartItem ) { return ( @@ -157,9 +156,9 @@ function remove( cartItemToRemove ) { * @param {bool} domainsWithPlansOnly - Whether we should consider domains as dependents of products * @returns {Function} the function that removes the items from a shopping cart */ -function removeItemAndDependencies( cartItemToRemove, cart, domainsWithPlansOnly ) { - var dependencies = getDependentProducts( cartItemToRemove, cart, domainsWithPlansOnly ), - changes = dependencies.map( remove ).concat( remove( cartItemToRemove ) ); +export function removeItemAndDependencies( cartItemToRemove, cart, domainsWithPlansOnly ) { + const dependencies = getDependentProducts( cartItemToRemove, cart, domainsWithPlansOnly ); + const changes = dependencies.map( remove ).concat( remove( cartItemToRemove ) ); return flow.apply( null, changes ); } @@ -172,7 +171,7 @@ function removeItemAndDependencies( cartItemToRemove, cart, domainsWithPlansOnly * @param {bool} domainsWithPlansOnly - Whether we should consider domains as dependents of products * @returns {Object[]} the list of dependency items in the shopping cart */ -function getDependentProducts( cartItem, cart, domainsWithPlansOnly ) { +export function getDependentProducts( cartItem, cart, domainsWithPlansOnly ) { const dependentProducts = getAll( cart ).filter( function( existingCartItem ) { return isDependentProduct( cartItem, existingCartItem, domainsWithPlansOnly ); } ); @@ -192,7 +191,7 @@ function getDependentProducts( cartItem, cart, domainsWithPlansOnly ) { * @param {Object} cart - cart as `CartValue` object * @returns {Object[]} the list of items in the shopping cart as `CartItemValue` objects */ -function getAll( cart ) { +export function getAll( cart ) { return ( cart && cart.products ) || []; } @@ -203,7 +202,7 @@ function getAll( cart ) { * * @returns {Object[]} the sorted list of items in the shopping cart */ -function getAllSorted( cart ) { +export function getAllSorted( cart ) { return sortProducts( getAll( cart ) ); } @@ -213,7 +212,7 @@ function getAllSorted( cart ) { * @param {Object} cart - cart as `CartValue` object * @returns {Array} an array of renewal items */ -function getRenewalItems( cart ) { +export function getRenewalItems( cart ) { return getAll( cart ).filter( isRenewal ); } @@ -223,7 +222,7 @@ function getRenewalItems( cart ) { * @param {Object} cart - cart as `CartValue` object * @returns {boolean} true if there is at least one item with free trial, false otherwise */ -function hasFreeTrial( cart ) { +export function hasFreeTrial( cart ) { return some( getAll( cart ), 'free_trial' ); } @@ -233,15 +232,15 @@ function hasFreeTrial( cart ) { * @param {Object} cart - cart as `CartValue` object * @returns {boolean} true if there is at least one plan, false otherwise */ -function hasPlan( cart ) { +export function hasPlan( cart ) { return cart && some( getAll( cart ), isPlan ); } -function hasPremiumPlan( cart ) { +export function hasPremiumPlan( cart ) { return some( getAll( cart ), isPremium ); } -function hasDomainCredit( cart ) { +export function hasDomainCredit( cart ) { return cart.has_bundle_credit || hasPlan( cart ); } @@ -253,13 +252,13 @@ function hasDomainCredit( cart ) { * * @returns {Boolean} - Whether or not the cart contains a domain with that TLD */ -function hasTld( cart, tld ) { +export function hasTld( cart, tld ) { return some( getDomainRegistrations( cart ), function( cartItem ) { return getDomainRegistrationTld( cartItem ) === '.' + tld; } ); } -function getTlds( cart ) { +export function getTlds( cart ) { return uniq( map( getDomainRegistrations( cart ), function( cartItem ) { return trimStart( getDomainRegistrationTld( cartItem ), '.' ); @@ -267,7 +266,7 @@ function getTlds( cart ) { ); } -function getDomainRegistrationTld( cartItem ) { +export function getDomainRegistrationTld( cartItem ) { if ( ! isDomainRegistration( cartItem ) ) { throw new Error( 'This function only works on domain registration cart ' + 'items.' ); } @@ -282,7 +281,7 @@ function getDomainRegistrationTld( cartItem ) { * @returns {boolean} true if all items have free trial, false otherwise * @todo This will fail when a domain is purchased with a plan, as the domain will be included in the free trial */ -function hasOnlyFreeTrial( cart ) { +export function hasOnlyFreeTrial( cart ) { return cart.products && findFreeTrial( cart ) && every( getAll( cart ), { cost: 0 } ); } @@ -293,7 +292,7 @@ function hasOnlyFreeTrial( cart ) { * @param {Object} productSlug - the unique string that identifies the product * @returns {boolean} true if there is at least one item of the specified product type, false otherwise */ -function hasProduct( cart, productSlug ) { +export function hasProduct( cart, productSlug ) { return getAll( cart ).some( function( cartItem ) { return cartItem.product_slug === productSlug; } ); @@ -307,7 +306,7 @@ function hasProduct( cart, productSlug ) { * @param {Object} productSlug - the unique string that identifies the product * @returns {boolean} true if all the products in the cart are of the productSlug type */ -function hasOnlyProductsOf( cart, productSlug ) { +export function hasOnlyProductsOf( cart, productSlug ) { return cart.products && every( getAll( cart ), { product_slug: productSlug } ); } @@ -317,15 +316,15 @@ function hasOnlyProductsOf( cart, productSlug ) { * @param {Object} cart - cart as `CartValue` object * @returns {boolean} true if there is at least one domain registration item, false otherwise */ -function hasDomainRegistration( cart ) { +export function hasDomainRegistration( cart ) { return some( getAll( cart ), isDomainRegistration ); } -function hasOnlyDomainRegistrationsWithPrivacySupport( cart ) { +export function hasOnlyDomainRegistrationsWithPrivacySupport( cart ) { return every( getDomainRegistrations( cart ), privacyAvailable ); } -function hasDomainMapping( cart ) { +export function hasDomainMapping( cart ) { return some( getAll( cart ), isDomainMapping ); } @@ -335,17 +334,37 @@ function hasDomainMapping( cart ) { * @param {Object} cart - cart as `CartValue` object * @returns {boolean} true if there is at least one renewal item, false otherwise */ -function hasRenewalItem( cart ) { +export function hasRenewalItem( cart ) { return some( getAll( cart ), isRenewal ); } +/** + * Determines whether there is at least one domain transfer item in the specified shopping cart. + * + * @param {Object} cart - cart as `CartValue` object + * @returns {boolean} true if there is at least one domain transfer item, false otherwise + */ +export function hasTransferProduct( cart ) { + return some( getAll( cart ), isTransfer ); +} + +/** + * Retrieves all the domain transfer items in the specified shopping cart. + * + * @param {Object} cart - cart as `CartValue` object + * @returns {Object[]} the list of the corresponding items in the shopping cart as `CartItemValue` objects + */ +export function getDomainTransfers( cart ) { + return filter( getAll( cart ), { product_slug: domainProductSlugs.TRANSFER_IN } ); +} + /** * Determines whether all items are renewal items in the specified shopping cart. * * @param {Object} cart - cart as `CartValue` object * @returns {boolean} true if there are only renewal items, false otherwise */ -function hasOnlyRenewalItems( cart ) { +export function hasOnlyRenewalItems( cart ) { return every( getAll( cart ), isRenewal ); } @@ -356,7 +375,7 @@ function hasOnlyRenewalItems( cart ) { * @param {Object} cart - cart as `CartValue` object * @returns {boolean} true if any product in the cart renews */ -function hasRenewableSubscription( cart ) { +export function hasRenewableSubscription( cart ) { return cart.products && some( getAll( cart ), cartItem => cartItem.bill_period > 0 ); } @@ -364,10 +383,10 @@ function hasRenewableSubscription( cart ) { * Creates a new shopping cart item for a plan. * * @param {Object} productSlug - the unique string that identifies the product - * @param {boolean} isFreeTrial - optionally specifies if this is a free trial or not + * @param {boolean} isFreeTrialItem - optionally specifies if this is a free trial or not * @returns {Object} the new item as `CartItemValue` object */ -function planItem( productSlug, isFreeTrial = false ) { +export function planItem( productSlug, isFreeTrialItem = false ) { // Free plan doesn't have shopping cart. if ( productSlug === PLAN_FREE ) { return null; @@ -375,7 +394,7 @@ function planItem( productSlug, isFreeTrial = false ) { return { product_slug: productSlug, - free_trial: isFreeTrial, + free_trial: isFreeTrialItem, }; } @@ -386,7 +405,7 @@ function planItem( productSlug, isFreeTrial = false ) { * @param {Object} properties - list of properties * @returns {Object} the new item as `CartItemValue` object */ -function personalPlan( slug, properties ) { +export function personalPlan( slug, properties ) { return planItem( slug, properties.isFreeTrial ); } @@ -397,7 +416,7 @@ function personalPlan( slug, properties ) { * @param {Object} properties - list of properties * @returns {Object} the new item as `CartItemValue` object */ -function premiumPlan( slug, properties ) { +export function premiumPlan( slug, properties ) { return planItem( slug, properties.isFreeTrial ); } @@ -408,7 +427,7 @@ function premiumPlan( slug, properties ) { * @param {Object} properties - list of properties * @returns {Object} the new item as `CartItemValue` object */ -function businessPlan( slug, properties ) { +export function businessPlan( slug, properties ) { return planItem( slug, properties.isFreeTrial ); } @@ -420,8 +439,8 @@ function businessPlan( slug, properties ) { * @param {string} source - optional source for the domain item, e.g. `getdotblog`. * @returns {Object} the new item as `CartItemValue` object */ -function domainItem( productSlug, domain, source ) { - var extra = source ? { extra: { source: source } } : undefined; +export function domainItem( productSlug, domain, source ) { + const extra = source ? { extra: { source: source } } : undefined; return Object.assign( { @@ -432,7 +451,7 @@ function domainItem( productSlug, domain, source ) { ); } -function themeItem( themeSlug, source ) { +export function themeItem( themeSlug, source ) { return { product_slug: 'premium_theme', meta: themeSlug, @@ -448,7 +467,7 @@ function themeItem( themeSlug, source ) { * @param {Object} properties - list of properties * @returns {Object} the new item as `CartItemValue` object */ -function domainRegistration( properties ) { +export function domainRegistration( properties ) { return assign( domainItem( properties.productSlug, properties.domain, properties.source ), { is_domain_registration: true, ...( properties.extra ? { extra: properties.extra } : {} ), @@ -461,7 +480,7 @@ function domainRegistration( properties ) { * @param {Object} properties - list of properties * @returns {Object} the new item as `CartItemValue` object */ -function domainMapping( properties ) { +export function domainMapping( properties ) { return domainItem( 'domain_map', properties.domain, properties.source ); } @@ -471,7 +490,7 @@ function domainMapping( properties ) { * @param {Object} properties - list of properties * @returns {Object} the new item as `CartItemValue` object */ -function siteRedirect( properties ) { +export function siteRedirect( properties ) { return domainItem( 'offsite_redirect', properties.domain, properties.source ); } @@ -481,7 +500,7 @@ function siteRedirect( properties ) { * @param {Object} properties - list of properties * @returns {Object} the new item as `CartItemValue` object */ -function domainPrivacyProtection( properties ) { +export function domainPrivacyProtection( properties ) { return domainItem( 'private_whois', properties.domain, properties.source ); } @@ -491,24 +510,34 @@ function domainPrivacyProtection( properties ) { * @param {Object} properties - list of properties * @returns {Object} the new item as `CartItemValue` object */ -function domainRedemption( properties ) { +export function domainRedemption( properties ) { return domainItem( 'domain_redemption', properties.domain, properties.source ); } -function googleApps( properties ) { +/** + * Creates a new shopping cart item for an incoming domain transfer. + * + * @param {Object} properties - list of properties + * @returns {Object} the new item as `CartItemValue` object + */ +export function domainTransfer( properties ) { + return domainItem( domainProductSlugs.TRANSFER_IN, properties.domain, properties.source ); +} + +export function googleApps( properties ) { const productSlug = properties.product_slug || 'gapps', item = domainItem( productSlug, properties.meta ? properties.meta : properties.domain ); return assign( item, { extra: { google_apps_users: properties.users } } ); } -function googleAppsExtraLicenses( properties ) { - var item = domainItem( 'gapps_extra_license', properties.domain, properties.source ); +export function googleAppsExtraLicenses( properties ) { + const item = domainItem( 'gapps_extra_license', properties.domain, properties.source ); return assign( item, { extra: { google_apps_users: properties.users } } ); } -function fillGoogleAppsRegistrationData( cart, registrationData ) { +export function fillGoogleAppsRegistrationData( cart, registrationData ) { const googleAppsItems = filter( getAll( cart ), isGoogleApps ); return flow.apply( null, @@ -519,47 +548,47 @@ function fillGoogleAppsRegistrationData( cart, registrationData ) { ); } -function hasGoogleApps( cart ) { +export function hasGoogleApps( cart ) { return some( getAll( cart ), isGoogleApps ); } -function customDesignItem() { +export function customDesignItem() { return { product_slug: 'custom-design', }; } -function guidedTransferItem() { +export function guidedTransferItem() { return { product_slug: 'guided_transfer', }; } -function noAdsItem() { +export function noAdsItem() { return { product_slug: 'no-adverts/no-adverts.php', }; } -function videoPressItem() { +export function videoPressItem() { return { product_slug: 'videopress', }; } -function unlimitedSpaceItem() { +export function unlimitedSpaceItem() { return { product_slug: 'unlimited_space', }; } -function unlimitedThemesItem() { +export function unlimitedThemesItem() { return { product_slug: 'unlimited_themes', }; } -function spaceUpgradeItem( slug ) { +export function spaceUpgradeItem( slug ) { return { product_slug: slug, }; @@ -572,7 +601,7 @@ function spaceUpgradeItem( slug ) { * @param {Object} properties - list of properties * @returns {Object} the new item as `CartItemValue` object */ -function getItemForPlan( plan, properties ) { +export function getItemForPlan( plan, properties ) { properties = properties || {}; switch ( plan.product_slug ) { @@ -603,7 +632,7 @@ function getItemForPlan( plan, properties ) { * @param {Object} cart - cart as `CartValue` object * @returns {Object} the corresponding item in the shopping cart as `CartItemValue` object */ -function findFreeTrial( cart ) { +export function findFreeTrial( cart ) { return find( getAll( cart ), { free_trial: true } ); } @@ -613,7 +642,7 @@ function findFreeTrial( cart ) { * @param {Object} cart - cart as `CartValue` object * @returns {Object[]} the list of the corresponding items in the shopping cart as `CartItemValue` objects */ -function getDomainRegistrations( cart ) { +export function getDomainRegistrations( cart ) { return filter( getAll( cart ), { is_domain_registration: true } ); } @@ -623,7 +652,7 @@ function getDomainRegistrations( cart ) { * @param {Object} cart - cart as `CartValue` object * @returns {Object[]} the list of the corresponding items in the shopping cart as `CartItemValue` objects */ -function getDomainMappings( cart ) { +export function getDomainMappings( cart ) { return filter( getAll( cart ), { product_slug: 'domain_map' } ); } @@ -634,7 +663,7 @@ function getDomainMappings( cart ) { * @param {Object} [properties] - properties to be included in the new CartItem object * @returns {Object} a CartItem object */ -function getRenewalItemFromProduct( product, properties ) { +export function getRenewalItemFromProduct( product, properties ) { product = formatProduct( product ); let cartItem; @@ -693,7 +722,7 @@ function getRenewalItemFromProduct( product, properties ) { * @param {Object} properties - properties to be included in the new CartItem object * @returns {Object} a CartItem object */ -function getRenewalItemFromCartItem( cartItem, properties ) { +export function getRenewalItemFromCartItem( cartItem, properties ) { return merge( {}, cartItem, { extra: { purchaseId: properties.id, @@ -710,11 +739,11 @@ function getRenewalItemFromCartItem( cartItem, properties ) { * @param {Object} cart - cart as `CartValue` object * @returns {Object[]} the list of the corresponding items in the shopping cart as `CartItemValue` objects */ -function getSiteRedirects( cart ) { +export function getSiteRedirects( cart ) { return filter( getAll( cart ), { product_slug: 'offsite_redirect' } ); } -function hasDomainInCart( cart, domain ) { +export function hasDomainInCart( cart, domain ) { return some( getAll( cart ), { is_domain_registration: true, meta: domain } ); } @@ -725,7 +754,7 @@ function hasDomainInCart( cart, domain ) { * @param {Object} cart - cart as `CartValue` object * @returns {Object[]} the list of the corresponding items in the shopping cart as `CartItemValue` objects */ -function getDomainRegistrationsWithoutPrivacy( cart ) { +export function getDomainRegistrationsWithoutPrivacy( cart ) { return getDomainRegistrations( cart ).filter( function( cartItem ) { return ! some( cart.products, { meta: cartItem.meta, @@ -742,7 +771,7 @@ function getDomainRegistrationsWithoutPrivacy( cart ) { * @param {Function} changeFunction - the function that adds/removes the privacy protection to a shopping cart * @returns {Function} the function that adds/removes privacy protections from the shopping cart */ -function changePrivacyForDomains( cart, domainItems, changeFunction ) { +export function changePrivacyForDomains( cart, domainItems, changeFunction ) { return flow.apply( null, domainItems.map( function( item ) { @@ -751,11 +780,11 @@ function changePrivacyForDomains( cart, domainItems, changeFunction ) { ); } -function addPrivacyToAllDomains( cart ) { +export function addPrivacyToAllDomains( cart ) { return changePrivacyForDomains( cart, getDomainRegistrationsWithoutPrivacy( cart ), add ); } -function removePrivacyFromAllDomains( cart ) { +export function removePrivacyFromAllDomains( cart ) { return changePrivacyForDomains( cart, getDomainRegistrations( cart ), remove ); } @@ -765,17 +794,27 @@ function removePrivacyFromAllDomains( cart ) { * @param {Object} cartItem - `CartItemValue` object * @returns {boolean} true if item is a renewal */ -function isRenewal( cartItem ) { +export function isRenewal( cartItem ) { return cartItem.extra && cartItem.extra.purchaseType === 'renewal'; } +/** + * Determines whether a cart item is a transfer + * + * @param {Object} cartItem - `CartItemValue` object + * @returns {boolean} true if item is a renewal + */ +export function isTransfer( cartItem ) { + return cartItem.product_slug === domainProductSlugs.TRANSFER_IN; +} + /** * Determines whether a cart item supports privacy * * @param {Object} cartItem - `CartItemValue` object * @returns {boolean} true if item supports privacy */ -function privacyAvailable( cartItem ) { +export function privacyAvailable( cartItem ) { return get( cartItem, 'extra.privacy_available', true ); } @@ -785,15 +824,15 @@ function privacyAvailable( cartItem ) { * @param {Object} cartItem - `CartItemValue` object * @returns {string} the included domain */ -function getIncludedDomain( cartItem ) { +export function getIncludedDomain( cartItem ) { return cartItem.extra && cartItem.extra.includedDomain; } -function isNextDomainFree( cart ) { +export function isNextDomainFree( cart ) { return !! ( cart && cart.next_domain_is_free ); } -function isDomainBeingUsedForPlan( cart, domain ) { +export function isDomainBeingUsedForPlan( cart, domain ) { if ( cart && domain && hasPlan( cart ) ) { const domainProducts = getDomainRegistrations( cart ).concat( getDomainMappings( cart ) ), domainProduct = domainProducts.shift() || {}; @@ -803,7 +842,12 @@ function isDomainBeingUsedForPlan( cart, domain ) { return false; } -function shouldBundleDomainWithPlan( withPlansOnly, selectedSite, cart, suggestionOrCartItem ) { +export function shouldBundleDomainWithPlan( + withPlansOnly, + selectedSite, + cart, + suggestionOrCartItem +) { return ( withPlansOnly && // not free or a cart item @@ -818,7 +862,7 @@ function shouldBundleDomainWithPlan( withPlansOnly, selectedSite, cart, suggesti ); // site has a plan } -function getDomainPriceRule( withPlansOnly, selectedSite, cart, suggestion ) { +export function getDomainPriceRule( withPlansOnly, selectedSite, cart, suggestion ) { if ( ! suggestion.product_slug || suggestion.cost === 'Free' ) { return 'FREE_DOMAIN'; } @@ -844,7 +888,7 @@ function getDomainPriceRule( withPlansOnly, selectedSite, cart, suggestion ) { * @param {Object} cart - cart as `CartValue` object * @returns {boolean} true if there is at least one cart item added more than X time ago, false otherwise */ -function hasStaleItem( cart ) { +export function hasStaleItem( cart ) { return some( getAll( cart ), function( cartItem ) { // time_added_to_cart is in seconds, Date.now() returns milliseconds return ( @@ -863,6 +907,7 @@ export default { domainPrivacyProtection, domainRedemption, domainRegistration, + domainTransfer, fillGoogleAppsRegistrationData, findFreeTrial, getAll, @@ -872,6 +917,7 @@ export default { getDomainRegistrations, getDomainRegistrationsWithoutPrivacy, getDomainRegistrationTld, + getDomainTransfers, getIncludedDomain, getItemForPlan, getRenewalItemFromCartItem, @@ -882,8 +928,9 @@ export default { googleApps, googleAppsExtraLicenses, guidedTransferItem, - isNextDomainFree, isDomainBeingUsedForPlan, + isNextDomainFree, + isTransfer, hasDomainCredit, hasDomainInCart, hasDomainMapping, @@ -914,4 +961,5 @@ export default { unlimitedThemesItem, videoPressItem, hasStaleItem, + hasTransferProduct, }; diff --git a/client/lib/domains/assembler.js b/client/lib/domains/assembler.js index f7cb1da24a14f..720fd958780ff 100644 --- a/client/lib/domains/assembler.js +++ b/client/lib/domains/assembler.js @@ -10,7 +10,7 @@ import i18n from 'i18n-calypso'; /** * Internal dependencies */ -import { getDomainType } from './utils'; +import { getDomainType, getTransferStatus } from './utils'; function createDomainObjects( dataTransferObject ) { let domains = []; @@ -46,6 +46,7 @@ function createDomainObjects( dataTransferObject ) { subscriptionId: domain.subscription_id, transferLockOnWhoisUpdateOptional: domain.transfer_lock_on_whois_update_optional, type: getDomainType( domain ), + transferStatus: getTransferStatus( domain ), whoisUpdateUnmodifiableFields: domain.whois_update_unmodifiable_fields, }; } ); diff --git a/client/lib/domains/constants.js b/client/lib/domains/constants.js index 447616f64b756..b70ee9eb00497 100644 --- a/client/lib/domains/constants.js +++ b/client/lib/domains/constants.js @@ -11,6 +11,14 @@ const type = keyMirror( { REGISTERED: null, SITE_REDIRECT: null, WPCOM: null, + TRANSFER: null, +} ); + +const transferStatus = keyMirror( { + PENDING_OWNER: null, + PENDING_REGISTRY: null, + CANCELLED: null, + COMPLETED: null, } ); const registrar = { @@ -53,9 +61,15 @@ const dnsTemplates = { }, }; +const domainProductSlugs = { + TRANSFER_IN: 'domain_transfer', +}; + export default { dnsTemplates, domainAvailability, + domainProductSlugs, registrar, + transferStatus, type, }; diff --git a/client/lib/domains/index.js b/client/lib/domains/index.js index a9da97f2e6304..967d7b7afcfc3 100644 --- a/client/lib/domains/index.js +++ b/client/lib/domains/index.js @@ -50,6 +50,22 @@ function checkDomainAvailability( domainName, onComplete ) { } ); } +function checkInboundTransferStatus( domainName, onComplete ) { + if ( ! domainName ) { + onComplete( null ); + return; + } + + wpcom.undocumented().getInboundTransferStatus( domainName, function( serverError, result ) { + if ( serverError ) { + onComplete( serverError.error ); + return; + } + + onComplete( null, result ); + } ); +} + function canRedirect( siteId, domainName, onComplete ) { if ( ! domainName ) { onComplete( new ValidationError( 'empty_query' ) ); @@ -177,6 +193,7 @@ export { canAddGoogleApps, canRedirect, checkDomainAvailability, + checkInboundTransferStatus, getFixedDomainSearch, getGoogleAppsSupportedDomains, getPrimaryDomain, diff --git a/client/lib/domains/test/assembler.js b/client/lib/domains/test/assembler.js index 3d696ccacd747..300552330e17d 100644 --- a/client/lib/domains/test/assembler.js +++ b/client/lib/domains/test/assembler.js @@ -55,8 +55,9 @@ describe( 'assembler', () => { registrar: undefined, registrationMoment: undefined, subscriptionId: undefined, - type: domainTypes.SITE_REDIRECT, transferLockOnWhoisUpdateOptional: undefined, + transferStatus: null, + type: domainTypes.SITE_REDIRECT, whoisUpdateUnmodifiableFields: undefined, hasZone: undefined, pointsToWpcom: undefined, diff --git a/client/lib/domains/utils.js b/client/lib/domains/utils.js index f5f2386b84dd2..a9f2d342e651e 100644 --- a/client/lib/domains/utils.js +++ b/client/lib/domains/utils.js @@ -8,7 +8,7 @@ import { drop, isEmpty, join, find, split, values } from 'lodash'; /** * Internal dependencies */ -import { type as domainTypes } from './constants'; +import { type as domainTypes, transferStatus } from './constants'; import { cartItems } from 'lib/cart-values'; import { isDomainRegistration } from 'lib/products-values'; @@ -17,6 +17,10 @@ function getDomainType( domainFromApi ) { return domainTypes.SITE_REDIRECT; } + if ( domainFromApi.type === 'transfer' ) { + return domainTypes.TRANSFER; + } + if ( domainFromApi.wpcom_domain ) { return domainTypes.WPCOM; } @@ -28,6 +32,26 @@ function getDomainType( domainFromApi ) { return domainTypes.MAPPED; } +function getTransferStatus( domainFromApi ) { + if ( domainFromApi.transfer_status === 'pending_owner' ) { + return transferStatus.PENDING_OWNER; + } + + if ( domainFromApi.transfer_status === 'pending_registry' ) { + return transferStatus.PENDING_REGISTRY; + } + + if ( domainFromApi.transfer_status === 'cancelled' ) { + return transferStatus.CANCELLED; + } + + if ( domainFromApi.transfer_status === 'completed' ) { + return transferStatus.COMPLETED; + } + + return null; +} + /** * Depending on the current step in checkout, the user's domain can be found in * either the cart or the receipt. @@ -70,4 +94,9 @@ function parseDomainAgainstTldList( domainFragment, tldList ) { return parseDomainAgainstTldList( suffix, tldList ); } -export { getDomainNameFromReceiptOrCart, getDomainType, parseDomainAgainstTldList }; +export { + getDomainNameFromReceiptOrCart, + getDomainType, + getTransferStatus, + parseDomainAgainstTldList, +}; diff --git a/client/lib/products-values/index.js b/client/lib/products-values/index.js index 623d1ee592a38..36696d8cd5de2 100644 --- a/client/lib/products-values/index.js +++ b/client/lib/products-values/index.js @@ -27,6 +27,7 @@ import { PLAN_CHARGEBACK, PLAN_MONTHLY_PERIOD, } from 'lib/plans/constants'; +import { isTransfer } from 'lib/cart-values/cart-items'; import schema from './schema.json'; @@ -53,7 +54,7 @@ function assertValidProduct( product ) { } } -function formatProduct( product ) { +export function formatProduct( product ) { return assign( {}, product, { product_slug: product.product_slug || product.productSlug, product_type: product.product_type || product.productType, @@ -65,42 +66,42 @@ function formatProduct( product ) { } ); } -function isChargeback( product ) { +export function isChargeback( product ) { product = formatProduct( product ); assertValidProduct( product ); return product.product_slug === PLAN_CHARGEBACK; } -function includesProduct( products, product ) { +export function includesProduct( products, product ) { product = formatProduct( product ); assertValidProduct( product ); return products.indexOf( product.product_slug ) >= 0; } -function isFreePlan( product ) { +export function isFreePlan( product ) { product = formatProduct( product ); assertValidProduct( product ); return product.product_slug === PLAN_FREE; } -function isFreeJetpackPlan( product ) { +export function isFreeJetpackPlan( product ) { product = formatProduct( product ); assertValidProduct( product ); return product.product_slug === PLAN_JETPACK_FREE; } -function isFreeTrial( product ) { +export function isFreeTrial( product ) { product = formatProduct( product ); assertValidProduct( product ); return Boolean( product.free_trial ); } -function isPersonal( product ) { +export function isPersonal( product ) { const personalProducts = [ PLAN_PERSONAL, PLAN_JETPACK_PERSONAL, PLAN_JETPACK_PERSONAL_MONTHLY ]; product = formatProduct( product ); @@ -109,7 +110,7 @@ function isPersonal( product ) { return personalProducts.indexOf( product.product_slug ) >= 0; } -function isPremium( product ) { +export function isPremium( product ) { const premiumProducts = [ PLAN_PREMIUM, PLAN_JETPACK_PREMIUM, PLAN_JETPACK_PREMIUM_MONTHLY ]; product = formatProduct( product ); @@ -118,7 +119,7 @@ function isPremium( product ) { return premiumProducts.indexOf( product.product_slug ) >= 0; } -function isBusiness( product ) { +export function isBusiness( product ) { const businessProducts = [ PLAN_BUSINESS, PLAN_JETPACK_BUSINESS, PLAN_JETPACK_BUSINESS_MONTHLY ]; product = formatProduct( product ); @@ -127,60 +128,60 @@ function isBusiness( product ) { return businessProducts.indexOf( product.product_slug ) >= 0; } -function isEnterprise( product ) { +export function isEnterprise( product ) { product = formatProduct( product ); assertValidProduct( product ); return product.product_slug === PLAN_WPCOM_ENTERPRISE; } -function isJetpackPlan( product ) { +export function isJetpackPlan( product ) { product = formatProduct( product ); assertValidProduct( product ); return JETPACK_PLANS.indexOf( product.product_slug ) >= 0; } -function isJetpackBusiness( product ) { +export function isJetpackBusiness( product ) { product = formatProduct( product ); assertValidProduct( product ); return isBusiness( product ) && isJetpackPlan( product ); } -function isJetpackPremium( product ) { +export function isJetpackPremium( product ) { product = formatProduct( product ); assertValidProduct( product ); return isPremium( product ) && isJetpackPlan( product ); } -function isVipPlan( product ) { +export function isVipPlan( product ) { product = formatProduct( product ); assertValidProduct( product ); return 'vip' === product.product_slug; } -function isJetpackMonthlyPlan( product ) { +export function isJetpackMonthlyPlan( product ) { return isMonthly( product ) && isJetpackPlan( product ); } -function isMonthly( product ) { +export function isMonthly( product ) { product = formatProduct( product ); assertValidProduct( product ); return parseInt( product.bill_period, 10 ) === PLAN_MONTHLY_PERIOD; } -function isJpphpBundle( product ) { +export function isJpphpBundle( product ) { product = formatProduct( product ); assertValidProduct( product ); return product.product_slug === PLAN_HOST_BUNDLE; } -function isPlan( product ) { +export function isPlan( product ) { product = formatProduct( product ); assertValidProduct( product ); @@ -193,18 +194,18 @@ function isPlan( product ) { ); } -function isDotComPlan( product ) { +export function isDotComPlan( product ) { return isPlan( product ) && ! isJetpackPlan( product ); } -function isPrivacyProtection( product ) { +export function isPrivacyProtection( product ) { product = formatProduct( product ); assertValidProduct( product ); return product.product_slug === 'private_whois'; } -function isDomainProduct( product ) { +export function isDomainProduct( product ) { product = formatProduct( product ); assertValidProduct( product ); @@ -213,42 +214,49 @@ function isDomainProduct( product ) { ); } -function isDomainRedemption( product ) { +export function isDomainRedemption( product ) { product = formatProduct( product ); assertValidProduct( product ); return product.product_slug === 'domain_redemption'; } -function isDomainRegistration( product ) { +export function isDomainRegistration( product ) { product = formatProduct( product ); assertValidProduct( product ); return !! product.is_domain_registration; } -function isDomainMapping( product ) { +export function isDomainMapping( product ) { product = formatProduct( product ); assertValidProduct( product ); return product.product_slug === 'domain_map'; } -function isSiteRedirect( product ) { +export function isSiteRedirect( product ) { product = formatProduct( product ); assertValidProduct( product ); return product.product_slug === 'offsite_redirect'; } -function isCredits( product ) { +export function isDomainTransfer( product ) { + product = formatProduct( product ); + assertValidProduct( product ); + + return isTransfer( product ); +} + +export function isCredits( product ) { product = formatProduct( product ); assertValidProduct( product ); return 'wordpress-com-credits' === product.product_slug; } -function getDomainProductRanking( product ) { +export function getDomainProductRanking( product ) { product = formatProduct( product ); assertValidProduct( product ); @@ -261,7 +269,7 @@ function getDomainProductRanking( product ) { } } -function isDependentProduct( product, dependentProduct, domainsWithPlansOnly ) { +export function isDependentProduct( product, dependentProduct, domainsWithPlansOnly ) { let isPlansOnlyDependent = false; product = formatProduct( product ); @@ -285,13 +293,13 @@ function isDependentProduct( product, dependentProduct, domainsWithPlansOnly ) { product.meta === dependentProduct.meta ) ); } -function isFreeWordPressComDomain( product ) { +export function isFreeWordPressComDomain( product ) { product = formatProduct( product ); assertValidProduct( product ); return product.is_free === true; } -function isGoogleApps( product ) { +export function isGoogleApps( product ) { product = formatProduct( product ); assertValidProduct( product ); @@ -302,60 +310,60 @@ function isGoogleApps( product ) { ); } -function isGuidedTransfer( product ) { +export function isGuidedTransfer( product ) { product = formatProduct( product ); assertValidProduct( product ); return 'guided_transfer' === product.product_slug; } -function isTheme( product ) { +export function isTheme( product ) { product = formatProduct( product ); assertValidProduct( product ); return 'premium_theme' === product.product_slug; } -function isCustomDesign( product ) { +export function isCustomDesign( product ) { product = formatProduct( product ); assertValidProduct( product ); return 'custom-design' === product.product_slug; } -function isNoAds( product ) { +export function isNoAds( product ) { product = formatProduct( product ); assertValidProduct( product ); return 'no-adverts/no-adverts.php' === product.product_slug; } -function isVideoPress( product ) { +export function isVideoPress( product ) { product = formatProduct( product ); assertValidProduct( product ); return 'videopress' === product.product_slug; } -function isUnlimitedSpace( product ) { +export function isUnlimitedSpace( product ) { product = formatProduct( product ); assertValidProduct( product ); return 'unlimited_space' === product.product_slug; } -function isUnlimitedThemes( product ) { +export function isUnlimitedThemes( product ) { product = formatProduct( product ); assertValidProduct( product ); return 'unlimited_themes' === product.product_slug; } -function whitelistAttributes( product ) { +export function whitelistAttributes( product ) { return pick( product, Object.keys( schema.properties ) ); } -function isSpaceUpgrade( product ) { +export function isSpaceUpgrade( product ) { product = formatProduct( product ); assertValidProduct( product ); @@ -381,6 +389,7 @@ export default { isDomainProduct, isDomainRedemption, isDomainRegistration, + isDomainTransfer, isDotComPlan, isEnterprise, isFreeJetpackPlan, diff --git a/client/lib/wpcom-undocumented/lib/undocumented.js b/client/lib/wpcom-undocumented/lib/undocumented.js index 1e80fe39c5cab..e452d4b7d692f 100644 --- a/client/lib/wpcom-undocumented/lib/undocumented.js +++ b/client/lib/wpcom-undocumented/lib/undocumented.js @@ -548,12 +548,30 @@ Undocumented.prototype.isDomainAvailable = function( domain, fn ) { ); }; +/** + * Get the inbound transfer status for this domain + * + * @param {string} domain - The domain name to check. + * @param {Function} fn The callback function + * @returns {Promise} A promise that resolves when the request completes + * @api public + */ +Undocumented.prototype.getInboundTransferStatus = function( domain, fn ) { + return this.wpcom.req.get( + { + path: `/domains/${ encodeURIComponent( domain ) }/inbound-transfer-status`, + }, + fn + ); +}; + /** * Determine whether a domain name can be used for Site Redirect * * @param {int|string} siteId The site ID * @param {string} domain The domain name to check * @param {function} fn The callback function + * @returns {Promise} A promise that resolves when the request completes * @api public */ Undocumented.prototype.canRedirect = function( siteId, domain, fn ) { diff --git a/client/my-sites/checkout/checkout-thank-you/features-header.jsx b/client/my-sites/checkout/checkout-thank-you/features-header.jsx index 0bf518dc20c5d..da7b6da0b20de 100644 --- a/client/my-sites/checkout/checkout-thank-you/features-header.jsx +++ b/client/my-sites/checkout/checkout-thank-you/features-header.jsx @@ -15,6 +15,7 @@ import i18n from 'i18n-calypso'; import { isDomainMapping, isDomainRegistration, + isDomainTransfer, isGoogleApps, isGuidedTransfer, } from 'lib/products-values'; @@ -37,7 +38,8 @@ const FeaturesHeader = ( { isDataLoaded, isGenericReceipt, purchases, hasFailedP purchases.some( isGoogleApps ) || purchases.some( isDomainRegistration ) || purchases.some( isDomainMapping ) || - purchases.some( isGuidedTransfer ); + purchases.some( isGuidedTransfer ) || + purchases.some( isDomainTransfer ); if ( shouldHideFeaturesHeading ) { return
; diff --git a/client/my-sites/checkout/checkout-thank-you/header.jsx b/client/my-sites/checkout/checkout-thank-you/header.jsx index 31ce54a4b011c..4b11c5a7c2069 100644 --- a/client/my-sites/checkout/checkout-thank-you/header.jsx +++ b/client/my-sites/checkout/checkout-thank-you/header.jsx @@ -16,6 +16,7 @@ import { isChargeback, isDomainMapping, isDomainRegistration, + isDomainTransfer, isGoogleApps, isGuidedTransfer, isPlan, @@ -46,6 +47,10 @@ class CheckoutThankYouHeader extends PureComponent { return translate( 'Thank you!' ); } + if ( primaryPurchase && isDomainTransfer( primaryPurchase ) ) { + return translate( 'Check your email for important information about your transfer.' ); + } + return translate( 'Congratulations on your purchase!' ); } @@ -136,6 +141,17 @@ class CheckoutThankYouHeader extends PureComponent { ); } + if ( isDomainTransfer( primaryPurchase ) ) { + return translate( + "We're processing your request to transfer {{strong}}%(domainName)s{{/strong}} to WordPress.com. " + + 'Be on the lookout for an important mail from us to confirm the transfer.', + { + args: { domainName: primaryPurchase.meta }, + components: { strong: }, + } + ); + } + if ( isChargeback( primaryPurchase ) ) { return translate( 'Your chargeback fee is paid. Your site is doing somersaults in excitement!' @@ -184,17 +200,21 @@ class CheckoutThankYouHeader extends PureComponent { } render() { - const { isDataLoaded, hasFailedPurchases } = this.props; + const { isDataLoaded, hasFailedPurchases, primaryPurchase } = this.props; const classes = { 'is-placeholder': ! isDataLoaded }; + let svg = 'thank-you.svg'; + if ( isDomainTransfer( primaryPurchase ) ) { + svg = 'check-emails-desktop.svg'; + } + if ( hasFailedPurchases ) { + svg = 'items-failed.svg'; + } + return (
- +
diff --git a/client/my-sites/checkout/checkout-thank-you/index.jsx b/client/my-sites/checkout/checkout-thank-you/index.jsx index 757f4a639ad05..9bc05e7eb5cd5 100644 --- a/client/my-sites/checkout/checkout-thank-you/index.jsx +++ b/client/my-sites/checkout/checkout-thank-you/index.jsx @@ -43,6 +43,7 @@ import { isDomainProduct, isDomainRedemption, isDomainRegistration, + isDomainTransfer, isDotComPlan, isGoogleApps, isGuidedTransfer, @@ -369,6 +370,8 @@ class CheckoutThankYou extends React.Component { return [ DomainMappingDetails, ...findPurchaseAndDomain( purchases, isDomainMapping ) ]; } else if ( purchases.some( isSiteRedirect ) ) { return [ SiteRedirectDetails, ...findPurchaseAndDomain( purchases, isSiteRedirect ) ]; + } else if ( purchases.some( isDomainTransfer ) ) { + return [ false, ...findPurchaseAndDomain( purchases, isDomainTransfer ) ]; } else if ( purchases.some( isChargeback ) ) { return [ ChargebackDetails, find( purchases, isChargeback ) ]; } else if ( purchases.some( isGuidedTransfer ) ) { diff --git a/client/my-sites/checkout/checkout/checkout.jsx b/client/my-sites/checkout/checkout/checkout.jsx index 53467e66b90b7..abbbc77d3bb8e 100644 --- a/client/my-sites/checkout/checkout/checkout.jsx +++ b/client/my-sites/checkout/checkout/checkout.jsx @@ -5,7 +5,7 @@ */ import { connect } from 'react-redux'; -import { flatten, find, isEmpty, isEqual, reduce, startsWith } from 'lodash'; +import { flatten, find, findIndex, isEmpty, isEqual, reduce, startsWith } from 'lodash'; import i18n, { localize } from 'i18n-calypso'; import page from 'page'; import PropTypes from 'prop-types'; @@ -21,6 +21,7 @@ import { cartItems } from 'lib/cart-values'; import { clearSitePlans } from 'state/sites/plans/actions'; import { clearPurchases } from 'state/purchases/actions'; import DomainDetailsForm from './domain-details-form'; +import TransferDomainPrecheck from './transfer-domain-precheck'; import { domainMapping } from 'lib/cart-values/cart-items'; import { fetchReceiptCompleted } from 'state/receipts/actions'; import { getExitCheckoutUrl } from 'lib/checkout'; @@ -64,7 +65,10 @@ const Checkout = createReactClass( { }, getInitialState: function() { - return { previousCart: null }; + return { + previousCart: null, + domainTransfers: {}, + }; }, componentWillMount: function() { @@ -377,8 +381,36 @@ const Checkout = createReactClass( { page( redirectPath ); }, + setValidTransfer( domain ) { + const domainTransfers = {}; + domainTransfers[ domain ] = true; + + this.setState( { + domainTransfers: Object.assign( {}, this.state.domainTransfers, domainTransfers ), + } ); + }, + content: function() { - const { selectedSite } = this.props; + const { cart, selectedSite } = this.props; + const { domainTransfers } = this.state; + + if ( ! this.isLoading() && cartItems.hasTransferProduct( this.props.cart ) ) { + const domainTransfersCart = cartItems.getDomainTransfers( cart ); + const index = findIndex( domainTransfersCart, product => { + return ! domainTransfers[ product.meta ]; + } ); + + if ( index !== -1 ) { + return ( + + ); + } + } if ( ! this.isLoading() && this.needsDomainDetails() ) { return ( diff --git a/client/my-sites/checkout/checkout/style.scss b/client/my-sites/checkout/checkout/style.scss index 04c25f21ec5ac..8f1bd776f2e85 100644 --- a/client/my-sites/checkout/checkout/style.scss +++ b/client/my-sites/checkout/checkout/style.scss @@ -844,7 +844,7 @@ .checkout__payment-box-title { padding: 20px 16px 14px 20px; font-weight: bold; - display:none; + display: none; } .select-dropdown__option { @@ -917,7 +917,7 @@ div.section-nav { .section-nav-tabs__list { .checkout__payment-box-title { - display:block; + display: block; } } .section-nav-tab { diff --git a/client/my-sites/checkout/checkout/transfer-domain-precheck.jsx b/client/my-sites/checkout/checkout/transfer-domain-precheck.jsx new file mode 100644 index 0000000000000..865a072031ede --- /dev/null +++ b/client/my-sites/checkout/checkout/transfer-domain-precheck.jsx @@ -0,0 +1,185 @@ +/** + * External dependencies + * + * @format + */ +import PropTypes from 'prop-types'; +import React from 'react'; +import { localize } from 'i18n-calypso'; +import Gridicon from 'gridicons'; +import { isEmpty } from 'lodash'; + +/** + * Internal dependencies + */ +import Button from 'components/button'; +import Card from 'components/card'; +import SectionHeader from 'components/section-header'; +import { checkInboundTransferStatus } from 'lib/domains'; + +class TransferDomainPrecheck extends React.PureComponent { + static propTypes = { + total: PropTypes.number, + current: PropTypes.number, + domain: PropTypes.string, + setValid: PropTypes.func, + }; + + state = { + unlocked: false, + privacy: false, + email: '', + loading: true, + }; + + componentWillMount() { + this.refreshStatus(); + } + + componentWillUpdate( nextProps ) { + if ( nextProps.domain !== this.props.domain ) { + this.refreshStatus(); + } + } + + onClick = () => { + this.props.setValid( this.props.domain ); + }; + + refreshStatus = () => { + this.setState( { loading: true } ); + + checkInboundTransferStatus( this.props.domain, ( error, result ) => { + if ( ! isEmpty( error ) ) { + return; + } + + this.setState( { + email: result.admin_email, + privacy: false, + unlocked: result.unlocked, + loading: false, + } ); + } ); + }; + + getSection( icon, heading, message ) { + return ( +

+ { icon } + { heading } + { message } +

+ ); + } + + getStatusMessage() { + const { translate } = this.props; + const { unlocked } = this.state; + + const icon = unlocked ? ( + + ) : ( + + ); + const heading = unlocked + ? translate( 'Domain is unlocked.' ) + : translate( 'Unlock the domain.' ); + const message = unlocked + ? translate( 'Your domain is unlocked at your current registrar.' ) + : translate( 'Please unlock the domain at your current registrar so you can transfer it.' ); + + return this.getSection( icon, heading, message ); + } + + getPrivacyMessage() { + const { translate } = this.props; + const { email, privacy } = this.state; + + const icon = privacy ? ( + + ) : ( + + ); + const heading = privacy + ? translate( 'Whois privacy is disabled.' ) + : translate( 'Disable Whois privacy.' ); + const message = privacy + ? translate( + "We'll send an important email to %(email)s to start the domain transfer. If you don't recognize" + + 'this email address you might need to disable Whois privacy to make sure you receive it. After the transfer' + + 'is complete you can make your registration information private again.', + { + args: { email }, + } + ) + : translate( + "We'll send an important email to %(email)s to start the domain transfer. After the transfer" + + 'is complete you can enable privacy to hide your registration information again.', + { + args: { email }, + } + ); + + return this.getSection( icon, heading, message ); + } + + getEppMessage() { + const { translate } = this.props; + + const icon = ; + const heading = translate( 'Get domain authorization code.' ); + const message = translate( + 'Get an authorization code from your current registrar. This is sometimes ' + + 'called a secret code, EPP code, or auth code. You will need it to initiate the transfer.' + ); + + return this.getSection( icon, heading, message ); + } + + render() { + const { current, domain, total, translate } = this.props; + + let headerLabel = translate( 'Prepare Domain' ); + if ( total > 1 ) { + headerLabel = translate( 'Prepare Domain %(current)s of %(total)s: %(domain)s', { + args: { + current: current + 1, + total, + domain, + }, + } ); + } + + return ( +
+ ); + } +} + +export default localize( TransferDomainPrecheck ); diff --git a/client/my-sites/domains/components/domain-warnings/index.jsx b/client/my-sites/domains/components/domain-warnings/index.jsx index 7a38ad1e4ee19..7caac27881152 100644 --- a/client/my-sites/domains/components/domain-warnings/index.jsx +++ b/client/my-sites/domains/components/domain-warnings/index.jsx @@ -20,7 +20,7 @@ import Notice from 'components/notice'; import NoticeAction from 'components/notice/notice-action'; import PendingGappsTosNotice from './pending-gapps-tos-notice'; import purchasesPaths from 'me/purchases/paths'; -import { type as domainTypes } from 'lib/domains/constants'; +import { type as domainTypes, transferStatus } from 'lib/domains/constants'; import { isSubdomain } from 'lib/domains'; import support from 'lib/url/support'; import paths from 'my-sites/domains/paths'; @@ -61,6 +61,7 @@ export class DomainWarnings extends React.PureComponent { 'unverifiedDomainsCannotManage', 'wrongNSMappedDomains', 'newDomains', + 'transferStatus', ], }; @@ -100,6 +101,7 @@ export class DomainWarnings extends React.PureComponent { this.wrongNSMappedDomains, this.newDomains, this.pendingTransfer, + this.transferStatus, ]; const validRules = this.props.ruleWhiteList.map( ruleName => this[ ruleName ] ); @@ -749,7 +751,7 @@ export class DomainWarnings extends React.PureComponent { status="is-warning" showDismiss={ false } className="domain-warnings__notice" - key="unverified-domains" + key="pending-transfer" text={ this.props.isCompact && compactNotice } > { ! this.props.isCompact && fullNotice } @@ -757,6 +759,77 @@ export class DomainWarnings extends React.PureComponent { ); }; + transferStatus = () => { + const domainInTransfer = find( + this.getDomains(), + domain => domain.type === domainTypes.TRANSFER + ); + + if ( ! domainInTransfer ) { + return null; + } + + const { translate } = this.props; + + let status = 'is-warning'; + let compactMessage = null; + let message = translate( 'Transfer in Progress' ); + + const action = ( + + { translate( 'Fix' ) } + + ); + + switch ( domainInTransfer.transferStatus ) { + case transferStatus.PENDING_OWNER: + compactMessage = translate( 'Transfer confirmation required' ); + message = translate( + 'The transfer of {{strong}}%(domain)s{{/strong}} is in progress. We are waiting ' + + 'for authorization from your current domain provider to proceed. {{a}}Learn more{{/a}}', + { + components: { + strong: , + a: , + }, + args: { domain: domainInTransfer.name }, + } + ); + break; + case transferStatus.CANCELLED: + status = 'is-error'; + compactMessage = translate( 'Domain transfer failed' ); + message = translate( + 'The transfer of {{strong}}%(domain)s{{/strong}} has been cancelled. We were unable to ' + + 'verify the email address and authorization code within 7 days of the transfer request. ' + + 'If you still want to transfer the domain you can {{a}}try again{{/a}}.', + { + components: { + strong: , + a: , + }, + args: { domain: domainInTransfer.name }, + } + ); + break; + } + + return ( + + { this.props.isCompact ? compactMessage && action : message } + + ); + }; + componentWillMount() { if ( ! this.props.domains && ! this.props.domain ) { debug( 'You need provide either "domains" or "domain" property to this component.' ); diff --git a/client/my-sites/domains/controller.jsx b/client/my-sites/domains/controller.jsx index 8ae4a17c4d4eb..998c0fa239943 100644 --- a/client/my-sites/domains/controller.jsx +++ b/client/my-sites/domains/controller.jsx @@ -6,7 +6,7 @@ import page from 'page'; import qs from 'qs'; -import i18n from 'i18n-calypso'; +import { translate } from 'i18n-calypso'; import React from 'react'; import { get } from 'lodash'; @@ -14,10 +14,10 @@ import { get } from 'lodash'; * Internal Dependencies */ import analytics from 'lib/analytics'; +import DocumentHead from 'components/data/document-head'; import route from 'lib/route'; import Main from 'components/main'; import upgradesActions from 'lib/upgrades/actions'; -import { setDocumentHeadTitle as setTitle } from 'state/document-head/actions'; import productsFactory from 'lib/products-list'; import { renderWithReduxStore } from 'lib/react-helpers'; import { canCurrentUser } from 'state/selectors'; @@ -31,7 +31,7 @@ const productsList = productsFactory(); const domainsAddHeader = ( context, next ) => { context.getSiteSelectionHeaderText = () => { - return i18n.translate( 'Select a site to add a domain' ); + return translate( 'Select a site to add a domain' ); }; next(); @@ -39,7 +39,7 @@ const domainsAddHeader = ( context, next ) => { const domainsAddRedirectHeader = ( context, next ) => { context.getSiteSelectionHeaderText = () => { - return i18n.translate( 'Select a site to add Site Redirect' ); + return translate( 'Select a site to add Site Redirect' ); }; next(); @@ -50,9 +50,6 @@ const domainSearch = context => { const DomainSearch = require( './domain-search' ); const basePath = route.sectionify( context.path ); - // FIXME: Auto-converted from the Flux setTitle action. Please use instead. - context.store.dispatch( setTitle( i18n.translate( 'Domain Search' ) ) ); - analytics.pageView.record( basePath, 'Domain Search > Domain Registration' ); // Scroll to the top @@ -61,9 +58,12 @@ const domainSearch = context => { } renderWithReduxStore( - - - , +
+ + + + +
, document.getElementById( 'primary' ), context.store ); @@ -74,15 +74,15 @@ const siteRedirect = context => { const SiteRedirect = require( './domain-search/site-redirect' ); const basePath = route.sectionify( context.path ); - // FIXME: Auto-converted from the Flux setTitle action. Please use instead. - context.store.dispatch( setTitle( i18n.translate( 'Redirect a Site' ) ) ); - analytics.pageView.record( basePath, 'Domain Search > Site Redirect' ); renderWithReduxStore( - - - , +
+ + + + +
, document.getElementById( 'primary' ), context.store ); @@ -93,12 +93,11 @@ const mapDomain = context => { const MapDomain = require( 'my-sites/domains/map-domain' ).default; const basePath = route.sectionify( context.path ); - // FIXME: Auto-converted from the Flux setTitle action. Please use instead. - context.store.dispatch( setTitle( i18n.translate( 'Map a Domain' ) ) ); - analytics.pageView.record( basePath, 'Domain Search > Domain Mapping' ); renderWithReduxStore(
+ + @@ -108,18 +107,27 @@ const mapDomain = context => { ); }; -const googleAppsWithRegistration = context => { +const transferDomain = context => { const CartData = require( 'components/data/cart' ); - const GoogleApps = require( 'components/upgrades/google-apps' ); + const TransferDomain = require( 'my-sites/domains/transfer-domain' ).default; + const basePath = route.sectionify( context.path ); - // FIXME: Auto-converted from the Flux setTitle action. Please use instead. - context.store.dispatch( - setTitle( - i18n.translate( 'Register %(domain)s', { - args: { domain: context.params.registerDomain }, - } ) - ) + analytics.pageView.record( basePath, 'Domain Search > Domain Transfer' ); + renderWithReduxStore( +
+ + + + +
, + document.getElementById( 'primary' ), + context.store ); +}; + +const googleAppsWithRegistration = context => { + const CartData = require( 'components/data/cart' ); + const GoogleApps = require( 'components/upgrades/google-apps' ); const state = context.store.getState(); const siteSlug = getSelectedSiteSlug( state ) || ''; @@ -144,6 +152,11 @@ const googleAppsWithRegistration = context => { renderWithReduxStore(
+ + { message } + + ); + } +} + +export default localize( DomainTransferFlag ); diff --git a/client/my-sites/domains/domain-management/edit/card/header/index.jsx b/client/my-sites/domains/domain-management/edit/card/header/index.jsx index 145963350440a..4d81d8c85841d 100644 --- a/client/my-sites/domains/domain-management/edit/card/header/index.jsx +++ b/client/my-sites/domains/domain-management/edit/card/header/index.jsx @@ -11,6 +11,7 @@ import React from 'react'; * Internal dependencies */ import DomainPrimaryFlag from 'my-sites/domains/domain-management/components/domain/primary-flag'; +import DomainTransferFlag from 'my-sites/domains/domain-management/components/domain/transfer-flag'; import PrimaryDomainButton from './primary-domain-button'; import SectionHeader from 'components/section-header'; @@ -31,6 +32,7 @@ class Header extends React.Component { return ( + { this.props.selectedSite && ! this.props.selectedSite.jetpack && ( diff --git a/client/my-sites/domains/domain-management/edit/card/subscription-settings/index.jsx b/client/my-sites/domains/domain-management/edit/card/subscription-settings/index.jsx index 4d8dcbea004a9..5e8d40b73f1e5 100644 --- a/client/my-sites/domains/domain-management/edit/card/subscription-settings/index.jsx +++ b/client/my-sites/domains/domain-management/edit/card/subscription-settings/index.jsx @@ -28,6 +28,7 @@ class SubscriptionSettings extends React.Component { case domainTypes.MAPPED: case domainTypes.REGISTERED: case domainTypes.SITE_REDIRECT: + case domainTypes.TRANSFER: return purchasesPaths.managePurchase( this.props.siteSlug, this.props.subscriptionId ); default: diff --git a/client/my-sites/domains/domain-management/edit/index.jsx b/client/my-sites/domains/domain-management/edit/index.jsx index c439700247941..f74ba87f9d76b 100644 --- a/client/my-sites/domains/domain-management/edit/index.jsx +++ b/client/my-sites/domains/domain-management/edit/index.jsx @@ -21,6 +21,7 @@ import paths from 'my-sites/domains/paths'; import RegisteredDomain from './registered-domain'; import { registrar as registrarNames } from 'lib/domains/constants'; import SiteRedirect from './site-redirect'; +import Transfer from './transfer'; import { type as domainTypes } from 'lib/domains/constants'; import WpcomDomain from './wpcom-domain'; @@ -57,6 +58,9 @@ class Edit extends React.Component { case domainTypes.SITE_REDIRECT: return SiteRedirect; + case domainTypes.TRANSFER: + return Transfer; + case domainTypes.WPCOM: return WpcomDomain; diff --git a/client/my-sites/domains/domain-management/edit/transfer.jsx b/client/my-sites/domains/domain-management/edit/transfer.jsx new file mode 100644 index 0000000000000..fd6594af6e817 --- /dev/null +++ b/client/my-sites/domains/domain-management/edit/transfer.jsx @@ -0,0 +1,85 @@ +/** + * External dependencies + * + * @format + */ + +import React from 'react'; +import { localize } from 'i18n-calypso'; +import { connect } from 'react-redux'; + +/** + * Internal dependencies + */ +import Card from 'components/card/compact'; +import Header from './card/header'; +import Property from './card/property'; +import SubscriptionSettings from './card/subscription-settings'; +import DomainWarnings from 'my-sites/domains/components/domain-warnings'; +import { composeAnalytics, recordGoogleEvent, recordTracksEvent } from 'state/analytics/actions'; + +class Transfer extends React.PureComponent { + domainWarnings() { + return ( + + ); + } + + render() { + return ( +
+ { this.domainWarnings() } + { this.getDomainDetailsCard() } +
+ ); + } + + handlePaymentSettingsClick = () => { + this.props.paymentSettingsClick( this.props.domain ); + }; + + getDomainDetailsCard() { + const { domain, selectedSite, translate } = this.props; + + return ( +
+
+ + + + { translate( 'Transfer' ) } + + + + +
+ ); + } +} + +const paymentSettingsClick = domain => + composeAnalytics( + recordGoogleEvent( + 'Domain Management', + `Clicked "Payment Settings" Button on a ${ domain.type } in Edit`, + 'Domain Name', + domain.name + ), + recordTracksEvent( 'calypso_domain_management_edit_payment_settings_click', { + section: domain.type, + } ) + ); + +export default connect( null, { + paymentSettingsClick, +} )( localize( Transfer ) ); diff --git a/client/my-sites/domains/domain-management/list/item.jsx b/client/my-sites/domains/domain-management/list/item.jsx index d02fec94f453d..8ba6b49899541 100644 --- a/client/my-sites/domains/domain-management/list/item.jsx +++ b/client/my-sites/domains/domain-management/list/item.jsx @@ -15,6 +15,7 @@ import { localize } from 'i18n-calypso'; */ import CompactCard from 'components/card/compact'; import DomainPrimaryFlag from 'my-sites/domains/domain-management/components/domain/primary-flag'; +import DomainTransferFlag from 'my-sites/domains/domain-management/components/domain/transfer-flag'; import Notice from 'components/notice'; import { type as domainTypes } from 'lib/domains/constants'; import Spinner from 'components/spinner'; @@ -57,6 +58,7 @@ class ListItem extends React.PureComponent { { this.props.domain.type !== 'WPCOM' && this.showDomainExpirationWarning( this.props.domain ) } + { this.busyMessage() }
@@ -161,6 +163,9 @@ class ListItem extends React.PureComponent { case domainTypes.SITE_REDIRECT: return translate( 'Site Redirect' ); + case domainTypes.TRANSFER: + return translate( 'Transfer' ); + case domainTypes.WPCOM: return translate( 'Included with Site' ); } diff --git a/client/my-sites/domains/domain-management/style.scss b/client/my-sites/domains/domain-management/style.scss index 2a255a94fadd9..e8a44bcae2e3a 100644 --- a/client/my-sites/domains/domain-management/style.scss +++ b/client/my-sites/domains/domain-management/style.scss @@ -249,7 +249,7 @@ input[type=radio].domain-management-list-item__radio { } } -.domain-details-card { +.domain-details-card, .edit__domain-details-card { .flag { font-size: 11px; padding: 3px 10px 3px 5px; diff --git a/client/my-sites/domains/domain-search/domain-search.jsx b/client/my-sites/domains/domain-search/domain-search.jsx index a922e1deb49fd..53b8045a609e1 100644 --- a/client/my-sites/domains/domain-search/domain-search.jsx +++ b/client/my-sites/domains/domain-search/domain-search.jsx @@ -63,6 +63,11 @@ class DomainSearch extends Component { page( '/checkout/' + this.props.selectedSiteSlug ); }; + handleAddTransfer = domain => { + upgradesActions.addItem( cartItems.domainTransfer( { domain } ) ); + page( '/checkout/' + this.props.selectedSiteSlug ); + }; + componentWillMount() { this.checkSiteIsUpgradeable( this.props ); } @@ -145,9 +150,10 @@ class DomainSearch extends Component { onDomainsAvailabilityChange={ this.handleDomainsAvailabilityChange } onAddDomain={ this.handleAddRemoveDomain } onAddMapping={ this.handleAddMapping } + onAddTransfer={ this.handleAddTransfer } cart={ this.props.cart } selectedSite={ selectedSite } - offerMappingOption + offerUnavailableOption basePath={ this.props.basePath } products={ this.props.productsList } /> diff --git a/client/my-sites/domains/index.js b/client/my-sites/domains/index.js index 44a8b2bc3a169..5b6f9f73313bc 100644 --- a/client/my-sites/domains/index.js +++ b/client/my-sites/domains/index.js @@ -164,6 +164,14 @@ export default function() { controller.sites ); + page( + '/domains/add/transfer', + controller.siteSelection, + domainsController.domainsAddHeader, + controller.jetPackWarning, + controller.sites + ); + page( '/domains/add/site-redirect', controller.siteSelection, @@ -218,6 +226,15 @@ export default function() { controller.jetPackWarning, domainsController.siteRedirect ); + + page( + '/domains/add/transfer/:domain', + controller.siteSelection, + controller.navigation, + domainsController.redirectIfNoSite( '/domains/add/transfer' ), + controller.jetPackWarning, + domainsController.transferDomain + ); } page( '/domains', controller.siteSelection, controller.sites ); diff --git a/client/my-sites/domains/transfer-domain/index.jsx b/client/my-sites/domains/transfer-domain/index.jsx new file mode 100644 index 0000000000000..afa4623dcf3ac --- /dev/null +++ b/client/my-sites/domains/transfer-domain/index.jsx @@ -0,0 +1,136 @@ +/** + * External dependencies + * + * @format + */ +import page from 'page'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { localize } from 'i18n-calypso'; + +/** + * Internal dependencies + */ +import HeaderCake from 'components/header-cake'; +import TransferDomainStep from 'components/domains/transfer-domain-step'; +import { DOMAINS_WITH_PLANS_ONLY } from 'state/current-user/constants'; +import { cartItems } from 'lib/cart-values'; +import upgradesActions from 'lib/upgrades/actions'; +import Notice from 'components/notice'; +import { currentUserHasFlag } from 'state/current-user/selectors'; +import { isSiteUpgradeable } from 'state/selectors'; +import { getSelectedSite, getSelectedSiteId, getSelectedSiteSlug } from 'state/ui/selectors'; +import QueryProductsList from 'components/data/query-products-list'; +import { getProductsList } from 'state/products-list/selectors'; + +export class TransferDomain extends Component { + static propTypes = { + initialQuery: PropTypes.string, + query: PropTypes.string, + cart: PropTypes.object.isRequired, + domainsWithPlansOnly: PropTypes.bool.isRequired, + isSiteUpgradeable: PropTypes.bool, + productsList: PropTypes.object.isRequired, + selectedSite: PropTypes.object, + selectedSiteId: PropTypes.number, + selectedSiteSlug: PropTypes.string, + translate: PropTypes.func.isRequired, + }; + + state = { + errorMessage: null, + }; + + goBack = () => { + const { selectedSite, selectedSiteSlug } = this.props; + + if ( ! selectedSite ) { + page( '/domains/add' ); + return; + } + + page( '/domains/add/' + selectedSiteSlug ); + }; + + handleRegisterDomain = suggestion => { + const { selectedSiteSlug } = this.props; + + upgradesActions.addItem( + cartItems.domainRegistration( { + productSlug: suggestion.product_slug, + domain: suggestion.domain_name, + } ) + ); + + page( '/checkout/' + selectedSiteSlug ); + }; + + handleTransferDomain = domain => { + const { selectedSiteSlug } = this.props; + + this.setState( { errorMessage: null } ); + + upgradesActions.addItem( cartItems.domainTransfer( { domain } ) ); + + page( '/checkout/' + selectedSiteSlug ); + }; + + componentWillMount() { + this.checkSiteIsUpgradeable( this.props ); + } + + componentWillReceiveProps( nextProps ) { + this.checkSiteIsUpgradeable( nextProps ); + } + + checkSiteIsUpgradeable( props ) { + if ( props.selectedSite && ! props.isSiteUpgradeable ) { + page.redirect( '/domains/add/transfer' ); + } + } + + render() { + const { + cart, + domainsWithPlansOnly, + initialQuery, + productsList, + selectedSite, + translate, + } = this.props; + + const { errorMessage } = this.state; + + return ( + + + + { translate( 'Use My Own Domain' ) } + + { errorMessage && } + + + + ); + } +} + +export default connect( state => ( { + selectedSite: getSelectedSite( state ), + selectedSiteId: getSelectedSiteId( state ), + selectedSiteSlug: getSelectedSiteSlug( state ), + domainsWithPlansOnly: currentUserHasFlag( state, DOMAINS_WITH_PLANS_ONLY ), + isSiteUpgradeable: isSiteUpgradeable( state, getSelectedSiteId( state ) ), + productsList: getProductsList( state ), +} ) )( localize( TransferDomain ) ); diff --git a/client/signup/steps/domains/index.jsx b/client/signup/steps/domains/index.jsx index 1c7a0d0c4a03a..fc75e364b8be3 100644 --- a/client/signup/steps/domains/index.jsx +++ b/client/signup/steps/domains/index.jsx @@ -211,7 +211,7 @@ class DomainsStep extends React.Component { mapDomainUrl={ this.getMapDomainUrl() } onAddMapping={ this.handleAddMapping.bind( this, 'domainForm' ) } onSave={ this.handleSave.bind( this, 'domainForm' ) } - offerMappingOption={ ! this.props.isDomainOnly } + offerUnavailableOption={ ! this.props.isDomainOnly } analyticsSection="signup" domainsWithPlansOnly={ this.props.domainsWithPlansOnly } includeWordPressDotCom={ ! this.props.isDomainOnly && ! this.isDomainForAtomicSite() } diff --git a/client/state/current-user/constants.js b/client/state/current-user/constants.js index 110fe5698981c..c866630150387 100644 --- a/client/state/current-user/constants.js +++ b/client/state/current-user/constants.js @@ -1,4 +1,5 @@ /** @format */ export default { DOMAINS_WITH_PLANS_ONLY: 'calypso_domains_with_plans_only', + TRANSFER_IN: 'calypso_transfer_in', }; From feed512c8963ddd007be8137fa598339db98edb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s?= Date: Mon, 30 Oct 2017 11:47:53 +0100 Subject: [PATCH 021/192] Introduce a new way to interact with SocketIO (#19216) --- client/lib/happychat/README.md | 71 +++++ client/lib/happychat/connection-ng.js | 172 +++++++++++ client/state/action-types.js | 20 ++ client/state/happychat/connection/actions.js | 289 +++++++++++++++++++ client/state/happychat/constants.js | 1 + client/state/happychat/middleware.js | 37 +++ 6 files changed, 590 insertions(+) create mode 100644 client/lib/happychat/README.md create mode 100644 client/lib/happychat/connection-ng.js diff --git a/client/lib/happychat/README.md b/client/lib/happychat/README.md new file mode 100644 index 0000000000000..31d21b67dfb0a --- /dev/null +++ b/client/lib/happychat/README.md @@ -0,0 +1,71 @@ +# SocketIO API + +The SocketIO API models the SocketIO event flow purely as Redux actions. By making the SocketIO API Redux-driven, we have several advantages: + +* the API surface is minimal and simpler to reason about: the Redux action becomes the single point of truth, no need to modify anything else. +* testability and real-time inspection of the system behavior is a matter of taking a look at the Redux flow and state. + +## API + +The connection has the following methods: + +* `init( ... )`: configure the connection. +* `send( action )`: receives a send Redux action and emits the corresponding SocketIO event. +* `request( action, timeout )`: receives a request Redux action and emits the corresponding SocketIO event. Unlike send, the event fired takes a callback to be called upon ACK, or a timeout callback to be called if the event didn't respond after timeout milliseconds. + +### Inbound SocketIO events + +Every inbound SocketIO event dispatches its own Redux action, which is namespaced with the `HAPPYCHAT_IO_RECEIVE_EVENTNAME` type. Its action creator name convention is `receiveEventname`. + +For example: + +- the `init` SocketIO event dispatches the `receiveInit` action whose type is `HAPPYCHAT_IO_RECEIVE_INIT`. +- the `message` SocketIO event dispatches the `receiveMessage` action whose type is `HAPPYCHAT_IO_RECEIVE_MESSAGE`. + +See `client/state/happychat/connection/actions.js` for a complete list of actions. + +### Outbound SocketIO events + +Every outbound SocketIO event has a corresponding Redux action. The middleware binds the Redux action with the proper connection method. The Redux actions types are namespaced with the `HAPPYCHAT_IO_SEND_EVENTNAME` or `HAPPYCHAT_IO_REQUEST_EVENTNAME` and the corresponding action creators are named after the connection method they use and its event name. + +See `client/state/happychat/connection/actions.js` for a complete list of actions. + +#### INIT action + +The `init` action uses the `connection.init` method. Its action creator is called `initConnection` and the action shape is: + +``` +{ + type: HAPPYCHAT_IO_INIT, + auth // promise that holds the Authentication mechanism +} +``` + +#### SEND actions + +Any `send` action uses the `connection.send` method. Its action creator name convention is `sendEventname` and the action shape: + +``` +{ + type: HAPPYCHAT_IO_SEND_EVENTNAME, + event: 'eventname' + payload: ... // contents to be sent, can be anything: object, string, etc +} +``` + +Note that, at the moment of writing, we are using the `message` event to send different kind of messages: user messages, regular events, log events, and user info. These actions were shortened to convey a better API to upper layers without leaking underlying details, so the actions are named `sendMessage`, `sendEvent`, `sendLog` `sendUserInfo` instead of `sendMessageMessage`, `sendMessageEvent`, etc. + +#### REQUEST actions + +Any `request` action uses the `connection.request` method. Its creator name convention is `requestEventname` and the action shape: + +``` +{ + type: HAPPYCHAT_IO_REQUEST_EVENTNAME, + event: 'eventname', + payload: ... // contents to be sent, can be anything: object, string, etc + timeout: timeout, + callback: receiveTranscript, + callbackTimeout: receiveTranscriptTimeout, +} +``` diff --git a/client/lib/happychat/connection-ng.js b/client/lib/happychat/connection-ng.js new file mode 100644 index 0000000000000..e205fbf6e18fa --- /dev/null +++ b/client/lib/happychat/connection-ng.js @@ -0,0 +1,172 @@ +/** @format */ + +/** + * External dependencies + */ +import IO from 'socket.io-client'; +import { isString } from 'lodash'; + +/** + * Internal dependencies + */ +import { + receiveAccept, + receiveConnect, + receiveDisconnect, + receiveError, + receiveInit, + receiveMessage, + receiveReconnecting, + receiveStatus, + receiveToken, + receiveUnauthorized, + requestTranscript, +} from 'state/happychat/connection/actions'; + +const debug = require( 'debug' )( 'calypso:happychat:connection' ); + +const buildConnection = socket => + isString( socket ) + ? new IO( socket ) // If socket is an URL, connect to server. + : socket; // If socket is not an url, use it directly. Useful for testing. + +class Connection { + /** + * Init the SockeIO connection: check user authorization and bind socket events + * + * @param { Function } dispatch Redux dispatch function + * @param { Promise } auth Authentication promise, will return the user info upon fulfillment + * @return { Promise } Fulfilled (returns the opened socket) + * or rejected (returns an error message) + */ + init( dispatch, auth ) { + if ( this.openSocket ) { + debug( 'socket is already connected' ); + return this.openSocket; + } + this.dispatch = dispatch; + + this.openSocket = new Promise( ( resolve, reject ) => { + auth + .then( ( { url, user: { signer_user_id, jwt, locale, groups, geoLocation } } ) => { + const socket = buildConnection( url ); + socket + .once( 'connect', () => dispatch( receiveConnect() ) ) + .on( 'token', handler => { + dispatch( receiveToken() ); + handler( { signer_user_id, jwt, locale, groups } ); + } ) + .on( 'init', () => { + dispatch( receiveInit( { signer_user_id, locale, groups, geoLocation } ) ); + dispatch( requestTranscript() ); + resolve( socket ); + } ) + .on( 'unauthorized', () => { + socket.close(); + dispatch( receiveUnauthorized( 'User is not authorized' ) ); + reject( 'User is not authorized' ); + } ) + .on( 'disconnect', reason => dispatch( receiveDisconnect( reason ) ) ) + .on( 'reconnecting', () => dispatch( receiveReconnecting() ) ) + .on( 'status', status => dispatch( receiveStatus( status ) ) ) + .on( 'accept', accept => dispatch( receiveAccept( accept ) ) ) + .on( 'message', message => dispatch( receiveMessage( message ) ) ); + } ) + .catch( e => reject( e ) ); + } ); + + return this.openSocket; + } + + /** + * Given a Redux action, emits a SocketIO event. + * + * @param { Object } action A Redux action with props + * { + * event: SocketIO event name, + * payload: contents to be sent, + * error: message to be shown should the event fails to be sent, + * } + * @return { Promise } Fulfilled (returns nothing) + * or rejected (returns an error message) + */ + send( action ) { + if ( ! this.openSocket ) { + return; + } + return this.openSocket.then( + socket => socket.emit( action.event, action.payload ), + e => { + this.dispatch( receiveError( 'failed to send ' + action.event + ': ' + e ) ); + // so we can relay the error message, for testing purposes + return Promise.reject( e ); + } + ); + } + + /** + * + * Given a Redux action and a timeout, emits a SocketIO event that request + * some info to the Happychat server. + * + * The request can have three states, and will dispatch an action accordingly: + * + * - request was succesful: would dispatch action.callback + * - request was unsucessful: would dispatch receiveError + * - request timeout: would dispatch action.callbackTimeout + * + * @param { Object } action A Redux action with props + * { + * event: SocketIO event name, + * payload: contents to be sent, + * callback: a Redux action creator, + * callbackTimeout: a Redux action creator, + * } + * @param { Number } timeout How long (in milliseconds) has the server to respond + * @return { Promise } Fulfilled (returns the transcript response) + * or rejected (returns an error message) + */ + request( action, timeout ) { + if ( ! this.openSocket ) { + return; + } + + return this.openSocket.then( + socket => { + const promiseRace = Promise.race( [ + new Promise( ( resolve, reject ) => { + socket.emit( action.event, action.payload, ( e, result ) => { + if ( e ) { + return reject( new Error( e ) ); // request successful + } + return resolve( result ); // request failed + } ); + } ), + new Promise( ( resolve, reject ) => + setTimeout( () => { + return reject( Error( 'timeout' ) ); // request timeout + }, timeout ) + ), + ] ); + + // dispatch the request state upon promise race resolution + promiseRace.then( + result => this.dispatch( action.callback( result ) ), + e => + e.message === 'timeout' + ? this.dispatch( action.callbackTimeout() ) + : this.dispatch( receiveError( action.event + ' request failed: ' + e.message ) ) + ); + + return promiseRace; + }, + e => { + this.dispatch( receiveError( 'failed to send ' + action.event + ': ' + e ) ); + // so we can relay the error message, for testing purposes + return Promise.reject( e ); + } + ); + } +} + +export default () => new Connection(); diff --git a/client/state/action-types.js b/client/state/action-types.js index 7407cebc313b1..f643df7ae251e 100644 --- a/client/state/action-types.js +++ b/client/state/action-types.js @@ -212,6 +212,26 @@ export const HAPPYCHAT_CONNECTED = 'HAPPYCHAT_CONNECTED'; export const HAPPYCHAT_CONNECTING = 'HAPPYCHAT_CONNECTING'; export const HAPPYCHAT_DISCONNECTED = 'HAPPYCHAT_DISCONNECTED'; export const HAPPYCHAT_FOCUS = 'HAPPYCHAT_FOCUS'; +export const HAPPYCHAT_IO_INIT = 'HAPPYCHAT_IO_INIT'; +export const HAPPYCHAT_IO_RECEIVE_ACCEPT = 'HAPPYCHAT_IO_RECEIVE_ACCEPT'; +export const HAPPYCHAT_IO_RECEIVE_CONNECT = 'HAPPYCHAT_IO_RECEIVE_CONNECT'; +export const HAPPYCHAT_IO_RECEIVE_DISCONNECT = 'HAPPYCHAT_IO_RECEIVE_DISCONNECT'; +export const HAPPYCHAT_IO_RECEIVE_ERROR = 'HAPPYCHAT_IO_RECEIVE_ERROR'; +export const HAPPYCHAT_IO_RECEIVE_INIT = 'HAPPYCHAT_IO_RECEIVE_INIT'; +export const HAPPYCHAT_IO_RECEIVE_MESSAGE = 'HAPPYCHAT_IO_RECEIVE_MESSAGE'; +export const HAPPYCHAT_IO_RECEIVE_RECONNECTING = 'HAPPYCHAT_IO_RECEIVE_RECONNECTING'; +export const HAPPYCHAT_IO_RECEIVE_STATUS = 'HAPPYCHAT_IO_RECEIVE_STATUS'; +export const HAPPYCHAT_IO_RECEIVE_TOKEN = 'HAPPYCHAT_IO_RECEIVE_TOKEN'; +export const HAPPYCHAT_IO_RECEIVE_UNAUTHORIZED = 'HAPPYCHAT_IO_RECEIVE_UNAUTHORIZED'; +export const HAPPYCHAT_IO_REQUEST_TRANSCRIPT = 'HAPPYCHAT_IO_REQUEST_TRANSCRIPT'; +export const HAPPYCHAT_IO_REQUEST_TRANSCRIPT_RECEIVE = 'HAPPYCHAT_IO_REQUEST_TRANSCRIPT_RECEIVE'; +export const HAPPYCHAT_IO_REQUEST_TRANSCRIPT_TIMEOUT = 'HAPPYCHAT_IO_REQUEST_TRANSCRIPT_TIMEOUT'; +export const HAPPYCHAT_IO_SEND_MESSAGE_EVENT = 'HAPPYCHAT_IO_SEND_MESSAGE_EVENT'; +export const HAPPYCHAT_IO_SEND_MESSAGE_LOG = 'HAPPYCHAT_IO_SEND_MESSAGE_LOG'; +export const HAPPYCHAT_IO_SEND_MESSAGE_MESSAGE = 'HAPPYCHAT_IO_SEND_MESSAGE_MESSAGE'; +export const HAPPYCHAT_IO_SEND_MESSAGE_USERINFO = 'HAPPYCHAT_IO_SEND_MESSAGE_USERINFO'; +export const HAPPYCHAT_IO_SEND_PREFERENCES = 'HAPPYCHAT_IO_SEND_PREFERENCES'; +export const HAPPYCHAT_IO_SEND_TYPING = 'HAPPYCHAT_IO_SEND_TYPING'; export const HAPPYCHAT_INITIALIZE = 'HAPPYCHAT_INITIALIZE'; export const HAPPYCHAT_MINIMIZING = 'HAPPYCHAT_MINIMIZING'; export const HAPPYCHAT_OPEN = 'HAPPYCHAT_OPEN'; diff --git a/client/state/happychat/connection/actions.js b/client/state/happychat/connection/actions.js index b9a14058aaaa0..31e6820b155e2 100644 --- a/client/state/happychat/connection/actions.js +++ b/client/state/happychat/connection/actions.js @@ -1,5 +1,10 @@ /** @format **/ +/** + * External dependencies + */ +import { v4 as uuid } from 'uuid'; + /** * Internal dependencies */ @@ -16,7 +21,29 @@ import { HAPPYCHAT_SET_AVAILABLE, HAPPYCHAT_TRANSCRIPT_RECEIVE, HAPPYCHAT_TRANSCRIPT_REQUEST, + // NEW ACTION TYPES + HAPPYCHAT_IO_INIT, + HAPPYCHAT_IO_RECEIVE_ACCEPT, + HAPPYCHAT_IO_RECEIVE_CONNECT, + HAPPYCHAT_IO_RECEIVE_DISCONNECT, + HAPPYCHAT_IO_RECEIVE_ERROR, + HAPPYCHAT_IO_RECEIVE_INIT, + HAPPYCHAT_IO_RECEIVE_MESSAGE, + HAPPYCHAT_IO_RECEIVE_RECONNECTING, + HAPPYCHAT_IO_RECEIVE_STATUS, + HAPPYCHAT_IO_RECEIVE_TOKEN, + HAPPYCHAT_IO_RECEIVE_UNAUTHORIZED, + HAPPYCHAT_IO_REQUEST_TRANSCRIPT, + HAPPYCHAT_IO_REQUEST_TRANSCRIPT_RECEIVE, + HAPPYCHAT_IO_REQUEST_TRANSCRIPT_TIMEOUT, + HAPPYCHAT_IO_SEND_MESSAGE_EVENT, + HAPPYCHAT_IO_SEND_MESSAGE_MESSAGE, + HAPPYCHAT_IO_SEND_MESSAGE_LOG, + HAPPYCHAT_IO_SEND_MESSAGE_USERINFO, + HAPPYCHAT_IO_SEND_PREFERENCES, + HAPPYCHAT_IO_SEND_TYPING, } from 'state/action-types'; +import { HAPPYCHAT_MESSAGE_TYPES } from 'state/happychat/constants'; export const connectChat = () => ( { type: HAPPYCHAT_CONNECT } ); @@ -67,3 +94,265 @@ export const receiveChatTranscript = ( messages, timestamp ) => ( { messages, timestamp, } ); + +// === NEW ACTION CREATORS ===================================================== +// === NEW ACTION CREATORS ===================================================== +// === NEW ACTION CREATORS ===================================================== + +/** + * Returns an action object indicating that the connection is being stablished. + * + * @param { Promise } auth Authentication promise, will return the user info upon fulfillment + * @return { Object } Action object + */ +export const initConnection = auth => ( { type: HAPPYCHAT_IO_INIT, auth } ); + +/** + * Returns an action object for the connect event, + * as it was received from Happychat. + * + * @return { Object } Action object + */ +export const receiveConnect = () => ( { type: HAPPYCHAT_IO_RECEIVE_CONNECT } ); + +/** + * Returns an action object for the disconnect event, + * as it was received from Happychat. + * + * @param { String } error The error + * @return { Object } Action object + */ +export const receiveDisconnect = error => ( { + type: HAPPYCHAT_IO_RECEIVE_DISCONNECT, + error, +} ); + +/** + * Returns an action object for the token event, + * as it was received from Happychat. + * + * @return { Object } Action object + */ +export const receiveToken = () => ( { type: HAPPYCHAT_IO_RECEIVE_TOKEN } ); + +/** + * Returns an action object for the init event, as received from Happychat. + * Indicates that the connection is ready to be used. + * + * @param { Object } user User object received + * @return { Object } Action object + */ +export const receiveInit = user => ( { type: HAPPYCHAT_IO_RECEIVE_INIT, user } ); + +/** + * Returns an action object for the unauthorized event, + * as it was received from Happychat + * + * @param { String } error Error reported + * @return { Object } Action object + */ +export const receiveUnauthorized = error => ( { + type: HAPPYCHAT_IO_RECEIVE_UNAUTHORIZED, + error, +} ); + +/** + * Returns an action object for the reconnecting event, + * as it was received from Happychat. + * + * @return { Object } Action object + */ +export const receiveReconnecting = () => ( { type: HAPPYCHAT_IO_RECEIVE_RECONNECTING } ); + +/** + * Returns an action object for the accept event indicating the system availability, + * as it was received from Happychat. + * + * @param { Object } isAvailable Whether Happychat is available + * @return { Object } Action object + */ +export const receiveAccept = isAvailable => ( { + type: HAPPYCHAT_IO_RECEIVE_ACCEPT, + isAvailable, +} ); + +/** + * Returns an action object for the message event, + * as it was received from Happychat. + * + * @param { Object } message Message received + * @return { Object } Action object + */ +export const receiveMessage = message => ( { type: HAPPYCHAT_IO_RECEIVE_MESSAGE, message } ); + +/** + * Returns an action object for the status event, + * as it was received from Happychat. + * + * @param { String } status New chat status + * @return { Object } Action object + */ +export const receiveStatus = status => ( { + type: HAPPYCHAT_IO_RECEIVE_STATUS, + status, +} ); + +/** + * Returns an action object with the error received from Happychat + * upon trying to send an event. + * + * @param { Object } error Error received + * @return { Object } Action object + */ +export const receiveError = error => ( { type: HAPPYCHAT_IO_RECEIVE_ERROR, error } ); + +/** + * Returns an action object for the transcript reception. + * + * @param { Object } result An object with {messages, timestamp} props + * @return { Object } Action object + */ +export const receiveTranscript = ( { messages, timestamp } ) => ( { + type: HAPPYCHAT_IO_REQUEST_TRANSCRIPT_RECEIVE, + messages, + timestamp, +} ); + +/** + * Returns an action object for the timeout of the transcript request. + * + * @return { Object } Action object + */ +export const receiveTranscriptTimeout = () => ( { + type: HAPPYCHAT_IO_REQUEST_TRANSCRIPT_TIMEOUT, +} ); + +/** + * Returns an action object that prepares the transcript request + * to be send to happychat as a SocketIO event. + * + * @param { String } timestamp Latest transcript timestamp + * @param { Number } timeout The number of milliseconds to wait for server response. + * If it hasn't responded after the timeout, the connection library + * will dispatch the receiveTranscriptTimeout action. + * @return { Object } Action object + */ +export const requestTranscript = ( timestamp, timeout = 10000 ) => ( { + type: HAPPYCHAT_IO_REQUEST_TRANSCRIPT, + event: 'transcript', + payload: timestamp, + timeout: timeout, + callback: receiveTranscript, + callbackTimeout: receiveTranscriptTimeout, +} ); + +/** + * Returns an action object that prepares the chat message + * to be send to Happychat as a SocketIO event. + * + * @param { Object } message Message to be sent + * @param { Object } meta meta info to be sent along the message + * @return { Object } Action object + */ +export const sendMessage = ( message, meta = {} ) => ( { + type: HAPPYCHAT_IO_SEND_MESSAGE_MESSAGE, + event: 'message', + payload: { id: uuid(), text: message, meta }, +} ); + +/** + * Returns an action object that prepares the event message + * to be send to Happychat as a SocketIO event. + * + * @param { Object } message Message to be sent + * @return { Object } Action object + */ +export const sendEvent = message => ( { + type: HAPPYCHAT_IO_SEND_MESSAGE_EVENT, + event: 'message', + payload: { + id: uuid(), + text: message, + type: HAPPYCHAT_MESSAGE_TYPES.CUSTOMER_EVENT, + meta: { forOperator: true, event_type: HAPPYCHAT_MESSAGE_TYPES.CUSTOMER_EVENT }, + }, +} ); + +/** + * Returns an action object that prepares the log message + * to be send to Happychat as a SocketIO event. + * + * @param { Object } message Message to be sent + * @return { Object } Action object + */ +export const sendLog = message => ( { + type: HAPPYCHAT_IO_SEND_MESSAGE_LOG, + event: 'message', + payload: { + id: uuid(), + text: message, + type: HAPPYCHAT_MESSAGE_TYPES.LOG, + meta: { forOperator: true, event_type: HAPPYCHAT_MESSAGE_TYPES.LOG }, + }, +} ); + +/** + * Returns an action object that prepares the user information + * to be send to Happychat as a SocketIO event. + * + * @param { Object } info Selected user info + * @return { Object } Action object + */ +// TODO: rename to sendUserInfo when this substitutes old action +export const sendUserInfoNG = info => ( { + type: HAPPYCHAT_IO_SEND_MESSAGE_USERINFO, + event: 'message', + payload: { + id: uuid(), + type: HAPPYCHAT_MESSAGE_TYPES.CUSTOMER_INFO, + meta: { + forOperator: true, + ...info, + }, + }, +} ); + +/** + * Returns an action object that prepares the typing info + * to be sent to Happychat as a SocketIO event. + * + * @param { Object } message What the user is typing + * @return { Object } Action object + */ +export const sendTyping = message => ( { + type: HAPPYCHAT_IO_SEND_TYPING, + event: 'typing', + payload: { + message, + }, +} ); + +/** + * Returns an action object that prepares typing info (the user stopped typing) + * to be sent to Happychat as a SocketIO event. + * + * @return { Object } Action object + */ +export const sendNotTyping = () => sendTyping( false ); + +/** + * Returns an action object that prepares the user routing preferences (locale and groups) + * to be send to happychat as a SocketIO event. + * + * @param { String } locale representing the user selected locale + * @param { Array } groups of string happychat groups (wp.com, jpop) based on the site selected + * @return { Object } Action object + */ +export const sendPreferences = ( locale, groups ) => ( { + type: HAPPYCHAT_IO_SEND_PREFERENCES, + event: 'preferences', + payload: { + locale, + groups, + }, +} ); diff --git a/client/state/happychat/constants.js b/client/state/happychat/constants.js index 83a3af5032bcb..34265fa318296 100644 --- a/client/state/happychat/constants.js +++ b/client/state/happychat/constants.js @@ -12,6 +12,7 @@ export const HAPPYCHAT_CONNECTION_STATUS_CONNECTING = 'connecting'; export const HAPPYCHAT_CONNECTION_STATUS_CONNECTED = 'connected'; export const HAPPYCHAT_CONNECTION_STATUS_DISCONNECTED = 'disconnected'; export const HAPPYCHAT_CONNECTION_STATUS_RECONNECTING = 'reconnecting'; +export const HAPPYCHAT_CONNECTION_STATUS_UNAUTHORIZED = 'unauthorized'; // Max number of messages to save between refreshes export const HAPPYCHAT_MAX_STORED_MESSAGES = 30; diff --git a/client/state/happychat/middleware.js b/client/state/happychat/middleware.js index 2012426f4e2cc..31190eb5e2dd6 100644 --- a/client/state/happychat/middleware.js +++ b/client/state/happychat/middleware.js @@ -16,6 +16,16 @@ import { ANALYTICS_EVENT_RECORD, HAPPYCHAT_CONNECT, HAPPYCHAT_INITIALIZE, + // new happychat action types + HAPPYCHAT_IO_INIT, + HAPPYCHAT_IO_REQUEST_TRANSCRIPT, + HAPPYCHAT_IO_SEND_MESSAGE_EVENT, + HAPPYCHAT_IO_SEND_MESSAGE_LOG, + HAPPYCHAT_IO_SEND_MESSAGE_MESSAGE, + HAPPYCHAT_IO_SEND_MESSAGE_USERINFO, + HAPPYCHAT_IO_SEND_PREFERENCES, + HAPPYCHAT_IO_SEND_TYPING, + // end of new happychat action types HAPPYCHAT_SEND_USER_INFO, HAPPYCHAT_SEND_MESSAGE, HAPPYCHAT_SET_CURRENT_MESSAGE, @@ -313,6 +323,14 @@ export default function( connection = null ) { connection = require( './common' ).connection; } + // This is a placeholder to make sure connectionNG is never used, + // but doesn't give a compilation error either. + const connectionNG = { + init: () => {}, + send: () => {}, + request: () => {}, + }; + return store => next => action => { // Send any relevant log/event data from this action to Happychat sendActionLogsAndEvents( connection, store, action ); @@ -349,6 +367,25 @@ export default function( connection = null ) { case ROUTE_SET: sendRouteSetEventMessage( connection, store, action ); break; + + // NEW SOCKET API SURFACE + case HAPPYCHAT_IO_INIT: + connectionNG.init( store.dispatch, action.config ); + break; + + case HAPPYCHAT_IO_SEND_MESSAGE_EVENT: + case HAPPYCHAT_IO_SEND_MESSAGE_LOG: + case HAPPYCHAT_IO_SEND_MESSAGE_MESSAGE: + case HAPPYCHAT_IO_SEND_MESSAGE_USERINFO: + case HAPPYCHAT_IO_SEND_PREFERENCES: + case HAPPYCHAT_IO_SEND_TYPING: + connectionNG.send( action ); + break; + + case HAPPYCHAT_IO_REQUEST_TRANSCRIPT: + connectionNG.request( action, action.timeout ); + break; + // END OF NEW SOCKET API SURFACE } return next( action ); }; From 5541ab5304a1ab5dd663d9a8177d15e4161186c7 Mon Sep 17 00:00:00 2001 From: Jon Surrell Date: Mon, 30 Oct 2017 12:10:14 +0100 Subject: [PATCH 022/192] Cards: Remove create-react-class (#19265) * Update Card tests to use Jest over Chai * Add snapshot tests to card component * Replace create-react-class with function component * Simplify props passing --- client/components/card/compact.jsx | 24 +- .../card/test/__snapshots__/index.js.snap | 721 ++++++++++++++++++ client/components/card/test/index.js | 31 +- 3 files changed, 748 insertions(+), 28 deletions(-) create mode 100644 client/components/card/test/__snapshots__/index.js.snap diff --git a/client/components/card/compact.jsx b/client/components/card/compact.jsx index 755e0103dd42f..03d733e29314c 100644 --- a/client/components/card/compact.jsx +++ b/client/components/card/compact.jsx @@ -1,27 +1,19 @@ +/** @format */ /** * External dependencies - * - * @format */ - import React from 'react'; -import { assign } from 'lodash'; import classnames from 'classnames'; -import createClass from 'create-react-class'; /** * Internal dependencies */ import Card from 'components/card'; -export default createClass( { - displayName: 'CompactCard', - - render: function() { - const props = assign( {}, this.props, { - className: classnames( this.props.className, 'is-compact' ), - } ); - - return { this.props.children }; - }, -} ); +export default function CompactCard( props ) { + return ( + + { props.children } + + ); +} diff --git a/client/components/card/test/__snapshots__/index.js.snap b/client/components/card/test/__snapshots__/index.js.snap new file mode 100644 index 0000000000000..4434e45ade7a1 --- /dev/null +++ b/client/components/card/test/__snapshots__/index.js.snap @@ -0,0 +1,721 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Card should be linkable 1`] = ` +ShallowWrapper { + "complexSelector": ComplexSelector { + "buildPredicate": [Function], + "childrenOfNode": [Function], + "findWhereUnwrapped": [Function], + }, + "length": 1, + "node": + + This is a linked card + , + "nodes": Array [ + + + This is a linked card + , + ], + "options": Object {}, + "renderer": ReactShallowRenderer { + "_instance": ShallowComponentWrapper { + "_calledComponentWillUnmount": false, + "_compositeType": 0, + "_context": Object {}, + "_currentElement": + This is a linked card + , + "_debugID": 7, + "_hostContainerInfo": null, + "_hostParent": null, + "_instance": Card { + "_reactInternalInstance": [Circular], + "context": Object {}, + "props": Object { + "children": "This is a linked card", + "highlight": false, + "href": "/test", + "tagName": "div", + }, + "refs": Object {}, + "state": null, + "updater": Object { + "enqueueCallback": [Function], + "enqueueCallbackInternal": [Function], + "enqueueElementInternal": [Function], + "enqueueForceUpdate": [Function], + "enqueueReplaceState": [Function], + "enqueueSetState": [Function], + "isMounted": [Function], + "validateCallback": [Function], + }, + }, + "_mountOrder": 4, + "_pendingCallbacks": null, + "_pendingElement": null, + "_pendingForceUpdate": false, + "_pendingReplaceState": false, + "_pendingStateQueue": null, + "_renderedComponent": NoopInternalComponent { + "_currentElement": + + This is a linked card + , + "_debugID": 8, + "_renderedOutput": + + This is a linked card + , + }, + "_renderedNodeType": 0, + "_rootNodeID": 0, + "_topLevelWrapper": null, + "_updateBatchNumber": null, + "_warnedAboutRefsInRender": false, + }, + "getRenderOutput": [Function], + "render": [Function], + }, + "root": [Circular], + "unrendered": + This is a linked card + , +} +`; + +exports[`Card should have \`card\` class 1`] = ` +ShallowWrapper { + "complexSelector": ComplexSelector { + "buildPredicate": [Function], + "childrenOfNode": [Function], + "findWhereUnwrapped": [Function], + }, + "length": 1, + "node":
, + "nodes": Array [ +
, + ], + "options": Object {}, + "renderer": ReactShallowRenderer { + "_instance": ShallowComponentWrapper { + "_calledComponentWillUnmount": false, + "_compositeType": 0, + "_context": Object {}, + "_currentElement": , + "_debugID": 1, + "_hostContainerInfo": null, + "_hostParent": null, + "_instance": Card { + "_reactInternalInstance": [Circular], + "context": Object {}, + "props": Object { + "highlight": false, + "tagName": "div", + }, + "refs": Object {}, + "state": null, + "updater": Object { + "enqueueCallback": [Function], + "enqueueCallbackInternal": [Function], + "enqueueElementInternal": [Function], + "enqueueForceUpdate": [Function], + "enqueueReplaceState": [Function], + "enqueueSetState": [Function], + "isMounted": [Function], + "validateCallback": [Function], + }, + }, + "_mountOrder": 1, + "_pendingCallbacks": null, + "_pendingElement": null, + "_pendingForceUpdate": false, + "_pendingReplaceState": false, + "_pendingStateQueue": null, + "_renderedComponent": NoopInternalComponent { + "_currentElement":
, + "_debugID": 2, + "_renderedOutput":
, + }, + "_renderedNodeType": 0, + "_rootNodeID": 0, + "_topLevelWrapper": null, + "_updateBatchNumber": null, + "_warnedAboutRefsInRender": false, + }, + "getRenderOutput": [Function], + "render": [Function], + }, + "root": [Circular], + "unrendered": , +} +`; + +exports[`Card should have custom class of \`test__ace\` 1`] = ` +ShallowWrapper { + "complexSelector": ComplexSelector { + "buildPredicate": [Function], + "childrenOfNode": [Function], + "findWhereUnwrapped": [Function], + }, + "length": 1, + "node":
, + "nodes": Array [ +
, + ], + "options": Object {}, + "renderer": ReactShallowRenderer { + "_instance": ShallowComponentWrapper { + "_calledComponentWillUnmount": false, + "_compositeType": 0, + "_context": Object {}, + "_currentElement": , + "_debugID": 3, + "_hostContainerInfo": null, + "_hostParent": null, + "_instance": Card { + "_reactInternalInstance": [Circular], + "context": Object {}, + "props": Object { + "className": "test__ace", + "highlight": false, + "tagName": "div", + }, + "refs": Object {}, + "state": null, + "updater": Object { + "enqueueCallback": [Function], + "enqueueCallbackInternal": [Function], + "enqueueElementInternal": [Function], + "enqueueForceUpdate": [Function], + "enqueueReplaceState": [Function], + "enqueueSetState": [Function], + "isMounted": [Function], + "validateCallback": [Function], + }, + }, + "_mountOrder": 2, + "_pendingCallbacks": null, + "_pendingElement": null, + "_pendingForceUpdate": false, + "_pendingReplaceState": false, + "_pendingStateQueue": null, + "_renderedComponent": NoopInternalComponent { + "_currentElement":
, + "_debugID": 4, + "_renderedOutput":
, + }, + "_renderedNodeType": 0, + "_rootNodeID": 0, + "_topLevelWrapper": null, + "_updateBatchNumber": null, + "_warnedAboutRefsInRender": false, + }, + "getRenderOutput": [Function], + "render": [Function], + }, + "root": [Circular], + "unrendered": , +} +`; + +exports[`Card should render children 1`] = ` +ShallowWrapper { + "complexSelector": ComplexSelector { + "buildPredicate": [Function], + "childrenOfNode": [Function], + "findWhereUnwrapped": [Function], + }, + "length": 1, + "node":
+ This is a card +
, + "nodes": Array [ +
+ This is a card +
, + ], + "options": Object {}, + "renderer": ReactShallowRenderer { + "_instance": ShallowComponentWrapper { + "_calledComponentWillUnmount": false, + "_compositeType": 0, + "_context": Object {}, + "_currentElement": + This is a card + , + "_debugID": 5, + "_hostContainerInfo": null, + "_hostParent": null, + "_instance": Card { + "_reactInternalInstance": [Circular], + "context": Object {}, + "props": Object { + "children": "This is a card", + "highlight": false, + "tagName": "div", + }, + "refs": Object {}, + "state": null, + "updater": Object { + "enqueueCallback": [Function], + "enqueueCallbackInternal": [Function], + "enqueueElementInternal": [Function], + "enqueueForceUpdate": [Function], + "enqueueReplaceState": [Function], + "enqueueSetState": [Function], + "isMounted": [Function], + "validateCallback": [Function], + }, + }, + "_mountOrder": 3, + "_pendingCallbacks": null, + "_pendingElement": null, + "_pendingForceUpdate": false, + "_pendingReplaceState": false, + "_pendingStateQueue": null, + "_renderedComponent": NoopInternalComponent { + "_currentElement":
+ This is a card +
, + "_debugID": 6, + "_renderedOutput":
+ This is a card +
, + }, + "_renderedNodeType": 0, + "_rootNodeID": 0, + "_topLevelWrapper": null, + "_updateBatchNumber": null, + "_warnedAboutRefsInRender": false, + }, + "getRenderOutput": [Function], + "render": [Function], + }, + "root": [Circular], + "unrendered": + This is a card + , +} +`; + +exports[`CompactCard should have \`is-compact\` class 1`] = ` +ShallowWrapper { + "complexSelector": ComplexSelector { + "buildPredicate": [Function], + "childrenOfNode": [Function], + "findWhereUnwrapped": [Function], + }, + "length": 1, + "node": , + "nodes": Array [ + , + ], + "options": Object {}, + "renderer": ReactShallowRenderer { + "_instance": ShallowComponentWrapper { + "_calledComponentWillUnmount": false, + "_compositeType": 2, + "_context": Object {}, + "_currentElement": , + "_debugID": 9, + "_hostContainerInfo": null, + "_hostParent": null, + "_instance": StatelessComponent { + "_reactInternalInstance": [Circular], + "context": Object {}, + "props": Object {}, + "refs": Object {}, + "state": null, + "updater": Object { + "enqueueCallback": [Function], + "enqueueCallbackInternal": [Function], + "enqueueElementInternal": [Function], + "enqueueForceUpdate": [Function], + "enqueueReplaceState": [Function], + "enqueueSetState": [Function], + "isMounted": [Function], + "validateCallback": [Function], + }, + }, + "_mountOrder": 5, + "_pendingCallbacks": null, + "_pendingElement": null, + "_pendingForceUpdate": false, + "_pendingReplaceState": false, + "_pendingStateQueue": null, + "_renderedComponent": NoopInternalComponent { + "_currentElement": , + "_debugID": 10, + "_renderedOutput": , + }, + "_renderedNodeType": 1, + "_rootNodeID": 0, + "_topLevelWrapper": null, + "_updateBatchNumber": null, + "_warnedAboutRefsInRender": false, + }, + "getRenderOutput": [Function], + "render": [Function], + }, + "root": [Circular], + "unrendered": , +} +`; + +exports[`CompactCard should have custom class of \`test__ace\` 1`] = ` +ShallowWrapper { + "complexSelector": ComplexSelector { + "buildPredicate": [Function], + "childrenOfNode": [Function], + "findWhereUnwrapped": [Function], + }, + "length": 1, + "node": , + "nodes": Array [ + , + ], + "options": Object {}, + "renderer": ReactShallowRenderer { + "_instance": ShallowComponentWrapper { + "_calledComponentWillUnmount": false, + "_compositeType": 2, + "_context": Object {}, + "_currentElement": , + "_debugID": 11, + "_hostContainerInfo": null, + "_hostParent": null, + "_instance": StatelessComponent { + "_reactInternalInstance": [Circular], + "context": Object {}, + "props": Object { + "className": "test__ace", + }, + "refs": Object {}, + "state": null, + "updater": Object { + "enqueueCallback": [Function], + "enqueueCallbackInternal": [Function], + "enqueueElementInternal": [Function], + "enqueueForceUpdate": [Function], + "enqueueReplaceState": [Function], + "enqueueSetState": [Function], + "isMounted": [Function], + "validateCallback": [Function], + }, + }, + "_mountOrder": 6, + "_pendingCallbacks": null, + "_pendingElement": null, + "_pendingForceUpdate": false, + "_pendingReplaceState": false, + "_pendingStateQueue": null, + "_renderedComponent": NoopInternalComponent { + "_currentElement": , + "_debugID": 12, + "_renderedOutput": , + }, + "_renderedNodeType": 1, + "_rootNodeID": 0, + "_topLevelWrapper": null, + "_updateBatchNumber": null, + "_warnedAboutRefsInRender": false, + }, + "getRenderOutput": [Function], + "render": [Function], + }, + "root": [Circular], + "unrendered": , +} +`; + +exports[`CompactCard should render children 1`] = ` +ShallowWrapper { + "complexSelector": ComplexSelector { + "buildPredicate": [Function], + "childrenOfNode": [Function], + "findWhereUnwrapped": [Function], + }, + "length": 1, + "node": + This is a compact card + , + "nodes": Array [ + + This is a compact card + , + ], + "options": Object {}, + "renderer": ReactShallowRenderer { + "_instance": ShallowComponentWrapper { + "_calledComponentWillUnmount": false, + "_compositeType": 2, + "_context": Object {}, + "_currentElement": + This is a compact card + , + "_debugID": 13, + "_hostContainerInfo": null, + "_hostParent": null, + "_instance": StatelessComponent { + "_reactInternalInstance": [Circular], + "context": Object {}, + "props": Object { + "children": "This is a compact card", + }, + "refs": Object {}, + "state": null, + "updater": Object { + "enqueueCallback": [Function], + "enqueueCallbackInternal": [Function], + "enqueueElementInternal": [Function], + "enqueueForceUpdate": [Function], + "enqueueReplaceState": [Function], + "enqueueSetState": [Function], + "isMounted": [Function], + "validateCallback": [Function], + }, + }, + "_mountOrder": 7, + "_pendingCallbacks": null, + "_pendingElement": null, + "_pendingForceUpdate": false, + "_pendingReplaceState": false, + "_pendingStateQueue": null, + "_renderedComponent": NoopInternalComponent { + "_currentElement": + This is a compact card + , + "_debugID": 14, + "_renderedOutput": + This is a compact card + , + }, + "_renderedNodeType": 1, + "_rootNodeID": 0, + "_topLevelWrapper": null, + "_updateBatchNumber": null, + "_warnedAboutRefsInRender": false, + }, + "getRenderOutput": [Function], + "render": [Function], + }, + "root": [Circular], + "unrendered": + This is a compact card + , +} +`; + +exports[`CompactCard should use the card component 1`] = ` +ShallowWrapper { + "complexSelector": ComplexSelector { + "buildPredicate": [Function], + "childrenOfNode": [Function], + "findWhereUnwrapped": [Function], + }, + "length": 1, + "node": , + "nodes": Array [ + , + ], + "options": Object {}, + "renderer": ReactShallowRenderer { + "_instance": ShallowComponentWrapper { + "_calledComponentWillUnmount": false, + "_compositeType": 2, + "_context": Object {}, + "_currentElement": , + "_debugID": 15, + "_hostContainerInfo": null, + "_hostParent": null, + "_instance": StatelessComponent { + "_reactInternalInstance": [Circular], + "context": Object {}, + "props": Object {}, + "refs": Object {}, + "state": null, + "updater": Object { + "enqueueCallback": [Function], + "enqueueCallbackInternal": [Function], + "enqueueElementInternal": [Function], + "enqueueForceUpdate": [Function], + "enqueueReplaceState": [Function], + "enqueueSetState": [Function], + "isMounted": [Function], + "validateCallback": [Function], + }, + }, + "_mountOrder": 8, + "_pendingCallbacks": null, + "_pendingElement": null, + "_pendingForceUpdate": false, + "_pendingReplaceState": false, + "_pendingStateQueue": null, + "_renderedComponent": NoopInternalComponent { + "_currentElement": , + "_debugID": 16, + "_renderedOutput": , + }, + "_renderedNodeType": 1, + "_rootNodeID": 0, + "_topLevelWrapper": null, + "_updateBatchNumber": null, + "_warnedAboutRefsInRender": false, + }, + "getRenderOutput": [Function], + "render": [Function], + }, + "root": [Circular], + "unrendered": , +} +`; diff --git a/client/components/card/test/index.js b/client/components/card/test/index.js index dee6d0f28f14b..cfe69a8c60a9a 100644 --- a/client/components/card/test/index.js +++ b/client/components/card/test/index.js @@ -2,7 +2,6 @@ /** * External dependencies */ -import { expect } from 'chai'; import { shallow } from 'enzyme'; import React from 'react'; @@ -16,27 +15,31 @@ describe( 'Card', () => { // it should have a class of `card` test( 'should have `card` class', () => { const card = shallow( ); - expect( card.is( '.card' ) ).to.equal( true ); + expect( card.is( '.card' ) ).toBe( true ); + expect( card ).toMatchSnapshot(); } ); // it should accept a custom class of `test__ace` test( 'should have custom class of `test__ace`', () => { const card = shallow( ); - expect( card.is( '.test__ace' ) ).to.equal( true ); + expect( card.is( '.test__ace' ) ).toBe( true ); + expect( card ).toMatchSnapshot(); } ); // check that content within a card renders correctly test( 'should render children', () => { const card = shallow( This is a card ); - expect( card.contains( 'This is a card' ) ).to.equal( true ); + expect( card.contains( 'This is a card' ) ).toBe( true ); + expect( card ).toMatchSnapshot(); } ); // check it will accept a href test( 'should be linkable', () => { const card = shallow( This is a linked card ); - expect( card.find( 'a[href="/test"]' ) ).to.have.length( 1 ); - expect( card.props().href ).to.equal( '/test' ); - expect( card.is( '.is-card-link' ) ).to.equal( true ); + expect( card.find( 'a[href="/test"]' ) ).toHaveLength( 1 ); + expect( card.props().href ).toBe( '/test' ); + expect( card.is( '.is-card-link' ) ).toBe( true ); + expect( card ).toMatchSnapshot(); } ); } ); @@ -44,24 +47,28 @@ describe( 'CompactCard', () => { // it should have a class of `is-compact` test( 'should have `is-compact` class', () => { const compactCard = shallow( ); - expect( compactCard.find( '.is-compact' ) ).to.have.length( 1 ); + expect( compactCard.find( '.is-compact' ) ).toHaveLength( 1 ); + expect( compactCard ).toMatchSnapshot(); } ); // it should accept a custom class of `test__ace` test( 'should have custom class of `test__ace`', () => { const compactCard = shallow( ); - expect( compactCard.is( '.test__ace' ) ).to.equal( true ); + expect( compactCard.is( '.test__ace' ) ).toBe( true ); + expect( compactCard ).toMatchSnapshot(); } ); - // check that content within a card renders correctly + // check that content within a CompactCard renders correctly test( 'should render children', () => { const compactCard = shallow( This is a compact card ); - expect( compactCard.contains( 'This is a compact card' ) ).to.equal( true ); + expect( compactCard.contains( 'This is a compact card' ) ).toBe( true ); + expect( compactCard ).toMatchSnapshot(); } ); // test for card component test( 'should use the card component', () => { const compactCard = shallow( ); - expect( compactCard.find( 'Card' ) ).to.have.length( 1 ); + expect( compactCard.find( 'Card' ) ).toHaveLength( 1 ); + expect( compactCard ).toMatchSnapshot(); } ); } ); From 18facc0c49f171e8c0fd0dad4c5aaaa15286da67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Thomas?= Date: Mon, 30 Oct 2017 12:20:47 +0100 Subject: [PATCH 023/192] Fix missing white spaces and dot in Browse Happy page --- server/pages/browsehappy.jade | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/pages/browsehappy.jade b/server/pages/browsehappy.jade index 2d9a49639fd93..65bbff4fec437 100644 --- a/server/pages/browsehappy.jade +++ b/server/pages/browsehappy.jade @@ -58,10 +58,13 @@ html(lang=lang, dir=isRTL ? 'rtl' : 'ltr', class=isFluidWidth ? 'is-fluid-width' h2.empty-content__title Unsupported Browser h3.empty-content__line | Unfortunately this page cannot be used by your browser. You can either + = ' ' a( href=dashboardUrl ) use the classic WordPress dashboard | , or + = ' ' a( href="https://browsehappy.com" ) upgrade your browser + = '.' From 26ddb6deaaf447541abfda7de2de84217eeb5f41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Thomas?= Date: Mon, 30 Oct 2017 12:22:08 +0100 Subject: [PATCH 024/192] Fix error thrown when loading Browse Happy page This fixes a `TypeError: Cannot read property 'sectionCss' of undefined` error thrown in development. --- server/pages/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/pages/index.js b/server/pages/index.js index eb5f92c4972c8..1c3a9c40a7f62 100644 --- a/server/pages/index.js +++ b/server/pages/index.js @@ -165,7 +165,7 @@ function getDefaultContext( request ) { bodyClasses.push( 'pride' ); } - if ( request.context.sectionCss ) { + if ( request.context && request.context.sectionCss ) { const urls = utils.getCssUrls( request.context.sectionCss ); sectionCss = urls.ltr; sectionCssRtl = urls.rtl; From 402e69ecc439655ce6e4711bc8d999fb43158373 Mon Sep 17 00:00:00 2001 From: Jacopo Tomasone Date: Mon, 30 Oct 2017 12:23:36 +0000 Subject: [PATCH 025/192] Comments Redesign: Add CommentAuthorMoreInfo (#19225) --- .../comment/comment-author-more-info.jsx | 249 +++++++++++++++++- .../comments/comment/comment-header.jsx | 20 +- client/my-sites/comments/comment/style.scss | 59 +++-- client/my-sites/comments/comment/utils.js | 12 +- 4 files changed, 297 insertions(+), 43 deletions(-) diff --git a/client/my-sites/comments/comment/comment-author-more-info.jsx b/client/my-sites/comments/comment/comment-author-more-info.jsx index 1908760ee888f..75637a1f42f19 100644 --- a/client/my-sites/comments/comment/comment-author-more-info.jsx +++ b/client/my-sites/comments/comment/comment-author-more-info.jsx @@ -3,33 +3,254 @@ * External dependencies */ import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import { localize } from 'i18n-calypso'; +import Gridicon from 'gridicons'; +import { get } from 'lodash'; /** * Internal dependencies */ -import InfoPopover from 'components/info-popover'; +import Button from 'components/button'; +import Emojify from 'components/emojify'; +import ExternalLink from 'components/external-link'; +import Popover from 'components/popover'; +import { decodeEntities } from 'lib/formatting'; +import { urlToDomainAndPath } from 'lib/url'; +import { + canCurrentUser, + getSiteComment, + getSiteSetting, + isEmailBlacklisted, +} from 'state/selectors'; +import { + bumpStat, + composeAnalytics, + recordTracksEvent, + withAnalytics, +} from 'state/analytics/actions'; +import { getCurrentUserEmail } from 'state/current-user/selectors'; +import { successNotice } from 'state/notices/actions'; +import { saveSiteSettings } from 'state/site-settings/actions'; +import { getSelectedSiteId, getSelectedSiteSlug } from 'state/ui/selectors'; export class CommentAuthorMoreInfo extends Component { - storeLabelRef = label => ( this.authorMoreInfoLabel = label ); + static propTypes = { + commentId: PropTypes.number, + }; - storePopoverRef = popover => ( this.authorMoreInfoPopover = popover ); + state = { + showPopover: false, + }; - openAuthorMoreInfoPopover = event => this.authorMoreInfoPopover.handleClick( event ); + storePopoverButtonRef = button => ( this.popoverButton = button ); + + closePopover = () => this.setState( { showPopover: false } ); + + togglePopover = () => this.setState( ( { showPopover } ) => ( { showPopover: ! showPopover } ) ); + + toggleBlockUser = () => { + const { + authorEmail, + authorId, + commentId, + isAuthorBlacklisted, + showNotice, + siteBlacklist, + siteId, + translate, + updateBlacklist, + } = this.props; + + const noticeOptions = { + duration: 5000, + id: `comment-notice-${ commentId }`, + isPersistent: true, + }; + + const analytics = { + action: isAuthorBlacklisted ? 'unblock_user' : 'block_user', + user_type: authorId ? 'wpcom' : 'email_only', + }; + + if ( isAuthorBlacklisted ) { + const newBlacklist = siteBlacklist + .split( '\n' ) + .filter( item => item !== authorEmail ) + .join( '\n' ); + + updateBlacklist( siteId, newBlacklist, analytics ); + + return showNotice( + translate( 'User %(email)s unblocked.', { args: { email: authorEmail } } ), + noticeOptions + ); + } + + const newBlacklist = !! siteBlacklist ? siteBlacklist + '\n' + authorEmail : authorEmail; + + updateBlacklist( siteId, newBlacklist, analytics ); + + showNotice( + translate( 'User %(email)s is blocked and can no longer comment on your site.', { + args: { email: authorEmail }, + } ), + noticeOptions + ); + }; render() { + const { + authorDisplayName, + authorEmail, + authorIp, + authorUrl, + authorUsername, + isAuthorBlacklisted, + showBlockUser, + siteSlug, + trackAnonymousModeration, + translate, + } = this.props; + + const { showPopover } = this.state; + return ( -
- - Author More Info - - +
+ + + +
+ +
+
+ { authorDisplayName || translate( 'Anonymous' ) } +
+
{ authorUsername }
+
+
+ +
+ +
{ authorEmail || { translate( 'No email address' ) } }
+
+ +
+ +
+ { !! authorUrl && ( + + { urlToDomainAndPath( authorUrl ) } + + ) } + { ! authorUrl && { translate( 'No website' ) } } +
+
+ +
+ +
{ authorIp || { translate( 'No IP address' ) } }
+
+ + { showBlockUser && ( +
+ +
+ ) } + + { ! authorEmail && ( +
+
+ { translate( + "Anonymous messages can't be blocked individually, " + + 'but you can update your {{a}}settings{{/a}} to ' + + 'only allow comments from registered users.', + { + components: { + a: ( + + ), + }, + } + ) } +
+
+ ) } +
); } } -export default CommentAuthorMoreInfo; +const mapStateToProps = ( state, { commentId } ) => { + const siteId = getSelectedSiteId( state ); + const comment = getSiteComment( state, siteId, commentId ); + + const authorDisplayName = decodeEntities( get( comment, 'author.name' ) ); + const authorEmail = get( comment, 'author.email' ); + + const showBlockUser = + canCurrentUser( state, siteId, 'manage_options' ) && + !! authorEmail && + authorEmail !== getCurrentUserEmail( state ); + + return { + authorDisplayName, + authorEmail, + authorId: get( comment, 'author.ID' ), + authorIp: get( comment, 'author.ip_address' ), + authorUsername: get( comment, 'author.nice_name' ), + authorUrl: get( comment, 'author.URL', '' ), + isAuthorBlacklisted: isEmailBlacklisted( state, siteId, authorEmail ), + showBlockUser, + siteBlacklist: getSiteSetting( state, siteId, 'blacklist_keys' ), + siteId, + siteSlug: getSelectedSiteSlug( state ), + }; +}; + +const mapDispatchToProps = dispatch => ( { + showNotice: ( text, options ) => dispatch( successNotice( text, options ) ), + updateBlacklist: ( siteId, blacklist_keys, analytics ) => + dispatch( + withAnalytics( + composeAnalytics( + recordTracksEvent( 'calypso_comment_management_moderate_user', analytics ), + bumpStat( + 'calypso_comment_management', + 'block_user' === analytics.action + ? 'comment_author_blocked' + : 'comment_author_unblocked' + ) + ), + saveSiteSettings( siteId, { blacklist_keys } ) + ) + ), + trackAnonymousModeration: () => + dispatch( + composeAnalytics( + recordTracksEvent( 'calypso_comment_management_moderate_user', { + action: 'open_discussion_settings', + user_type: 'anonymous', + } ), + bumpStat( 'calypso_comment_management', 'open_discussion_settings' ) + ) + ), +} ); + +export default connect( mapStateToProps, mapDispatchToProps )( localize( CommentAuthorMoreInfo ) ); diff --git a/client/my-sites/comments/comment/comment-header.jsx b/client/my-sites/comments/comment/comment-header.jsx index c6999d11a105d..36933e4baadc5 100644 --- a/client/my-sites/comments/comment/comment-header.jsx +++ b/client/my-sites/comments/comment/comment-header.jsx @@ -3,8 +3,9 @@ * External dependencies */ import React from 'react'; +import { connect } from 'react-redux'; import Gridicon from 'gridicons'; -import noop from 'lodash'; +import { get, noop } from 'lodash'; /** * Internal dependencies @@ -13,6 +14,8 @@ import Button from 'components/button'; import CommentAuthor from 'my-sites/comments/comment/comment-author'; import CommentAuthorMoreInfo from 'my-sites/comments/comment/comment-author-more-info'; import FormCheckbox from 'components/forms/form-checkbox'; +import { getSiteComment } from 'state/selectors'; +import { getSelectedSiteId } from 'state/ui/selectors'; export const CommentHeader = ( { commentId, @@ -20,6 +23,7 @@ export const CommentHeader = ( { isEditMode, isExpanded, isSelected, + showAuthorMoreInfo, toggleExpanded, } ) => (
@@ -31,7 +35,7 @@ export const CommentHeader = ( { - { isExpanded && } + { showAuthorMoreInfo && } { ! isBulkMode && ( ); } diff --git a/client/my-sites/site-settings/manage-connection/disconnect-site-link.jsx b/client/my-sites/site-settings/manage-connection/disconnect-site-link.jsx index f195984b63399..fa903b7af751b 100644 --- a/client/my-sites/site-settings/manage-connection/disconnect-site-link.jsx +++ b/client/my-sites/site-settings/manage-connection/disconnect-site-link.jsx @@ -13,7 +13,9 @@ import { localize } from 'i18n-calypso'; */ import DisconnectJetpackDialog from 'blocks/disconnect-jetpack/dialog'; import QuerySitePlans from 'components/data/query-site-plans'; +import { isEnabled } from 'config'; import SiteToolsLink from 'my-sites/site-settings/site-tools/link'; +import { getSiteSlug } from 'state/sites/selectors'; import { getSelectedSiteId } from 'state/ui/selectors'; import { isSiteAutomatedTransfer } from 'state/selectors'; import { recordTracksEvent } from 'state/analytics/actions'; @@ -24,7 +26,9 @@ class DisconnectSiteLink extends Component { }; handleClick = event => { - event.preventDefault(); + if ( ! isEnabled( 'manage/site-settings/disconnect-flow' ) ) { + event.preventDefault(); + } this.setState( { dialogVisible: true, @@ -40,7 +44,7 @@ class DisconnectSiteLink extends Component { }; render() { - const { isAutomatedTransfer, siteId, translate } = this.props; + const { isAutomatedTransfer, siteId, siteSlug, translate } = this.props; if ( ! siteId || isAutomatedTransfer ) { return null; @@ -51,7 +55,13 @@ class DisconnectSiteLink extends Component { - + { ! isEnabled( 'manage/site-settings/disconnect-flow' ) && ( + + ) }
); } @@ -79,6 +91,7 @@ export default connect( return { isAutomatedTransfer: isSiteAutomatedTransfer( state, siteId ), siteId, + siteSlug: getSiteSlug( state, siteId ), }; }, { recordTracksEvent } From af29d995ecf6f68cb185812ed52379234e254005 Mon Sep 17 00:00:00 2001 From: James Koster Date: Mon, 30 Oct 2017 14:26:13 +0000 Subject: [PATCH 029/192] Markup, language and style tweaks for MailChimp integration (#19232) * Mailchimp getting started dashboard widget styling * progress bar styling * language tweak * Markup and language tweaks in the mailchimp wizard * Fix a few react warnings and linter errors --- .../email/mailchimp/getting-started.js | 4 +- .../email/mailchimp/setup-mailchimp.js | 28 ++++--- .../setup-steps/campaign-defaults.js | 52 +++++++----- .../email/mailchimp/setup-steps/key-input.js | 18 ++--- .../setup-steps/log-into-mailchimp.js | 3 +- .../setup-steps/newsletter-settings.js | 22 ++--- .../email/mailchimp/setup-steps/store-info.js | 72 +++++++++-------- .../app/settings/email/mailchimp/style.scss | 81 +++++++++++++++---- 8 files changed, 175 insertions(+), 105 deletions(-) diff --git a/client/extensions/woocommerce/app/settings/email/mailchimp/getting-started.js b/client/extensions/woocommerce/app/settings/email/mailchimp/getting-started.js index 94f6874453e1f..e6208d0cb1e39 100644 --- a/client/extensions/woocommerce/app/settings/email/mailchimp/getting-started.js +++ b/client/extensions/woocommerce/app/settings/email/mailchimp/getting-started.js @@ -15,7 +15,7 @@ import { localize } from 'i18n-calypso'; const GettingStarted = localize( ( { translate, onClick, isPlaceholder, site, redirectToSettings } ) => { const allow = translate( 'Allow customers to subscribe to your Email list' ); - const send = translate( 'Send abandon cart emails' ); + const send = translate( 'Send abandoned cart emails' ); const create = translate( 'Create purchase-based segments for targeted campaigns' ); const getStarted = translate( 'Get started with MailChimp' ); const list = [ allow, send, create ]; @@ -29,7 +29,7 @@ const GettingStarted = localize( ( { translate, onClick, isPlaceholder, site, re { translate( 'Allow your customers to subscribe to your MailChimp email list.' ) }
- { ! isPlaceholder && + { ! isPlaceholder && -
MailChimp
- - -
- { this.renderStep() } -
+ +
+ + +
+
+ { this.renderStep() } +
); } diff --git a/client/extensions/woocommerce/app/settings/email/mailchimp/setup-steps/campaign-defaults.js b/client/extensions/woocommerce/app/settings/email/mailchimp/setup-steps/campaign-defaults.js index fb28989b185fa..1541f14796972 100644 --- a/client/extensions/woocommerce/app/settings/email/mailchimp/setup-steps/campaign-defaults.js +++ b/client/extensions/woocommerce/app/settings/email/mailchimp/setup-steps/campaign-defaults.js @@ -11,40 +11,50 @@ import FormFieldset from 'components/forms/form-fieldset'; import FormInputValidation from 'components/forms/form-input-validation'; import FormLabel from 'components/forms/form-label'; import FormTextInput from 'components/forms/form-text-input'; +import FormSettingExplanation from 'components/forms/form-setting-explanation'; import { translate } from 'i18n-calypso'; // Get reed of this, this should not be visible to the user - he does not need this. const CampaignDefaults = ( { storeData = {}, onChange, validateFields } ) => { const fields = [ - { name: 'campaign_from_name', label: translate( 'From', + { name: 'campaign_from_name', explanation: translate( 'This is the name your emails will come from. Use ' + + 'something your subscribers will instantly recognize, like your company name.' ), label: translate( 'Default from name', { comment: 'label for field that informs who sends the message' } ) }, - { name: 'campaign_from_email', label: translate( 'From Email' ) }, - { name: 'campaign_subject', label: translate( 'Subject' ) }, - { name: 'campaign_permission_reminder', label: translate( 'Permission reminder' ) }, + { name: 'campaign_from_email', explanation: translate( 'The address that will receive reply emails. Check it ' + + 'regularly to stay in touch with your audience.' ), label: translate( 'Default from address' ) }, + { name: 'campaign_subject', explanation: translate( 'Keep it relevant and non-spammy.' ), label: translate( 'Default subject' ) }, + { name: 'campaign_permission_reminder', explanation: translate( 'Displayed at the bottom of ' + + 'your emails' ), label: translate( 'Permission reminder' ) }, // campaign_language will be silently passed based on choice from the previous step. ]; return (
-
- { translate( 'Campaign Email Settings.' ) } -
- +

+ { translate( 'Configure the settings for emails sent using your MailChimp account below.' ) } +

+
{ fields.map( ( item, index ) => ( -
- - { item.label } - - - { ( validateFields && ! storeData[ item.name ] ) && } -
+ +
+ + { item.label } + + + + { item.explanation } + + { ( validateFields && ! storeData[ item.name ] ) && + } +
+
) ) } - +
); }; diff --git a/client/extensions/woocommerce/app/settings/email/mailchimp/setup-steps/key-input.js b/client/extensions/woocommerce/app/settings/email/mailchimp/setup-steps/key-input.js index 6573cb86b0540..607433db70407 100644 --- a/client/extensions/woocommerce/app/settings/email/mailchimp/setup-steps/key-input.js +++ b/client/extensions/woocommerce/app/settings/email/mailchimp/setup-steps/key-input.js @@ -11,20 +11,14 @@ import FormFieldset from 'components/forms/form-fieldset'; import FormLabel from 'components/forms/form-label'; import FormTextInput from 'components/forms/form-text-input'; import FormInputValidation from 'components/forms/form-input-validation'; +import FormSettingExplanation from 'components/forms/form-setting-explanation'; import { localize } from 'i18n-calypso'; const KeyInputStep = localize( ( { translate, onChange, apiKey, isKeyCorrect } ) => ( -
- { translate( 'Now that you\'re signed in to MailChimp, you need an API key to start the connection process' ) } -
-
-

{ translate( 'To find your Mailchimp API key ' ) } - - { translate( 'click your profile picture, select \'Account\', and go to Extras > API keys.' ) } - - { translate( ' From there, grab an existing key or generate a new one for your store.' ) }

-
+

+ { translate( 'Now that you\'re signed in to MailChimp, you need an API key to start the connection process.' ) } +

{ translate( 'Mailchimp API Key:' ) } @@ -40,6 +34,10 @@ const KeyInputStep = localize( ( { translate, onChange, apiKey, isKeyCorrect } ) ? : ) } + + { translate( 'To find your MailChimp API key click your profile picture, select Account and go to Extras ' + + '> API keys. From there, grab an existing key or generate a new one for your store.' ) } +
) ); diff --git a/client/extensions/woocommerce/app/settings/email/mailchimp/setup-steps/log-into-mailchimp.js b/client/extensions/woocommerce/app/settings/email/mailchimp/setup-steps/log-into-mailchimp.js index ae46318a33bad..f2708f3bfa45e 100644 --- a/client/extensions/woocommerce/app/settings/email/mailchimp/setup-steps/log-into-mailchimp.js +++ b/client/extensions/woocommerce/app/settings/email/mailchimp/setup-steps/log-into-mailchimp.js @@ -12,9 +12,8 @@ import { translate } from 'i18n-calypso'; export default () => (
-
{ translate( 'Get started' ) }

- { translate( 'First, you\'ll have to have a MailChimp account. If you already have one, log in.' ) } + { translate( 'To get started, you need a MailChimp account. Please log in or register by clicking the button below.' ) }

- ) } -
-); +export class CommentHeader extends PureComponent { + toggleSelected = () => this.props.toggleSelected( this.props.minimumComment ); + + render() { + const { + commentId, + isBulkMode, + isEditMode, + isExpanded, + isSelected, + showAuthorMoreInfo, + toggleExpanded, + } = this.props; + + return ( +
+ { isBulkMode && ( + + ) } + + + + { showAuthorMoreInfo && } + + { ! isBulkMode && ( + + ) } +
+ ); + } +} const mapStateToProps = ( state, { commentId, isExpanded } ) => { const siteId = getSelectedSiteId( state ); @@ -56,6 +65,7 @@ const mapStateToProps = ( state, { commentId, isExpanded } ) => { const commentType = get( comment, 'type', 'comment' ); return { + minimumComment: getMinimumComment( comment ), showAuthorMoreInfo: isExpanded && 'comment' === commentType, }; }; diff --git a/client/my-sites/comments/comment/comment-post-link.jsx b/client/my-sites/comments/comment/comment-post-link.jsx index 79762694ee961..360b725d915dd 100644 --- a/client/my-sites/comments/comment/comment-post-link.jsx +++ b/client/my-sites/comments/comment/comment-post-link.jsx @@ -53,6 +53,7 @@ const mapStateToProps = ( state, { commentId } ) => { postId, postTitle, siteSlug, + siteId, }; }; diff --git a/client/my-sites/comments/comment/index.jsx b/client/my-sites/comments/comment/index.jsx index 039d40496d704..65d910b8b7869 100644 --- a/client/my-sites/comments/comment/index.jsx +++ b/client/my-sites/comments/comment/index.jsx @@ -4,8 +4,10 @@ */ import React, { Component } from 'react'; import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; import classNames from 'classnames'; import ReactDom from 'react-dom'; +import { get, isUndefined } from 'lodash'; /** * Internal dependencies @@ -13,18 +15,21 @@ import ReactDom from 'react-dom'; import Card from 'components/card'; import CommentContent from 'my-sites/comments/comment/comment-content'; import CommentHeader from 'my-sites/comments/comment/comment-header'; +import QueryComment from 'components/data/query-comment'; +import { getSiteComment } from 'state/selectors'; +import { getSelectedSiteId } from 'state/ui/selectors'; export class Comment extends Component { static propTypes = { commentId: PropTypes.number, isBulkMode: PropTypes.bool, - isLoading: PropTypes.bool, isSelected: PropTypes.bool, + refreshCommentData: PropTypes.bool, + toggleSelected: PropTypes.func, }; static defaultProps = { isBulkMode: false, - isLoading: false, isSelected: false, }; @@ -33,6 +38,12 @@ export class Comment extends Component { isExpanded: false, }; + componentWillReceiveProps( nextProps ) { + if ( nextProps.isBulkMode && ! this.props.isBulkMode ) { + this.setState( { isExpanded: false } ); + } + } + storeCardRef = card => ( this.commentCard = card ); keyDownHandler = event => { @@ -59,13 +70,24 @@ export class Comment extends Component { }; render() { - const { commentId, isBulkMode, isSelected } = this.props; + const { + commentId, + commentStatus, + isBulkMode, + isLoading, + isSelected, + refreshCommentData, + siteId, + toggleSelected, + } = this.props; const { isEditMode, isExpanded } = this.state; const classes = classNames( 'comment', { 'is-bulk-mode': isBulkMode, 'is-collapsed': ! isExpanded && ! isBulkMode, 'is-expanded': isExpanded, + 'is-placeholder': isLoading, + 'is-unapproved': 'unapproved' === commentStatus, } ); return ( @@ -75,10 +97,12 @@ export class Comment extends Component { ref={ this.storeCardRef } tabIndex="0" > + { refreshCommentData && } + { ! isEditMode && (
@@ -90,4 +114,14 @@ export class Comment extends Component { } } -export default Comment; +const mapStateToProps = ( state, { commentId } ) => { + const siteId = getSelectedSiteId( state ); + const comment = getSiteComment( state, siteId, commentId ); + return { + commentStatus: get( comment, 'status' ), + isLoading: isUndefined( comment ), + siteId, + }; +}; + +export default connect( mapStateToProps )( Comment ); diff --git a/client/my-sites/comments/comment/style.scss b/client/my-sites/comments/comment/style.scss index 2de1921a56bbf..696ed72abe115 100644 --- a/client/my-sites/comments/comment/style.scss +++ b/client/my-sites/comments/comment/style.scss @@ -6,7 +6,6 @@ font-size: 14px; margin: 0 auto; padding: 0; - transition: margin 0.15s linear; .accessible-focus &:focus { box-shadow: 0 0 0 1px $blue-medium, 0 0 0 3px $blue-light; @@ -14,6 +13,11 @@ } } +// `transition` here is applied with less specificity to avoid overwriting ReactCSSTransitionGroup's animation. +.comment { + transition: margin 0.15s linear; +} + // Comment Header Block .comment__header { @@ -46,8 +50,11 @@ transition: transform 0.15s cubic-bezier(0.175, 0.885, 0.32, 1.275), color 0.2s ease-in; } - &:hover .gridicon { - fill: $blue-medium; + &:focus, + &:hover { + .gridicon { + fill: $blue-medium; + } } } @@ -179,13 +186,16 @@ .comment__content { padding: 0 16px 16px 56px; - @include breakpoint( '>480px' ) { - padding-right: 56px; + .comment__content-info { + display: flex; + flex-flow: row; + flex-wrap: nowrap; + justify-content: space-between; + margin: 8px 0 1em 0; } .comment__post-link { font-weight: 600; - margin: 8px 0 1em 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -195,6 +205,16 @@ } } + .comment__status-label { + align-self: center; + background: lighten($alert-yellow, 18%); + border-radius: 9px; + flex-grow: 0; + flex-shrink: 0; + font-size: 12px; + padding: 0 10px; + } + .comment__in-reply-to { color: $gray-text-min; overflow: hidden; @@ -259,6 +279,16 @@ } } +// Collapsed View + +.card.comment.is-collapsed { + &.is-unapproved { + background: mix($alert-yellow, $white, 8.5%); + box-shadow: inset 4px 0 0 0 $alert-yellow, 0 0 0 1px transparentize(lighten($gray, 20%), 0.5), + 0 1px 2px lighten($gray, 30%); + } +} + // Expanded View .card.comment.is-expanded { @@ -294,3 +324,60 @@ padding-left: 0; } } + +// Placeholder View + +.card.comment.is-placeholder { + @include placeholder(); + + background-color: $white; + + .comment__bulk-select { + display: none; + } + + .comment__header .comment__author { + padding: 8px; + } + + .comment__author-gravatar-placeholder { + background-color: lighten($gray, 30%); + border-radius: 50%; + display: block; + height: 32px; + width: 32px; + } + + .comment__author-info { + padding: 5px 8px 5px 0; + } + + .comment__author-info-element { + background-color: lighten($gray, 30%); + color: transparent; + height: 16px; + + a, + a:focus, + a:hover, + .gridicon, + .comment__author-url-separator { + color: transparent; + cursor: default; + } + } + + .button.comment__toggle-expanded { + display: none; + } + + .comment__content-preview { + background-color: lighten($gray, 30%); + color: transparent; + height: 21px; + } + + .comment__in-reply-to { + display: none; + } +} diff --git a/client/my-sites/comments/comment/utils.js b/client/my-sites/comments/comment/utils.js index fddf75a645506..cec844a3cba72 100644 --- a/client/my-sites/comments/comment/utils.js +++ b/client/my-sites/comments/comment/utils.js @@ -5,13 +5,12 @@ import { get } from 'lodash'; /** - * Create a stripped down comment object containing only the information needed by - * CommentList's change status and reply functions, and their respective undos. + * Create a stripped down comment object containing only the bare minimum fields needed by CommentList's actions. * * @param {Object} comment A comment object. * @returns {Object} A stripped down comment object. */ -export const getMinimalComment = comment => ( { +export const getMinimumComment = comment => ( { commentId: get( comment, 'ID' ), isLiked: get( comment, 'i_like' ), postId: get( comment, 'post.ID' ), diff --git a/config/development.json b/config/development.json index 45c2c229def9c..4b3aca6eea3a9 100644 --- a/config/development.json +++ b/config/development.json @@ -43,6 +43,7 @@ "comments/moderation-tools-in-posts": true, "comments/management": true, "comments/management/comment-view": true, + "comments/management/m3-design": false, "comments/management/post-view": true, "comments/management/quick-actions": true, "comments/management/sorting": true, From ff726333a2846736a9987c57bda35d2abf5929a5 Mon Sep 17 00:00:00 2001 From: Bernie Reiter Date: Tue, 31 Oct 2017 13:12:04 +0100 Subject: [PATCH 071/192] Card: Style cursor as pointer when onClick prop exists (#19272) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rationale: Previously, some instances of `Card` (or components that internally render `Card`s) used to include a workaround such as `href=„#“` (alongside `event.preventDefault()` in the `onClick` handler) in order to get a `pointer` style cursor. --- client/components/card/index.jsx | 9 ++++++--- client/components/card/style.scss | 5 ++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/client/components/card/index.jsx b/client/components/card/index.jsx index 7555754af79f8..922463c570323 100644 --- a/client/components/card/index.jsx +++ b/client/components/card/index.jsx @@ -26,7 +26,7 @@ class Card extends Component { }; render() { - const { href, tagName, target, compact, children, highlight } = this.props; + const { children, compact, highlight, href, onClick, tagName, target } = this.props; const highlightClass = highlight ? 'is-' + highlight : false; @@ -35,6 +35,7 @@ class Card extends Component { this.props.className, { 'is-card-link': !! href, + 'is-clickable': !! onClick, 'is-compact': compact, }, highlightClass @@ -43,11 +44,13 @@ class Card extends Component { const omitProps = [ 'compact', 'highlight', 'tagName' ]; let linkIndicator; - if ( href ) { + if ( href || onClick ) { linkIndicator = ( ); - } else { + } + + if ( ! href ) { omitProps.push( 'href', 'target' ); } diff --git a/client/components/card/style.scss b/client/components/card/style.scss index 1b759144938ea..a78d1a0008b25 100644 --- a/client/components/card/style.scss +++ b/client/components/card/style.scss @@ -29,6 +29,10 @@ padding-right: 48px; } + &.is-clickable { + cursor: pointer; + } + &.is-error { box-shadow: inset 3px 0 0 $alert-red; } @@ -69,4 +73,3 @@ a.card:focus { color: $link-highlight; } } - From 301c314440f47fbf7ac2832ac051cd0cf0c476ca Mon Sep 17 00:00:00 2001 From: James Koster Date: Tue, 31 Oct 2017 12:42:19 +0000 Subject: [PATCH 072/192] product search field input margin --- client/extensions/woocommerce/app/promotions/fields/style.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/extensions/woocommerce/app/promotions/fields/style.scss b/client/extensions/woocommerce/app/promotions/fields/style.scss index 850a1eae7787c..496ee0b5b398e 100644 --- a/client/extensions/woocommerce/app/promotions/fields/style.scss +++ b/client/extensions/woocommerce/app/promotions/fields/style.scss @@ -1,7 +1,7 @@ .promotion-applies-to-field { .search { - margin-bottom: 1px; + margin-bottom: 0; border: 1px solid lighten( $gray, 20% ); z-index: 0; // Keeps search from overlapping header above. box-sizing: border-box; From a8d56ac1450380fc88f733e0fdd527590bc355bf Mon Sep 17 00:00:00 2001 From: Bernie Reiter Date: Tue, 31 Oct 2017 13:44:38 +0100 Subject: [PATCH 073/192] Jetpack Disconnect Survey: Really disable Submit button if input field is empty (#19324) Previously, while the button was styled in a way that suggested it was disabled, it was actually still clickable. --- .../disconnect-site/missing-feature.jsx | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/client/my-sites/site-settings/disconnect-site/missing-feature.jsx b/client/my-sites/site-settings/disconnect-site/missing-feature.jsx index 3d8b12088d416..53173cf70186e 100644 --- a/client/my-sites/site-settings/disconnect-site/missing-feature.jsx +++ b/client/my-sites/site-settings/disconnect-site/missing-feature.jsx @@ -59,13 +59,17 @@ class MissingFeature extends PureComponent { />
From ea56286a300a6b0b273e5306b2ddca6f904887a2 Mon Sep 17 00:00:00 2001 From: Rodrigo Iloro Date: Tue, 31 Oct 2017 14:33:33 -0300 Subject: [PATCH 101/192] Comments: fixes relative time calculations for the redesigned components (#19337) --- .../comments/comment/comment-author.jsx | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/client/my-sites/comments/comment/comment-author.jsx b/client/my-sites/comments/comment/comment-author.jsx index bd3922ef32bb6..4f75a1b837898 100644 --- a/client/my-sites/comments/comment/comment-author.jsx +++ b/client/my-sites/comments/comment/comment-author.jsx @@ -16,12 +16,10 @@ import Emojify from 'components/emojify'; import ExternalLink from 'components/external-link'; import Gravatar from 'components/gravatar'; import CommentPostLink from 'my-sites/comments/comment/comment-post-link'; -import { convertDateToUserLocation } from 'components/post-schedule/utils'; import { decodeEntities } from 'lib/formatting'; -import { gmtOffset, timezone } from 'lib/site/utils'; import { urlToDomainAndPath } from 'lib/url'; import { getSiteComment } from 'state/selectors'; -import { getSelectedSite, getSelectedSiteId } from 'state/ui/selectors'; +import { getSelectedSiteId } from 'state/ui/selectors'; export class CommentAuthor extends Component { static propTypes = { @@ -41,23 +39,16 @@ export class CommentAuthor extends Component { gravatarUser, isExpanded, moment, - site, translate, } = this.props; - const localizedDate = convertDateToUserLocation( - commentDate || moment(), - timezone( site ), - gmtOffset( site ) - ); - - const formattedDate = localizedDate.format( 'll LT' ); + const formattedDate = moment( commentDate ).format( 'll LT' ); const relativeDate = moment() .subtract( 1, 'month' ) - .isBefore( localizedDate ) - ? localizedDate.fromNow() - : localizedDate.format( 'll' ); + .isBefore( commentDate ) + ? moment( commentDate ).fromNow() + : moment( commentDate ).format( 'll' ); return (
@@ -99,7 +90,6 @@ export class CommentAuthor extends Component { } const mapStateToProps = ( state, { commentId } ) => { - const site = getSelectedSite( state ); const siteId = getSelectedSiteId( state ); const comment = getSiteComment( state, siteId, commentId ); @@ -115,7 +105,6 @@ const mapStateToProps = ( state, { commentId } ) => { commentType: get( comment, 'type', 'comment' ), commentUrl: get( comment, 'URL' ), gravatarUser, - site, }; }; From 81c5f105c44eda0906b7d4805555fe84d23c6eb4 Mon Sep 17 00:00:00 2001 From: Kerry Liu Date: Tue, 31 Oct 2017 10:34:01 -0700 Subject: [PATCH 102/192] Comments hide bulk and sort controls if no comments are in view (#19311) --- .../my-sites/comments/comment-navigation/index.jsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/client/my-sites/comments/comment-navigation/index.jsx b/client/my-sites/comments/comment-navigation/index.jsx index 8781c030ce0b3..93da518c04c8a 100644 --- a/client/my-sites/comments/comment-navigation/index.jsx +++ b/client/my-sites/comments/comment-navigation/index.jsx @@ -106,6 +106,7 @@ export class CommentNavigation extends Component { const { doSearch, hasSearch, + hasComments, isBulkEdit, isCommentsTreeSupported, isSelectedAll, @@ -208,7 +209,8 @@ export class CommentNavigation extends Component { { isEnabled( 'comments/management/sorting' ) && - isCommentsTreeSupported && ( + isCommentsTreeSupported && + hasComments && ( ) } - + { hasComments && ( + + ) } { hasSearch && ( @@ -257,6 +261,7 @@ const mapStateToProps = ( state, { commentsPage, siteId } ) => { return { visibleComments, + hasComments: visibleComments.length > 0, isCommentsTreeSupported: ! isJetpackSite( state, siteId ) || isJetpackMinimumVersion( state, siteId, '5.3' ), }; From 004d2071f7625cc628b70516e8764dad805847d8 Mon Sep 17 00:00:00 2001 From: Rodrigo Iloro Date: Tue, 31 Oct 2017 15:14:52 -0300 Subject: [PATCH 103/192] Comments: the relative date calculations now uses the user timezone. (#19285) Comments: removes timezone checks because moment's fromNow does it for us --- .../comment-detail/comment-detail-header.jsx | 19 +++++-------------- client/blocks/comment-detail/index.jsx | 6 ++---- 2 files changed, 7 insertions(+), 18 deletions(-) diff --git a/client/blocks/comment-detail/comment-detail-header.jsx b/client/blocks/comment-detail/comment-detail-header.jsx index 7e532a1016283..8d00a14601274 100644 --- a/client/blocks/comment-detail/comment-detail-header.jsx +++ b/client/blocks/comment-detail/comment-detail-header.jsx @@ -22,25 +22,17 @@ import FormCheckbox from 'components/forms/form-checkbox'; import { stripHTML, decodeEntities } from 'lib/formatting'; import { urlToDomainAndPath } from 'lib/url'; import viewport from 'lib/viewport'; -import { convertDateToUserLocation } from 'components/post-schedule/utils'; -import { gmtOffset, timezone } from 'lib/site/utils'; - -const getRelativeTimePeriod = ( commentDate, site, moment ) => { - const localizedDate = convertDateToUserLocation( - commentDate || moment(), - timezone( site ), - gmtOffset( site ) - ); +const getRelativeTimePeriod = ( commentDate, moment ) => { if ( moment() .subtract( 1, 'month' ) - .isBefore( localizedDate ) + .isBefore( commentDate ) ) { - return localizedDate.fromNow(); + return moment( commentDate ).fromNow(); } - return localizedDate.format( 'll' ); + return moment( commentDate ).format( 'll' ); }; export class CommentDetailHeader extends Component { @@ -69,7 +61,6 @@ export class CommentDetailHeader extends Component { isExpanded, moment, postTitle, - site, toggleReply, toggleApprove, toggleEditMode, @@ -152,7 +143,7 @@ export class CommentDetailHeader extends Component {
- { getRelativeTimePeriod( commentDate, site, moment ) } + { getRelativeTimePeriod( commentDate, moment ) }
diff --git a/client/blocks/comment-detail/index.jsx b/client/blocks/comment-detail/index.jsx index 31f3f595b4799..eaf022b227b27 100644 --- a/client/blocks/comment-detail/index.jsx +++ b/client/blocks/comment-detail/index.jsx @@ -27,7 +27,7 @@ import { decodeEntities, stripHTML } from 'lib/formatting'; import { getPostCommentsTree } from 'state/comments/selectors'; import { getSitePost } from 'state/posts/selectors'; import getSiteComment from 'state/selectors/get-site-comment'; -import { getSite, isJetpackMinimumVersion, isJetpackSite } from 'state/sites/selectors'; +import { isJetpackMinimumVersion, isJetpackSite } from 'state/sites/selectors'; import { bumpStat, composeAnalytics, recordTracksEvent } from 'state/analytics/actions'; import { getSelectedSiteSlug } from 'state/ui/selectors'; import config from 'config'; @@ -279,7 +279,6 @@ export class CommentDetail extends Component { refreshCommentData, repliedToComment, replyComment, - site, siteBlacklist, siteId, translate, @@ -333,7 +332,6 @@ export class CommentDetail extends Component { isExpanded={ isExpanded } postId={ postId } postTitle={ postTitle } - site={ site } toggleApprove={ this.toggleApprove } toggleEditMode={ this.toggleEditMode } toggleExpanded={ this.toggleExpanded } @@ -464,8 +462,8 @@ const mapStateToProps = ( state, ownProps ) => { postUrl: isJetpack ? get( comment, 'URL' ) : `/read/blogs/${ siteId }/posts/${ postId }`, postTitle, repliedToComment: get( comment, 'replied' ), // TODO: not available in the current data structure - site: getSite( state, siteId ), siteSlug: getSelectedSiteSlug( state ), + siteId: get( comment, 'siteId', siteId ), }; }; From 047a6852b1f7abfaaf3ae20270a77768a175aebf Mon Sep 17 00:00:00 2001 From: Rastislav Lamos Date: Tue, 31 Oct 2017 19:25:33 +0100 Subject: [PATCH 104/192] Atomic Store: show default checkout-thank-you page if no hasPendingAT (#19293) --- client/my-sites/checkout/checkout-thank-you/index.jsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/client/my-sites/checkout/checkout-thank-you/index.jsx b/client/my-sites/checkout/checkout-thank-you/index.jsx index 9bc05e7eb5cd5..f79339bfa0736 100644 --- a/client/my-sites/checkout/checkout-thank-you/index.jsx +++ b/client/my-sites/checkout/checkout-thank-you/index.jsx @@ -6,7 +6,7 @@ import { connect } from 'react-redux'; import { localize } from 'i18n-calypso'; -import { find, get } from 'lodash'; +import { find } from 'lodash'; import page from 'page'; import PropTypes from 'prop-types'; import React from 'react'; @@ -77,7 +77,7 @@ import { PLAN_JETPACK_BUSINESS, PLAN_JETPACK_BUSINESS_MONTHLY, } from 'lib/plans/constants'; -import { getSiteOptions, isSiteAutomatedTransfer } from 'state/selectors'; +import { hasSitePendingAutomatedTransfer, isSiteAutomatedTransfer } from 'state/selectors'; function getPurchases( props ) { return ( props.receipt.data && props.receipt.data.purchases ) || []; @@ -268,9 +268,9 @@ class CheckoutThankYou extends React.Component { return ; } - const { signupIsStore, isAtomicSite } = this.props; + const { hasPendingAT, isAtomicSite } = this.props; - if ( wasDotcomPlanPurchased && ( signupIsStore || isAtomicSite ) ) { + if ( wasDotcomPlanPurchased && ( hasPendingAT || isAtomicSite ) ) { return (
{ this.renderConfirmationNotice() } @@ -447,7 +447,6 @@ export default connect( ( state, props ) => { const siteId = getSelectedSiteId( state ); const planSlug = getSitePlanSlug( state, siteId ); - const siteOptions = getSiteOptions( state, siteId ); return { planSlug, @@ -455,7 +454,7 @@ export default connect( sitePlans: getPlansBySite( state, props.selectedSite ), user: getCurrentUser( state ), userDate: getCurrentUserDate( state ), - signupIsStore: get( siteOptions, 'signup_is_store', false ), + hasPendingAT: hasSitePendingAutomatedTransfer( state, siteId ), isAtomicSite: isSiteAutomatedTransfer( state, siteId ), }; }, From 07f3473aed7e2afcf143b65a2c6454a2b466c561 Mon Sep 17 00:00:00 2001 From: Jake Date: Tue, 31 Oct 2017 15:39:48 -0400 Subject: [PATCH 105/192] Reader: mishandling of sites that were deleted (#18142) * Reader: mishandling of sites that were deleted 1. feed-stream had a bad check for errors so was missing cases where site is deleted. 2. get-reader-follows wasn't filtering out follows that had errored. * reciving a site twice is valid.... * only care about 403s * updates after 410 modification --- client/reader/feed-stream/index.jsx | 2 +- client/state/reader/sites/actions.js | 6 +++--- client/state/reader/sites/reducer.js | 19 +++++++++++-------- client/state/reader/sites/test/reducer.js | 8 ++++---- client/state/selectors/get-reader-follows.js | 14 +++++++++++++- 5 files changed, 32 insertions(+), 17 deletions(-) diff --git a/client/reader/feed-stream/index.jsx b/client/reader/feed-stream/index.jsx index 4d09f4767b0f4..a038a6e94d190 100644 --- a/client/reader/feed-stream/index.jsx +++ b/client/reader/feed-stream/index.jsx @@ -41,7 +41,7 @@ class FeedStream extends React.Component { const emptyContent = ; const title = getSiteName( { feed, site } ) || this.props.translate( 'Loading Feed' ); - if ( feed && feed.is_error ) { + if ( ( feed && feed.is_error ) || ( site && site.is_error && site.error.code === 410 ) ) { return ; } diff --git a/client/state/reader/sites/actions.js b/client/state/reader/sites/actions.js index ee237299e1aee..7946afeab1d36 100644 --- a/client/state/reader/sites/actions.js +++ b/client/state/reader/sites/actions.js @@ -52,15 +52,15 @@ export function requestSite( siteId ) { } ); return data; }, - function failure( err ) { + function failure( error ) { dispatch( { type: READER_SITE_REQUEST_FAILURE, payload: { ID: siteId, }, - error: err, + error, } ); - throw err; + throw error; } ); }; diff --git a/client/state/reader/sites/reducer.js b/client/state/reader/sites/reducer.js index 70db75fadbffa..67700162c175f 100644 --- a/client/state/reader/sites/reducer.js +++ b/client/state/reader/sites/reducer.js @@ -45,16 +45,19 @@ function handleDeserialize( state ) { } function handleRequestFailure( state, action ) { + // 410 means site moved. site used to be wpcom but is no longer + if ( action.error && action.error.code !== 410 ) { + return state; + } + // new object proceeds current state to prevent new errors from overwriting existing values - return assign( - { - [ action.payload.ID ]: { - ID: action.payload.ID, - is_error: true, - }, + return assign( {}, state, { + [ action.payload.ID ]: { + ID: action.payload.ID, + is_error: true, + error: action.error, }, - state - ); + } ); } function adaptSite( attributes ) { diff --git a/client/state/reader/sites/test/reducer.js b/client/state/reader/sites/test/reducer.js index de68b02b6c808..cfb36ee1d2162 100644 --- a/client/state/reader/sites/test/reducer.js +++ b/client/state/reader/sites/test/reducer.js @@ -173,17 +173,17 @@ describe( 'reducer', () => { expect( items( validState, { type: DESERIALIZE } ) ).to.deep.equal( validState ); } ); - test( 'should stash an error object in the map if the request fails', () => { + test( 'should stash an error object in the map if the request fails with a 410', () => { expect( items( {}, { type: READER_SITE_REQUEST_FAILURE, - error: new Error( 'request failed' ), + error: { code: 410 }, payload: { ID: 666 }, } ) - ).to.deep.equal( { 666: { ID: 666, is_error: true } } ); + ).to.deep.equal( { 666: { ID: 666, is_error: true, error: { code: 410 } } } ); } ); test( 'should overwrite an existing entry on receiving a new feed', () => { @@ -204,7 +204,7 @@ describe( 'reducer', () => { expect( items( startingState, { type: READER_SITE_REQUEST_FAILURE, - error: new Error( 'request failed' ), + error: { code: 500 }, payload: { ID: 666 }, } ) ).to.deep.equal( startingState ); diff --git a/client/state/selectors/get-reader-follows.js b/client/state/selectors/get-reader-follows.js index b2c49a0b84660..6d3c1c79b5b4c 100644 --- a/client/state/selectors/get-reader-follows.js +++ b/client/state/selectors/get-reader-follows.js @@ -1,3 +1,4 @@ +/** @format */ /** * External dependencies * @@ -21,13 +22,24 @@ import { getFeed } from 'state/reader/feeds/selectors'; */ const getReaderFollows = createSelector( state => { + // remove subs where the sub has an error const items = reject( values( state.reader.follows.items ), 'error' ); + // this is important. don't mutate the original items. - return items.map( item => ( { + const withSiteAndFeed = items.map( item => ( { ...item, site: getSite( state, item.blog_ID ), feed: getFeed( state, item.feed_ID ), } ) ); + + // remove subs where the feed or site has an error + const withoutErrors = reject( + withSiteAndFeed, + item => + ( item.site && item.site.is_error && item.site.error.code === 410 ) || + ( item.feed && item.feed.is_error ) + ); + return withoutErrors; }, state => [ state.reader.follows.items, From b65170528b20834eb8999ab2d8daa5e87bf3b2f0 Mon Sep 17 00:00:00 2001 From: Jake Date: Tue, 31 Oct 2017 15:48:11 -0400 Subject: [PATCH 106/192] i18n-calypso: upgrade i18n-calypso for React 16 compat (#19358) * i18n-calypso: upgrade i18n-calypso for React 16 compat * shrinkwrap --- npm-shrinkwrap.json | 8 ++++---- package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 500b8f254e1d8..f02d92b9473e7 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -13,7 +13,7 @@ "dev": true, "dependencies": { "ast-types": { - "version": "0.9.12", + "version": "0.9.14", "dev": true }, "esprima": { @@ -25,7 +25,7 @@ "dev": true }, "recast": { - "version": "0.12.7", + "version": "0.12.8", "dev": true }, "source-map": { @@ -185,7 +185,7 @@ "version": "0.2.3" }, "asn1.js": { - "version": "4.9.1" + "version": "4.9.2" }, "assert": { "version": "1.4.1" @@ -3099,7 +3099,7 @@ } }, "i18n-calypso": { - "version": "1.8.1", + "version": "1.8.2", "dependencies": { "async": { "version": "1.5.2" diff --git a/package.json b/package.json index 7c2c5ae220894..649308c1840ff 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "hash.js": "1.1.3", "he": "0.5.0", "html-loader": "0.4.0", - "i18n-calypso": "1.8.1", + "i18n-calypso": "1.8.2", "immutability-helper": "2.4.0", "immutable": "3.7.6", "imports-loader": "0.6.5", From 928b78a0ff388c471ba0621775a81335feaef1b4 Mon Sep 17 00:00:00 2001 From: Allen Snook Date: Tue, 31 Oct 2017 12:56:34 -0700 Subject: [PATCH 107/192] Store: Add Stripe Connect Flows (#19115) * Add redux for fetching stripe connect account details * Display fetched stripe connect details from redux * Add new unit tests for new redux goodies for fetching acct details * Switch to use plain request instead of http-request to gain the flexibility of dispatching additional actions on success or failure * Add docblocks and get defaults for selectors * Remove account creation reducers (will be delivered in next PR) * Make placeholder dialog a stateless functional component * Do not use the entire site as a prop - just use the id and domain * Store: Stripe Connect: Wire the disconnect link through redux (#19244) * Connect the disconnect link through redux * Update redux to use request technique from base PR, update unit tests, rename everything we can to deauthorize (instead of disconnect) * Use that javascript thing where key is the same as variable with value * Store: Stripe Connect: Add create (deferred) account flow (#19292) * Connect connect UX through to redux; rewrite create redux to use new request approach; add a clear error action creator * We went through the trouble of getting a siteId, we should use it * Store: Stripe Connect: Add OAuth flow (#19300) * Add redux for oauth init * Add redux for the oauth connect endpoint * Fetch and redirect to OAuth URL * Add a dialog to complete the oauth flow and sign in the user * Navigate the user away from the code URL when OAuth fails * Pass the state and code strings under the correct keys * Fetch the account details on success and close the progress dialog * Set oauth_completed in the url to prompt reopening connection dialog on successful oauth * Remove js extension that crept in a few places * Move page import to externals and fix ordering of imports * Correct some docblocks and comments * Added docblocks and fixed one action creator argument list --- .../app/settings/payments/index.js | 13 + .../settings/payments/payment-method-item.js | 2 +- .../payments/payment-method-stripe.js | 105 ++- ...ent-method-stripe-complete-oauth-dialog.js | 164 ++++ .../payment-method-stripe-connect-account.js | 29 +- .../payment-method-stripe-connected-dialog.js | 61 +- .../payment-method-stripe-key-based-dialog.js | 2 +- ...ayment-method-stripe-placeholder-dialog.js | 29 + .../payment-method-stripe-setup-dialog.js | 129 +++- .../stripe/payment-method-stripe-utils.js | 43 +- .../app/settings/payments/stripe/style.scss | 15 + .../extensions/woocommerce/lib/nav-utils.js | 17 + .../woocommerce/state/action-types.js | 18 + .../woocommerce/state/data-layer/index.js | 2 - .../stripe-connect-account/actions.js | 428 ++++++++++- .../stripe-connect-account/handlers.js | 50 -- .../stripe-connect-account/reducer.js | 185 ++++- .../stripe-connect-account/selectors.js | 101 +++ .../stripe-connect-account/test/actions.js | 264 ++++++- .../stripe-connect-account/test/handlers.js | 104 --- .../stripe-connect-account/test/reducer.js | 721 +++++++++++++++++- .../stripe-connect-account/test/selectors.js | 406 ++++++++++ 22 files changed, 2656 insertions(+), 232 deletions(-) create mode 100644 client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-complete-oauth-dialog.js create mode 100644 client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-placeholder-dialog.js delete mode 100644 client/extensions/woocommerce/state/sites/settings/stripe-connect-account/handlers.js create mode 100644 client/extensions/woocommerce/state/sites/settings/stripe-connect-account/selectors.js delete mode 100644 client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/handlers.js create mode 100644 client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/selectors.js diff --git a/client/extensions/woocommerce/app/settings/payments/index.js b/client/extensions/woocommerce/app/settings/payments/index.js index 4d3317f2444f0..0f236d5ab3933 100644 --- a/client/extensions/woocommerce/app/settings/payments/index.js +++ b/client/extensions/woocommerce/app/settings/payments/index.js @@ -24,6 +24,11 @@ import { getActionList } from 'woocommerce/state/action-list/selectors'; import { getFinishedInitialSetup } from 'woocommerce/state/sites/setup-choices/selectors'; import { getLink } from 'woocommerce/lib/nav-utils'; import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; +import { + hasOAuthParamsInLocation, + hasOAuthCompleteInLocation, +} from './stripe/payment-method-stripe-utils'; +import { openPaymentMethodForEdit } from 'woocommerce/state/ui/payments/methods/actions'; import Main from 'components/main'; import SettingsPaymentsLocationCurrency from './payments-location-currency'; import SettingsNavigation from '../navigation'; @@ -47,6 +52,13 @@ class SettingsPayments extends Component { if ( site && site.ID ) { this.props.fetchSetupChoices( site.ID ); } + + // If we are in the middle of the Stripe Connect OAuth flow + // go ahead and option the Stripe dialog right away so + // we can complete the flow + if ( hasOAuthParamsInLocation() || hasOAuthCompleteInLocation() ) { + this.props.openPaymentMethodForEdit( site.ID, 'stripe' ); + } }; componentWillReceiveProps = newProps => { @@ -121,6 +133,7 @@ function mapDispatchToProps( dispatch ) { { createPaymentSettingsActionList, fetchSetupChoices, + openPaymentMethodForEdit, }, dispatch ); diff --git a/client/extensions/woocommerce/app/settings/payments/payment-method-item.js b/client/extensions/woocommerce/app/settings/payments/payment-method-item.js index 9365c29eab5a2..0444e2c991a95 100644 --- a/client/extensions/woocommerce/app/settings/payments/payment-method-item.js +++ b/client/extensions/woocommerce/app/settings/payments/payment-method-item.js @@ -26,7 +26,7 @@ import { getCurrentlyEditingPaymentMethod } from 'woocommerce/state/ui/payments/ import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; import FormFieldset from 'components/forms/form-fieldset'; import FormLabel from 'components/forms/form-label'; -import { hasStripeKeyPairForMode } from './stripe/payment-method-stripe-utils.js'; +import { hasStripeKeyPairForMode } from './stripe/payment-method-stripe-utils'; import ListItem from 'woocommerce/components/list/list-item'; import ListItemField from 'woocommerce/components/list/list-item-field'; import PaymentMethodEditDialog from './payment-method-edit-dialog'; diff --git a/client/extensions/woocommerce/app/settings/payments/payment-method-stripe.js b/client/extensions/woocommerce/app/settings/payments/payment-method-stripe.js index 59365f08314b0..072e66bdfb891 100644 --- a/client/extensions/woocommerce/app/settings/payments/payment-method-stripe.js +++ b/client/extensions/woocommerce/app/settings/payments/payment-method-stripe.js @@ -4,17 +4,31 @@ * @format */ -import config from 'config'; import React, { Component } from 'react'; -import { hasStripeKeyPairForMode } from './stripe/payment-method-stripe-utils.js'; +import { bindActionCreators } from 'redux'; +import config from 'config'; +import { connect } from 'react-redux'; +import { + getOAuthParamsFromLocation, + hasOAuthParamsInLocation, + hasStripeKeyPairForMode, +} from './stripe/payment-method-stripe-utils'; import { localize } from 'i18n-calypso'; import PropTypes from 'prop-types'; /** * Internal dependencies */ +import { fetchAccountDetails } from 'woocommerce/state/sites/settings/stripe-connect-account/actions'; +import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; +import { + getIsRequesting, + getStripeConnectAccount, +} from 'woocommerce/state/sites/settings/stripe-connect-account/selectors'; import PaymentMethodStripeConnectedDialog from './stripe/payment-method-stripe-connected-dialog'; import PaymentMethodStripeKeyBasedDialog from './stripe/payment-method-stripe-key-based-dialog'; +import PaymentMethodStripeCompleteOAuthDialog from './stripe/payment-method-stripe-complete-oauth-dialog'; +import PaymentMethodStripePlaceholderDialog from './stripe/payment-method-stripe-placeholder-dialog'; import PaymentMethodStripeSetupDialog from './stripe/payment-method-stripe-setup-dialog'; class PaymentMethodStripe extends Component { @@ -42,23 +56,8 @@ class PaymentMethodStripe extends Component { onCancel: PropTypes.func.isRequired, onEditField: PropTypes.func.isRequired, onDone: PropTypes.func.isRequired, - site: PropTypes.shape( { - domain: PropTypes.string.isRequired, - } ), - }; - - //////////////////////////////////////////////////////////////////////////// - // TODO - temporary to facilitate testing - will be removed in a subsequent PR - static defaultProps = { - stripeConnectAccount: { - connectedUserID: '', // e.g. acct_14qyt6Alijdnw0EA - displayName: '', - email: '', - firstName: '', - isActivated: false, - lastName: '', - logo: '', - }, + siteId: PropTypes.number.isRequired, + domain: PropTypes.string.isRequired, }; constructor( props ) { @@ -70,6 +69,22 @@ class PaymentMethodStripe extends Component { }; } + componentDidMount() { + const { siteId } = this.props; + if ( siteId && ! hasOAuthParamsInLocation() ) { + this.props.fetchAccountDetails( siteId ); + } + } + + componentWillReceiveProps( newProps ) { + const { siteId } = this.props; + const newSiteId = newProps.siteId; + + if ( siteId !== newSiteId && ! hasOAuthParamsInLocation() ) { + this.props.fetchAccountDetails( newSiteId ); + } + } + //////////////////////////////////////////////////////////////////////////// // Misc helpers @@ -100,8 +115,9 @@ class PaymentMethodStripe extends Component { // And render brings it all together render() { - const { method, onCancel, onDone, site, stripeConnectAccount } = this.props; + const { domain, isRequesting, method, onCancel, onDone, stripeConnectAccount } = this.props; const { connectedUserID } = stripeConnectAccount; + const oauthParams = getOAuthParamsFromLocation(); const connectFlowsEnabled = config.isEnabled( 'woocommerce/extension-settings-stripe-connect-flows' @@ -126,6 +142,16 @@ class PaymentMethodStripe extends Component { if ( connectedUserID ) { dialog = 'connected'; } + + // But if we are still waiting for account details to arrive, well then you get a placeholder + if ( isRequesting ) { + dialog = 'placeholder'; + } + + // In the middle of OAuth? + if ( hasOAuthParamsInLocation() ) { + dialog = 'oauth'; + } } // Now, render the appropriate dialog @@ -139,7 +165,7 @@ class PaymentMethodStripe extends Component { } else if ( 'connected' === dialog ) { return ( ); + } else if ( 'oauth' === dialog ) { + return ( + + ); + } else if ( 'placeholder' === dialog ) { + return ; } // Key-based dialog by default return ( { + this.props.clearError(); + }; + + componentDidMount = () => { + const { oauthCode, oauthState, siteId, stripeConnectAccount } = this.props; + + // Kick off the last step of the OAuth flow, but only if we don't + // have a connected user ID (to prevent re-entrancy) + const connectedUserID = get( stripeConnectAccount, [ 'connectedUserID' ], '' ); + if ( isEmpty( connectedUserID ) ) { + this.props.oauthConnect( siteId, oauthCode, oauthState ); + } + }; + + componentWillReceiveProps = ( { stripeConnectAccount } ) => { + // Did we receive a connected user ID? Connect must have finished, so + // let's close this dialog + const connectedUserID = get( stripeConnectAccount, [ 'connectedUserID' ], '' ); + if ( ! isEmpty( connectedUserID ) ) { + this.onClose(); + } + }; + + possiblyRenderProgress = () => { + const { error } = this.props; + if ( 0 === error.length ) { + return ; + } + return null; + }; + + possiblyRenderNotice = () => { + const { error } = this.props; + if ( 0 === error.length ) { + return null; + } + return ; + }; + + onClose = () => { + const { error, site } = this.props; + const oauthCompleted = isEmpty( error ); + this.props.clearError(); + + // Important - when the user closes the dialog (which should only happen + // in case of error), let's clear the query params by calling page + // with the payment settings path + const paymentsSettingsLink = getLink( '/store/settings/payments/:site', site ); + const paymentsSettingsQuery = oauthCompleted ? '?oauth_complete=1' : ''; + page( paymentsSettingsLink + paymentsSettingsQuery ); + + // Lastly, in the case of an error, let's make sure state reflects that the dialog is closed + if ( ! oauthCompleted ) { + this.props.onCancel(); + } + }; + + getButtons = () => { + const { isOAuthConnecting, isRequesting, translate } = this.props; + + if ( isOAuthConnecting || isRequesting ) { + return []; + } + + return [ + { + action: 'cancel', + label: translate( 'Close' ), + onClick: this.onClose, + }, + ]; + }; + + render = () => { + const { translate } = this.props; + + return ( + +
+ { translate( 'Completing Your Connection to Stripe' ) } +
+ { this.possiblyRenderProgress() } + { this.possiblyRenderNotice() } +
+ ); + }; +} + +function mapStateToProps( state ) { + const site = getSelectedSiteWithFallback( state ); + const siteId = site.ID || false; + const error = getError( state, siteId ); + const isOAuthConnecting = getIsOAuthConnecting( state, siteId ); + const isRequesting = getIsRequesting( state, siteId ); + const stripeConnectAccount = getStripeConnectAccount( state, siteId ); + + return { + error, + isOAuthConnecting, + isRequesting, + site, + siteId, + stripeConnectAccount, + }; +} + +function mapDispatchToProps( dispatch ) { + return bindActionCreators( + { + clearError, + oauthConnect, + }, + dispatch + ); +} + +export default localize( + connect( mapStateToProps, mapDispatchToProps )( PaymentMethodStripeCompleteOAuthDialog ) +); diff --git a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connect-account.js b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connect-account.js index e3fc5b97e603d..af5ba34777168 100644 --- a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connect-account.js +++ b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connect-account.js @@ -25,7 +25,8 @@ class StripeConnectAccount extends Component { lastName: PropTypes.string, logo: PropTypes.string, } ), - onDisconnect: PropTypes.func, // TODO - require most of these props in subsequent PR + isDeauthorizing: PropTypes.bool.isRequired, + onDeauthorize: PropTypes.func.isRequired, }; renderLogo = () => { @@ -61,19 +62,17 @@ class StripeConnectAccount extends Component { ); }; - // TODO - when we are ready to connect this for-reals, this layer may not be needed - onDisconnect = event => { + onDeauthorize = event => { event.preventDefault(); - if ( this.props.onDisconnect ) { - this.props.onDisconnect(); - } + this.props.onDeauthorize(); }; renderStatus = () => { - const { stripeConnectAccount, translate } = this.props; + const { isDeauthorizing, stripeConnectAccount, translate } = this.props; const { isActivated } = stripeConnectAccount; let status = null; + let deauthorize = null; if ( isActivated ) { status = ( @@ -89,12 +88,22 @@ class StripeConnectAccount extends Component { ); } + if ( isDeauthorizing ) { + deauthorize = ( + { translate( 'Disconnecting' ) } + ); + } else { + deauthorize = ( + + { translate( 'Disconnect' ) } + + ); + } + return (
{ status } - - { translate( 'Disconnect' ) } - + { deauthorize }
); }; diff --git a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connected-dialog.js b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connected-dialog.js index afb1afeb456a6..515f4d14a262c 100644 --- a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connected-dialog.js +++ b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connected-dialog.js @@ -5,6 +5,8 @@ */ import React, { Component } from 'react'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; import { localize } from 'i18n-calypso'; import PropTypes from 'prop-types'; @@ -12,12 +14,18 @@ import PropTypes from 'prop-types'; * Internal dependencies */ import AuthCaptureToggle from 'woocommerce/components/auth-capture-toggle'; +import { deauthorizeAccount } from 'woocommerce/state/sites/settings/stripe-connect-account/actions'; import Dialog from 'components/dialog'; import FormFieldset from 'components/forms/form-fieldset'; import FormLabel from 'components/forms/form-label'; import FormSettingExplanation from 'components/forms/form-setting-explanation'; import FormTextInput from 'components/forms/form-text-input'; -import { getStripeSampleStatementDescriptor } from './payment-method-stripe-utils.js'; +import { + getIsDeauthorizing, + getStripeConnectAccount, +} from 'woocommerce/state/sites/settings/stripe-connect-account/selectors'; +import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; +import { getStripeSampleStatementDescriptor } from './payment-method-stripe-utils'; import PaymentMethodEditFormToggle from '../payment-method-edit-form-toggle'; import StripeConnectAccount from './payment-method-stripe-connect-account'; @@ -68,6 +76,11 @@ class PaymentMethodStripeConnectedDialog extends Component { this.props.onEditField( { target: { name: 'capture', value: 'yes' } } ); }; + onDeauthorize = () => { + const { siteId } = this.props; + this.props.deauthorizeAccount( siteId ); + }; + renderMoreSettings = () => { const { domain, method, onEditField, translate } = this.props; const sampleDescriptor = getStripeSampleStatementDescriptor( domain ); @@ -111,15 +124,23 @@ class PaymentMethodStripeConnectedDialog extends Component { }; getButtons = () => { - const { onCancel, onDone, stripeConnectAccount, translate } = this.props; + const { onCancel, onDone, isDeauthorizing, stripeConnectAccount, translate } = this.props; const buttons = []; + const disabled = isDeauthorizing; + if ( stripeConnectAccount.isActivated ) { - buttons.push( { action: 'cancel', label: translate( 'Cancel' ), onClick: onCancel } ); + buttons.push( { + action: 'cancel', + disabled, + label: translate( 'Cancel' ), + onClick: onCancel, + } ); buttons.push( { action: 'save', + disabled, label: translate( 'Done' ), onClick: onDone, isPrimary: true, @@ -127,6 +148,7 @@ class PaymentMethodStripeConnectedDialog extends Component { } else { buttons.push( { action: 'cancel', + disabled, label: translate( 'Close' ), onClick: onCancel, isPrimary: true, @@ -137,7 +159,7 @@ class PaymentMethodStripeConnectedDialog extends Component { }; render() { - const { stripeConnectAccount, translate } = this.props; + const { isDeauthorizing, stripeConnectAccount, translate } = this.props; return (
{ translate( 'Manage Stripe' ) }
- + { stripeConnectAccount.isActivated && this.renderMoreSettings() }
); } } -export default localize( PaymentMethodStripeConnectedDialog ); +function mapStateToProps( state ) { + const site = getSelectedSiteWithFallback( state ); + const siteId = site.ID || false; + const isDeauthorizing = getIsDeauthorizing( state, siteId ); + const stripeConnectAccount = getStripeConnectAccount( state, siteId ); + return { + isDeauthorizing, + siteId, + stripeConnectAccount, + }; +} + +function mapDispatchToProps( dispatch ) { + return bindActionCreators( + { + deauthorizeAccount, + }, + dispatch + ); +} + +export default localize( + connect( mapStateToProps, mapDispatchToProps )( PaymentMethodStripeConnectedDialog ) +); diff --git a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-key-based-dialog.js b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-key-based-dialog.js index 1066ef541cf90..cd8392cb7e313 100644 --- a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-key-based-dialog.js +++ b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-key-based-dialog.js @@ -21,7 +21,7 @@ import FormTextInput from 'components/forms/form-text-input'; import { getStripeSampleStatementDescriptor, hasStripeKeyPairForMode, -} from './payment-method-stripe-utils.js'; +} from './payment-method-stripe-utils'; import PaymentMethodEditFormToggle from '../payment-method-edit-form-toggle'; import TestLiveToggle from 'woocommerce/components/test-live-toggle'; diff --git a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-placeholder-dialog.js b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-placeholder-dialog.js new file mode 100644 index 0000000000000..2ab2b2737bb7c --- /dev/null +++ b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-placeholder-dialog.js @@ -0,0 +1,29 @@ +/** + * External dependencies + * + * @format + */ + +import React from 'react'; +import { localize } from 'i18n-calypso'; +import { noop } from 'lodash'; + +/** + * Internal dependencies + */ +import Dialog from 'components/dialog'; + +const PaymentMethodStripePlaceholderDialog = ( { translate } ) => { + const buttons = [ + { action: 'cancel', disabled: true, label: translate( 'Cancel' ), onClick: noop }, + ]; + + return ( + +
{ translate( 'Stripe' ) }
+
+
+ ); +}; + +export default localize( PaymentMethodStripePlaceholderDialog ); diff --git a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-setup-dialog.js b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-setup-dialog.js index bda4d55572fa4..73353de00c7be 100644 --- a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-setup-dialog.js +++ b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-setup-dialog.js @@ -5,14 +5,36 @@ */ import React, { Component } from 'react'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; import { localize } from 'i18n-calypso'; import PropTypes from 'prop-types'; /** * Internal dependencies */ +import { + areSettingsGeneralLoading, + getStoreLocation, +} from 'woocommerce/state/sites/settings/general/selectors'; +import { + clearError, + createAccount, + oauthInit, +} from 'woocommerce/state/sites/settings/stripe-connect-account/actions'; import Dialog from 'components/dialog'; +import { getCurrentUserEmail } from 'state/current-user/selectors'; +import { + getError, + getIsCreating, + getIsOAuthInitializing, + getOAuthURL, +} from 'woocommerce/state/sites/settings/stripe-connect-account/selectors'; +import { getLink, getOrigin } from 'woocommerce/lib/nav-utils'; +import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; +import Notice from 'components/notice'; import StripeConnectPrompt from './payment-method-stripe-connect-prompt'; +import QuerySettingsGeneral from 'woocommerce/components/query-settings-general'; class PaymentMethodStripeSetupDialog extends Component { static propTypes = { @@ -27,21 +49,56 @@ class PaymentMethodStripeSetupDialog extends Component { }; } + componentWillMount = () => { + this.props.clearError(); + }; + onSelectCreate = () => { + const { isCreating } = this.props; + if ( isCreating ) { + return; + } this.setState( { createSelected: true } ); }; onSelectConnect = () => { + const { isCreating, isOAuthInitializing, oauthUrl, siteId, siteSlug } = this.props; + if ( isCreating ) { + return; + } this.setState( { createSelected: false } ); + + // See if we still need to initialize OAuth, and if so, do so + if ( ! isOAuthInitializing && 0 === oauthUrl.length ) { + const origin = getOrigin(); + const path = getLink( '/store/settings/payments/:site', { slug: siteSlug } ); + const returnUrl = `${ origin }${ path }`; + this.props.oauthInit( siteId, returnUrl ); + } }; onConnect = () => { - // Not yet implemented + const { country, email, oauthUrl, siteId } = this.props; + + if ( this.state.createSelected ) { + this.props.createAccount( siteId, email, country ); + } else { + window.location = oauthUrl; + } }; getButtons = () => { - const { onCancel, onUserRequestsKeyFlow, translate } = this.props; + const { + isCreating, + isLoadingAddress, + isOAuthInitializing, + onCancel, + onUserRequestsKeyFlow, + translate, + } = this.props; + const buttons = []; + const isBusy = isCreating || isLoadingAddress || isOAuthInitializing; // Allow them to switch to key based flow if they want buttons.push( { @@ -52,12 +109,17 @@ class PaymentMethodStripeSetupDialog extends Component { } ); // Always give the user a Cancel button - buttons.push( { action: 'cancel', label: translate( 'Cancel' ), onClick: onCancel } ); + buttons.push( { + action: 'cancel', + disabled: isBusy, + label: translate( 'Cancel' ), + onClick: onCancel, + } ); // And then the connect button itself buttons.push( { action: 'connect', - disabled: true, // TODO: will be enabled in a subsequent PR + disabled: isBusy, isPrimary: true, label: translate( 'Connect' ), onClick: this.onConnect, @@ -66,8 +128,16 @@ class PaymentMethodStripeSetupDialog extends Component { return buttons; }; - render() { - const { translate } = this.props; + possiblyRenderNotice = () => { + const { error } = this.props; + if ( 0 === error.length ) { + return null; + } + return ; + }; + + render = () => { + const { siteId, translate } = this.props; return ( + { this.possiblyRenderNotice() } + ); - } + }; +} + +function mapStateToProps( state ) { + const email = getCurrentUserEmail( state ); + const site = getSelectedSiteWithFallback( state ); + const siteId = site.ID || false; + const siteSlug = site.slug || ''; + + const error = getError( state, siteId ); + const isCreating = getIsCreating( state, siteId ); + const isOAuthInitializing = getIsOAuthInitializing( state, siteId ); + const oauthUrl = getOAuthURL( state, siteId ); + + const isLoadingAddress = areSettingsGeneralLoading( state, siteId ); + const storeLocation = getStoreLocation( state, siteId ); + const country = isLoadingAddress ? '' : storeLocation.country; + + return { + country, + email, + error, + isCreating, + isLoadingAddress, + isOAuthInitializing, + oauthUrl, + siteId, + siteSlug, + }; +} + +function mapDispatchToProps( dispatch ) { + return bindActionCreators( + { + clearError, + createAccount, + oauthInit, + }, + dispatch + ); } -export default localize( PaymentMethodStripeSetupDialog ); +export default localize( + connect( mapStateToProps, mapDispatchToProps )( PaymentMethodStripeSetupDialog ) +); diff --git a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-utils.js b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-utils.js index 99a6cb1faede8..12ca108f75428 100644 --- a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-utils.js +++ b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-utils.js @@ -1,4 +1,15 @@ -/** @format */ +/** + * External dependencies + * + * @format + */ +import { get } from 'lodash'; +import url from 'url'; + +/** + * Internal dependencies + */ + export function hasStripeKeyPairForMode( method ) { const { settings } = method; const isLiveMode = method.settings.testmode.value !== 'yes'; @@ -14,3 +25,33 @@ export function getStripeSampleStatementDescriptor( domain ) { .trim() .toUpperCase(); } + +export function hasOAuthParamsInLocation() { + const oauthParams = getOAuthParamsFromLocation(); + return oauthParams.state.length && oauthParams.code.length; +} + +export function hasOAuthCompleteInLocation() { + try { + const parsedURL = url.parse( window.location.href, true, true ); + return get( parsedURL, [ 'query', 'oauth_complete' ], false ); + } catch ( e ) { + return false; + } +} + +export function getOAuthParamsFromLocation() { + let state = ''; + let code = ''; + + try { + const parsedURL = url.parse( window.location.href, true, true ); + state = get( parsedURL, [ 'query', 'wcs_stripe_state' ], false ); + code = get( parsedURL, [ 'query', 'wcs_stripe_code' ], false ); + } catch ( e ) {} + + return { + state, + code, + }; +} diff --git a/client/extensions/woocommerce/app/settings/payments/stripe/style.scss b/client/extensions/woocommerce/app/settings/payments/stripe/style.scss index 19197650b2d66..c3de8bfdc0999 100644 --- a/client/extensions/woocommerce/app/settings/payments/stripe/style.scss +++ b/client/extensions/woocommerce/app/settings/payments/stripe/style.scss @@ -75,4 +75,19 @@ font-size: 18px; justify-content: space-between; padding: 0 0 16px 0; + + &.placeholder { + margin-bottom: 16px; + width: 30%; + @include placeholder(); + } +} + +.stripe__method-edit-body { + padding: 0 0 16px 0; + + &.placeholder { + height: 60px; + @include placeholder(); + } } diff --git a/client/extensions/woocommerce/lib/nav-utils.js b/client/extensions/woocommerce/lib/nav-utils.js index 47eb5e5380c10..386482fa3fd0c 100644 --- a/client/extensions/woocommerce/lib/nav-utils.js +++ b/client/extensions/woocommerce/lib/nav-utils.js @@ -12,3 +12,20 @@ export const getLink = ( path, site ) => { } return path.replace( ':site', site.slug ); }; + +/* Returns the origin for the current browser window + * + * @return {String} origin for the current browser window, wordpress.com by default + */ +export const getOrigin = () => { + let origin = 'https://wordpress.com'; + if ( 'undefined' !== typeof window && window.location ) { + origin = `${ window.location.protocol }//${ window.location.hostname }`; + } + + if ( window.location.port ) { + origin += `:${ window.location.port }`; + } + + return origin; +}; diff --git a/client/extensions/woocommerce/state/action-types.js b/client/extensions/woocommerce/state/action-types.js index 6db4ff830154c..4ee8b7ae1fd8f 100644 --- a/client/extensions/woocommerce/state/action-types.js +++ b/client/extensions/woocommerce/state/action-types.js @@ -152,10 +152,28 @@ export const WOOCOMMERCE_SETTINGS_GENERAL_RECEIVE = 'WOOCOMMERCE_SETTINGS_GENERA export const WOOCOMMERCE_SETTINGS_PRODUCTS_REQUEST = 'WOOCOMMERCE_SETTINGS_PRODUCTS_REQUEST'; export const WOOCOMMERCE_SETTINGS_PRODUCTS_REQUEST_SUCCESS = 'WOOCOMMERCE_SETTINGS_PRODUCTS_REQUEST_SUCCESS'; +export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR = + 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR'; export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE = 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE'; export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE = 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE'; +export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST = + 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST'; +export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE = + 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE'; +export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE = + 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE'; +export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE = + 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE'; +export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT = + 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_OAUTH_INIT'; +export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE = + 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE'; +export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT = + 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT'; +export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE = + 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE'; export const WOOCOMMERCE_SETTINGS_TAX_BATCH_REQUEST = 'WOOCOMMERCE_SETTINGS_TAX_BATCH_REQUEST'; export const WOOCOMMERCE_SETTINGS_TAX_BATCH_REQUEST_SUCCESS = 'WOOCOMMERCE_SETTINGS_TAX_BATCH_REQUEST_SUCCESS'; diff --git a/client/extensions/woocommerce/state/data-layer/index.js b/client/extensions/woocommerce/state/data-layer/index.js index 6e246ea7d9dea..0574b529d41d2 100644 --- a/client/extensions/woocommerce/state/data-layer/index.js +++ b/client/extensions/woocommerce/state/data-layer/index.js @@ -20,7 +20,6 @@ import settingsGeneral from '../sites/settings/general/handlers'; import shippingZoneLocations from './shipping-zone-locations'; import shippingZoneMethods from './shipping-zone-methods'; import shippingZones from './shipping-zones'; -import stripeConnectAccount from '../sites/settings/stripe-connect-account/handlers'; import ui from './ui'; import debugFactory from 'debug'; @@ -41,7 +40,6 @@ const handlers = mergeHandlers( shippingZoneLocations, shippingZoneMethods, shippingZones, - stripeConnectAccount, ui ); diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/actions.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/actions.js index 1ecab7459b8c8..4a66a5320dcb4 100644 --- a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/actions.js +++ b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/actions.js @@ -1,16 +1,434 @@ /** - * Internal dependencies + * External dependencies * * @format */ -import { WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE } from 'woocommerce/state/action-types'; +/** + * Internal dependencies + */ +import { + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, +} from 'woocommerce/state/action-types'; +import { getSelectedSiteId } from 'state/ui/selectors'; +import request from '../../request'; -export function createAccount( siteId, email, countryCode ) { - return { +/** + * Action Creator: Clear any error from a previous action. + * + * @param {Number} siteId The id of the site for which to clear errors. + * @return {Object} Action object + */ +export const clearError = siteId => ( dispatch, getState ) => { + const state = getState(); + if ( ! siteId ) { + siteId = getSelectedSiteId( state ); + } + + const clearErrorAction = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR, + siteId, + }; + + dispatch( clearErrorAction ); +}; + +/** + * Action Creator: Create (and connect) a Stripe Connect Account. + * + * @param {Number} siteId The id of the site for which to create an account. + * @param {String} email Email address (i.e. of the logged in WordPress.com user) to pass to Stripe. + * @param {String} country Two character country code to pass to Stripe (e.g. US). + * @param {String} [successAction=undefined] Optional action object to be dispatched upon success. + * @param {String} [failureAction=undefined] Optional action object to be dispatched upon error. + * @return {Object} Action object + */ +export const createAccount = ( + siteId, + email, + country, + successAction = null, + failureAction = null +) => ( dispatch, getState ) => { + const state = getState(); + if ( ! siteId ) { + siteId = getSelectedSiteId( state ); + } + + const createAction = { type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, - countryCode, + country, + email, + siteId, + }; + + dispatch( createAction ); + + return request( siteId ) + .post( 'connect/stripe/account', { email, country }, 'wc/v1' ) + .then( data => { + dispatch( createSuccess( siteId, createAction, data ) ); + if ( successAction ) { + dispatch( successAction( siteId, createAction, data ) ); + } + } ) + .catch( error => { + dispatch( createFailure( siteId, createAction, error ) ); + if ( failureAction ) { + dispatch( failureAction( siteId, createAction, error ) ); + } + } ); +}; + +/** + * Action Creator: Stripe Connect Account creation completed successfully + * + * @param {Number} siteId The id of the site for which to create an account. + * @param {Object} email The email address used to create the account. + * @param {Object} account_id The Stripe Connect Account id created for the site (from the data object). + * @return {Object} Action object + */ +function createSuccess( siteId, { email }, { account_id } ) { + return { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + connectedUserID: account_id, + email, + siteId, + }; +} + +/** + * Action Creator: Stripe Connect Account creation failed + * + * @param {Number} siteId The id of the site for which account creation failed. + * @param {Object} action The action used to attempt to create the account. + * @param {Object} message Error message returned (from the error object). + * @return {Object} Action object + */ +function createFailure( siteId, action, { message } ) { + return { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + error: message, + siteId, + }; +} + +/** + * Action Creator: Fetch Stripe Connect Account Details. + * + * @param {Number} siteId The id of the site for which to fetch connected account details. + * @param {String} [successAction=undefined] Optional action object to be dispatched upon success. + * @param {String} [failureAction=undefined] Optional action object to be dispatched upon error. + * @return {Object} Action object + */ +export const fetchAccountDetails = ( siteId, successAction = null, failureAction = null ) => ( + dispatch, + getState +) => { + const state = getState(); + if ( ! siteId ) { + siteId = getSelectedSiteId( state ); + } + + const fetchAction = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, + siteId, + }; + + dispatch( fetchAction ); + + return request( siteId ) + .get( 'connect/stripe/account', 'wc/v1' ) + .then( data => { + dispatch( fetchSuccess( siteId, fetchAction, data ) ); + if ( successAction ) { + dispatch( successAction( siteId, fetchAction, data ) ); + } + } ) + .catch( error => { + dispatch( fetchFailure( siteId, fetchAction, error ) ); + if ( failureAction ) { + dispatch( failureAction( error ) ); + } + } ); +}; + +/** + * Action Creator: Stripe Connect Account details were fetched successfully + * + * @param {Number} siteId The id of the site for which details were fetched. + * @param {Object} fetchAction The action used to fetch the account details. + * @param {Object} data The entire data object that was returned from the API. + * @return {Object} Action object + */ +function fetchSuccess( siteId, fetchAction, data ) { + const { account_id, display_name, email, business_logo, legal_entity, payouts_enabled } = data; + return { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, + connectedUserID: account_id, + displayName: display_name, email, + firstName: legal_entity.first_name, + isActivated: payouts_enabled, + logo: business_logo, + lastName: legal_entity.last_name, + siteId, + }; +} + +/** + * Action Creator: Stripe Connect Account details were unable to be fetched + * + * @param {Number} siteId The id of the site for which details could not be fetched. + * @param {Object} action The action used to attempt to fetch the account details. + * @param {Object} message Error message returned (from the error object). + * @return {Object} Action object + */ +function fetchFailure( siteId, action, { message } ) { + return { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, + error: message, + siteId, + }; +} + +/** + * Action Creator: Disconnect Account. + * + * @param {Number} siteId The id of the site to disconnect from Stripe Connect. + * @param {String} [successAction=undefined] Optional action object to be dispatched upon success. + * @param {String} [failureAction=undefined] Optional action object to be dispatched upon error. + * @return {Object} Action object + */ +export const deauthorizeAccount = ( siteId, successAction = null, failureAction = null ) => ( + dispatch, + getState +) => { + const state = getState(); + if ( ! siteId ) { + siteId = getSelectedSiteId( state ); + } + + const deauthorizeAction = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, + siteId, + }; + + dispatch( deauthorizeAction ); + + return request( siteId ) + .post( 'connect/stripe/account/deauthorize', {}, 'wc/v1' ) + .then( data => { + dispatch( deauthorizeSuccess( siteId, deauthorizeAction, data ) ); + if ( successAction ) { + dispatch( successAction( siteId, deauthorizeAction, data ) ); + } + } ) + .catch( error => { + dispatch( deauthorizeFailure( siteId, deauthorizeAction, error ) ); + if ( failureAction ) { + dispatch( failureAction( error ) ); + } + } ); +}; + +/** + * Action Creator: The Stripe Connect account was successfully deauthorized from our platform. + * + * @param {Number} siteId The id of the site which had its account deauthorized. + * @param {Object} action The action used to deauthorize the account. + * @param {Object} data The entire data object that was returned from the API. + * @return {Object} Action object + */ +// eslint-disable-next-line no-unused-vars +function deauthorizeSuccess( siteId, action, data ) { + return { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, + siteId, + }; +} + +/** + * Action Creator: The Stripe Connect account was unable to be deauthorized from our platform. + * + * @param {Number} siteId The id of the site which failed to have its account deauthorized. + * @param {Object} action The action used to attempt to deauthorize the account. + * @param {Object} errorMessage Error message returned. + * @return {Object} Action object + */ +function deauthorizeFailure( siteId, action, errorMessage ) { + return { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, + error: errorMessage, + siteId, + }; +} + +/** + * Action Creator: Get the initial OAuth URL for connecting a Stripe Account. + * + * @param {Number} siteId The id of the site for which to create an account. + * @param {String} returnUrl The URL for Stripe to return the user to (to complete the setup) + * @param {String} [successAction=undefined] Optional action object to be dispatched upon success. + * @param {String} [failureAction=undefined] Optional action object to be dispatched upon error. + * @return {Object} Action object + */ +export const oauthInit = ( siteId, returnUrl, successAction = null, failureAction = null ) => ( + dispatch, + getState +) => { + const state = getState(); + if ( ! siteId ) { + siteId = getSelectedSiteId( state ); + } + + const initAction = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, + returnUrl, + siteId, + }; + + dispatch( initAction ); + + return request( siteId ) + .post( 'connect/stripe/oauth/init', { returnUrl }, 'wc/v1' ) + .then( data => { + dispatch( oauthInitSuccess( siteId, initAction, data ) ); + if ( successAction ) { + dispatch( successAction( siteId, initAction, data ) ); + } + } ) + .catch( error => { + dispatch( oauthInitFailure( siteId, initAction, error ) ); + if ( failureAction ) { + dispatch( failureAction( siteId, initAction, error ) ); + } + } ); +}; + +/** + * Action Creator: The Stripe Connect account OAuth flow was successfully initialized. + * + * @param {Number} siteId The id of the site which we're doing OAuth for. + * @param {Object} action The action used to deauthorize the account. + * @param {Object} oauthUrl The URL to which the user needs to navigate to. + * @return {Object} Action object + */ +function oauthInitSuccess( siteId, action, { oauthUrl } ) { + return { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, + oauthUrl, + siteId, + }; +} + +/** + * Action Creator: The Stripe Connect account OAuth flow was unable to be initialized. + * + * @param {Number} siteId The id of the site which we tried doing OAuth for. + * @param {Object} action The action used to attempt to deauthorize the account. + * @param {Object} message Error message returned (from the error object). + * @return {Object} Action object + */ +function oauthInitFailure( siteId, action, { message } ) { + return { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, + error: message, + siteId, + }; +} + +/** + * Action Creator: Complete the OAuth flow and connect the Stripe Account. + * + * @param {Number} siteId The id of the site for which to create an account. + * @param {String} stripeCode The code which Stripe will exchange for the account id. + * @param {String} stripeState An arbitrary string passed throughout the flow as a CSRF protection. + * @param {String} [successAction=undefined] Optional action object to be dispatched upon success. + * @param {String} [failureAction=undefined] Optional action object to be dispatched upon error. + * @return {Object} Action object + */ +export const oauthConnect = ( + siteId, + stripeCode, + stripeState, + successAction = null, + failureAction = null +) => ( dispatch, getState ) => { + const state = getState(); + if ( ! siteId ) { + siteId = getSelectedSiteId( state ); + } + + const connectAction = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, + stripeCode, + stripeState, + siteId, + }; + + dispatch( connectAction ); + + return request( siteId ) + .post( 'connect/stripe/oauth/connect', { code: stripeCode, state: stripeState }, 'wc/v1' ) + .then( data => { + dispatch( oauthConnectSuccess( siteId, connectAction, data ) ); + if ( successAction ) { + dispatch( successAction( siteId, connectAction, data ) ); + } + } ) + .then( () => { + dispatch( fetchAccountDetails( siteId ) ); + } ) + .catch( error => { + dispatch( oauthConnectFailure( siteId, connectAction, error ) ); + if ( failureAction ) { + dispatch( failureAction( siteId, connectAction, error ) ); + } + } ); +}; + +/** + * Action Creator: The Stripe Connect account OAuth flow was successfully completed. + * + * @param {Number} siteId The id of the site which we're doing OAuth for. + * @param {Object} action The action used to complete OAuth for the account. + * @param {Object} account_id The account_id we are now connected to (from the data object) + * @return {Object} Action object + */ +function oauthConnectSuccess( siteId, action, { account_id } ) { + return { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, + connectedUserID: account_id, + siteId, + }; +} + +/** + * Action Creator: The Stripe Connect account OAuth flow was not able to be completed. + * + * @param {Number} siteId The id of the site which we tried doing OAuth for. + * @param {Object} action The action used to try and complete OAuth for the account. + * @param {Object} error Error and message returned (from the error object). + * @return {Object} Action object + */ +// Note: Stripe and WooCommerce Services server errors will be returned in message, but +// message will be empty for errors that the WooCommerce Services client generates itself +// so we need to grab the string from the error field inside the error object for those. +function oauthConnectFailure( siteId, action, { error, message } ) { + return { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, + error: message || error, siteId, }; } diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/handlers.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/handlers.js deleted file mode 100644 index 967ef9a427304..0000000000000 --- a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/handlers.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Internal dependencies - * - * @format - */ - -import { dispatchRequest } from 'state/data-layer/wpcom-http/utils'; -import request from 'woocommerce/state/sites/http-request'; -import { - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, -} from 'woocommerce/state/action-types'; - -export default { - [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE ]: [ - dispatchRequest( handleAccountCreate, handleAccountCreateSuccess, handleAccountCreateFailure ), - ], -}; - -export function handleAccountCreate( { dispatch }, action ) { - const { email, countryCode, siteId } = action; - dispatch( - request( siteId, action, '/wc/v1' ).post( 'connect/stripe/account/', { - email, - country: countryCode, - } ) - ); -} - -export function handleAccountCreateSuccess( store, action, { data } ) { - const { email, siteId } = action; - const { account_id } = data; - - store.dispatch( { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, - connectedUserID: account_id, - email, - siteId, - } ); -} - -export function handleAccountCreateFailure( { dispatch }, action, error ) { - const { email, siteId } = action; - dispatch( { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, - email, - error, - siteId, - } ); -} diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/reducer.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/reducer.js index 7b1cf837a875a..eec0f8e9d9ec4 100644 --- a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/reducer.js +++ b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/reducer.js @@ -1,49 +1,224 @@ /** - * Internal dependencies + * External dependencies * * @format */ +/** + * Internal dependencies + */ import { createReducer } from 'state/utils'; import { + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR, WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, } from 'woocommerce/state/action-types'; /** - * Updates state to indicate account creation request is in progress + * Updates state to clear any error from a previous action + * + * @param {Object} state Current state + * @return {Object} Updated state + */ +function connectAccountClearError( state = {} ) { + return Object.assign( {}, state, { + error: '', + } ); +} + +/** + * Updates state to indicate account creation is in progress * * @param {Object} state Current state - * @param {Object} action Action payload * @return {Object} Updated state */ function connectAccountCreate( state = {} ) { + return Object.assign( {}, state, { + error: '', + isCreating: true, + } ); +} + +/** + * Updates state to reflect account creation completed (or failed with an error) + * + * @param {Object} state Current state + * @param {Object} action Action payload + * @return {Object} Updated state + */ +function connectAccountCreateComplete( state = {}, action ) { + return Object.assign( {}, state, { + connectedUserID: action.connectedUserID || '', + displayName: '', + email: action.email || '', + error: action.error || '', + firstName: '', + isActivated: false, + isCreating: false, + isRequesting: false, + lastName: '', + logo: '', + } ); +} + +/** + * Updates state to indicate account (details) fetch request is in progress + * + * @param {Object} state Current state + * @return {Object} Updated state + */ +function connectAccountFetch( state = {} ) { return Object.assign( {}, state, { connectedUserID: '', + displayName: '', email: '', + error: '', + firstName: '', isActivated: false, + isDeauthorizing: false, isRequesting: true, + lastName: '', + logo: '', } ); } /** - * Updates state with created account details + * Updates state with fetched account details * * @param {Object} state Current state * @param {Object} action Action payload * @return {Object} Updated state */ -function connectAccountCreateComplete( state = {}, action ) { +function connectAccountFetchComplete( state = {}, action ) { return Object.assign( {}, state, { connectedUserID: action.connectedUserID || '', + displayName: action.displayName || '', email: action.email || '', error: action.error || '', + firstName: action.firstName || '', + isActivated: action.isActivated || false, + isDeauthorizing: false, + isRequesting: false, + lastName: action.lastName || '', + logo: action.logo || '', + } ); +} + +/** + * Updates state to indicate account deauthorization request is in progress + * + * @param {Object} state Current state + * @return {Object} Updated state + */ +function connectAccountDeauthorize( state = {} ) { + return Object.assign( {}, state, { + isDeauthorizing: true, + } ); +} + +/** + * Updates state after deauthorization completes + * + * @param {Object} state Current state + * @param {Object} action Action payload + * @return {Object} Updated state + */ +function connectAccountDeauthorizeComplete( state = {}, action ) { + return Object.assign( {}, state, { + connectedUserID: '', + displayName: '', + email: '', + error: action.error || '', + firstName: '', + isActivated: false, + isDeauthorizing: false, + isRequesting: false, + lastName: '', + logo: '', + } ); +} + +/** + * Updates state to indicate oauth initialization request is in progress + * + * @param {Object} state Current state + * @return {Object} Updated state + */ +function connectAccountOAuthInit( state = {} ) { + return Object.assign( {}, state, { + isOAuthInitializing: true, + oauthUrl: '', + } ); +} + +/** + * Updates state after oauth initialization completes + * + * @param {Object} state Current state + * @param {Object} action Action payload + * @return {Object} Updated state + */ +function connectAccountOAuthInitComplete( state = {}, action ) { + return Object.assign( {}, state, { + isOAuthInitializing: false, + error: action.error || '', + oauthUrl: action.oauthUrl || '', + } ); +} + +/** + * Updates state to indicate account creation is in progress + * + * @param {Object} state Current state + * @return {Object} Updated state + */ +function connectAccountOAuthConnect( state = {} ) { + return Object.assign( {}, state, { + error: '', + isOAuthConnecting: true, + } ); +} + +/** + * Updates state to reflect account creation completed (or failed with an error) + * + * @param {Object} state Current state + * @param {Object} action Action payload + * @return {Object} Updated state + */ +function connectAccountOAuthConnectComplete( state = {}, action ) { + return Object.assign( {}, state, { + connectedUserID: action.connectedUserID || '', + email: '', + error: action.error || '', + firstName: '', isActivated: false, + isCreating: false, + isOAuthConnecting: false, isRequesting: false, + lastName: '', + logo: '', } ); } export default createReducer( null, { + [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR ]: connectAccountClearError, [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE ]: connectAccountCreate, [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE ]: connectAccountCreateComplete, + [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE ]: connectAccountDeauthorize, + [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE ]: connectAccountDeauthorizeComplete, + [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST ]: connectAccountFetch, + [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE ]: connectAccountFetchComplete, + [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT ]: connectAccountOAuthInit, + [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE ]: connectAccountOAuthInitComplete, + [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT ]: connectAccountOAuthConnect, + [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE ]: connectAccountOAuthConnectComplete, } ); diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/selectors.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/selectors.js new file mode 100644 index 0000000000000..c52d6c95ec6bf --- /dev/null +++ b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/selectors.js @@ -0,0 +1,101 @@ +/** + * External dependencies + * + * @format + */ + +import { get, omit } from 'lodash'; + +/** + * Internal dependencies + */ +import { getSelectedSiteId } from 'state/ui/selectors'; + +const getRawSettings = ( state, siteId ) => { + return get( + state, + [ 'extensions', 'woocommerce', 'sites', siteId, 'settings', 'stripeConnectAccount' ], + {} + ); +}; + +/** + * @param {Object} state Whole Redux state tree + * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used + * @return {boolean} Whether we are presently attempting to create an account + */ +export function getError( state, siteId = getSelectedSiteId( state ) ) { + return get( getRawSettings( state, siteId ), [ 'error' ], '' ); +} + +/** + * @param {Object} state Whole Redux state tree + * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used + * @return {boolean} Whether we are presently attempting to create an account + */ +export function getIsCreating( state, siteId = getSelectedSiteId( state ) ) { + return get( getRawSettings( state, siteId ), [ 'isCreating' ], false ); +} + +/** + * @param {Object} state Whole Redux state tree + * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used + * @return {boolean} Whether we are presently attempting to deauthorize the connected account for the site + */ +export function getIsDeauthorizing( state, siteId = getSelectedSiteId( state ) ) { + return get( getRawSettings( state, siteId ), [ 'isDeauthorizing' ], false ); +} + +/** + * @param {Object} state Whole Redux state tree + * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used + * @return {boolean} Whether we are presently attempting to complete the OAuth connection + */ +export function getIsOAuthConnecting( state, siteId = getSelectedSiteId( state ) ) { + return get( getRawSettings( state, siteId ), [ 'isOAuthConnecting' ], false ); +} + +/** + * @param {Object} state Whole Redux state tree + * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used + * @return {boolean} Whether we are presently requesting oauth initialization + */ +export function getIsOAuthInitializing( state, siteId = getSelectedSiteId( state ) ) { + return get( getRawSettings( state, siteId ), [ 'isOAuthInitializing' ], false ); +} + +/** + * @param {Object} state Whole Redux state tree + * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used + * @return {String} URL to which to navigate to kick off the OAuth flow at Stripe + */ +export function getOAuthURL( state, siteId = getSelectedSiteId( state ) ) { + return get( getRawSettings( state, siteId ), [ 'oauthUrl' ], '' ); +} + +/** + * @param {Object} state Whole Redux state tree + * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used + * @return {boolean} Whether we are presently requesting connect account details from the server + */ +export function getIsRequesting( state, siteId = getSelectedSiteId( state ) ) { + return get( getRawSettings( state, siteId ), [ 'isRequesting' ], false ); +} + +/** + * @param {Object} state Whole Redux state tree + * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used + * @return {Object} The details of the connect account for the site, if any + */ +export function getStripeConnectAccount( state, siteId = getSelectedSiteId( state ) ) { + const rawSettings = getRawSettings( state, siteId ); + return omit( rawSettings, [ + 'error', + 'isCreating', + 'isDeauthorizing', + 'isOAuthConnecting', + 'isOAuthInitializing', + 'isRequesting', + 'oauthUrl', + ] ); +} diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/actions.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/actions.js index 7084549435c43..648e144340577 100644 --- a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/actions.js +++ b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/actions.js @@ -4,25 +4,271 @@ * External dependencies */ import { expect } from 'chai'; +import { spy } from 'sinon'; /** * Internal dependencies */ -import { createAccount } from '../actions'; -import { WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE } from 'woocommerce/state/action-types'; +import useNock from 'test/helpers/use-nock'; +import { + clearError, + createAccount, + deauthorizeAccount, + fetchAccountDetails, + oauthInit, + oauthConnect, +} from '../actions'; +import { + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, +} from 'woocommerce/state/action-types'; describe( 'actions', () => { + describe( '#clearError()', () => { + const siteId = '123'; + + test( 'should dispatch an action', () => { + const getState = () => ( {} ); + const dispatch = spy(); + clearError( siteId )( dispatch, getState ); + expect( dispatch ).to.have.been.calledWith( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR, + siteId, + } ); + } ); + } ); + describe( '#createAccount()', () => { const siteId = '123'; - const email = 'foo@bar.com'; - const countryCode = 'US'; - test( 'should return an action', () => { - const action = createAccount( siteId, email, countryCode ); - expect( action ).to.eql( { + + useNock( nock => { + nock( 'https://public-api.wordpress.com:443' ) + .persist() + .post( '/rest/v1.1/jetpack-blogs/123/rest-api/' ) + .query( { path: '/wc/v1/connect/stripe/account&_method=post', json: true } ) + .reply( 200, { + data: { + success: true, + account_id: 'acct_14qyt6Alijdnw0EA', + }, + } ); + } ); + + test( 'should dispatch an action', () => { + const getState = () => ( {} ); + const dispatch = spy(); + createAccount( siteId, 'foo@bar.com', 'US' )( dispatch, getState ); + expect( dispatch ).to.have.been.calledWith( { type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, + country: 'US', + email: 'foo@bar.com', + siteId, + } ); + } ); + + test( 'should dispatch a success action with account details when the request completes', () => { + const getState = () => ( {} ); + const dispatch = spy(); + const response = createAccount( siteId, 'foo@bar.com', 'US' )( dispatch, getState ); + + return response.then( () => { + expect( dispatch ).to.have.been.calledWith( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + siteId, + connectedUserID: 'acct_14qyt6Alijdnw0EA', + email: 'foo@bar.com', + } ); + } ); + } ); + } ); + + describe( '#fetchAccountDetails()', () => { + const siteId = '123'; + + useNock( nock => { + nock( 'https://public-api.wordpress.com:443' ) + .persist() + .get( '/rest/v1.1/jetpack-blogs/123/rest-api/' ) + .query( { path: '/wc/v1/connect/stripe/account&_method=get', json: true } ) + .reply( 200, { + data: { + success: true, + account_id: 'acct_14qyt6Alijdnw0EA', + display_name: 'Foo Bar', + email: 'foo@bar.com', + legal_entity: { + first_name: 'Foo', + last_name: 'Bar', + }, + payouts_enabled: true, + business_logo: 'https://foo.com/bar.png', + }, + } ); + } ); + + test( 'should dispatch an action', () => { + const getState = () => ( {} ); + const dispatch = spy(); + fetchAccountDetails( siteId )( dispatch, getState ); + expect( dispatch ).to.have.been.calledWith( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, + siteId, + } ); + } ); + + test( 'should dispatch a success action with account details when the request completes', () => { + const getState = () => ( {} ); + const dispatch = spy(); + const response = fetchAccountDetails( siteId )( dispatch, getState ); + + return response.then( () => { + expect( dispatch ).to.have.been.calledWith( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, + siteId, + connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: 'Foo Bar', + email: 'foo@bar.com', + firstName: 'Foo', + isActivated: true, + logo: 'https://foo.com/bar.png', + lastName: 'Bar', + } ); + } ); + } ); + } ); + + describe( '#deauthorizeAccount()', () => { + const siteId = '123'; + + useNock( nock => { + nock( 'https://public-api.wordpress.com:443' ) + .persist() + .post( '/rest/v1.1/jetpack-blogs/123/rest-api/' ) + .query( { path: '/wc/v1/connect/stripe/account/deauthorize&_method=post', json: true } ) + .reply( 200, { + data: { + success: true, + account_id: 'acct_14qyt6Alijdnw0EA', + }, + } ); + } ); + + test( 'should dispatch an action', () => { + const getState = () => ( {} ); + const dispatch = spy(); + deauthorizeAccount( siteId )( dispatch, getState ); + expect( dispatch ).to.have.been.calledWith( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, siteId, - email, - countryCode, + } ); + } ); + + test( 'should dispatch a success action when the request completes', () => { + const getState = () => ( {} ); + const dispatch = spy(); + const response = deauthorizeAccount( siteId )( dispatch, getState ); + + return response.then( () => { + expect( dispatch ).to.have.been.calledWith( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, + siteId, + } ); + } ); + } ); + } ); + + describe( '#oauthInit()', () => { + const siteId = '123'; + + useNock( nock => { + nock( 'https://public-api.wordpress.com:443' ) + .persist() + .post( '/rest/v1.1/jetpack-blogs/123/rest-api/' ) + .query( { path: '/wc/v1/connect/stripe/oauth/init&_method=post', json: true } ) + .reply( 200, { + data: { + success: true, + oauthUrl: + 'https://connect.stripe.com/oauth/authorize?response_type=code&client_id=xxx&scope=read_write&state=yyy', + }, + } ); + } ); + + test( 'should dispatch an action', () => { + const getState = () => ( {} ); + const dispatch = spy(); + oauthInit( siteId, 'https://return.url.com/' )( dispatch, getState ); + expect( dispatch ).to.have.been.calledWith( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, + returnUrl: 'https://return.url.com/', + siteId, + } ); + } ); + + test( 'should dispatch a success action with a Stripe URL when the request completes', () => { + const getState = () => ( {} ); + const dispatch = spy(); + const response = oauthInit( siteId, 'https://return.url.com/' )( dispatch, getState ); + + return response.then( () => { + expect( dispatch ).to.have.been.calledWith( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, + siteId, + oauthUrl: + 'https://connect.stripe.com/oauth/authorize?response_type=code&client_id=xxx&scope=read_write&state=yyy', + } ); + } ); + } ); + } ); + + describe( '#oauthConnect()', () => { + const siteId = '123'; + + useNock( nock => { + nock( 'https://public-api.wordpress.com:443' ) + .persist() + .post( '/rest/v1.1/jetpack-blogs/123/rest-api/' ) + .query( { path: '/wc/v1/connect/stripe/oauth/connect&_method=post', json: true } ) + .reply( 200, { + data: { + success: true, + account_id: 'acct_14qyt6Alijdnw0EA', + }, + } ); + } ); + + test( 'should dispatch an action', () => { + const getState = () => ( {} ); + const dispatch = spy(); + oauthConnect( siteId, 'STRIPECODE', 'STRIPESTATE' )( dispatch, getState ); + expect( dispatch ).to.have.been.calledWith( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, + stripeCode: 'STRIPECODE', + stripeState: 'STRIPESTATE', + siteId, + } ); + } ); + + test( 'should dispatch a success action with a Stripe account when the request completes', () => { + const getState = () => ( {} ); + const dispatch = spy(); + const response = oauthConnect( siteId, 'STRIPECODE', 'STRIPESTATE' )( dispatch, getState ); + + return response.then( () => { + expect( dispatch ).to.have.been.calledWith( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, + connectedUserID: 'acct_14qyt6Alijdnw0EA', + siteId, + } ); } ); } ); } ); diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/handlers.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/handlers.js deleted file mode 100644 index 149eddf775ebd..0000000000000 --- a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/handlers.js +++ /dev/null @@ -1,104 +0,0 @@ -/** @format */ - -/** - * External dependencies - */ -import { expect } from 'chai'; -import { spy } from 'sinon'; - -/** - * Internal dependencies - */ -import { createAccount } from '../actions.js'; -import { - handleAccountCreate, - handleAccountCreateSuccess, - handleAccountCreateFailure, -} from '../handlers.js'; -import { WPCOM_HTTP_REQUEST } from 'state/action-types'; -import { WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE } from 'woocommerce/state/action-types'; - -describe( 'handlers', () => { - describe( '#handleCreateAccountRequest', () => { - test( 'should dispatch a POST request', () => { - const siteId = '123'; - const email = 'foo@bar.com'; - const country = 'US'; - const dispatch = spy(); - const action = createAccount( siteId, email, country ); - handleAccountCreate( { dispatch }, action ); - expect( dispatch ).to.have.been.calledWithMatch( { - type: WPCOM_HTTP_REQUEST, - body: { - path: '/wc/v1/connect/stripe/account/&_method=POST', - body: JSON.stringify( { email, country } ), - }, - method: 'POST', - path: `/jetpack-blogs/${ siteId }/rest-api/`, - query: { - json: true, - apiVersion: '1.1', - }, - } ); - } ); - } ); - - describe( '#handleAccountCreateSuccess()', () => { - test( 'should dispatch create account receive on success with the account info', () => { - const siteId = '123'; - const email = 'foo@bar.com'; - const countryCode = 'US'; - const store = { - dispatch: spy(), - }; - const response = { - data: { - account_id: 'acct_14qyt6Alijdnw0EA', - success: true, - }, - }; - - const action = createAccount( siteId, email, countryCode ); - handleAccountCreateSuccess( store, action, response ); - - expect( store.dispatch ).calledWith( { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, - connectedUserID: response.data.account_id, - email, - siteId, - } ); - } ); - } ); - - describe( '#handleAccountCreateFailure()', () => { - test( 'should dispatch create account error', () => { - const siteId = '123'; - const email = 'foo@bar.com'; - const countryCode = 'US'; - const store = { - dispatch: spy(), - }; - const response = { - data: { - body: { - data: { - message: 'An account using that email address already exists.', - }, - success: false, - }, - status: 400, - }, - }; - - const action = createAccount( siteId, email, countryCode ); - handleAccountCreateFailure( store, action, response.data.body.data.message ); - - expect( store.dispatch ).calledWith( { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, - email, - error: response.data.body.data.message, - siteId, - } ); - } ); - } ); -} ); diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/reducer.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/reducer.js index fcf6acb81cc7c..050bbc99b17b7 100644 --- a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/reducer.js +++ b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/reducer.js @@ -10,8 +10,17 @@ import { expect } from 'chai'; */ import stripeConnectAccountReducer from '../reducer'; import { + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR, WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, } from 'woocommerce/state/action-types'; import sitesReducer from 'woocommerce/state/sites/reducer'; @@ -23,6 +32,17 @@ describe( 'reducer', () => { } ); } ); + describe( 'clearError', () => { + test( 'should reset error in state', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR, + siteId: 123, + }; + const newState = stripeConnectAccountReducer( { error: 'My error message' }, action ); + expect( newState.error ).to.eql( '' ); + } ); + } ); + describe( 'connectAccountCreate', () => { test( 'should update state to show request in progress', () => { const action = { @@ -30,7 +50,7 @@ describe( 'reducer', () => { siteId: 123, }; const newState = stripeConnectAccountReducer( undefined, action ); - expect( newState.isRequesting ).to.eql( true ); + expect( newState.isCreating ).to.eql( true ); } ); test( 'should only update the request in progress flag for the appropriate siteId', () => { @@ -38,6 +58,154 @@ describe( 'reducer', () => { type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, siteId: 123, }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + isCreating: false, + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + isCreating: false, + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.isCreating ).to.eql( true ); + expect( newState[ 456 ].settings.stripeConnectAccount.isCreating ).to.eql( false ); + } ); + } ); + + describe( 'connectAccountCreateComplete', () => { + test( 'should update state with the received account details', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + connectedUserID: 'acct_14qyt6Alijdnw0EA', + email: 'foo@bar.com', + siteId: 123, + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState ).to.eql( { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: '', + email: 'foo@bar.com', + error: '', + firstName: '', + isActivated: false, + isCreating: false, + isRequesting: false, + lastName: '', + logo: '', + } ); + } ); + + test( 'should leave other sites state unchanged', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + connectedUserID: 'acct_14qyt6Alijdnw0EA', + email: 'foo@bar.com', + siteId: 123, + }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + email: '', + isActivated: false, + isCreating: true, + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + email: '', + isActivated: false, + isCreating: true, + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.isCreating ).to.eql( false ); + expect( newState[ 123 ].settings.stripeConnectAccount.connectedUserID ).to.eql( + 'acct_14qyt6Alijdnw0EA' + ); + expect( newState[ 123 ].settings.stripeConnectAccount.email ).to.eql( 'foo@bar.com' ); + expect( newState[ 456 ].settings.stripeConnectAccount.isCreating ).to.eql( true ); + } ); + } ); + + describe( 'connectAccountCreateError', () => { + test( 'should reset the isCreating flag in state and store the email and error', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + siteId: 123, + email: 'foo@bar.com', + error: 'My error', + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState.error ).to.eql( 'My error' ); + expect( newState.email ).to.eql( 'foo@bar.com' ); + expect( newState.isCreating ).to.eql( false ); + } ); + + test( 'should leave other sites state unchanged', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + siteId: 123, + email: 'foo@bar.com', + error: 'My error', + }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + isCreating: true, + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + isCreating: true, + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.isCreating ).to.eql( false ); + expect( newState[ 456 ].settings.stripeConnectAccount.isCreating ).to.eql( true ); + } ); + } ); + + describe( 'connectAccountFetch', () => { + test( 'should update state to show request in progress', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, + siteId: 123, + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState.isRequesting ).to.eql( true ); + } ); + + test( 'should only update the request in progress flag for the appropriate siteId', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, + siteId: 123, + }; const newState = sitesReducer( { 123: { @@ -68,27 +236,37 @@ describe( 'reducer', () => { } ); } ); - describe( 'connectAccountCreateComplete', () => { + describe( 'connectAccountFetchComplete', () => { test( 'should update state with the received account details', () => { const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: 'Foo Bar', email: 'foo@bar.com', + firstName: 'Foo', + isActivated: false, + lastName: 'Bar', + logo: 'http://bar.com/foo.png', siteId: 123, }; const newState = stripeConnectAccountReducer( undefined, action ); expect( newState ).to.eql( { connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: 'Foo Bar', email: 'foo@bar.com', error: '', + firstName: 'Foo', isActivated: false, + isDeauthorizing: false, isRequesting: false, + lastName: 'Bar', + logo: 'http://bar.com/foo.png', } ); } ); test( 'should leave other sites state unchanged', () => { const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, connectedUserID: 'acct_14qyt6Alijdnw0EA', email: 'foo@bar.com', siteId: 123, @@ -127,10 +305,10 @@ describe( 'reducer', () => { } ); } ); - describe( 'receivingAccountCreationError', () => { + describe( 'connectAccountFetchError', () => { test( 'should reset the isRequesting flag in state', () => { const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, siteId: 123, email: 'foo@bar.com', error: 'My error', @@ -143,7 +321,7 @@ describe( 'reducer', () => { test( 'should leave other sites state unchanged', () => { const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, siteId: 123, email: 'foo@bar.com', error: 'My error', @@ -177,4 +355,533 @@ describe( 'reducer', () => { expect( newState[ 456 ].settings.stripeConnectAccount.isRequesting ).to.eql( true ); } ); } ); + + describe( 'connectAccountDeauthorize', () => { + test( 'should update state to show request in progress', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, + siteId: 123, + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState.isDeauthorizing ).to.eql( true ); + } ); + + test( 'should only update the request in progress flag for the appropriate siteId', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, + siteId: 123, + }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + isDeauthorizing: false, + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + isDeauthorizing: false, + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.isDeauthorizing ).to.eql( true ); + expect( newState[ 456 ].settings.stripeConnectAccount.isDeauthorizing ).to.eql( false ); + } ); + } ); + + describe( 'connectAccountDeauthorizeComplete Success', () => { + test( 'should update state', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, + siteId: 123, + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState ).to.eql( { + connectedUserID: '', + displayName: '', + email: '', + error: '', + firstName: '', + isActivated: false, + isDeauthorizing: false, + isRequesting: false, + lastName: '', + logo: '', + } ); + } ); + + test( 'should leave other sites state unchanged', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, + siteId: 123, + }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: 'acct_25rzu7Alijdnw0FB', + displayName: 'Bar Foo', + email: 'bar@foo.com', + error: '', + firstName: 'Bar', + isActivated: false, + isDeauthorizing: true, + isRequesting: false, + lastName: 'Foo', + logo: '', + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: 'Foo Bar', + email: 'foo@bar.com', + error: '', + firstName: 'Foo', + isActivated: false, + isDeauthorizing: true, + isRequesting: false, + lastName: 'Bar', + logo: '', + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.isDeauthorizing ).to.eql( false ); + expect( newState[ 123 ].settings.stripeConnectAccount.connectedUserID ).to.eql( '' ); + expect( newState[ 456 ].settings.stripeConnectAccount.isDeauthorizing ).to.eql( true ); + expect( newState[ 456 ].settings.stripeConnectAccount.connectedUserID ).to.eql( + 'acct_14qyt6Alijdnw0EA' + ); + } ); + } ); + + describe( 'connectAccountDeauthorizeComplete w/ Error', () => { + test( 'should set the error in state', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, + siteId: 123, + error: 'My error message', + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState.error ).to.eql( 'My error message' ); + } ); + + test( 'should leave other sites state unchanged', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, + siteId: 123, + error: 'My error message', + }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: 'Foo Bar', + email: 'foo@bar.com', + error: '', + firstName: 'Foo', + isActivated: false, + isDeauthorizing: true, + isRequesting: false, + lastName: 'Bar', + logo: '', + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: 'Foo Bar', + email: 'foo@bar.com', + error: '', + firstName: 'Foo', + isActivated: false, + isDeauthorizing: true, + isRequesting: false, + lastName: 'Bar', + logo: '', + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.error ).to.eql( 'My error message' ); + expect( newState[ 123 ].settings.stripeConnectAccount.isDeauthorizing ).to.eql( false ); + expect( newState[ 456 ].settings.stripeConnectAccount.error ).to.eql( '' ); + expect( newState[ 456 ].settings.stripeConnectAccount.isDeauthorizing ).to.eql( true ); + } ); + } ); + + describe( 'connectAccountOAuthInit', () => { + test( 'should update state to show request in progress', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, + siteId: 123, + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState.isOAuthInitializing ).to.eql( true ); + } ); + + test( 'should only update the request in progress flag for the appropriate siteId', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, + siteId: 123, + }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + isOAuthInitializing: false, + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + isOAuthInitializing: false, + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.isOAuthInitializing ).to.eql( true ); + expect( newState[ 456 ].settings.stripeConnectAccount.isOAuthInitializing ).to.eql( false ); + } ); + } ); + + describe( 'connectAccountOAuthInitComplete Success', () => { + test( 'should update state', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, + oauthUrl: 'https://connect.stripe.com/oauth/authorize', + siteId: 123, + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState ).to.eql( { + error: '', + isOAuthInitializing: false, + oauthUrl: 'https://connect.stripe.com/oauth/authorize', + } ); + } ); + + test( 'should leave other sites state unchanged', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, + oauthUrl: 'https://connect.stripe.com/oauth/authorize', + siteId: 123, + }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: 'acct_25rzu7Alijdnw0FB', + displayName: 'Bar Foo', + email: 'bar@foo.com', + error: '', + firstName: 'Bar', + isActivated: false, + isDeauthorizing: false, + isOAuthInitializing: true, + isRequesting: false, + lastName: 'Foo', + logo: '', + oauthUrl: 'https://connect.stripe.com/oauth/authorize', + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: 'Foo Bar', + email: 'foo@bar.com', + error: '', + firstName: 'Foo', + isActivated: false, + isDeauthorizing: true, + isOAuthInitializing: false, + isRequesting: false, + lastName: 'Bar', + logo: '', + oauthUrl: '', + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.isOAuthInitializing ).to.eql( false ); + expect( newState[ 123 ].settings.stripeConnectAccount.oauthUrl ).to.eql( + 'https://connect.stripe.com/oauth/authorize' + ); + expect( newState[ 456 ].settings.stripeConnectAccount.isOAuthInitializing ).to.eql( false ); + expect( newState[ 456 ].settings.stripeConnectAccount.oauthUrl ).to.eql( '' ); + } ); + } ); + + describe( 'connectAccountOAuthInitComplete w/ Error', () => { + test( 'should set the error in state', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, + siteId: 123, + error: 'My error message', + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState.error ).to.eql( 'My error message' ); + } ); + + test( 'should leave other sites state unchanged', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, + siteId: 123, + error: 'My error message', + }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: 'Foo Bar', + email: 'foo@bar.com', + error: '', + firstName: 'Foo', + isActivated: false, + isDeauthorizing: false, + isOAuthInitializing: true, + isRequesting: false, + lastName: 'Bar', + logo: '', + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: 'Foo Bar', + email: 'foo@bar.com', + error: '', + firstName: 'Foo', + isActivated: false, + isDeauthorizing: false, + isOAuthInitializing: true, + isRequesting: false, + lastName: 'Bar', + logo: '', + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.error ).to.eql( 'My error message' ); + expect( newState[ 123 ].settings.stripeConnectAccount.isOAuthInitializing ).to.eql( false ); + expect( newState[ 456 ].settings.stripeConnectAccount.error ).to.eql( '' ); + expect( newState[ 456 ].settings.stripeConnectAccount.isOAuthInitializing ).to.eql( true ); + } ); + } ); + + describe( 'connectAccountOAuthConnect', () => { + test( 'should update state to show request in progress', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, + siteId: 123, + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState.isOAuthConnecting ).to.eql( true ); + } ); + + test( 'should only update the request in progress flag for the appropriate siteId', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, + siteId: 123, + }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + isOAuthConnecting: false, + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + isOAuthConnecting: false, + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.isOAuthConnecting ).to.eql( true ); + expect( newState[ 456 ].settings.stripeConnectAccount.isOAuthConnecting ).to.eql( false ); + } ); + } ); + + describe( 'connectAccountOAuthConnectComplete Success', () => { + test( 'should update state', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, + connectedUserID: 'acct_14qyt6Alijdnw0EA', + siteId: 123, + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState ).to.eql( { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + email: '', + error: '', + firstName: '', + isActivated: false, + isCreating: false, + isOAuthConnecting: false, + isRequesting: false, + lastName: '', + logo: '', + } ); + } ); + + test( 'should leave other sites state unchanged', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, + connectedUserID: 'acct_14qyt6Alijdnw0EA', + siteId: 123, + }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + displayName: '', + email: '', + error: '', + firstName: '', + isActivated: false, + isDeauthorizing: false, + isOAuthConnecting: true, + isOAuthInitializing: false, + isRequesting: false, + lastName: '', + logo: '', + oauthUrl: '', + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + displayName: '', + email: '', + error: '', + firstName: '', + isActivated: false, + isDeauthorizing: false, + isOAuthConnecting: true, + isOAuthInitializing: false, + isRequesting: false, + lastName: '', + logo: '', + oauthUrl: '', + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.isOAuthConnecting ).to.eql( false ); + expect( newState[ 123 ].settings.stripeConnectAccount.connectedUserID ).to.eql( + 'acct_14qyt6Alijdnw0EA' + ); + expect( newState[ 456 ].settings.stripeConnectAccount.isOAuthConnecting ).to.eql( true ); + expect( newState[ 456 ].settings.stripeConnectAccount.connectedUserID ).to.eql( '' ); + } ); + } ); + + describe( 'connectAccountOAuthConnectComplete w/ Error', () => { + test( 'should set the error in state', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, + siteId: 123, + error: 'My error message', + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState.error ).to.eql( 'My error message' ); + } ); + + test( 'should leave other sites state unchanged', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, + siteId: 123, + error: 'My error message', + }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + displayName: 'Foo Bar', + email: 'foo@bar.com', + error: '', + firstName: 'Foo', + isActivated: false, + isDeauthorizing: false, + isOAuthConnecting: true, + isOAuthInitializing: false, + isRequesting: false, + lastName: 'Bar', + logo: '', + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + displayName: 'Foo Bar', + email: 'foo@bar.com', + error: '', + firstName: 'Foo', + isActivated: false, + isDeauthorizing: false, + isOAuthConnecting: true, + isOAuthInitializing: false, + isRequesting: false, + lastName: 'Bar', + logo: '', + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.error ).to.eql( 'My error message' ); + expect( newState[ 123 ].settings.stripeConnectAccount.isOAuthConnecting ).to.eql( false ); + expect( newState[ 456 ].settings.stripeConnectAccount.error ).to.eql( '' ); + expect( newState[ 456 ].settings.stripeConnectAccount.isOAuthConnecting ).to.eql( true ); + } ); + } ); } ); diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/selectors.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/selectors.js new file mode 100644 index 0000000000000..25d617ce25e7f --- /dev/null +++ b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/selectors.js @@ -0,0 +1,406 @@ +/** @format */ + +/** + * External dependencies + */ +import { expect } from 'chai'; + +/** + * Internal dependencies + */ +import { + getError, + getIsCreating, + getIsDeauthorizing, + getIsOAuthConnecting, + getIsOAuthInitializing, + getIsRequesting, + getOAuthURL, + getStripeConnectAccount, +} from '../selectors'; + +const uninitializedState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: {}, + }, + }, + }, + }, + }, +}; + +const creatingState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: { + isCreating: true, + }, + }, + }, + }, + }, + }, +}; + +const createdState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + email: 'foo@bar.com', + isCreating: false, + }, + }, + }, + }, + }, + }, +}; + +const errorState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: { + error: 'My error message', + }, + }, + }, + }, + }, + }, +}; + +const fetchingState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + displayName: '', + email: '', + firstName: '', + isActivated: false, + isDeauthorizing: false, + isRequesting: true, + lastName: '', + logo: '', + }, + }, + }, + }, + }, + }, +}; + +const fetchedState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: 'Foo Bar', + email: 'foo@bar.com', + firstName: 'Foo', + isActivated: true, + isDeauthorizing: false, + isRequesting: false, + lastName: 'Bar', + logo: 'https://foo.com/bar.png', + }, + }, + }, + }, + }, + }, +}; + +const deauthorizingState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + displayName: '', + email: '', + firstName: '', + isActivated: false, + isDeauthorizing: true, + isRequesting: false, + logo: '', + lastName: '', + }, + }, + }, + }, + }, + }, +}; + +const deauthorizedState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + displayName: '', + email: '', + firstName: '', + isActivated: false, + isDeauthorizing: false, + isRequesting: false, + logo: '', + lastName: '', + }, + }, + }, + }, + }, + }, +}; + +const oauthInitializingState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + displayName: '', + email: '', + firstName: '', + isActivated: false, + isDeauthorizing: false, + isOAuthInitializing: true, + isRequesting: false, + logo: '', + lastName: '', + oauthUrl: '', + }, + }, + }, + }, + }, + }, +}; + +const oauthConnectingState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + displayName: '', + email: '', + firstName: '', + isActivated: false, + isDeauthorizing: false, + isOAuthInitializing: false, + isOAuthConnecting: true, + isRequesting: false, + logo: '', + lastName: '', + oauthUrl: '', + }, + }, + }, + }, + }, + }, +}; + +const oauthConnectedState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: '', + email: '', + firstName: '', + isActivated: false, + isDeauthorizing: false, + isOAuthInitializing: false, + isOAuthConnecting: false, + isRequesting: false, + logo: '', + lastName: '', + oauthUrl: '', + }, + }, + }, + }, + }, + }, +}; + +const oauthInitializedState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + displayName: '', + email: '', + firstName: '', + isActivated: false, + isDeauthorizing: false, + isOAuthInitializing: false, + isRequesting: false, + logo: '', + lastName: '', + oauthUrl: 'https://connect.stripe.com/oauth/authorize', + }, + }, + }, + }, + }, + }, +}; + +describe( 'selectors', () => { + describe( '#getIsCreating', () => { + test( 'should be false when state is uninitialized.', () => { + expect( getIsCreating( uninitializedState, 123 ) ).to.be.false; + } ); + + test( 'should be true when attempting to create an account.', () => { + expect( getIsCreating( creatingState, 123 ) ).to.be.true; + } ); + + test( 'should be false after creating an account.', () => { + expect( getIsCreating( createdState, 123 ) ).to.be.false; + } ); + } ); + + describe( '#getError', () => { + test( 'should return error when present.', () => { + expect( getError( errorState, 123 ) ).to.eql( 'My error message' ); + } ); + + test( 'should return empty string when not.', () => { + expect( getError( createdState, 123 ) ).to.eql( '' ); + } ); + } ); + + describe( '#getIsRequesting', () => { + test( 'should be false when state is uninitialized.', () => { + expect( getIsRequesting( uninitializedState, 123 ) ).to.be.false; + } ); + + test( 'should be true when fetching account details.', () => { + expect( getIsRequesting( fetchingState, 123 ) ).to.be.true; + } ); + + test( 'should be false when not fetching account details.', () => { + expect( getIsRequesting( fetchedState, 123 ) ).to.be.false; + } ); + } ); + + describe( '#getStripeConnectAccount', () => { + test( 'should be empty when state is uninitialized.', () => { + expect( getStripeConnectAccount( uninitializedState, 123 ) ).to.eql( {} ); + } ); + + test( 'should return account details when they are available in state.', () => { + expect( getStripeConnectAccount( fetchedState, 123 ) ).to.eql( { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: 'Foo Bar', + email: 'foo@bar.com', + firstName: 'Foo', + isActivated: true, + lastName: 'Bar', + logo: 'https://foo.com/bar.png', + } ); + } ); + } ); + + describe( '#getIsDeauthorizing', () => { + test( 'should be false when woocommerce state is not available.', () => { + expect( getIsDeauthorizing( uninitializedState, 123 ) ).to.be.false; + } ); + + test( 'should be false when connected.', () => { + expect( getIsDeauthorizing( fetchedState, 123 ) ).to.be.false; + } ); + + test( 'should be true when deauthorizing.', () => { + expect( getIsDeauthorizing( deauthorizingState, 123 ) ).to.be.true; + } ); + + test( 'should be false when deauthorization has completed.', () => { + expect( getIsDeauthorizing( deauthorizedState, 123 ) ).to.be.false; + } ); + } ); + + describe( '#getIsOAuthInitializing', () => { + test( 'should be false when woocommerce state is not available.', () => { + expect( getIsOAuthInitializing( uninitializedState, 123 ) ).to.be.false; + } ); + + test( 'should be true when initializing.', () => { + expect( getIsOAuthInitializing( oauthInitializingState, 123 ) ).to.be.true; + } ); + + test( 'should be false when initialization has completed.', () => { + expect( getIsOAuthInitializing( oauthInitializedState, 123 ) ).to.be.false; + } ); + } ); + + describe( '#getIsOAuthConnecting', () => { + test( 'should be false when woocommerce state is not available.', () => { + expect( getIsOAuthConnecting( uninitializedState, 123 ) ).to.be.false; + } ); + + test( 'should be true when connecting.', () => { + expect( getIsOAuthConnecting( oauthConnectingState, 123 ) ).to.be.true; + } ); + + test( 'should be false when connection has completed.', () => { + expect( getIsOAuthConnecting( oauthConnectedState, 123 ) ).to.be.false; + } ); + } ); + + describe( '#getOAuthURL', () => { + test( 'should be empty when woocommerce state is not available.', () => { + expect( getOAuthURL( uninitializedState, 123 ) ).to.eql( '' ); + } ); + + test( 'should be empty when initializing.', () => { + expect( getOAuthURL( oauthInitializingState, 123 ) ).to.be.eql( '' ); + } ); + + test( 'should have a URL when initialization has completed.', () => { + expect( getOAuthURL( oauthInitializedState, 123 ) ).to.eql( + 'https://connect.stripe.com/oauth/authorize' + ); + } ); + } ); +} ); From 5bec866a39205bfacc586738ef2a6cf935d81b84 Mon Sep 17 00:00:00 2001 From: Jake Date: Tue, 31 Oct 2017 16:23:22 -0400 Subject: [PATCH 108/192] Revert 2 most recent prs: #19115 and #19358 (#19362) * Revert "i18n-calypso: upgrade i18n-calypso for React 16 compat (#19358)" This reverts commit b65170528b20834eb8999ab2d8daa5e87bf3b2f0. * Revert "Store: Add Stripe Connect Flows (#19115)" This reverts commit 928b78a0ff388c471ba0621775a81335feaef1b4. --- .../app/settings/payments/index.js | 13 - .../settings/payments/payment-method-item.js | 2 +- .../payments/payment-method-stripe.js | 105 +-- ...ent-method-stripe-complete-oauth-dialog.js | 164 ---- .../payment-method-stripe-connect-account.js | 29 +- .../payment-method-stripe-connected-dialog.js | 61 +- .../payment-method-stripe-key-based-dialog.js | 2 +- ...ayment-method-stripe-placeholder-dialog.js | 29 - .../payment-method-stripe-setup-dialog.js | 129 +--- .../stripe/payment-method-stripe-utils.js | 43 +- .../app/settings/payments/stripe/style.scss | 15 - .../extensions/woocommerce/lib/nav-utils.js | 17 - .../woocommerce/state/action-types.js | 18 - .../woocommerce/state/data-layer/index.js | 2 + .../stripe-connect-account/actions.js | 428 +---------- .../stripe-connect-account/handlers.js | 50 ++ .../stripe-connect-account/reducer.js | 185 +---- .../stripe-connect-account/selectors.js | 101 --- .../stripe-connect-account/test/actions.js | 264 +------ .../stripe-connect-account/test/handlers.js | 104 +++ .../stripe-connect-account/test/reducer.js | 721 +----------------- .../stripe-connect-account/test/selectors.js | 406 ---------- npm-shrinkwrap.json | 8 +- package.json | 2 +- 24 files changed, 237 insertions(+), 2661 deletions(-) delete mode 100644 client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-complete-oauth-dialog.js delete mode 100644 client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-placeholder-dialog.js create mode 100644 client/extensions/woocommerce/state/sites/settings/stripe-connect-account/handlers.js delete mode 100644 client/extensions/woocommerce/state/sites/settings/stripe-connect-account/selectors.js create mode 100644 client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/handlers.js delete mode 100644 client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/selectors.js diff --git a/client/extensions/woocommerce/app/settings/payments/index.js b/client/extensions/woocommerce/app/settings/payments/index.js index 0f236d5ab3933..4d3317f2444f0 100644 --- a/client/extensions/woocommerce/app/settings/payments/index.js +++ b/client/extensions/woocommerce/app/settings/payments/index.js @@ -24,11 +24,6 @@ import { getActionList } from 'woocommerce/state/action-list/selectors'; import { getFinishedInitialSetup } from 'woocommerce/state/sites/setup-choices/selectors'; import { getLink } from 'woocommerce/lib/nav-utils'; import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; -import { - hasOAuthParamsInLocation, - hasOAuthCompleteInLocation, -} from './stripe/payment-method-stripe-utils'; -import { openPaymentMethodForEdit } from 'woocommerce/state/ui/payments/methods/actions'; import Main from 'components/main'; import SettingsPaymentsLocationCurrency from './payments-location-currency'; import SettingsNavigation from '../navigation'; @@ -52,13 +47,6 @@ class SettingsPayments extends Component { if ( site && site.ID ) { this.props.fetchSetupChoices( site.ID ); } - - // If we are in the middle of the Stripe Connect OAuth flow - // go ahead and option the Stripe dialog right away so - // we can complete the flow - if ( hasOAuthParamsInLocation() || hasOAuthCompleteInLocation() ) { - this.props.openPaymentMethodForEdit( site.ID, 'stripe' ); - } }; componentWillReceiveProps = newProps => { @@ -133,7 +121,6 @@ function mapDispatchToProps( dispatch ) { { createPaymentSettingsActionList, fetchSetupChoices, - openPaymentMethodForEdit, }, dispatch ); diff --git a/client/extensions/woocommerce/app/settings/payments/payment-method-item.js b/client/extensions/woocommerce/app/settings/payments/payment-method-item.js index 0444e2c991a95..9365c29eab5a2 100644 --- a/client/extensions/woocommerce/app/settings/payments/payment-method-item.js +++ b/client/extensions/woocommerce/app/settings/payments/payment-method-item.js @@ -26,7 +26,7 @@ import { getCurrentlyEditingPaymentMethod } from 'woocommerce/state/ui/payments/ import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; import FormFieldset from 'components/forms/form-fieldset'; import FormLabel from 'components/forms/form-label'; -import { hasStripeKeyPairForMode } from './stripe/payment-method-stripe-utils'; +import { hasStripeKeyPairForMode } from './stripe/payment-method-stripe-utils.js'; import ListItem from 'woocommerce/components/list/list-item'; import ListItemField from 'woocommerce/components/list/list-item-field'; import PaymentMethodEditDialog from './payment-method-edit-dialog'; diff --git a/client/extensions/woocommerce/app/settings/payments/payment-method-stripe.js b/client/extensions/woocommerce/app/settings/payments/payment-method-stripe.js index 072e66bdfb891..59365f08314b0 100644 --- a/client/extensions/woocommerce/app/settings/payments/payment-method-stripe.js +++ b/client/extensions/woocommerce/app/settings/payments/payment-method-stripe.js @@ -4,31 +4,17 @@ * @format */ -import React, { Component } from 'react'; -import { bindActionCreators } from 'redux'; import config from 'config'; -import { connect } from 'react-redux'; -import { - getOAuthParamsFromLocation, - hasOAuthParamsInLocation, - hasStripeKeyPairForMode, -} from './stripe/payment-method-stripe-utils'; +import React, { Component } from 'react'; +import { hasStripeKeyPairForMode } from './stripe/payment-method-stripe-utils.js'; import { localize } from 'i18n-calypso'; import PropTypes from 'prop-types'; /** * Internal dependencies */ -import { fetchAccountDetails } from 'woocommerce/state/sites/settings/stripe-connect-account/actions'; -import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; -import { - getIsRequesting, - getStripeConnectAccount, -} from 'woocommerce/state/sites/settings/stripe-connect-account/selectors'; import PaymentMethodStripeConnectedDialog from './stripe/payment-method-stripe-connected-dialog'; import PaymentMethodStripeKeyBasedDialog from './stripe/payment-method-stripe-key-based-dialog'; -import PaymentMethodStripeCompleteOAuthDialog from './stripe/payment-method-stripe-complete-oauth-dialog'; -import PaymentMethodStripePlaceholderDialog from './stripe/payment-method-stripe-placeholder-dialog'; import PaymentMethodStripeSetupDialog from './stripe/payment-method-stripe-setup-dialog'; class PaymentMethodStripe extends Component { @@ -56,8 +42,23 @@ class PaymentMethodStripe extends Component { onCancel: PropTypes.func.isRequired, onEditField: PropTypes.func.isRequired, onDone: PropTypes.func.isRequired, - siteId: PropTypes.number.isRequired, - domain: PropTypes.string.isRequired, + site: PropTypes.shape( { + domain: PropTypes.string.isRequired, + } ), + }; + + //////////////////////////////////////////////////////////////////////////// + // TODO - temporary to facilitate testing - will be removed in a subsequent PR + static defaultProps = { + stripeConnectAccount: { + connectedUserID: '', // e.g. acct_14qyt6Alijdnw0EA + displayName: '', + email: '', + firstName: '', + isActivated: false, + lastName: '', + logo: '', + }, }; constructor( props ) { @@ -69,22 +70,6 @@ class PaymentMethodStripe extends Component { }; } - componentDidMount() { - const { siteId } = this.props; - if ( siteId && ! hasOAuthParamsInLocation() ) { - this.props.fetchAccountDetails( siteId ); - } - } - - componentWillReceiveProps( newProps ) { - const { siteId } = this.props; - const newSiteId = newProps.siteId; - - if ( siteId !== newSiteId && ! hasOAuthParamsInLocation() ) { - this.props.fetchAccountDetails( newSiteId ); - } - } - //////////////////////////////////////////////////////////////////////////// // Misc helpers @@ -115,9 +100,8 @@ class PaymentMethodStripe extends Component { // And render brings it all together render() { - const { domain, isRequesting, method, onCancel, onDone, stripeConnectAccount } = this.props; + const { method, onCancel, onDone, site, stripeConnectAccount } = this.props; const { connectedUserID } = stripeConnectAccount; - const oauthParams = getOAuthParamsFromLocation(); const connectFlowsEnabled = config.isEnabled( 'woocommerce/extension-settings-stripe-connect-flows' @@ -142,16 +126,6 @@ class PaymentMethodStripe extends Component { if ( connectedUserID ) { dialog = 'connected'; } - - // But if we are still waiting for account details to arrive, well then you get a placeholder - if ( isRequesting ) { - dialog = 'placeholder'; - } - - // In the middle of OAuth? - if ( hasOAuthParamsInLocation() ) { - dialog = 'oauth'; - } } // Now, render the appropriate dialog @@ -165,7 +139,7 @@ class PaymentMethodStripe extends Component { } else if ( 'connected' === dialog ) { return ( ); - } else if ( 'oauth' === dialog ) { - return ( - - ); - } else if ( 'placeholder' === dialog ) { - return ; } // Key-based dialog by default return ( { - this.props.clearError(); - }; - - componentDidMount = () => { - const { oauthCode, oauthState, siteId, stripeConnectAccount } = this.props; - - // Kick off the last step of the OAuth flow, but only if we don't - // have a connected user ID (to prevent re-entrancy) - const connectedUserID = get( stripeConnectAccount, [ 'connectedUserID' ], '' ); - if ( isEmpty( connectedUserID ) ) { - this.props.oauthConnect( siteId, oauthCode, oauthState ); - } - }; - - componentWillReceiveProps = ( { stripeConnectAccount } ) => { - // Did we receive a connected user ID? Connect must have finished, so - // let's close this dialog - const connectedUserID = get( stripeConnectAccount, [ 'connectedUserID' ], '' ); - if ( ! isEmpty( connectedUserID ) ) { - this.onClose(); - } - }; - - possiblyRenderProgress = () => { - const { error } = this.props; - if ( 0 === error.length ) { - return ; - } - return null; - }; - - possiblyRenderNotice = () => { - const { error } = this.props; - if ( 0 === error.length ) { - return null; - } - return ; - }; - - onClose = () => { - const { error, site } = this.props; - const oauthCompleted = isEmpty( error ); - this.props.clearError(); - - // Important - when the user closes the dialog (which should only happen - // in case of error), let's clear the query params by calling page - // with the payment settings path - const paymentsSettingsLink = getLink( '/store/settings/payments/:site', site ); - const paymentsSettingsQuery = oauthCompleted ? '?oauth_complete=1' : ''; - page( paymentsSettingsLink + paymentsSettingsQuery ); - - // Lastly, in the case of an error, let's make sure state reflects that the dialog is closed - if ( ! oauthCompleted ) { - this.props.onCancel(); - } - }; - - getButtons = () => { - const { isOAuthConnecting, isRequesting, translate } = this.props; - - if ( isOAuthConnecting || isRequesting ) { - return []; - } - - return [ - { - action: 'cancel', - label: translate( 'Close' ), - onClick: this.onClose, - }, - ]; - }; - - render = () => { - const { translate } = this.props; - - return ( - -
- { translate( 'Completing Your Connection to Stripe' ) } -
- { this.possiblyRenderProgress() } - { this.possiblyRenderNotice() } -
- ); - }; -} - -function mapStateToProps( state ) { - const site = getSelectedSiteWithFallback( state ); - const siteId = site.ID || false; - const error = getError( state, siteId ); - const isOAuthConnecting = getIsOAuthConnecting( state, siteId ); - const isRequesting = getIsRequesting( state, siteId ); - const stripeConnectAccount = getStripeConnectAccount( state, siteId ); - - return { - error, - isOAuthConnecting, - isRequesting, - site, - siteId, - stripeConnectAccount, - }; -} - -function mapDispatchToProps( dispatch ) { - return bindActionCreators( - { - clearError, - oauthConnect, - }, - dispatch - ); -} - -export default localize( - connect( mapStateToProps, mapDispatchToProps )( PaymentMethodStripeCompleteOAuthDialog ) -); diff --git a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connect-account.js b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connect-account.js index af5ba34777168..e3fc5b97e603d 100644 --- a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connect-account.js +++ b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connect-account.js @@ -25,8 +25,7 @@ class StripeConnectAccount extends Component { lastName: PropTypes.string, logo: PropTypes.string, } ), - isDeauthorizing: PropTypes.bool.isRequired, - onDeauthorize: PropTypes.func.isRequired, + onDisconnect: PropTypes.func, // TODO - require most of these props in subsequent PR }; renderLogo = () => { @@ -62,17 +61,19 @@ class StripeConnectAccount extends Component { ); }; - onDeauthorize = event => { + // TODO - when we are ready to connect this for-reals, this layer may not be needed + onDisconnect = event => { event.preventDefault(); - this.props.onDeauthorize(); + if ( this.props.onDisconnect ) { + this.props.onDisconnect(); + } }; renderStatus = () => { - const { isDeauthorizing, stripeConnectAccount, translate } = this.props; + const { stripeConnectAccount, translate } = this.props; const { isActivated } = stripeConnectAccount; let status = null; - let deauthorize = null; if ( isActivated ) { status = ( @@ -88,22 +89,12 @@ class StripeConnectAccount extends Component { ); } - if ( isDeauthorizing ) { - deauthorize = ( - { translate( 'Disconnecting' ) } - ); - } else { - deauthorize = ( - - { translate( 'Disconnect' ) } - - ); - } - return (
{ status } - { deauthorize } + + { translate( 'Disconnect' ) } +
); }; diff --git a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connected-dialog.js b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connected-dialog.js index 515f4d14a262c..afb1afeb456a6 100644 --- a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connected-dialog.js +++ b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connected-dialog.js @@ -5,8 +5,6 @@ */ import React, { Component } from 'react'; -import { bindActionCreators } from 'redux'; -import { connect } from 'react-redux'; import { localize } from 'i18n-calypso'; import PropTypes from 'prop-types'; @@ -14,18 +12,12 @@ import PropTypes from 'prop-types'; * Internal dependencies */ import AuthCaptureToggle from 'woocommerce/components/auth-capture-toggle'; -import { deauthorizeAccount } from 'woocommerce/state/sites/settings/stripe-connect-account/actions'; import Dialog from 'components/dialog'; import FormFieldset from 'components/forms/form-fieldset'; import FormLabel from 'components/forms/form-label'; import FormSettingExplanation from 'components/forms/form-setting-explanation'; import FormTextInput from 'components/forms/form-text-input'; -import { - getIsDeauthorizing, - getStripeConnectAccount, -} from 'woocommerce/state/sites/settings/stripe-connect-account/selectors'; -import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; -import { getStripeSampleStatementDescriptor } from './payment-method-stripe-utils'; +import { getStripeSampleStatementDescriptor } from './payment-method-stripe-utils.js'; import PaymentMethodEditFormToggle from '../payment-method-edit-form-toggle'; import StripeConnectAccount from './payment-method-stripe-connect-account'; @@ -76,11 +68,6 @@ class PaymentMethodStripeConnectedDialog extends Component { this.props.onEditField( { target: { name: 'capture', value: 'yes' } } ); }; - onDeauthorize = () => { - const { siteId } = this.props; - this.props.deauthorizeAccount( siteId ); - }; - renderMoreSettings = () => { const { domain, method, onEditField, translate } = this.props; const sampleDescriptor = getStripeSampleStatementDescriptor( domain ); @@ -124,23 +111,15 @@ class PaymentMethodStripeConnectedDialog extends Component { }; getButtons = () => { - const { onCancel, onDone, isDeauthorizing, stripeConnectAccount, translate } = this.props; + const { onCancel, onDone, stripeConnectAccount, translate } = this.props; const buttons = []; - const disabled = isDeauthorizing; - if ( stripeConnectAccount.isActivated ) { - buttons.push( { - action: 'cancel', - disabled, - label: translate( 'Cancel' ), - onClick: onCancel, - } ); + buttons.push( { action: 'cancel', label: translate( 'Cancel' ), onClick: onCancel } ); buttons.push( { action: 'save', - disabled, label: translate( 'Done' ), onClick: onDone, isPrimary: true, @@ -148,7 +127,6 @@ class PaymentMethodStripeConnectedDialog extends Component { } else { buttons.push( { action: 'cancel', - disabled, label: translate( 'Close' ), onClick: onCancel, isPrimary: true, @@ -159,7 +137,7 @@ class PaymentMethodStripeConnectedDialog extends Component { }; render() { - const { isDeauthorizing, stripeConnectAccount, translate } = this.props; + const { stripeConnectAccount, translate } = this.props; return (
{ translate( 'Manage Stripe' ) }
- + { stripeConnectAccount.isActivated && this.renderMoreSettings() }
); } } -function mapStateToProps( state ) { - const site = getSelectedSiteWithFallback( state ); - const siteId = site.ID || false; - const isDeauthorizing = getIsDeauthorizing( state, siteId ); - const stripeConnectAccount = getStripeConnectAccount( state, siteId ); - return { - isDeauthorizing, - siteId, - stripeConnectAccount, - }; -} - -function mapDispatchToProps( dispatch ) { - return bindActionCreators( - { - deauthorizeAccount, - }, - dispatch - ); -} - -export default localize( - connect( mapStateToProps, mapDispatchToProps )( PaymentMethodStripeConnectedDialog ) -); +export default localize( PaymentMethodStripeConnectedDialog ); diff --git a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-key-based-dialog.js b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-key-based-dialog.js index cd8392cb7e313..1066ef541cf90 100644 --- a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-key-based-dialog.js +++ b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-key-based-dialog.js @@ -21,7 +21,7 @@ import FormTextInput from 'components/forms/form-text-input'; import { getStripeSampleStatementDescriptor, hasStripeKeyPairForMode, -} from './payment-method-stripe-utils'; +} from './payment-method-stripe-utils.js'; import PaymentMethodEditFormToggle from '../payment-method-edit-form-toggle'; import TestLiveToggle from 'woocommerce/components/test-live-toggle'; diff --git a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-placeholder-dialog.js b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-placeholder-dialog.js deleted file mode 100644 index 2ab2b2737bb7c..0000000000000 --- a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-placeholder-dialog.js +++ /dev/null @@ -1,29 +0,0 @@ -/** - * External dependencies - * - * @format - */ - -import React from 'react'; -import { localize } from 'i18n-calypso'; -import { noop } from 'lodash'; - -/** - * Internal dependencies - */ -import Dialog from 'components/dialog'; - -const PaymentMethodStripePlaceholderDialog = ( { translate } ) => { - const buttons = [ - { action: 'cancel', disabled: true, label: translate( 'Cancel' ), onClick: noop }, - ]; - - return ( - -
{ translate( 'Stripe' ) }
-
-
- ); -}; - -export default localize( PaymentMethodStripePlaceholderDialog ); diff --git a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-setup-dialog.js b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-setup-dialog.js index 73353de00c7be..bda4d55572fa4 100644 --- a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-setup-dialog.js +++ b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-setup-dialog.js @@ -5,36 +5,14 @@ */ import React, { Component } from 'react'; -import { bindActionCreators } from 'redux'; -import { connect } from 'react-redux'; import { localize } from 'i18n-calypso'; import PropTypes from 'prop-types'; /** * Internal dependencies */ -import { - areSettingsGeneralLoading, - getStoreLocation, -} from 'woocommerce/state/sites/settings/general/selectors'; -import { - clearError, - createAccount, - oauthInit, -} from 'woocommerce/state/sites/settings/stripe-connect-account/actions'; import Dialog from 'components/dialog'; -import { getCurrentUserEmail } from 'state/current-user/selectors'; -import { - getError, - getIsCreating, - getIsOAuthInitializing, - getOAuthURL, -} from 'woocommerce/state/sites/settings/stripe-connect-account/selectors'; -import { getLink, getOrigin } from 'woocommerce/lib/nav-utils'; -import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; -import Notice from 'components/notice'; import StripeConnectPrompt from './payment-method-stripe-connect-prompt'; -import QuerySettingsGeneral from 'woocommerce/components/query-settings-general'; class PaymentMethodStripeSetupDialog extends Component { static propTypes = { @@ -49,56 +27,21 @@ class PaymentMethodStripeSetupDialog extends Component { }; } - componentWillMount = () => { - this.props.clearError(); - }; - onSelectCreate = () => { - const { isCreating } = this.props; - if ( isCreating ) { - return; - } this.setState( { createSelected: true } ); }; onSelectConnect = () => { - const { isCreating, isOAuthInitializing, oauthUrl, siteId, siteSlug } = this.props; - if ( isCreating ) { - return; - } this.setState( { createSelected: false } ); - - // See if we still need to initialize OAuth, and if so, do so - if ( ! isOAuthInitializing && 0 === oauthUrl.length ) { - const origin = getOrigin(); - const path = getLink( '/store/settings/payments/:site', { slug: siteSlug } ); - const returnUrl = `${ origin }${ path }`; - this.props.oauthInit( siteId, returnUrl ); - } }; onConnect = () => { - const { country, email, oauthUrl, siteId } = this.props; - - if ( this.state.createSelected ) { - this.props.createAccount( siteId, email, country ); - } else { - window.location = oauthUrl; - } + // Not yet implemented }; getButtons = () => { - const { - isCreating, - isLoadingAddress, - isOAuthInitializing, - onCancel, - onUserRequestsKeyFlow, - translate, - } = this.props; - + const { onCancel, onUserRequestsKeyFlow, translate } = this.props; const buttons = []; - const isBusy = isCreating || isLoadingAddress || isOAuthInitializing; // Allow them to switch to key based flow if they want buttons.push( { @@ -109,17 +52,12 @@ class PaymentMethodStripeSetupDialog extends Component { } ); // Always give the user a Cancel button - buttons.push( { - action: 'cancel', - disabled: isBusy, - label: translate( 'Cancel' ), - onClick: onCancel, - } ); + buttons.push( { action: 'cancel', label: translate( 'Cancel' ), onClick: onCancel } ); // And then the connect button itself buttons.push( { action: 'connect', - disabled: isBusy, + disabled: true, // TODO: will be enabled in a subsequent PR isPrimary: true, label: translate( 'Connect' ), onClick: this.onConnect, @@ -128,16 +66,8 @@ class PaymentMethodStripeSetupDialog extends Component { return buttons; }; - possiblyRenderNotice = () => { - const { error } = this.props; - if ( 0 === error.length ) { - return null; - } - return ; - }; - - render = () => { - const { siteId, translate } = this.props; + render() { + const { translate } = this.props; return ( - { this.possiblyRenderNotice() } - ); - }; -} - -function mapStateToProps( state ) { - const email = getCurrentUserEmail( state ); - const site = getSelectedSiteWithFallback( state ); - const siteId = site.ID || false; - const siteSlug = site.slug || ''; - - const error = getError( state, siteId ); - const isCreating = getIsCreating( state, siteId ); - const isOAuthInitializing = getIsOAuthInitializing( state, siteId ); - const oauthUrl = getOAuthURL( state, siteId ); - - const isLoadingAddress = areSettingsGeneralLoading( state, siteId ); - const storeLocation = getStoreLocation( state, siteId ); - const country = isLoadingAddress ? '' : storeLocation.country; - - return { - country, - email, - error, - isCreating, - isLoadingAddress, - isOAuthInitializing, - oauthUrl, - siteId, - siteSlug, - }; -} - -function mapDispatchToProps( dispatch ) { - return bindActionCreators( - { - clearError, - createAccount, - oauthInit, - }, - dispatch - ); + } } -export default localize( - connect( mapStateToProps, mapDispatchToProps )( PaymentMethodStripeSetupDialog ) -); +export default localize( PaymentMethodStripeSetupDialog ); diff --git a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-utils.js b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-utils.js index 12ca108f75428..99a6cb1faede8 100644 --- a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-utils.js +++ b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-utils.js @@ -1,15 +1,4 @@ -/** - * External dependencies - * - * @format - */ -import { get } from 'lodash'; -import url from 'url'; - -/** - * Internal dependencies - */ - +/** @format */ export function hasStripeKeyPairForMode( method ) { const { settings } = method; const isLiveMode = method.settings.testmode.value !== 'yes'; @@ -25,33 +14,3 @@ export function getStripeSampleStatementDescriptor( domain ) { .trim() .toUpperCase(); } - -export function hasOAuthParamsInLocation() { - const oauthParams = getOAuthParamsFromLocation(); - return oauthParams.state.length && oauthParams.code.length; -} - -export function hasOAuthCompleteInLocation() { - try { - const parsedURL = url.parse( window.location.href, true, true ); - return get( parsedURL, [ 'query', 'oauth_complete' ], false ); - } catch ( e ) { - return false; - } -} - -export function getOAuthParamsFromLocation() { - let state = ''; - let code = ''; - - try { - const parsedURL = url.parse( window.location.href, true, true ); - state = get( parsedURL, [ 'query', 'wcs_stripe_state' ], false ); - code = get( parsedURL, [ 'query', 'wcs_stripe_code' ], false ); - } catch ( e ) {} - - return { - state, - code, - }; -} diff --git a/client/extensions/woocommerce/app/settings/payments/stripe/style.scss b/client/extensions/woocommerce/app/settings/payments/stripe/style.scss index c3de8bfdc0999..19197650b2d66 100644 --- a/client/extensions/woocommerce/app/settings/payments/stripe/style.scss +++ b/client/extensions/woocommerce/app/settings/payments/stripe/style.scss @@ -75,19 +75,4 @@ font-size: 18px; justify-content: space-between; padding: 0 0 16px 0; - - &.placeholder { - margin-bottom: 16px; - width: 30%; - @include placeholder(); - } -} - -.stripe__method-edit-body { - padding: 0 0 16px 0; - - &.placeholder { - height: 60px; - @include placeholder(); - } } diff --git a/client/extensions/woocommerce/lib/nav-utils.js b/client/extensions/woocommerce/lib/nav-utils.js index 386482fa3fd0c..47eb5e5380c10 100644 --- a/client/extensions/woocommerce/lib/nav-utils.js +++ b/client/extensions/woocommerce/lib/nav-utils.js @@ -12,20 +12,3 @@ export const getLink = ( path, site ) => { } return path.replace( ':site', site.slug ); }; - -/* Returns the origin for the current browser window - * - * @return {String} origin for the current browser window, wordpress.com by default - */ -export const getOrigin = () => { - let origin = 'https://wordpress.com'; - if ( 'undefined' !== typeof window && window.location ) { - origin = `${ window.location.protocol }//${ window.location.hostname }`; - } - - if ( window.location.port ) { - origin += `:${ window.location.port }`; - } - - return origin; -}; diff --git a/client/extensions/woocommerce/state/action-types.js b/client/extensions/woocommerce/state/action-types.js index 4ee8b7ae1fd8f..6db4ff830154c 100644 --- a/client/extensions/woocommerce/state/action-types.js +++ b/client/extensions/woocommerce/state/action-types.js @@ -152,28 +152,10 @@ export const WOOCOMMERCE_SETTINGS_GENERAL_RECEIVE = 'WOOCOMMERCE_SETTINGS_GENERA export const WOOCOMMERCE_SETTINGS_PRODUCTS_REQUEST = 'WOOCOMMERCE_SETTINGS_PRODUCTS_REQUEST'; export const WOOCOMMERCE_SETTINGS_PRODUCTS_REQUEST_SUCCESS = 'WOOCOMMERCE_SETTINGS_PRODUCTS_REQUEST_SUCCESS'; -export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR = - 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR'; export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE = 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE'; export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE = 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE'; -export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST = - 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST'; -export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE = - 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE'; -export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE = - 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE'; -export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE = - 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE'; -export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT = - 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_OAUTH_INIT'; -export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE = - 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE'; -export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT = - 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT'; -export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE = - 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE'; export const WOOCOMMERCE_SETTINGS_TAX_BATCH_REQUEST = 'WOOCOMMERCE_SETTINGS_TAX_BATCH_REQUEST'; export const WOOCOMMERCE_SETTINGS_TAX_BATCH_REQUEST_SUCCESS = 'WOOCOMMERCE_SETTINGS_TAX_BATCH_REQUEST_SUCCESS'; diff --git a/client/extensions/woocommerce/state/data-layer/index.js b/client/extensions/woocommerce/state/data-layer/index.js index 0574b529d41d2..6e246ea7d9dea 100644 --- a/client/extensions/woocommerce/state/data-layer/index.js +++ b/client/extensions/woocommerce/state/data-layer/index.js @@ -20,6 +20,7 @@ import settingsGeneral from '../sites/settings/general/handlers'; import shippingZoneLocations from './shipping-zone-locations'; import shippingZoneMethods from './shipping-zone-methods'; import shippingZones from './shipping-zones'; +import stripeConnectAccount from '../sites/settings/stripe-connect-account/handlers'; import ui from './ui'; import debugFactory from 'debug'; @@ -40,6 +41,7 @@ const handlers = mergeHandlers( shippingZoneLocations, shippingZoneMethods, shippingZones, + stripeConnectAccount, ui ); diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/actions.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/actions.js index 4a66a5320dcb4..1ecab7459b8c8 100644 --- a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/actions.js +++ b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/actions.js @@ -1,434 +1,16 @@ -/** - * External dependencies - * - * @format - */ - /** * Internal dependencies - */ -import { - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, -} from 'woocommerce/state/action-types'; -import { getSelectedSiteId } from 'state/ui/selectors'; -import request from '../../request'; - -/** - * Action Creator: Clear any error from a previous action. - * - * @param {Number} siteId The id of the site for which to clear errors. - * @return {Object} Action object - */ -export const clearError = siteId => ( dispatch, getState ) => { - const state = getState(); - if ( ! siteId ) { - siteId = getSelectedSiteId( state ); - } - - const clearErrorAction = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR, - siteId, - }; - - dispatch( clearErrorAction ); -}; - -/** - * Action Creator: Create (and connect) a Stripe Connect Account. - * - * @param {Number} siteId The id of the site for which to create an account. - * @param {String} email Email address (i.e. of the logged in WordPress.com user) to pass to Stripe. - * @param {String} country Two character country code to pass to Stripe (e.g. US). - * @param {String} [successAction=undefined] Optional action object to be dispatched upon success. - * @param {String} [failureAction=undefined] Optional action object to be dispatched upon error. - * @return {Object} Action object - */ -export const createAccount = ( - siteId, - email, - country, - successAction = null, - failureAction = null -) => ( dispatch, getState ) => { - const state = getState(); - if ( ! siteId ) { - siteId = getSelectedSiteId( state ); - } - - const createAction = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, - country, - email, - siteId, - }; - - dispatch( createAction ); - - return request( siteId ) - .post( 'connect/stripe/account', { email, country }, 'wc/v1' ) - .then( data => { - dispatch( createSuccess( siteId, createAction, data ) ); - if ( successAction ) { - dispatch( successAction( siteId, createAction, data ) ); - } - } ) - .catch( error => { - dispatch( createFailure( siteId, createAction, error ) ); - if ( failureAction ) { - dispatch( failureAction( siteId, createAction, error ) ); - } - } ); -}; - -/** - * Action Creator: Stripe Connect Account creation completed successfully - * - * @param {Number} siteId The id of the site for which to create an account. - * @param {Object} email The email address used to create the account. - * @param {Object} account_id The Stripe Connect Account id created for the site (from the data object). - * @return {Object} Action object - */ -function createSuccess( siteId, { email }, { account_id } ) { - return { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, - connectedUserID: account_id, - email, - siteId, - }; -} - -/** - * Action Creator: Stripe Connect Account creation failed * - * @param {Number} siteId The id of the site for which account creation failed. - * @param {Object} action The action used to attempt to create the account. - * @param {Object} message Error message returned (from the error object). - * @return {Object} Action object - */ -function createFailure( siteId, action, { message } ) { - return { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, - error: message, - siteId, - }; -} - -/** - * Action Creator: Fetch Stripe Connect Account Details. - * - * @param {Number} siteId The id of the site for which to fetch connected account details. - * @param {String} [successAction=undefined] Optional action object to be dispatched upon success. - * @param {String} [failureAction=undefined] Optional action object to be dispatched upon error. - * @return {Object} Action object + * @format */ -export const fetchAccountDetails = ( siteId, successAction = null, failureAction = null ) => ( - dispatch, - getState -) => { - const state = getState(); - if ( ! siteId ) { - siteId = getSelectedSiteId( state ); - } - - const fetchAction = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, - siteId, - }; - - dispatch( fetchAction ); - return request( siteId ) - .get( 'connect/stripe/account', 'wc/v1' ) - .then( data => { - dispatch( fetchSuccess( siteId, fetchAction, data ) ); - if ( successAction ) { - dispatch( successAction( siteId, fetchAction, data ) ); - } - } ) - .catch( error => { - dispatch( fetchFailure( siteId, fetchAction, error ) ); - if ( failureAction ) { - dispatch( failureAction( error ) ); - } - } ); -}; +import { WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE } from 'woocommerce/state/action-types'; -/** - * Action Creator: Stripe Connect Account details were fetched successfully - * - * @param {Number} siteId The id of the site for which details were fetched. - * @param {Object} fetchAction The action used to fetch the account details. - * @param {Object} data The entire data object that was returned from the API. - * @return {Object} Action object - */ -function fetchSuccess( siteId, fetchAction, data ) { - const { account_id, display_name, email, business_logo, legal_entity, payouts_enabled } = data; +export function createAccount( siteId, email, countryCode ) { return { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, - connectedUserID: account_id, - displayName: display_name, + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, + countryCode, email, - firstName: legal_entity.first_name, - isActivated: payouts_enabled, - logo: business_logo, - lastName: legal_entity.last_name, - siteId, - }; -} - -/** - * Action Creator: Stripe Connect Account details were unable to be fetched - * - * @param {Number} siteId The id of the site for which details could not be fetched. - * @param {Object} action The action used to attempt to fetch the account details. - * @param {Object} message Error message returned (from the error object). - * @return {Object} Action object - */ -function fetchFailure( siteId, action, { message } ) { - return { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, - error: message, - siteId, - }; -} - -/** - * Action Creator: Disconnect Account. - * - * @param {Number} siteId The id of the site to disconnect from Stripe Connect. - * @param {String} [successAction=undefined] Optional action object to be dispatched upon success. - * @param {String} [failureAction=undefined] Optional action object to be dispatched upon error. - * @return {Object} Action object - */ -export const deauthorizeAccount = ( siteId, successAction = null, failureAction = null ) => ( - dispatch, - getState -) => { - const state = getState(); - if ( ! siteId ) { - siteId = getSelectedSiteId( state ); - } - - const deauthorizeAction = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, - siteId, - }; - - dispatch( deauthorizeAction ); - - return request( siteId ) - .post( 'connect/stripe/account/deauthorize', {}, 'wc/v1' ) - .then( data => { - dispatch( deauthorizeSuccess( siteId, deauthorizeAction, data ) ); - if ( successAction ) { - dispatch( successAction( siteId, deauthorizeAction, data ) ); - } - } ) - .catch( error => { - dispatch( deauthorizeFailure( siteId, deauthorizeAction, error ) ); - if ( failureAction ) { - dispatch( failureAction( error ) ); - } - } ); -}; - -/** - * Action Creator: The Stripe Connect account was successfully deauthorized from our platform. - * - * @param {Number} siteId The id of the site which had its account deauthorized. - * @param {Object} action The action used to deauthorize the account. - * @param {Object} data The entire data object that was returned from the API. - * @return {Object} Action object - */ -// eslint-disable-next-line no-unused-vars -function deauthorizeSuccess( siteId, action, data ) { - return { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, - siteId, - }; -} - -/** - * Action Creator: The Stripe Connect account was unable to be deauthorized from our platform. - * - * @param {Number} siteId The id of the site which failed to have its account deauthorized. - * @param {Object} action The action used to attempt to deauthorize the account. - * @param {Object} errorMessage Error message returned. - * @return {Object} Action object - */ -function deauthorizeFailure( siteId, action, errorMessage ) { - return { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, - error: errorMessage, - siteId, - }; -} - -/** - * Action Creator: Get the initial OAuth URL for connecting a Stripe Account. - * - * @param {Number} siteId The id of the site for which to create an account. - * @param {String} returnUrl The URL for Stripe to return the user to (to complete the setup) - * @param {String} [successAction=undefined] Optional action object to be dispatched upon success. - * @param {String} [failureAction=undefined] Optional action object to be dispatched upon error. - * @return {Object} Action object - */ -export const oauthInit = ( siteId, returnUrl, successAction = null, failureAction = null ) => ( - dispatch, - getState -) => { - const state = getState(); - if ( ! siteId ) { - siteId = getSelectedSiteId( state ); - } - - const initAction = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, - returnUrl, - siteId, - }; - - dispatch( initAction ); - - return request( siteId ) - .post( 'connect/stripe/oauth/init', { returnUrl }, 'wc/v1' ) - .then( data => { - dispatch( oauthInitSuccess( siteId, initAction, data ) ); - if ( successAction ) { - dispatch( successAction( siteId, initAction, data ) ); - } - } ) - .catch( error => { - dispatch( oauthInitFailure( siteId, initAction, error ) ); - if ( failureAction ) { - dispatch( failureAction( siteId, initAction, error ) ); - } - } ); -}; - -/** - * Action Creator: The Stripe Connect account OAuth flow was successfully initialized. - * - * @param {Number} siteId The id of the site which we're doing OAuth for. - * @param {Object} action The action used to deauthorize the account. - * @param {Object} oauthUrl The URL to which the user needs to navigate to. - * @return {Object} Action object - */ -function oauthInitSuccess( siteId, action, { oauthUrl } ) { - return { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, - oauthUrl, - siteId, - }; -} - -/** - * Action Creator: The Stripe Connect account OAuth flow was unable to be initialized. - * - * @param {Number} siteId The id of the site which we tried doing OAuth for. - * @param {Object} action The action used to attempt to deauthorize the account. - * @param {Object} message Error message returned (from the error object). - * @return {Object} Action object - */ -function oauthInitFailure( siteId, action, { message } ) { - return { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, - error: message, - siteId, - }; -} - -/** - * Action Creator: Complete the OAuth flow and connect the Stripe Account. - * - * @param {Number} siteId The id of the site for which to create an account. - * @param {String} stripeCode The code which Stripe will exchange for the account id. - * @param {String} stripeState An arbitrary string passed throughout the flow as a CSRF protection. - * @param {String} [successAction=undefined] Optional action object to be dispatched upon success. - * @param {String} [failureAction=undefined] Optional action object to be dispatched upon error. - * @return {Object} Action object - */ -export const oauthConnect = ( - siteId, - stripeCode, - stripeState, - successAction = null, - failureAction = null -) => ( dispatch, getState ) => { - const state = getState(); - if ( ! siteId ) { - siteId = getSelectedSiteId( state ); - } - - const connectAction = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, - stripeCode, - stripeState, - siteId, - }; - - dispatch( connectAction ); - - return request( siteId ) - .post( 'connect/stripe/oauth/connect', { code: stripeCode, state: stripeState }, 'wc/v1' ) - .then( data => { - dispatch( oauthConnectSuccess( siteId, connectAction, data ) ); - if ( successAction ) { - dispatch( successAction( siteId, connectAction, data ) ); - } - } ) - .then( () => { - dispatch( fetchAccountDetails( siteId ) ); - } ) - .catch( error => { - dispatch( oauthConnectFailure( siteId, connectAction, error ) ); - if ( failureAction ) { - dispatch( failureAction( siteId, connectAction, error ) ); - } - } ); -}; - -/** - * Action Creator: The Stripe Connect account OAuth flow was successfully completed. - * - * @param {Number} siteId The id of the site which we're doing OAuth for. - * @param {Object} action The action used to complete OAuth for the account. - * @param {Object} account_id The account_id we are now connected to (from the data object) - * @return {Object} Action object - */ -function oauthConnectSuccess( siteId, action, { account_id } ) { - return { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, - connectedUserID: account_id, - siteId, - }; -} - -/** - * Action Creator: The Stripe Connect account OAuth flow was not able to be completed. - * - * @param {Number} siteId The id of the site which we tried doing OAuth for. - * @param {Object} action The action used to try and complete OAuth for the account. - * @param {Object} error Error and message returned (from the error object). - * @return {Object} Action object - */ -// Note: Stripe and WooCommerce Services server errors will be returned in message, but -// message will be empty for errors that the WooCommerce Services client generates itself -// so we need to grab the string from the error field inside the error object for those. -function oauthConnectFailure( siteId, action, { error, message } ) { - return { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, - error: message || error, siteId, }; } diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/handlers.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/handlers.js new file mode 100644 index 0000000000000..967ef9a427304 --- /dev/null +++ b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/handlers.js @@ -0,0 +1,50 @@ +/** + * Internal dependencies + * + * @format + */ + +import { dispatchRequest } from 'state/data-layer/wpcom-http/utils'; +import request from 'woocommerce/state/sites/http-request'; +import { + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, +} from 'woocommerce/state/action-types'; + +export default { + [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE ]: [ + dispatchRequest( handleAccountCreate, handleAccountCreateSuccess, handleAccountCreateFailure ), + ], +}; + +export function handleAccountCreate( { dispatch }, action ) { + const { email, countryCode, siteId } = action; + dispatch( + request( siteId, action, '/wc/v1' ).post( 'connect/stripe/account/', { + email, + country: countryCode, + } ) + ); +} + +export function handleAccountCreateSuccess( store, action, { data } ) { + const { email, siteId } = action; + const { account_id } = data; + + store.dispatch( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + connectedUserID: account_id, + email, + siteId, + } ); +} + +export function handleAccountCreateFailure( { dispatch }, action, error ) { + const { email, siteId } = action; + dispatch( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + email, + error, + siteId, + } ); +} diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/reducer.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/reducer.js index eec0f8e9d9ec4..7b1cf837a875a 100644 --- a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/reducer.js +++ b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/reducer.js @@ -1,224 +1,49 @@ /** - * External dependencies + * Internal dependencies * * @format */ -/** - * Internal dependencies - */ import { createReducer } from 'state/utils'; import { - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR, WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, } from 'woocommerce/state/action-types'; /** - * Updates state to clear any error from a previous action - * - * @param {Object} state Current state - * @return {Object} Updated state - */ -function connectAccountClearError( state = {} ) { - return Object.assign( {}, state, { - error: '', - } ); -} - -/** - * Updates state to indicate account creation is in progress - * - * @param {Object} state Current state - * @return {Object} Updated state - */ -function connectAccountCreate( state = {} ) { - return Object.assign( {}, state, { - error: '', - isCreating: true, - } ); -} - -/** - * Updates state to reflect account creation completed (or failed with an error) + * Updates state to indicate account creation request is in progress * * @param {Object} state Current state * @param {Object} action Action payload * @return {Object} Updated state */ -function connectAccountCreateComplete( state = {}, action ) { - return Object.assign( {}, state, { - connectedUserID: action.connectedUserID || '', - displayName: '', - email: action.email || '', - error: action.error || '', - firstName: '', - isActivated: false, - isCreating: false, - isRequesting: false, - lastName: '', - logo: '', - } ); -} - -/** - * Updates state to indicate account (details) fetch request is in progress - * - * @param {Object} state Current state - * @return {Object} Updated state - */ -function connectAccountFetch( state = {} ) { +function connectAccountCreate( state = {} ) { return Object.assign( {}, state, { connectedUserID: '', - displayName: '', email: '', - error: '', - firstName: '', isActivated: false, - isDeauthorizing: false, isRequesting: true, - lastName: '', - logo: '', } ); } /** - * Updates state with fetched account details + * Updates state with created account details * * @param {Object} state Current state * @param {Object} action Action payload * @return {Object} Updated state */ -function connectAccountFetchComplete( state = {}, action ) { +function connectAccountCreateComplete( state = {}, action ) { return Object.assign( {}, state, { connectedUserID: action.connectedUserID || '', - displayName: action.displayName || '', email: action.email || '', error: action.error || '', - firstName: action.firstName || '', - isActivated: action.isActivated || false, - isDeauthorizing: false, - isRequesting: false, - lastName: action.lastName || '', - logo: action.logo || '', - } ); -} - -/** - * Updates state to indicate account deauthorization request is in progress - * - * @param {Object} state Current state - * @return {Object} Updated state - */ -function connectAccountDeauthorize( state = {} ) { - return Object.assign( {}, state, { - isDeauthorizing: true, - } ); -} - -/** - * Updates state after deauthorization completes - * - * @param {Object} state Current state - * @param {Object} action Action payload - * @return {Object} Updated state - */ -function connectAccountDeauthorizeComplete( state = {}, action ) { - return Object.assign( {}, state, { - connectedUserID: '', - displayName: '', - email: '', - error: action.error || '', - firstName: '', - isActivated: false, - isDeauthorizing: false, - isRequesting: false, - lastName: '', - logo: '', - } ); -} - -/** - * Updates state to indicate oauth initialization request is in progress - * - * @param {Object} state Current state - * @return {Object} Updated state - */ -function connectAccountOAuthInit( state = {} ) { - return Object.assign( {}, state, { - isOAuthInitializing: true, - oauthUrl: '', - } ); -} - -/** - * Updates state after oauth initialization completes - * - * @param {Object} state Current state - * @param {Object} action Action payload - * @return {Object} Updated state - */ -function connectAccountOAuthInitComplete( state = {}, action ) { - return Object.assign( {}, state, { - isOAuthInitializing: false, - error: action.error || '', - oauthUrl: action.oauthUrl || '', - } ); -} - -/** - * Updates state to indicate account creation is in progress - * - * @param {Object} state Current state - * @return {Object} Updated state - */ -function connectAccountOAuthConnect( state = {} ) { - return Object.assign( {}, state, { - error: '', - isOAuthConnecting: true, - } ); -} - -/** - * Updates state to reflect account creation completed (or failed with an error) - * - * @param {Object} state Current state - * @param {Object} action Action payload - * @return {Object} Updated state - */ -function connectAccountOAuthConnectComplete( state = {}, action ) { - return Object.assign( {}, state, { - connectedUserID: action.connectedUserID || '', - email: '', - error: action.error || '', - firstName: '', isActivated: false, - isCreating: false, - isOAuthConnecting: false, isRequesting: false, - lastName: '', - logo: '', } ); } export default createReducer( null, { - [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR ]: connectAccountClearError, [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE ]: connectAccountCreate, [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE ]: connectAccountCreateComplete, - [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE ]: connectAccountDeauthorize, - [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE ]: connectAccountDeauthorizeComplete, - [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST ]: connectAccountFetch, - [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE ]: connectAccountFetchComplete, - [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT ]: connectAccountOAuthInit, - [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE ]: connectAccountOAuthInitComplete, - [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT ]: connectAccountOAuthConnect, - [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE ]: connectAccountOAuthConnectComplete, } ); diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/selectors.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/selectors.js deleted file mode 100644 index c52d6c95ec6bf..0000000000000 --- a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/selectors.js +++ /dev/null @@ -1,101 +0,0 @@ -/** - * External dependencies - * - * @format - */ - -import { get, omit } from 'lodash'; - -/** - * Internal dependencies - */ -import { getSelectedSiteId } from 'state/ui/selectors'; - -const getRawSettings = ( state, siteId ) => { - return get( - state, - [ 'extensions', 'woocommerce', 'sites', siteId, 'settings', 'stripeConnectAccount' ], - {} - ); -}; - -/** - * @param {Object} state Whole Redux state tree - * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used - * @return {boolean} Whether we are presently attempting to create an account - */ -export function getError( state, siteId = getSelectedSiteId( state ) ) { - return get( getRawSettings( state, siteId ), [ 'error' ], '' ); -} - -/** - * @param {Object} state Whole Redux state tree - * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used - * @return {boolean} Whether we are presently attempting to create an account - */ -export function getIsCreating( state, siteId = getSelectedSiteId( state ) ) { - return get( getRawSettings( state, siteId ), [ 'isCreating' ], false ); -} - -/** - * @param {Object} state Whole Redux state tree - * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used - * @return {boolean} Whether we are presently attempting to deauthorize the connected account for the site - */ -export function getIsDeauthorizing( state, siteId = getSelectedSiteId( state ) ) { - return get( getRawSettings( state, siteId ), [ 'isDeauthorizing' ], false ); -} - -/** - * @param {Object} state Whole Redux state tree - * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used - * @return {boolean} Whether we are presently attempting to complete the OAuth connection - */ -export function getIsOAuthConnecting( state, siteId = getSelectedSiteId( state ) ) { - return get( getRawSettings( state, siteId ), [ 'isOAuthConnecting' ], false ); -} - -/** - * @param {Object} state Whole Redux state tree - * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used - * @return {boolean} Whether we are presently requesting oauth initialization - */ -export function getIsOAuthInitializing( state, siteId = getSelectedSiteId( state ) ) { - return get( getRawSettings( state, siteId ), [ 'isOAuthInitializing' ], false ); -} - -/** - * @param {Object} state Whole Redux state tree - * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used - * @return {String} URL to which to navigate to kick off the OAuth flow at Stripe - */ -export function getOAuthURL( state, siteId = getSelectedSiteId( state ) ) { - return get( getRawSettings( state, siteId ), [ 'oauthUrl' ], '' ); -} - -/** - * @param {Object} state Whole Redux state tree - * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used - * @return {boolean} Whether we are presently requesting connect account details from the server - */ -export function getIsRequesting( state, siteId = getSelectedSiteId( state ) ) { - return get( getRawSettings( state, siteId ), [ 'isRequesting' ], false ); -} - -/** - * @param {Object} state Whole Redux state tree - * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used - * @return {Object} The details of the connect account for the site, if any - */ -export function getStripeConnectAccount( state, siteId = getSelectedSiteId( state ) ) { - const rawSettings = getRawSettings( state, siteId ); - return omit( rawSettings, [ - 'error', - 'isCreating', - 'isDeauthorizing', - 'isOAuthConnecting', - 'isOAuthInitializing', - 'isRequesting', - 'oauthUrl', - ] ); -} diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/actions.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/actions.js index 648e144340577..7084549435c43 100644 --- a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/actions.js +++ b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/actions.js @@ -4,271 +4,25 @@ * External dependencies */ import { expect } from 'chai'; -import { spy } from 'sinon'; /** * Internal dependencies */ -import useNock from 'test/helpers/use-nock'; -import { - clearError, - createAccount, - deauthorizeAccount, - fetchAccountDetails, - oauthInit, - oauthConnect, -} from '../actions'; -import { - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, -} from 'woocommerce/state/action-types'; +import { createAccount } from '../actions'; +import { WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE } from 'woocommerce/state/action-types'; describe( 'actions', () => { - describe( '#clearError()', () => { - const siteId = '123'; - - test( 'should dispatch an action', () => { - const getState = () => ( {} ); - const dispatch = spy(); - clearError( siteId )( dispatch, getState ); - expect( dispatch ).to.have.been.calledWith( { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR, - siteId, - } ); - } ); - } ); - describe( '#createAccount()', () => { const siteId = '123'; - - useNock( nock => { - nock( 'https://public-api.wordpress.com:443' ) - .persist() - .post( '/rest/v1.1/jetpack-blogs/123/rest-api/' ) - .query( { path: '/wc/v1/connect/stripe/account&_method=post', json: true } ) - .reply( 200, { - data: { - success: true, - account_id: 'acct_14qyt6Alijdnw0EA', - }, - } ); - } ); - - test( 'should dispatch an action', () => { - const getState = () => ( {} ); - const dispatch = spy(); - createAccount( siteId, 'foo@bar.com', 'US' )( dispatch, getState ); - expect( dispatch ).to.have.been.calledWith( { + const email = 'foo@bar.com'; + const countryCode = 'US'; + test( 'should return an action', () => { + const action = createAccount( siteId, email, countryCode ); + expect( action ).to.eql( { type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, - country: 'US', - email: 'foo@bar.com', - siteId, - } ); - } ); - - test( 'should dispatch a success action with account details when the request completes', () => { - const getState = () => ( {} ); - const dispatch = spy(); - const response = createAccount( siteId, 'foo@bar.com', 'US' )( dispatch, getState ); - - return response.then( () => { - expect( dispatch ).to.have.been.calledWith( { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, - siteId, - connectedUserID: 'acct_14qyt6Alijdnw0EA', - email: 'foo@bar.com', - } ); - } ); - } ); - } ); - - describe( '#fetchAccountDetails()', () => { - const siteId = '123'; - - useNock( nock => { - nock( 'https://public-api.wordpress.com:443' ) - .persist() - .get( '/rest/v1.1/jetpack-blogs/123/rest-api/' ) - .query( { path: '/wc/v1/connect/stripe/account&_method=get', json: true } ) - .reply( 200, { - data: { - success: true, - account_id: 'acct_14qyt6Alijdnw0EA', - display_name: 'Foo Bar', - email: 'foo@bar.com', - legal_entity: { - first_name: 'Foo', - last_name: 'Bar', - }, - payouts_enabled: true, - business_logo: 'https://foo.com/bar.png', - }, - } ); - } ); - - test( 'should dispatch an action', () => { - const getState = () => ( {} ); - const dispatch = spy(); - fetchAccountDetails( siteId )( dispatch, getState ); - expect( dispatch ).to.have.been.calledWith( { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, - siteId, - } ); - } ); - - test( 'should dispatch a success action with account details when the request completes', () => { - const getState = () => ( {} ); - const dispatch = spy(); - const response = fetchAccountDetails( siteId )( dispatch, getState ); - - return response.then( () => { - expect( dispatch ).to.have.been.calledWith( { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, - siteId, - connectedUserID: 'acct_14qyt6Alijdnw0EA', - displayName: 'Foo Bar', - email: 'foo@bar.com', - firstName: 'Foo', - isActivated: true, - logo: 'https://foo.com/bar.png', - lastName: 'Bar', - } ); - } ); - } ); - } ); - - describe( '#deauthorizeAccount()', () => { - const siteId = '123'; - - useNock( nock => { - nock( 'https://public-api.wordpress.com:443' ) - .persist() - .post( '/rest/v1.1/jetpack-blogs/123/rest-api/' ) - .query( { path: '/wc/v1/connect/stripe/account/deauthorize&_method=post', json: true } ) - .reply( 200, { - data: { - success: true, - account_id: 'acct_14qyt6Alijdnw0EA', - }, - } ); - } ); - - test( 'should dispatch an action', () => { - const getState = () => ( {} ); - const dispatch = spy(); - deauthorizeAccount( siteId )( dispatch, getState ); - expect( dispatch ).to.have.been.calledWith( { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, siteId, - } ); - } ); - - test( 'should dispatch a success action when the request completes', () => { - const getState = () => ( {} ); - const dispatch = spy(); - const response = deauthorizeAccount( siteId )( dispatch, getState ); - - return response.then( () => { - expect( dispatch ).to.have.been.calledWith( { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, - siteId, - } ); - } ); - } ); - } ); - - describe( '#oauthInit()', () => { - const siteId = '123'; - - useNock( nock => { - nock( 'https://public-api.wordpress.com:443' ) - .persist() - .post( '/rest/v1.1/jetpack-blogs/123/rest-api/' ) - .query( { path: '/wc/v1/connect/stripe/oauth/init&_method=post', json: true } ) - .reply( 200, { - data: { - success: true, - oauthUrl: - 'https://connect.stripe.com/oauth/authorize?response_type=code&client_id=xxx&scope=read_write&state=yyy', - }, - } ); - } ); - - test( 'should dispatch an action', () => { - const getState = () => ( {} ); - const dispatch = spy(); - oauthInit( siteId, 'https://return.url.com/' )( dispatch, getState ); - expect( dispatch ).to.have.been.calledWith( { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, - returnUrl: 'https://return.url.com/', - siteId, - } ); - } ); - - test( 'should dispatch a success action with a Stripe URL when the request completes', () => { - const getState = () => ( {} ); - const dispatch = spy(); - const response = oauthInit( siteId, 'https://return.url.com/' )( dispatch, getState ); - - return response.then( () => { - expect( dispatch ).to.have.been.calledWith( { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, - siteId, - oauthUrl: - 'https://connect.stripe.com/oauth/authorize?response_type=code&client_id=xxx&scope=read_write&state=yyy', - } ); - } ); - } ); - } ); - - describe( '#oauthConnect()', () => { - const siteId = '123'; - - useNock( nock => { - nock( 'https://public-api.wordpress.com:443' ) - .persist() - .post( '/rest/v1.1/jetpack-blogs/123/rest-api/' ) - .query( { path: '/wc/v1/connect/stripe/oauth/connect&_method=post', json: true } ) - .reply( 200, { - data: { - success: true, - account_id: 'acct_14qyt6Alijdnw0EA', - }, - } ); - } ); - - test( 'should dispatch an action', () => { - const getState = () => ( {} ); - const dispatch = spy(); - oauthConnect( siteId, 'STRIPECODE', 'STRIPESTATE' )( dispatch, getState ); - expect( dispatch ).to.have.been.calledWith( { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, - stripeCode: 'STRIPECODE', - stripeState: 'STRIPESTATE', - siteId, - } ); - } ); - - test( 'should dispatch a success action with a Stripe account when the request completes', () => { - const getState = () => ( {} ); - const dispatch = spy(); - const response = oauthConnect( siteId, 'STRIPECODE', 'STRIPESTATE' )( dispatch, getState ); - - return response.then( () => { - expect( dispatch ).to.have.been.calledWith( { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, - connectedUserID: 'acct_14qyt6Alijdnw0EA', - siteId, - } ); + email, + countryCode, } ); } ); } ); diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/handlers.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/handlers.js new file mode 100644 index 0000000000000..149eddf775ebd --- /dev/null +++ b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/handlers.js @@ -0,0 +1,104 @@ +/** @format */ + +/** + * External dependencies + */ +import { expect } from 'chai'; +import { spy } from 'sinon'; + +/** + * Internal dependencies + */ +import { createAccount } from '../actions.js'; +import { + handleAccountCreate, + handleAccountCreateSuccess, + handleAccountCreateFailure, +} from '../handlers.js'; +import { WPCOM_HTTP_REQUEST } from 'state/action-types'; +import { WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE } from 'woocommerce/state/action-types'; + +describe( 'handlers', () => { + describe( '#handleCreateAccountRequest', () => { + test( 'should dispatch a POST request', () => { + const siteId = '123'; + const email = 'foo@bar.com'; + const country = 'US'; + const dispatch = spy(); + const action = createAccount( siteId, email, country ); + handleAccountCreate( { dispatch }, action ); + expect( dispatch ).to.have.been.calledWithMatch( { + type: WPCOM_HTTP_REQUEST, + body: { + path: '/wc/v1/connect/stripe/account/&_method=POST', + body: JSON.stringify( { email, country } ), + }, + method: 'POST', + path: `/jetpack-blogs/${ siteId }/rest-api/`, + query: { + json: true, + apiVersion: '1.1', + }, + } ); + } ); + } ); + + describe( '#handleAccountCreateSuccess()', () => { + test( 'should dispatch create account receive on success with the account info', () => { + const siteId = '123'; + const email = 'foo@bar.com'; + const countryCode = 'US'; + const store = { + dispatch: spy(), + }; + const response = { + data: { + account_id: 'acct_14qyt6Alijdnw0EA', + success: true, + }, + }; + + const action = createAccount( siteId, email, countryCode ); + handleAccountCreateSuccess( store, action, response ); + + expect( store.dispatch ).calledWith( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + connectedUserID: response.data.account_id, + email, + siteId, + } ); + } ); + } ); + + describe( '#handleAccountCreateFailure()', () => { + test( 'should dispatch create account error', () => { + const siteId = '123'; + const email = 'foo@bar.com'; + const countryCode = 'US'; + const store = { + dispatch: spy(), + }; + const response = { + data: { + body: { + data: { + message: 'An account using that email address already exists.', + }, + success: false, + }, + status: 400, + }, + }; + + const action = createAccount( siteId, email, countryCode ); + handleAccountCreateFailure( store, action, response.data.body.data.message ); + + expect( store.dispatch ).calledWith( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + email, + error: response.data.body.data.message, + siteId, + } ); + } ); + } ); +} ); diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/reducer.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/reducer.js index 050bbc99b17b7..fcf6acb81cc7c 100644 --- a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/reducer.js +++ b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/reducer.js @@ -10,17 +10,8 @@ import { expect } from 'chai'; */ import stripeConnectAccountReducer from '../reducer'; import { - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR, WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, } from 'woocommerce/state/action-types'; import sitesReducer from 'woocommerce/state/sites/reducer'; @@ -32,17 +23,6 @@ describe( 'reducer', () => { } ); } ); - describe( 'clearError', () => { - test( 'should reset error in state', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR, - siteId: 123, - }; - const newState = stripeConnectAccountReducer( { error: 'My error message' }, action ); - expect( newState.error ).to.eql( '' ); - } ); - } ); - describe( 'connectAccountCreate', () => { test( 'should update state to show request in progress', () => { const action = { @@ -50,160 +30,12 @@ describe( 'reducer', () => { siteId: 123, }; const newState = stripeConnectAccountReducer( undefined, action ); - expect( newState.isCreating ).to.eql( true ); - } ); - - test( 'should only update the request in progress flag for the appropriate siteId', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, - siteId: 123, - }; - const newState = sitesReducer( - { - 123: { - settings: { - stripeConnectAccount: { - isCreating: false, - }, - }, - }, - 456: { - settings: { - stripeConnectAccount: { - isCreating: false, - }, - }, - }, - }, - action - ); - expect( newState[ 123 ].settings.stripeConnectAccount.isCreating ).to.eql( true ); - expect( newState[ 456 ].settings.stripeConnectAccount.isCreating ).to.eql( false ); - } ); - } ); - - describe( 'connectAccountCreateComplete', () => { - test( 'should update state with the received account details', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, - connectedUserID: 'acct_14qyt6Alijdnw0EA', - email: 'foo@bar.com', - siteId: 123, - }; - const newState = stripeConnectAccountReducer( undefined, action ); - expect( newState ).to.eql( { - connectedUserID: 'acct_14qyt6Alijdnw0EA', - displayName: '', - email: 'foo@bar.com', - error: '', - firstName: '', - isActivated: false, - isCreating: false, - isRequesting: false, - lastName: '', - logo: '', - } ); - } ); - - test( 'should leave other sites state unchanged', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, - connectedUserID: 'acct_14qyt6Alijdnw0EA', - email: 'foo@bar.com', - siteId: 123, - }; - const newState = sitesReducer( - { - 123: { - settings: { - stripeConnectAccount: { - connectedUserID: '', - email: '', - isActivated: false, - isCreating: true, - }, - }, - }, - 456: { - settings: { - stripeConnectAccount: { - connectedUserID: '', - email: '', - isActivated: false, - isCreating: true, - }, - }, - }, - }, - action - ); - expect( newState[ 123 ].settings.stripeConnectAccount.isCreating ).to.eql( false ); - expect( newState[ 123 ].settings.stripeConnectAccount.connectedUserID ).to.eql( - 'acct_14qyt6Alijdnw0EA' - ); - expect( newState[ 123 ].settings.stripeConnectAccount.email ).to.eql( 'foo@bar.com' ); - expect( newState[ 456 ].settings.stripeConnectAccount.isCreating ).to.eql( true ); - } ); - } ); - - describe( 'connectAccountCreateError', () => { - test( 'should reset the isCreating flag in state and store the email and error', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, - siteId: 123, - email: 'foo@bar.com', - error: 'My error', - }; - const newState = stripeConnectAccountReducer( undefined, action ); - expect( newState.error ).to.eql( 'My error' ); - expect( newState.email ).to.eql( 'foo@bar.com' ); - expect( newState.isCreating ).to.eql( false ); - } ); - - test( 'should leave other sites state unchanged', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, - siteId: 123, - email: 'foo@bar.com', - error: 'My error', - }; - const newState = sitesReducer( - { - 123: { - settings: { - stripeConnectAccount: { - isCreating: true, - }, - }, - }, - 456: { - settings: { - stripeConnectAccount: { - isCreating: true, - }, - }, - }, - }, - action - ); - expect( newState[ 123 ].settings.stripeConnectAccount.isCreating ).to.eql( false ); - expect( newState[ 456 ].settings.stripeConnectAccount.isCreating ).to.eql( true ); - } ); - } ); - - describe( 'connectAccountFetch', () => { - test( 'should update state to show request in progress', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, - siteId: 123, - }; - const newState = stripeConnectAccountReducer( undefined, action ); expect( newState.isRequesting ).to.eql( true ); } ); test( 'should only update the request in progress flag for the appropriate siteId', () => { const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, siteId: 123, }; const newState = sitesReducer( @@ -236,37 +68,27 @@ describe( 'reducer', () => { } ); } ); - describe( 'connectAccountFetchComplete', () => { + describe( 'connectAccountCreateComplete', () => { test( 'should update state with the received account details', () => { const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, connectedUserID: 'acct_14qyt6Alijdnw0EA', - displayName: 'Foo Bar', email: 'foo@bar.com', - firstName: 'Foo', - isActivated: false, - lastName: 'Bar', - logo: 'http://bar.com/foo.png', siteId: 123, }; const newState = stripeConnectAccountReducer( undefined, action ); expect( newState ).to.eql( { connectedUserID: 'acct_14qyt6Alijdnw0EA', - displayName: 'Foo Bar', email: 'foo@bar.com', error: '', - firstName: 'Foo', isActivated: false, - isDeauthorizing: false, isRequesting: false, - lastName: 'Bar', - logo: 'http://bar.com/foo.png', } ); } ); test( 'should leave other sites state unchanged', () => { const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, connectedUserID: 'acct_14qyt6Alijdnw0EA', email: 'foo@bar.com', siteId: 123, @@ -305,10 +127,10 @@ describe( 'reducer', () => { } ); } ); - describe( 'connectAccountFetchError', () => { + describe( 'receivingAccountCreationError', () => { test( 'should reset the isRequesting flag in state', () => { const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, siteId: 123, email: 'foo@bar.com', error: 'My error', @@ -321,7 +143,7 @@ describe( 'reducer', () => { test( 'should leave other sites state unchanged', () => { const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, siteId: 123, email: 'foo@bar.com', error: 'My error', @@ -355,533 +177,4 @@ describe( 'reducer', () => { expect( newState[ 456 ].settings.stripeConnectAccount.isRequesting ).to.eql( true ); } ); } ); - - describe( 'connectAccountDeauthorize', () => { - test( 'should update state to show request in progress', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, - siteId: 123, - }; - const newState = stripeConnectAccountReducer( undefined, action ); - expect( newState.isDeauthorizing ).to.eql( true ); - } ); - - test( 'should only update the request in progress flag for the appropriate siteId', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, - siteId: 123, - }; - const newState = sitesReducer( - { - 123: { - settings: { - stripeConnectAccount: { - isDeauthorizing: false, - }, - }, - }, - 456: { - settings: { - stripeConnectAccount: { - isDeauthorizing: false, - }, - }, - }, - }, - action - ); - expect( newState[ 123 ].settings.stripeConnectAccount.isDeauthorizing ).to.eql( true ); - expect( newState[ 456 ].settings.stripeConnectAccount.isDeauthorizing ).to.eql( false ); - } ); - } ); - - describe( 'connectAccountDeauthorizeComplete Success', () => { - test( 'should update state', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, - siteId: 123, - }; - const newState = stripeConnectAccountReducer( undefined, action ); - expect( newState ).to.eql( { - connectedUserID: '', - displayName: '', - email: '', - error: '', - firstName: '', - isActivated: false, - isDeauthorizing: false, - isRequesting: false, - lastName: '', - logo: '', - } ); - } ); - - test( 'should leave other sites state unchanged', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, - siteId: 123, - }; - const newState = sitesReducer( - { - 123: { - settings: { - stripeConnectAccount: { - connectedUserID: 'acct_25rzu7Alijdnw0FB', - displayName: 'Bar Foo', - email: 'bar@foo.com', - error: '', - firstName: 'Bar', - isActivated: false, - isDeauthorizing: true, - isRequesting: false, - lastName: 'Foo', - logo: '', - }, - }, - }, - 456: { - settings: { - stripeConnectAccount: { - connectedUserID: 'acct_14qyt6Alijdnw0EA', - displayName: 'Foo Bar', - email: 'foo@bar.com', - error: '', - firstName: 'Foo', - isActivated: false, - isDeauthorizing: true, - isRequesting: false, - lastName: 'Bar', - logo: '', - }, - }, - }, - }, - action - ); - expect( newState[ 123 ].settings.stripeConnectAccount.isDeauthorizing ).to.eql( false ); - expect( newState[ 123 ].settings.stripeConnectAccount.connectedUserID ).to.eql( '' ); - expect( newState[ 456 ].settings.stripeConnectAccount.isDeauthorizing ).to.eql( true ); - expect( newState[ 456 ].settings.stripeConnectAccount.connectedUserID ).to.eql( - 'acct_14qyt6Alijdnw0EA' - ); - } ); - } ); - - describe( 'connectAccountDeauthorizeComplete w/ Error', () => { - test( 'should set the error in state', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, - siteId: 123, - error: 'My error message', - }; - const newState = stripeConnectAccountReducer( undefined, action ); - expect( newState.error ).to.eql( 'My error message' ); - } ); - - test( 'should leave other sites state unchanged', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, - siteId: 123, - error: 'My error message', - }; - const newState = sitesReducer( - { - 123: { - settings: { - stripeConnectAccount: { - connectedUserID: 'acct_14qyt6Alijdnw0EA', - displayName: 'Foo Bar', - email: 'foo@bar.com', - error: '', - firstName: 'Foo', - isActivated: false, - isDeauthorizing: true, - isRequesting: false, - lastName: 'Bar', - logo: '', - }, - }, - }, - 456: { - settings: { - stripeConnectAccount: { - connectedUserID: 'acct_14qyt6Alijdnw0EA', - displayName: 'Foo Bar', - email: 'foo@bar.com', - error: '', - firstName: 'Foo', - isActivated: false, - isDeauthorizing: true, - isRequesting: false, - lastName: 'Bar', - logo: '', - }, - }, - }, - }, - action - ); - expect( newState[ 123 ].settings.stripeConnectAccount.error ).to.eql( 'My error message' ); - expect( newState[ 123 ].settings.stripeConnectAccount.isDeauthorizing ).to.eql( false ); - expect( newState[ 456 ].settings.stripeConnectAccount.error ).to.eql( '' ); - expect( newState[ 456 ].settings.stripeConnectAccount.isDeauthorizing ).to.eql( true ); - } ); - } ); - - describe( 'connectAccountOAuthInit', () => { - test( 'should update state to show request in progress', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, - siteId: 123, - }; - const newState = stripeConnectAccountReducer( undefined, action ); - expect( newState.isOAuthInitializing ).to.eql( true ); - } ); - - test( 'should only update the request in progress flag for the appropriate siteId', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, - siteId: 123, - }; - const newState = sitesReducer( - { - 123: { - settings: { - stripeConnectAccount: { - isOAuthInitializing: false, - }, - }, - }, - 456: { - settings: { - stripeConnectAccount: { - isOAuthInitializing: false, - }, - }, - }, - }, - action - ); - expect( newState[ 123 ].settings.stripeConnectAccount.isOAuthInitializing ).to.eql( true ); - expect( newState[ 456 ].settings.stripeConnectAccount.isOAuthInitializing ).to.eql( false ); - } ); - } ); - - describe( 'connectAccountOAuthInitComplete Success', () => { - test( 'should update state', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, - oauthUrl: 'https://connect.stripe.com/oauth/authorize', - siteId: 123, - }; - const newState = stripeConnectAccountReducer( undefined, action ); - expect( newState ).to.eql( { - error: '', - isOAuthInitializing: false, - oauthUrl: 'https://connect.stripe.com/oauth/authorize', - } ); - } ); - - test( 'should leave other sites state unchanged', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, - oauthUrl: 'https://connect.stripe.com/oauth/authorize', - siteId: 123, - }; - const newState = sitesReducer( - { - 123: { - settings: { - stripeConnectAccount: { - connectedUserID: 'acct_25rzu7Alijdnw0FB', - displayName: 'Bar Foo', - email: 'bar@foo.com', - error: '', - firstName: 'Bar', - isActivated: false, - isDeauthorizing: false, - isOAuthInitializing: true, - isRequesting: false, - lastName: 'Foo', - logo: '', - oauthUrl: 'https://connect.stripe.com/oauth/authorize', - }, - }, - }, - 456: { - settings: { - stripeConnectAccount: { - connectedUserID: 'acct_14qyt6Alijdnw0EA', - displayName: 'Foo Bar', - email: 'foo@bar.com', - error: '', - firstName: 'Foo', - isActivated: false, - isDeauthorizing: true, - isOAuthInitializing: false, - isRequesting: false, - lastName: 'Bar', - logo: '', - oauthUrl: '', - }, - }, - }, - }, - action - ); - expect( newState[ 123 ].settings.stripeConnectAccount.isOAuthInitializing ).to.eql( false ); - expect( newState[ 123 ].settings.stripeConnectAccount.oauthUrl ).to.eql( - 'https://connect.stripe.com/oauth/authorize' - ); - expect( newState[ 456 ].settings.stripeConnectAccount.isOAuthInitializing ).to.eql( false ); - expect( newState[ 456 ].settings.stripeConnectAccount.oauthUrl ).to.eql( '' ); - } ); - } ); - - describe( 'connectAccountOAuthInitComplete w/ Error', () => { - test( 'should set the error in state', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, - siteId: 123, - error: 'My error message', - }; - const newState = stripeConnectAccountReducer( undefined, action ); - expect( newState.error ).to.eql( 'My error message' ); - } ); - - test( 'should leave other sites state unchanged', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, - siteId: 123, - error: 'My error message', - }; - const newState = sitesReducer( - { - 123: { - settings: { - stripeConnectAccount: { - connectedUserID: 'acct_14qyt6Alijdnw0EA', - displayName: 'Foo Bar', - email: 'foo@bar.com', - error: '', - firstName: 'Foo', - isActivated: false, - isDeauthorizing: false, - isOAuthInitializing: true, - isRequesting: false, - lastName: 'Bar', - logo: '', - }, - }, - }, - 456: { - settings: { - stripeConnectAccount: { - connectedUserID: 'acct_14qyt6Alijdnw0EA', - displayName: 'Foo Bar', - email: 'foo@bar.com', - error: '', - firstName: 'Foo', - isActivated: false, - isDeauthorizing: false, - isOAuthInitializing: true, - isRequesting: false, - lastName: 'Bar', - logo: '', - }, - }, - }, - }, - action - ); - expect( newState[ 123 ].settings.stripeConnectAccount.error ).to.eql( 'My error message' ); - expect( newState[ 123 ].settings.stripeConnectAccount.isOAuthInitializing ).to.eql( false ); - expect( newState[ 456 ].settings.stripeConnectAccount.error ).to.eql( '' ); - expect( newState[ 456 ].settings.stripeConnectAccount.isOAuthInitializing ).to.eql( true ); - } ); - } ); - - describe( 'connectAccountOAuthConnect', () => { - test( 'should update state to show request in progress', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, - siteId: 123, - }; - const newState = stripeConnectAccountReducer( undefined, action ); - expect( newState.isOAuthConnecting ).to.eql( true ); - } ); - - test( 'should only update the request in progress flag for the appropriate siteId', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, - siteId: 123, - }; - const newState = sitesReducer( - { - 123: { - settings: { - stripeConnectAccount: { - isOAuthConnecting: false, - }, - }, - }, - 456: { - settings: { - stripeConnectAccount: { - isOAuthConnecting: false, - }, - }, - }, - }, - action - ); - expect( newState[ 123 ].settings.stripeConnectAccount.isOAuthConnecting ).to.eql( true ); - expect( newState[ 456 ].settings.stripeConnectAccount.isOAuthConnecting ).to.eql( false ); - } ); - } ); - - describe( 'connectAccountOAuthConnectComplete Success', () => { - test( 'should update state', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, - connectedUserID: 'acct_14qyt6Alijdnw0EA', - siteId: 123, - }; - const newState = stripeConnectAccountReducer( undefined, action ); - expect( newState ).to.eql( { - connectedUserID: 'acct_14qyt6Alijdnw0EA', - email: '', - error: '', - firstName: '', - isActivated: false, - isCreating: false, - isOAuthConnecting: false, - isRequesting: false, - lastName: '', - logo: '', - } ); - } ); - - test( 'should leave other sites state unchanged', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, - connectedUserID: 'acct_14qyt6Alijdnw0EA', - siteId: 123, - }; - const newState = sitesReducer( - { - 123: { - settings: { - stripeConnectAccount: { - connectedUserID: '', - displayName: '', - email: '', - error: '', - firstName: '', - isActivated: false, - isDeauthorizing: false, - isOAuthConnecting: true, - isOAuthInitializing: false, - isRequesting: false, - lastName: '', - logo: '', - oauthUrl: '', - }, - }, - }, - 456: { - settings: { - stripeConnectAccount: { - connectedUserID: '', - displayName: '', - email: '', - error: '', - firstName: '', - isActivated: false, - isDeauthorizing: false, - isOAuthConnecting: true, - isOAuthInitializing: false, - isRequesting: false, - lastName: '', - logo: '', - oauthUrl: '', - }, - }, - }, - }, - action - ); - expect( newState[ 123 ].settings.stripeConnectAccount.isOAuthConnecting ).to.eql( false ); - expect( newState[ 123 ].settings.stripeConnectAccount.connectedUserID ).to.eql( - 'acct_14qyt6Alijdnw0EA' - ); - expect( newState[ 456 ].settings.stripeConnectAccount.isOAuthConnecting ).to.eql( true ); - expect( newState[ 456 ].settings.stripeConnectAccount.connectedUserID ).to.eql( '' ); - } ); - } ); - - describe( 'connectAccountOAuthConnectComplete w/ Error', () => { - test( 'should set the error in state', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, - siteId: 123, - error: 'My error message', - }; - const newState = stripeConnectAccountReducer( undefined, action ); - expect( newState.error ).to.eql( 'My error message' ); - } ); - - test( 'should leave other sites state unchanged', () => { - const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, - siteId: 123, - error: 'My error message', - }; - const newState = sitesReducer( - { - 123: { - settings: { - stripeConnectAccount: { - connectedUserID: '', - displayName: 'Foo Bar', - email: 'foo@bar.com', - error: '', - firstName: 'Foo', - isActivated: false, - isDeauthorizing: false, - isOAuthConnecting: true, - isOAuthInitializing: false, - isRequesting: false, - lastName: 'Bar', - logo: '', - }, - }, - }, - 456: { - settings: { - stripeConnectAccount: { - connectedUserID: '', - displayName: 'Foo Bar', - email: 'foo@bar.com', - error: '', - firstName: 'Foo', - isActivated: false, - isDeauthorizing: false, - isOAuthConnecting: true, - isOAuthInitializing: false, - isRequesting: false, - lastName: 'Bar', - logo: '', - }, - }, - }, - }, - action - ); - expect( newState[ 123 ].settings.stripeConnectAccount.error ).to.eql( 'My error message' ); - expect( newState[ 123 ].settings.stripeConnectAccount.isOAuthConnecting ).to.eql( false ); - expect( newState[ 456 ].settings.stripeConnectAccount.error ).to.eql( '' ); - expect( newState[ 456 ].settings.stripeConnectAccount.isOAuthConnecting ).to.eql( true ); - } ); - } ); } ); diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/selectors.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/selectors.js deleted file mode 100644 index 25d617ce25e7f..0000000000000 --- a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/selectors.js +++ /dev/null @@ -1,406 +0,0 @@ -/** @format */ - -/** - * External dependencies - */ -import { expect } from 'chai'; - -/** - * Internal dependencies - */ -import { - getError, - getIsCreating, - getIsDeauthorizing, - getIsOAuthConnecting, - getIsOAuthInitializing, - getIsRequesting, - getOAuthURL, - getStripeConnectAccount, -} from '../selectors'; - -const uninitializedState = { - extensions: { - woocommerce: { - sites: { - 123: { - settings: { - stripeConnectAccount: {}, - }, - }, - }, - }, - }, -}; - -const creatingState = { - extensions: { - woocommerce: { - sites: { - 123: { - settings: { - stripeConnectAccount: { - isCreating: true, - }, - }, - }, - }, - }, - }, -}; - -const createdState = { - extensions: { - woocommerce: { - sites: { - 123: { - settings: { - stripeConnectAccount: { - connectedUserID: 'acct_14qyt6Alijdnw0EA', - email: 'foo@bar.com', - isCreating: false, - }, - }, - }, - }, - }, - }, -}; - -const errorState = { - extensions: { - woocommerce: { - sites: { - 123: { - settings: { - stripeConnectAccount: { - error: 'My error message', - }, - }, - }, - }, - }, - }, -}; - -const fetchingState = { - extensions: { - woocommerce: { - sites: { - 123: { - settings: { - stripeConnectAccount: { - connectedUserID: '', - displayName: '', - email: '', - firstName: '', - isActivated: false, - isDeauthorizing: false, - isRequesting: true, - lastName: '', - logo: '', - }, - }, - }, - }, - }, - }, -}; - -const fetchedState = { - extensions: { - woocommerce: { - sites: { - 123: { - settings: { - stripeConnectAccount: { - connectedUserID: 'acct_14qyt6Alijdnw0EA', - displayName: 'Foo Bar', - email: 'foo@bar.com', - firstName: 'Foo', - isActivated: true, - isDeauthorizing: false, - isRequesting: false, - lastName: 'Bar', - logo: 'https://foo.com/bar.png', - }, - }, - }, - }, - }, - }, -}; - -const deauthorizingState = { - extensions: { - woocommerce: { - sites: { - 123: { - settings: { - stripeConnectAccount: { - connectedUserID: '', - displayName: '', - email: '', - firstName: '', - isActivated: false, - isDeauthorizing: true, - isRequesting: false, - logo: '', - lastName: '', - }, - }, - }, - }, - }, - }, -}; - -const deauthorizedState = { - extensions: { - woocommerce: { - sites: { - 123: { - settings: { - stripeConnectAccount: { - connectedUserID: '', - displayName: '', - email: '', - firstName: '', - isActivated: false, - isDeauthorizing: false, - isRequesting: false, - logo: '', - lastName: '', - }, - }, - }, - }, - }, - }, -}; - -const oauthInitializingState = { - extensions: { - woocommerce: { - sites: { - 123: { - settings: { - stripeConnectAccount: { - connectedUserID: '', - displayName: '', - email: '', - firstName: '', - isActivated: false, - isDeauthorizing: false, - isOAuthInitializing: true, - isRequesting: false, - logo: '', - lastName: '', - oauthUrl: '', - }, - }, - }, - }, - }, - }, -}; - -const oauthConnectingState = { - extensions: { - woocommerce: { - sites: { - 123: { - settings: { - stripeConnectAccount: { - connectedUserID: '', - displayName: '', - email: '', - firstName: '', - isActivated: false, - isDeauthorizing: false, - isOAuthInitializing: false, - isOAuthConnecting: true, - isRequesting: false, - logo: '', - lastName: '', - oauthUrl: '', - }, - }, - }, - }, - }, - }, -}; - -const oauthConnectedState = { - extensions: { - woocommerce: { - sites: { - 123: { - settings: { - stripeConnectAccount: { - connectedUserID: 'acct_14qyt6Alijdnw0EA', - displayName: '', - email: '', - firstName: '', - isActivated: false, - isDeauthorizing: false, - isOAuthInitializing: false, - isOAuthConnecting: false, - isRequesting: false, - logo: '', - lastName: '', - oauthUrl: '', - }, - }, - }, - }, - }, - }, -}; - -const oauthInitializedState = { - extensions: { - woocommerce: { - sites: { - 123: { - settings: { - stripeConnectAccount: { - connectedUserID: '', - displayName: '', - email: '', - firstName: '', - isActivated: false, - isDeauthorizing: false, - isOAuthInitializing: false, - isRequesting: false, - logo: '', - lastName: '', - oauthUrl: 'https://connect.stripe.com/oauth/authorize', - }, - }, - }, - }, - }, - }, -}; - -describe( 'selectors', () => { - describe( '#getIsCreating', () => { - test( 'should be false when state is uninitialized.', () => { - expect( getIsCreating( uninitializedState, 123 ) ).to.be.false; - } ); - - test( 'should be true when attempting to create an account.', () => { - expect( getIsCreating( creatingState, 123 ) ).to.be.true; - } ); - - test( 'should be false after creating an account.', () => { - expect( getIsCreating( createdState, 123 ) ).to.be.false; - } ); - } ); - - describe( '#getError', () => { - test( 'should return error when present.', () => { - expect( getError( errorState, 123 ) ).to.eql( 'My error message' ); - } ); - - test( 'should return empty string when not.', () => { - expect( getError( createdState, 123 ) ).to.eql( '' ); - } ); - } ); - - describe( '#getIsRequesting', () => { - test( 'should be false when state is uninitialized.', () => { - expect( getIsRequesting( uninitializedState, 123 ) ).to.be.false; - } ); - - test( 'should be true when fetching account details.', () => { - expect( getIsRequesting( fetchingState, 123 ) ).to.be.true; - } ); - - test( 'should be false when not fetching account details.', () => { - expect( getIsRequesting( fetchedState, 123 ) ).to.be.false; - } ); - } ); - - describe( '#getStripeConnectAccount', () => { - test( 'should be empty when state is uninitialized.', () => { - expect( getStripeConnectAccount( uninitializedState, 123 ) ).to.eql( {} ); - } ); - - test( 'should return account details when they are available in state.', () => { - expect( getStripeConnectAccount( fetchedState, 123 ) ).to.eql( { - connectedUserID: 'acct_14qyt6Alijdnw0EA', - displayName: 'Foo Bar', - email: 'foo@bar.com', - firstName: 'Foo', - isActivated: true, - lastName: 'Bar', - logo: 'https://foo.com/bar.png', - } ); - } ); - } ); - - describe( '#getIsDeauthorizing', () => { - test( 'should be false when woocommerce state is not available.', () => { - expect( getIsDeauthorizing( uninitializedState, 123 ) ).to.be.false; - } ); - - test( 'should be false when connected.', () => { - expect( getIsDeauthorizing( fetchedState, 123 ) ).to.be.false; - } ); - - test( 'should be true when deauthorizing.', () => { - expect( getIsDeauthorizing( deauthorizingState, 123 ) ).to.be.true; - } ); - - test( 'should be false when deauthorization has completed.', () => { - expect( getIsDeauthorizing( deauthorizedState, 123 ) ).to.be.false; - } ); - } ); - - describe( '#getIsOAuthInitializing', () => { - test( 'should be false when woocommerce state is not available.', () => { - expect( getIsOAuthInitializing( uninitializedState, 123 ) ).to.be.false; - } ); - - test( 'should be true when initializing.', () => { - expect( getIsOAuthInitializing( oauthInitializingState, 123 ) ).to.be.true; - } ); - - test( 'should be false when initialization has completed.', () => { - expect( getIsOAuthInitializing( oauthInitializedState, 123 ) ).to.be.false; - } ); - } ); - - describe( '#getIsOAuthConnecting', () => { - test( 'should be false when woocommerce state is not available.', () => { - expect( getIsOAuthConnecting( uninitializedState, 123 ) ).to.be.false; - } ); - - test( 'should be true when connecting.', () => { - expect( getIsOAuthConnecting( oauthConnectingState, 123 ) ).to.be.true; - } ); - - test( 'should be false when connection has completed.', () => { - expect( getIsOAuthConnecting( oauthConnectedState, 123 ) ).to.be.false; - } ); - } ); - - describe( '#getOAuthURL', () => { - test( 'should be empty when woocommerce state is not available.', () => { - expect( getOAuthURL( uninitializedState, 123 ) ).to.eql( '' ); - } ); - - test( 'should be empty when initializing.', () => { - expect( getOAuthURL( oauthInitializingState, 123 ) ).to.be.eql( '' ); - } ); - - test( 'should have a URL when initialization has completed.', () => { - expect( getOAuthURL( oauthInitializedState, 123 ) ).to.eql( - 'https://connect.stripe.com/oauth/authorize' - ); - } ); - } ); -} ); diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index f02d92b9473e7..500b8f254e1d8 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -13,7 +13,7 @@ "dev": true, "dependencies": { "ast-types": { - "version": "0.9.14", + "version": "0.9.12", "dev": true }, "esprima": { @@ -25,7 +25,7 @@ "dev": true }, "recast": { - "version": "0.12.8", + "version": "0.12.7", "dev": true }, "source-map": { @@ -185,7 +185,7 @@ "version": "0.2.3" }, "asn1.js": { - "version": "4.9.2" + "version": "4.9.1" }, "assert": { "version": "1.4.1" @@ -3099,7 +3099,7 @@ } }, "i18n-calypso": { - "version": "1.8.2", + "version": "1.8.1", "dependencies": { "async": { "version": "1.5.2" diff --git a/package.json b/package.json index 649308c1840ff..7c2c5ae220894 100644 --- a/package.json +++ b/package.json @@ -83,7 +83,7 @@ "hash.js": "1.1.3", "he": "0.5.0", "html-loader": "0.4.0", - "i18n-calypso": "1.8.2", + "i18n-calypso": "1.8.1", "immutability-helper": "2.4.0", "immutable": "3.7.6", "imports-loader": "0.6.5", From ed8541e02e4aff0633cea258538e7d09e4c438e4 Mon Sep 17 00:00:00 2001 From: Justin Shreve Date: Tue, 31 Oct 2017 13:54:44 -0700 Subject: [PATCH 109/192] Store: Add a setting for the new order notification. (#19196) --- .../blogs-settings/blog.jsx | 24 ++++++++++++------- .../settings-form/constants.js | 2 +- .../settings-form/locales.js | 2 ++ client/state/sites/actions.js | 2 +- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/client/me/notification-settings/blogs-settings/blog.jsx b/client/me/notification-settings/blogs-settings/blog.jsx index cb34c4e381052..9dcc0279f7d71 100644 --- a/client/me/notification-settings/blogs-settings/blog.jsx +++ b/client/me/notification-settings/blogs-settings/blog.jsx @@ -56,6 +56,20 @@ class BlogSettings extends Component { 'is-expanded': isExpanded, } ); + const settingKeys = [ + 'new_comment', + 'comment_like', + 'post_like', + 'follow', + 'achievement', + 'mentions', + 'scheduled_publicize', + ]; + + if ( site.options.is_wpcom_store ) { + settingKeys.push( 'store_order' ); + } + return (
@@ -72,15 +86,7 @@ class BlogSettings extends Component { onSave, onSaveToAll, } } - settingKeys={ [ - 'new_comment', - 'comment_like', - 'post_like', - 'follow', - 'achievement', - 'mentions', - 'scheduled_publicize', - ] } + settingKeys={ settingKeys } /> ); } diff --git a/client/me/notification-settings/settings-form/constants.js b/client/me/notification-settings/settings-form/constants.js index e790a8ed70e07..bacca259ed753 100644 --- a/client/me/notification-settings/settings-form/constants.js +++ b/client/me/notification-settings/settings-form/constants.js @@ -1,4 +1,4 @@ /** @format */ export const NOTIFICATIONS_EXCEPTIONS = { - email: [ 'achievement', 'scheduled_publicize' ], + email: [ 'achievement', 'scheduled_publicize', 'store_order' ], }; diff --git a/client/me/notification-settings/settings-form/locales.js b/client/me/notification-settings/settings-form/locales.js index caa4b84d4ac03..d2fc74749b81c 100644 --- a/client/me/notification-settings/settings-form/locales.js +++ b/client/me/notification-settings/settings-form/locales.js @@ -16,6 +16,8 @@ export const settingLabels = { achievement: () => i18n.translate( 'Site achievements' ), mentions: () => i18n.translate( 'Username mentions' ), scheduled_publicize: () => i18n.translate( 'Post Publicized' ), + + store_order: () => i18n.translate( 'New order' ), }; export const getLabelForStream = stream => diff --git a/client/state/sites/actions.js b/client/state/sites/actions.js index 1bdd411d70097..4720bacc1711a 100644 --- a/client/state/sites/actions.js +++ b/client/state/sites/actions.js @@ -103,7 +103,7 @@ export function requestSites() { fields: 'ID,URL,name,capabilities,jetpack,visible,is_private,is_vip,icon,plan,jetpack_modules,single_user_site,is_multisite,options', //eslint-disable-line max-len options: - 'is_mapped_domain,unmapped_url,admin_url,is_redirect,is_automated_transfer,allowed_file_types,show_on_front,main_network_site,jetpack_version,software_version,default_post_format,created_at,frame_nonce,publicize_permanently_disabled,page_on_front,page_for_posts,advanced_seo_front_page_description,advanced_seo_title_formats,verification_services_codes,podcasting_archive,is_domain_only,default_sharing_status,default_likes_enabled,wordads,upgraded_filetypes_enabled,videopress_enabled,permalink_structure,gmt_offset,signup_is_store,has_pending_automated_transfer', //eslint-disable-line max-len + 'is_mapped_domain,unmapped_url,admin_url,is_redirect,is_automated_transfer,allowed_file_types,show_on_front,main_network_site,jetpack_version,software_version,default_post_format,created_at,frame_nonce,publicize_permanently_disabled,page_on_front,page_for_posts,advanced_seo_front_page_description,advanced_seo_title_formats,verification_services_codes,podcasting_archive,is_domain_only,default_sharing_status,default_likes_enabled,wordads,upgraded_filetypes_enabled,videopress_enabled,permalink_structure,gmt_offset,is_wpcom_store,signup_is_store,has_pending_automated_transfer', //eslint-disable-line max-len } ) .then( response => { dispatch( receiveSites( response.sites ) ); From ed42046eedb90b544e6ffae46c58ae648c8ac5b0 Mon Sep 17 00:00:00 2001 From: Marko Andrijasevic Date: Tue, 31 Oct 2017 22:35:43 +0100 Subject: [PATCH 110/192] Comments: add Jetpack update screen (#19206) Force users to update to Jetpack 5.5 in order to use the comments section. Until Jetpack 5.5 is released this will be gated behind a feature flag and turned off in all environments. --- client/my-sites/comments/main.jsx | 55 ++++++++++++++++++++++++++++--- config/desktop.json | 1 + config/development.json | 1 + config/horizon.json | 1 + config/production.json | 1 + config/stage.json | 1 + config/wpcalypso.json | 1 + 7 files changed, 56 insertions(+), 5 deletions(-) diff --git a/client/my-sites/comments/main.jsx b/client/my-sites/comments/main.jsx index a34ddbcf437e8..e506fb4cdeb7c 100644 --- a/client/my-sites/comments/main.jsx +++ b/client/my-sites/comments/main.jsx @@ -4,13 +4,17 @@ */ import React, { Component } from 'react'; import PropTypes from 'prop-types'; +import config from 'config'; import { connect } from 'react-redux'; import { localize } from 'i18n-calypso'; +import { find } from 'lodash'; /** * Internal dependencies */ +import EmptyContent from 'components/empty-content'; import getSiteId from 'state/selectors/get-site-id'; +import { isJetpackSite, isJetpackMinimumVersion } from 'state/sites/selectors'; import Main from 'components/main'; import PageViewTracker from 'lib/analytics/page-view-tracker'; import DocumentHead from 'components/data/document-head'; @@ -18,7 +22,10 @@ import CommentList from './comment-list'; import SidebarNavigation from 'my-sites/sidebar-navigation'; import { canCurrentUser } from 'state/selectors'; import { preventWidows } from 'lib/formatting'; -import EmptyContent from 'components/empty-content'; +import QueryJetpackPlugins from 'components/data/query-jetpack-plugins'; +import { updatePlugin } from 'state/plugins/installed/actions'; +import { getPlugins } from 'state/plugins/installed/selectors'; +import { infoNotice } from 'state/notices/actions'; export class CommentsManagement extends Component { static propTypes = { @@ -37,9 +44,17 @@ export class CommentsManagement extends Component { status: 'all', }; + updateJetpackHandler = () => { + const { siteId, translate, jetpackPlugin } = this.props; + + this.props.infoNotice( translate( 'Please wait while we update your Jetpack plugin.' ) ); + this.props.updatePlugin( siteId, jetpackPlugin ); + }; + render() { const { changePage, + showJetpackUpdateScreen, page, postId, showPermissionError, @@ -51,10 +66,23 @@ export class CommentsManagement extends Component { return (
+ { showJetpackUpdateScreen && } - - { showPermissionError && ( + { showJetpackUpdateScreen && ( + + ) } + { ! showJetpackUpdateScreen && } + { ! showJetpackUpdateScreen && + showPermissionError && ( ) } - { ! showPermissionError && ( + { ! showJetpackUpdateScreen && + ! showPermissionError && ( { const siteId = getSiteId( state, siteFragment ); + const isJetpack = isJetpackSite( state, siteId ); const canModerateComments = canCurrentUser( state, siteId, 'moderate_comments' ); + const showJetpackUpdateScreen = + isJetpack && + ! isJetpackMinimumVersion( state, siteId, '5.5' ) && + config.isEnabled( 'comments/management/jetpack-5.5' ); + + const sitePlugins = getPlugins( state, [ siteId ] ); + const jetpackPlugin = find( sitePlugins, { slug: 'jetpack' } ); + return { siteId, + jetpackPlugin, + showJetpackUpdateScreen, showPermissionError: canModerateComments === false, }; }; -export default connect( mapStateToProps )( localize( CommentsManagement ) ); +const mapDispatchToProps = { + updatePlugin, + infoNotice, +}; + +export default connect( mapStateToProps, mapDispatchToProps )( localize( CommentsManagement ) ); diff --git a/config/desktop.json b/config/desktop.json index c414009abc70f..66a66f41404b2 100644 --- a/config/desktop.json +++ b/config/desktop.json @@ -22,6 +22,7 @@ "catch-js-errors": false, "code-splitting": false, "comments/management": true, + "comments/management/jetpack-5.5": false, "comments/management/sorting": false, "desktop": true, "desktop-promo": false, diff --git a/config/development.json b/config/development.json index 4b3aca6eea3a9..5f7b78324e987 100644 --- a/config/development.json +++ b/config/development.json @@ -43,6 +43,7 @@ "comments/moderation-tools-in-posts": true, "comments/management": true, "comments/management/comment-view": true, + "comments/management/jetpack-5.5": false, "comments/management/m3-design": false, "comments/management/post-view": true, "comments/management/quick-actions": true, diff --git a/config/horizon.json b/config/horizon.json index 5a1b1bc7ad562..73ba4b01468d2 100644 --- a/config/horizon.json +++ b/config/horizon.json @@ -24,6 +24,7 @@ "catch-js-errors": true, "code-splitting": true, "comments/management": true, + "comments/management/jetpack-5.5": false, "comments/management/sorting": true, "devdocs": false, "domains/cctlds": true, diff --git a/config/production.json b/config/production.json index 9a4779e6d2b06..f7a523250b68c 100644 --- a/config/production.json +++ b/config/production.json @@ -23,6 +23,7 @@ "catch-js-errors": true, "code-splitting": true, "comments/management": true, + "comments/management/jetpack-5.5": false, "comments/management/sorting": false, "desktop-promo": true, "domains/cctlds": true, diff --git a/config/stage.json b/config/stage.json index c18e5a47aa3c4..e9b2ef12f33ea 100644 --- a/config/stage.json +++ b/config/stage.json @@ -25,6 +25,7 @@ "catch-js-errors": true, "code-splitting": true, "comments/management": true, + "comments/management/jetpack-5.5": false, "comments/management/sorting": false, "desktop-promo": true, "domains/cctlds": true, diff --git a/config/wpcalypso.json b/config/wpcalypso.json index 1fd8b249fc299..c8a4d19b63852 100644 --- a/config/wpcalypso.json +++ b/config/wpcalypso.json @@ -19,6 +19,7 @@ "automated-transfer": true, "apple-pay": true, "comments/management": true, + "comments/management/jetpack-5.5": false, "comments/management/quick-actions": true, "comments/management/sorting": true, "catch-js-errors": true, From 90ea08c33176da29a330bf5e3b82fe37b8aeb71e Mon Sep 17 00:00:00 2001 From: Kirk Wight Date: Tue, 31 Oct 2017 14:57:58 -0700 Subject: [PATCH 111/192] Update the CommentButton block to be able to accept a link and target. (#19239) --- client/blocks/comment-button/README.md | 4 +++- client/blocks/comment-button/index.jsx | 33 ++++++++++++++++---------- 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/client/blocks/comment-button/README.md b/client/blocks/comment-button/README.md index 41a3acfd9047c..67895abe23cc7 100644 --- a/client/blocks/comment-button/README.md +++ b/client/blocks/comment-button/README.md @@ -18,7 +18,9 @@ render() { #### Props * `commentCount`: Number indicating the number of comments to be displayed next to the button. +* `link`: String URL destination to be used with a `tagName` of `a`. Defaults to `null`. * `onClick`: Function to be executed when the user clicks the button. -* `tagName`: String with the HTML tag we are going to use to render the component. Defaults to 'li'. * `showLabel`: Boolean indicating whether or not the label with the comments count is visible. Defaults to `true`. * `size`: Number with the size of the comments icon to be displayed. Defaults to 24. +* `tagName`: String with the HTML tag we are going to use to render the component. Defaults to 'li'. +* `target`: String `target` attribute to be used with a `tagName` of `a`. Defaults to `null`. \ No newline at end of file diff --git a/client/blocks/comment-button/index.jsx b/client/blocks/comment-button/index.jsx index 37bcacc0bea77..a95b2ea204320 100644 --- a/client/blocks/comment-button/index.jsx +++ b/client/blocks/comment-button/index.jsx @@ -8,7 +8,7 @@ import PropTypes from 'prop-types'; import React, { Component } from 'react'; import { connect } from 'react-redux'; import { localize } from 'i18n-calypso'; -import { noop } from 'lodash'; +import { isNull, noop, omitBy } from 'lodash'; /** * Internal dependencies @@ -18,29 +18,38 @@ import { getPostTotalCommentsCount } from 'state/comments/selectors'; class CommentButton extends Component { static propTypes = { - onClick: PropTypes.func, - tagName: PropTypes.string, commentCount: PropTypes.number, + href: PropTypes.string, + onClick: PropTypes.func, showLabel: PropTypes.bool, + tagName: PropTypes.string, + target: PropTypes.string, }; static defaultProps = { - onClick: noop, - tagName: 'li', - size: 24, commentCount: 0, + href: null, + onClick: noop, showLabel: true, + size: 24, + tagName: 'li', + target: null, }; render() { - const { translate, commentCount, onClick, showLabel, tagName: containerTag } = this.props; + const { commentCount, href, onClick, showLabel, tagName, target, translate } = this.props; return React.createElement( - containerTag, - { - className: 'comment-button', - onClick, - }, + tagName, + omitBy( + { + className: 'comment-button', + href: 'a' === tagName ? href : null, + onClick, + target: 'a' === tagName ? target : null, + }, + isNull + ), , { commentCount > 0 && ( From 13969730c9627b43c48151ee6d8b1727c93ca9ae Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Mon, 30 Oct 2017 15:51:57 -0500 Subject: [PATCH 112/192] Promotions: Add export helper functions This adds functions that convert promotion objects to their respective product or coupon API update objects. --- .../state/sites/promotions/helpers.js | 42 +++++ .../state/sites/promotions/test/helpers.js | 151 ++++++++++++++++++ 2 files changed, 193 insertions(+) diff --git a/client/extensions/woocommerce/state/sites/promotions/helpers.js b/client/extensions/woocommerce/state/sites/promotions/helpers.js index 61c8f81f42756..ae97e98b31888 100644 --- a/client/extensions/woocommerce/state/sites/promotions/helpers.js +++ b/client/extensions/woocommerce/state/sites/promotions/helpers.js @@ -15,6 +15,22 @@ export function createPromotionFromProduct( product ) { }; } +export function createProductUpdateFromPromotion( promotion ) { + const { productIds } = promotion.appliesTo; + const id = productIds && productIds[ 0 ]; + + if ( ! id ) { + throw new Error( 'Cannot create product from promotion, product id not found.' ); + } + + return { + id, + sale_price: promotion.salePrice, + date_on_sale_from_gmt: promotion.startDate, + date_on_sale_to_gmt: promotion.endDate, + }; +} + export function createPromotionFromCoupon( coupon ) { const promotion = { id: uniqueId( 'promotion:' ), @@ -45,6 +61,32 @@ export function createPromotionFromCoupon( coupon ) { return promotion; } +export function createCouponUpdateFromPromotion( promotion ) { + if ( ! promotion.couponCode ) { + throw new Error( 'Cannot create coupon from promotion with nonexistant couponCode' ); + } + + const amount = ( 'percent' === promotion.type + ? promotion.percentDiscount + : promotion.fixedDiscount ); + + return { + id: promotion.couponId, // May not be present in case of create. + discount_type: promotion.type, + code: promotion.couponCode, + amount: amount, + date_expires_gmt: promotion.endDate, + individual_use: promotion.individualUse, + usage_limit: promotion.usageLimit, + usage_limit_per_user: promotion.usageLimitPerUser, + free_shipping: promotion.freeShipping, + minimum_amount: promotion.minimumAmount, + maximum_amount: promotion.maximumAmount, + product_ids: promotion.appliesTo.productIds, + product_categories: promotion.appliesTo.productCategoryIds, + }; +} + function calculateCouponAppliesTo( coupon ) { const { product_ids, product_categories } = coupon; diff --git a/client/extensions/woocommerce/state/sites/promotions/test/helpers.js b/client/extensions/woocommerce/state/sites/promotions/test/helpers.js index ca44a8c2ef69b..efcae1c847d52 100644 --- a/client/extensions/woocommerce/state/sites/promotions/test/helpers.js +++ b/client/extensions/woocommerce/state/sites/promotions/test/helpers.js @@ -8,7 +8,9 @@ import { expect } from 'chai'; */ import { createPromotionFromProduct, + createProductUpdateFromPromotion, createPromotionFromCoupon, + createCouponUpdateFromPromotion, isCategoryExplicitlySelected, isCategorySelected, isProductExplicitlySelected, @@ -42,6 +44,39 @@ describe( 'helpers', () => { } ); } ); + describe( '#createProductUpdateFromPromotion', () => { + test( 'should set product fields from promotion', () => { + const promotion = { + type: 'product_sale', + salePrice: '20', + startDate: '2017-09-20T12:12:15', + endDate: '2017-10-22T10:10:15', + appliesTo: { productIds: [ 52 ] }, + }; + + const productData = createProductUpdateFromPromotion( promotion ); + + expect( productData ).to.exist; + expect( productData.id ).to.equal( 52 ); + expect( productData.date_on_sale_from_gmt ).to.equal( promotion.startDate ); + expect( productData.date_on_sale_to_gmt ).to.equal( promotion.endDate ); + } ); + + test( 'should throw if promotion does not apply to product', () => { + const promotion = { + type: 'product_sale', + salePrice: '20', + appliesTo: { productCategoryIds: [ 52 ] }, + }; + + const badProductPromotionCall = () => { + createProductUpdateFromPromotion( promotion ); + }; + + expect( badProductPromotionCall ).to.throw( Error ); + } ); + } ); + describe( '#createPromotionFromCoupon', () => { test( 'should set fields from coupon', () => { const promotion = createPromotionFromCoupon( coupon1 ); @@ -124,6 +159,122 @@ describe( 'helpers', () => { } ); } ); + describe( '#createCouponUpdateFromPromotion', () => { + test( 'should set fixed discount fields from promotion', () => { + const promotion = { + type: 'fixed_cart', + couponCode: '20bucks', + fixedDiscount: '20', + appliesTo: { all: true }, + couponId: 25, + }; + + const couponData = createCouponUpdateFromPromotion( promotion ); + + expect( couponData ).to.exist; + expect( couponData.id ).to.equal( 25 ); + expect( couponData.discount_type ).to.equal( 'fixed_cart' ); + expect( couponData.code ).to.equal( promotion.couponCode ); + expect( couponData.amount ).to.equal( promotion.fixedDiscount ); + } ); + + test( 'should set percent discount fields from promotion', () => { + const promotion = { + type: 'percent', + couponCode: '10percent', + fixedDiscount: '10', + appliesTo: { all: true }, + couponId: 25, + }; + + const couponData = createCouponUpdateFromPromotion( promotion ); + + expect( couponData ).to.exist; + expect( couponData.id ).to.equal( 25 ); + expect( couponData.discount_type ).to.equal( 'percent' ); + expect( couponData.code ).to.equal( promotion.couponCode ); + expect( couponData.amount ).to.equal( promotion.percentDiscount ); + } ); + + test( 'should set applied product ids from promotion', () => { + const promotion = { + type: 'percent', + couponCode: '10percent', + percentDiscount: '10', + appliesTo: { productIds: [ 12, 14, 16 ] }, + couponId: 25, + }; + + const couponData = createCouponUpdateFromPromotion( promotion ); + + expect( couponData ).to.exist; + expect( couponData.id ).to.equal( 25 ); + expect( couponData.product_ids ).to.equal( promotion.appliesTo.productIds ); + } ); + + test( 'should set applied product category ids from promotion', () => { + const promotion = { + type: 'percent', + couponCode: '10percent', + fixedDiscount: '10', + appliesTo: { productCategoryIds: [ 22, 24, 26 ] }, + couponId: 25, + }; + + const couponData = createCouponUpdateFromPromotion( promotion ); + + expect( couponData ).to.exist; + expect( couponData.id ).to.equal( 25 ); + expect( couponData.product_categories ).to.equal( + promotion.appliesTo.productCategoryIds + ); + } ); + + test( 'should set conditions on coupon from promotion', () => { + const promotion = { + type: 'percent', + couponCode: '10percent', + fixedDiscount: '20', + appliesTo: { all: true }, + couponId: 25, + endDate: '2017-10-22T10:10:15', + individualUse: true, + usageLimit: '20', + usageLimitPerUser: '1', + freeShipping: true, + minimumAmount: '20', + maximumAmount: '200', + }; + + const couponData = createCouponUpdateFromPromotion( promotion ); + + expect( couponData ).to.exist; + expect( couponData.id ).to.equal( 25 ); + expect( couponData.date_expires_gmt ).to.equal( promotion.endDate ); + expect( couponData.individual_use ).to.equal( promotion.individualUse ); + expect( couponData.usage_limit ).to.equal( promotion.usageLimit ); + expect( couponData.usage_limit_per_user ).to.equal( promotion.usageLimitPerUser ); + expect( couponData.free_shipping ).to.equal( promotion.freeShipping ); + expect( couponData.minimum_amount ).to.equal( promotion.minimumAmount ); + expect( couponData.maximum_amount ).to.equal( promotion.maximumAmount ); + } ); + + test( 'should throw if promotion does not have a coupon code', () => { + const promotion = { + type: 'fixed_cart', + amount: '20', + appliesTo: { all: true }, + couponId: 25 + }; + + const badCouponPromotionCall = () => { + createCouponUpdateFromPromotion( promotion ); + }; + + expect( badCouponPromotionCall ).to.throw( Error ); + } ); + } ); + describe( '#isCategoryExplicitlySelected', () => { test( 'should explicitly select a category for a coupon', () => { const promotion = createPromotionFromCoupon( coupon3 ); From d442d4fbe79cc7b7f3bc2b88bfdda4bca07d0768 Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Mon, 30 Oct 2017 20:41:56 -0500 Subject: [PATCH 113/192] Store promotions: Add appliesTo check for exports. This makes an additional check for an appliesTo property on the promotion object. --- .../woocommerce/state/sites/promotions/helpers.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/client/extensions/woocommerce/state/sites/promotions/helpers.js b/client/extensions/woocommerce/state/sites/promotions/helpers.js index ae97e98b31888..2e537c79f1bd6 100644 --- a/client/extensions/woocommerce/state/sites/promotions/helpers.js +++ b/client/extensions/woocommerce/state/sites/promotions/helpers.js @@ -16,7 +16,7 @@ export function createPromotionFromProduct( product ) { } export function createProductUpdateFromPromotion( promotion ) { - const { productIds } = promotion.appliesTo; + const { productIds } = promotion.appliesTo || {}; const id = productIds && productIds[ 0 ]; if ( ! id ) { @@ -62,6 +62,8 @@ export function createPromotionFromCoupon( coupon ) { } export function createCouponUpdateFromPromotion( promotion ) { + const { appliesTo } = promotion; + if ( ! promotion.couponCode ) { throw new Error( 'Cannot create coupon from promotion with nonexistant couponCode' ); } @@ -70,6 +72,9 @@ export function createCouponUpdateFromPromotion( promotion ) { ? promotion.percentDiscount : promotion.fixedDiscount ); + const productIds = ( appliesTo && appliesTo.productIds ) || undefined; + const productCategoryIds = ( appliesTo && appliesTo.productCategoryIds ) || undefined; + return { id: promotion.couponId, // May not be present in case of create. discount_type: promotion.type, @@ -82,8 +87,8 @@ export function createCouponUpdateFromPromotion( promotion ) { free_shipping: promotion.freeShipping, minimum_amount: promotion.minimumAmount, maximum_amount: promotion.maximumAmount, - product_ids: promotion.appliesTo.productIds, - product_categories: promotion.appliesTo.productCategoryIds, + product_ids: productIds, + product_categories: productCategoryIds, }; } From 010ed3c8da880c7060fc29496843da6e26f9a388 Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Sat, 30 Sep 2017 12:57:38 -0500 Subject: [PATCH 114/192] Store Promotions: Add selectors for edit states This adds selectors to get promotion edits, edits overlaid on top of the existing promotions, and the currently editing promotion. --- .../state/selectors/test/promotions.js | 37 +++++++------------ 1 file changed, 13 insertions(+), 24 deletions(-) diff --git a/client/extensions/woocommerce/state/selectors/test/promotions.js b/client/extensions/woocommerce/state/selectors/test/promotions.js index 135e5550744bd..c8567b8cfe69b 100644 --- a/client/extensions/woocommerce/state/selectors/test/promotions.js +++ b/client/extensions/woocommerce/state/selectors/test/promotions.js @@ -117,9 +117,11 @@ describe( 'promotions', () => { const editedState = cloneDeep( rootState ); editedState.extensions.woocommerce.ui.promotions.edits = { [ 123 ]: { - creates: [ { id: 'coupon:4', type: 'empty4' } ], + creates: [ + { id: 'coupon:4', type: 'empty4' }, + ], currentlyEditingId: 'coupon:4', - }, + } }; const id = getCurrentlyEditingPromotionId( editedState, 123 ); @@ -134,13 +136,15 @@ describe( 'promotions', () => { expect( edits ).to.be.null; } ); - it( 'should return edits for a given string id', () => { + it( 'should return edits for a given id', () => { const editedState = cloneDeep( rootState ); editedState.extensions.woocommerce.ui.promotions.edits = { [ 123 ]: { - updates: [ { id: 'coupon:3', type: 'empty33' } ], + updates: [ + { id: 'coupon:3', type: 'empty33' }, + ], currentlyEditingId: 'coupon:3', - }, + } }; const edits = getPromotionEdits( editedState, 'coupon:3', 123 ); @@ -149,23 +153,6 @@ describe( 'promotions', () => { expect( edits.id ).to.equal( 'coupon:3' ); expect( edits.type ).to.equal( 'empty33' ); } ); - - it( 'should return edits for a given object placeholder id', () => { - const editedState = cloneDeep( rootState ); - const placeholderId = { placeholder: 'promotion_5' }; - editedState.extensions.woocommerce.ui.promotions.edits = { - [ 123 ]: { - creates: [ { id: placeholderId, type: 'empty33' } ], - currentlyEditingId: placeholderId, - }, - }; - - const edits = getPromotionEdits( editedState, placeholderId, 123 ); - - expect( edits ).to.exist; - expect( edits.id ).to.equal( placeholderId ); - expect( edits.type ).to.equal( 'empty33' ); - } ); } ); describe( '#getPromotionWithLocalEdits', () => { @@ -187,9 +174,11 @@ describe( 'promotions', () => { const editedState = cloneDeep( rootState ); editedState.extensions.woocommerce.ui.promotions.edits = { [ 123 ]: { - updates: [ { id: 'coupon:3', type: 'empty33' } ], + updates: [ + { id: 'coupon:3', type: 'empty33' }, + ], currentlyEditingId: 'coupon:3', - }, + } }; const editedPromotion = getPromotionWithLocalEdits( editedState, 'coupon:3', 123 ); From 58819251377be9ba1c20b0e1c29369f0af773ac2 Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Tue, 3 Oct 2017 20:47:53 -0500 Subject: [PATCH 115/192] Store Promotions: edit state selector adjustments This adjusts mostly the test code for the edit state selectors. --- .../extensions/woocommerce/state/selectors/test/promotions.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/extensions/woocommerce/state/selectors/test/promotions.js b/client/extensions/woocommerce/state/selectors/test/promotions.js index c8567b8cfe69b..5ae8254668c8f 100644 --- a/client/extensions/woocommerce/state/selectors/test/promotions.js +++ b/client/extensions/woocommerce/state/selectors/test/promotions.js @@ -136,7 +136,7 @@ describe( 'promotions', () => { expect( edits ).to.be.null; } ); - it( 'should return edits for a given id', () => { + it( 'should return edits for a given string id', () => { const editedState = cloneDeep( rootState ); editedState.extensions.woocommerce.ui.promotions.edits = { [ 123 ]: { From 8e69be05107e24b4e76082865dbdf1e2d32a4f6d Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Sat, 30 Sep 2017 12:57:38 -0500 Subject: [PATCH 116/192] Store Promotions: Add selectors for edit states This adds selectors to get promotion edits, edits overlaid on top of the existing promotions, and the currently editing promotion. --- .../state/selectors/test/promotions.js | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/client/extensions/woocommerce/state/selectors/test/promotions.js b/client/extensions/woocommerce/state/selectors/test/promotions.js index 5ae8254668c8f..51cd5ac0b1368 100644 --- a/client/extensions/woocommerce/state/selectors/test/promotions.js +++ b/client/extensions/woocommerce/state/selectors/test/promotions.js @@ -188,4 +188,86 @@ describe( 'promotions', () => { expect( editedPromotion.type ).to.equal( 'empty33' ); } ); } ); + + describe( '#getCurrentlyEditingPromotionId', () => { + it( 'should return null if nothing is being edited', () => { + const id = getCurrentlyEditingPromotionId( rootState, 123 ); + expect( id ).to.be.null; + } ); + + it( 'should return the id of the last edited promotion', () => { + const editedState = cloneDeep( rootState ); + editedState.extensions.woocommerce.ui.promotions.edits = { + [ 123 ]: { + creates: [ + { id: 'id4', type: 'empty4' }, + ], + currentlyEditingId: 'id4', + } + }; + + const id = getCurrentlyEditingPromotionId( editedState, 123 ); + expect( id ).to.equal( 'id4' ); + } ); + } ); + + describe( '#getPromotionWithLocalEdits', () => { + it( 'should return null if no edits are found for a given id', () => { + const edits = getPromotionEdits( rootState, 'notthere', 123 ); + + expect( edits ).to.be.null; + } ); + + it( 'should return edits for a given id', () => { + const editedState = cloneDeep( rootState ); + editedState.extensions.woocommerce.ui.promotions.edits = { + [ 123 ]: { + updates: [ + { id: 'coupon:3', type: 'empty33' }, + ], + currentlyEditingId: 'coupon:3', + } + }; + + const edits = getPromotionEdits( editedState, 'coupon:3', 123 ); + + expect( edits ).to.exist; + expect( edits.id ).to.equal( 'coupon:3' ); + expect( edits.type ).to.equal( 'empty33' ); + } ); + } ); + + describe( '#getPromotionWithLocalEdits', () => { + it( 'should return null if a promotion is not found by the id provided', () => { + const editedPromotion = getPromotionWithLocalEdits( rootState, 'notthere', 123 ); + + expect( editedPromotion ).to.be.null; + } ); + + it( 'should return an unedited promotion as-is', () => { + const editedPromotion = getPromotionWithLocalEdits( rootState, 'coupon:3', 123 ); + + expect( editedPromotion ).to.exist; + expect( editedPromotion.id ).to.equal( 'coupon:3' ); + expect( editedPromotion.type ).to.equal( 'empty3' ); + } ); + + it( 'should return a promotion with edits overlaid on it', () => { + const editedState = cloneDeep( rootState ); + editedState.extensions.woocommerce.ui.promotions.edits = { + [ 123 ]: { + updates: [ + { id: 'coupon:3', type: 'empty33' }, + ], + currentlyEditingId: 'coupon:3', + } + }; + + const editedPromotion = getPromotionWithLocalEdits( editedState, 'coupon:3', 123 ); + + expect( editedPromotion ).to.exist; + expect( editedPromotion.id ).to.equal( 'coupon:3' ); + expect( editedPromotion.type ).to.equal( 'empty33' ); + } ); + } ); } ); From 0ced249c4dc8247cb3c875553be688d5140b9344 Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Tue, 3 Oct 2017 22:08:31 -0500 Subject: [PATCH 117/192] Store Promotions: Clean up tests. This cleans up a few test specs for selectors that got clobbered in a merge. --- .../state/selectors/test/promotions.js | 82 ------------------- 1 file changed, 82 deletions(-) diff --git a/client/extensions/woocommerce/state/selectors/test/promotions.js b/client/extensions/woocommerce/state/selectors/test/promotions.js index 51cd5ac0b1368..5ae8254668c8f 100644 --- a/client/extensions/woocommerce/state/selectors/test/promotions.js +++ b/client/extensions/woocommerce/state/selectors/test/promotions.js @@ -188,86 +188,4 @@ describe( 'promotions', () => { expect( editedPromotion.type ).to.equal( 'empty33' ); } ); } ); - - describe( '#getCurrentlyEditingPromotionId', () => { - it( 'should return null if nothing is being edited', () => { - const id = getCurrentlyEditingPromotionId( rootState, 123 ); - expect( id ).to.be.null; - } ); - - it( 'should return the id of the last edited promotion', () => { - const editedState = cloneDeep( rootState ); - editedState.extensions.woocommerce.ui.promotions.edits = { - [ 123 ]: { - creates: [ - { id: 'id4', type: 'empty4' }, - ], - currentlyEditingId: 'id4', - } - }; - - const id = getCurrentlyEditingPromotionId( editedState, 123 ); - expect( id ).to.equal( 'id4' ); - } ); - } ); - - describe( '#getPromotionWithLocalEdits', () => { - it( 'should return null if no edits are found for a given id', () => { - const edits = getPromotionEdits( rootState, 'notthere', 123 ); - - expect( edits ).to.be.null; - } ); - - it( 'should return edits for a given id', () => { - const editedState = cloneDeep( rootState ); - editedState.extensions.woocommerce.ui.promotions.edits = { - [ 123 ]: { - updates: [ - { id: 'coupon:3', type: 'empty33' }, - ], - currentlyEditingId: 'coupon:3', - } - }; - - const edits = getPromotionEdits( editedState, 'coupon:3', 123 ); - - expect( edits ).to.exist; - expect( edits.id ).to.equal( 'coupon:3' ); - expect( edits.type ).to.equal( 'empty33' ); - } ); - } ); - - describe( '#getPromotionWithLocalEdits', () => { - it( 'should return null if a promotion is not found by the id provided', () => { - const editedPromotion = getPromotionWithLocalEdits( rootState, 'notthere', 123 ); - - expect( editedPromotion ).to.be.null; - } ); - - it( 'should return an unedited promotion as-is', () => { - const editedPromotion = getPromotionWithLocalEdits( rootState, 'coupon:3', 123 ); - - expect( editedPromotion ).to.exist; - expect( editedPromotion.id ).to.equal( 'coupon:3' ); - expect( editedPromotion.type ).to.equal( 'empty3' ); - } ); - - it( 'should return a promotion with edits overlaid on it', () => { - const editedState = cloneDeep( rootState ); - editedState.extensions.woocommerce.ui.promotions.edits = { - [ 123 ]: { - updates: [ - { id: 'coupon:3', type: 'empty33' }, - ], - currentlyEditingId: 'coupon:3', - } - }; - - const editedPromotion = getPromotionWithLocalEdits( editedState, 'coupon:3', 123 ); - - expect( editedPromotion ).to.exist; - expect( editedPromotion.id ).to.equal( 'coupon:3' ); - expect( editedPromotion.type ).to.equal( 'empty33' ); - } ); - } ); } ); From 5b0df8bfdeff85ce9c71fdf98d65ac4cc9a28f25 Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Sat, 30 Sep 2017 12:57:38 -0500 Subject: [PATCH 118/192] Store Promotions: Add selectors for edit states This adds selectors to get promotion edits, edits overlaid on top of the existing promotions, and the currently editing promotion. --- .../state/selectors/test/promotions.js | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/client/extensions/woocommerce/state/selectors/test/promotions.js b/client/extensions/woocommerce/state/selectors/test/promotions.js index 5ae8254668c8f..51cd5ac0b1368 100644 --- a/client/extensions/woocommerce/state/selectors/test/promotions.js +++ b/client/extensions/woocommerce/state/selectors/test/promotions.js @@ -188,4 +188,86 @@ describe( 'promotions', () => { expect( editedPromotion.type ).to.equal( 'empty33' ); } ); } ); + + describe( '#getCurrentlyEditingPromotionId', () => { + it( 'should return null if nothing is being edited', () => { + const id = getCurrentlyEditingPromotionId( rootState, 123 ); + expect( id ).to.be.null; + } ); + + it( 'should return the id of the last edited promotion', () => { + const editedState = cloneDeep( rootState ); + editedState.extensions.woocommerce.ui.promotions.edits = { + [ 123 ]: { + creates: [ + { id: 'id4', type: 'empty4' }, + ], + currentlyEditingId: 'id4', + } + }; + + const id = getCurrentlyEditingPromotionId( editedState, 123 ); + expect( id ).to.equal( 'id4' ); + } ); + } ); + + describe( '#getPromotionWithLocalEdits', () => { + it( 'should return null if no edits are found for a given id', () => { + const edits = getPromotionEdits( rootState, 'notthere', 123 ); + + expect( edits ).to.be.null; + } ); + + it( 'should return edits for a given id', () => { + const editedState = cloneDeep( rootState ); + editedState.extensions.woocommerce.ui.promotions.edits = { + [ 123 ]: { + updates: [ + { id: 'coupon:3', type: 'empty33' }, + ], + currentlyEditingId: 'coupon:3', + } + }; + + const edits = getPromotionEdits( editedState, 'coupon:3', 123 ); + + expect( edits ).to.exist; + expect( edits.id ).to.equal( 'coupon:3' ); + expect( edits.type ).to.equal( 'empty33' ); + } ); + } ); + + describe( '#getPromotionWithLocalEdits', () => { + it( 'should return null if a promotion is not found by the id provided', () => { + const editedPromotion = getPromotionWithLocalEdits( rootState, 'notthere', 123 ); + + expect( editedPromotion ).to.be.null; + } ); + + it( 'should return an unedited promotion as-is', () => { + const editedPromotion = getPromotionWithLocalEdits( rootState, 'coupon:3', 123 ); + + expect( editedPromotion ).to.exist; + expect( editedPromotion.id ).to.equal( 'coupon:3' ); + expect( editedPromotion.type ).to.equal( 'empty3' ); + } ); + + it( 'should return a promotion with edits overlaid on it', () => { + const editedState = cloneDeep( rootState ); + editedState.extensions.woocommerce.ui.promotions.edits = { + [ 123 ]: { + updates: [ + { id: 'coupon:3', type: 'empty33' }, + ], + currentlyEditingId: 'coupon:3', + } + }; + + const editedPromotion = getPromotionWithLocalEdits( editedState, 'coupon:3', 123 ); + + expect( editedPromotion ).to.exist; + expect( editedPromotion.id ).to.equal( 'coupon:3' ); + expect( editedPromotion.type ).to.equal( 'empty33' ); + } ); + } ); } ); From 523f5c375332c6c912a1e7f0d3dbcbc24af9cfa1 Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Tue, 3 Oct 2017 22:13:26 -0500 Subject: [PATCH 119/192] Store Promotions: Adjust test code This propagates the test fix up to another PR --- .../state/selectors/test/promotions.js | 82 ------------------- 1 file changed, 82 deletions(-) diff --git a/client/extensions/woocommerce/state/selectors/test/promotions.js b/client/extensions/woocommerce/state/selectors/test/promotions.js index 51cd5ac0b1368..5ae8254668c8f 100644 --- a/client/extensions/woocommerce/state/selectors/test/promotions.js +++ b/client/extensions/woocommerce/state/selectors/test/promotions.js @@ -188,86 +188,4 @@ describe( 'promotions', () => { expect( editedPromotion.type ).to.equal( 'empty33' ); } ); } ); - - describe( '#getCurrentlyEditingPromotionId', () => { - it( 'should return null if nothing is being edited', () => { - const id = getCurrentlyEditingPromotionId( rootState, 123 ); - expect( id ).to.be.null; - } ); - - it( 'should return the id of the last edited promotion', () => { - const editedState = cloneDeep( rootState ); - editedState.extensions.woocommerce.ui.promotions.edits = { - [ 123 ]: { - creates: [ - { id: 'id4', type: 'empty4' }, - ], - currentlyEditingId: 'id4', - } - }; - - const id = getCurrentlyEditingPromotionId( editedState, 123 ); - expect( id ).to.equal( 'id4' ); - } ); - } ); - - describe( '#getPromotionWithLocalEdits', () => { - it( 'should return null if no edits are found for a given id', () => { - const edits = getPromotionEdits( rootState, 'notthere', 123 ); - - expect( edits ).to.be.null; - } ); - - it( 'should return edits for a given id', () => { - const editedState = cloneDeep( rootState ); - editedState.extensions.woocommerce.ui.promotions.edits = { - [ 123 ]: { - updates: [ - { id: 'coupon:3', type: 'empty33' }, - ], - currentlyEditingId: 'coupon:3', - } - }; - - const edits = getPromotionEdits( editedState, 'coupon:3', 123 ); - - expect( edits ).to.exist; - expect( edits.id ).to.equal( 'coupon:3' ); - expect( edits.type ).to.equal( 'empty33' ); - } ); - } ); - - describe( '#getPromotionWithLocalEdits', () => { - it( 'should return null if a promotion is not found by the id provided', () => { - const editedPromotion = getPromotionWithLocalEdits( rootState, 'notthere', 123 ); - - expect( editedPromotion ).to.be.null; - } ); - - it( 'should return an unedited promotion as-is', () => { - const editedPromotion = getPromotionWithLocalEdits( rootState, 'coupon:3', 123 ); - - expect( editedPromotion ).to.exist; - expect( editedPromotion.id ).to.equal( 'coupon:3' ); - expect( editedPromotion.type ).to.equal( 'empty3' ); - } ); - - it( 'should return a promotion with edits overlaid on it', () => { - const editedState = cloneDeep( rootState ); - editedState.extensions.woocommerce.ui.promotions.edits = { - [ 123 ]: { - updates: [ - { id: 'coupon:3', type: 'empty33' }, - ], - currentlyEditingId: 'coupon:3', - } - }; - - const editedPromotion = getPromotionWithLocalEdits( editedState, 'coupon:3', 123 ); - - expect( editedPromotion ).to.exist; - expect( editedPromotion.id ).to.equal( 'coupon:3' ); - expect( editedPromotion.type ).to.equal( 'empty33' ); - } ); - } ); } ); From 72e515b65e349b6903d7d978a480d05c1541b77d Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Wed, 4 Oct 2017 06:18:32 -0500 Subject: [PATCH 120/192] Store Coupons: Add create/update/delete handlers This adds data-layer handler code to create/update/delete coupons. --- .../woocommerce/state/action-types.js | 3 + .../state/sites/coupons/actions.js | 35 ++++- .../state/sites/coupons/handlers.js | 65 ++++++++- .../state/sites/coupons/test/handlers.js | 132 +++++++++++++++++- 4 files changed, 228 insertions(+), 7 deletions(-) diff --git a/client/extensions/woocommerce/state/action-types.js b/client/extensions/woocommerce/state/action-types.js index 6db4ff830154c..13e292ec1a6b0 100644 --- a/client/extensions/woocommerce/state/action-types.js +++ b/client/extensions/woocommerce/state/action-types.js @@ -4,6 +4,9 @@ export const WOOCOMMERCE_ACTION_LIST_CLEAR = 'WOOCOMMERCE_ACTION_LIST_CLEAR'; export const WOOCOMMERCE_ACTION_LIST_STEP_NEXT = 'WOOCOMMERCE_ACTION_LIST_STEP_NEXT'; export const WOOCOMMERCE_ACTION_LIST_STEP_SUCCESS = 'WOOCOMMERCE_ACTION_LIST_STEP_SUCCESS'; export const WOOCOMMERCE_ACTION_LIST_STEP_FAILURE = 'WOOCOMMERCE_ACTION_LIST_STEP_FAILURE'; +export const WOOCOMMERCE_COUPON_CREATE = 'WOOCOMMERCE_COUPON_CREATE'; +export const WOOCOMMERCE_COUPON_DELETE = 'WOOCOMMERCE_COUPON_DELETE'; +export const WOOCOMMERCE_COUPON_UPDATE = 'WOOCOMMERCE_COUPON_UPDATE'; export const WOOCOMMERCE_COUPONS_REQUEST = 'WOOCOMMERCE_COUPONS_REQUEST'; export const WOOCOMMERCE_COUPONS_UPDATED = 'WOOCOMMERCE_COUPONS_UPDATED'; export const WOOCOMMERCE_CURRENCIES_REQUEST = 'WOOCOMMERCE_CURRENCIES_REQUEST'; diff --git a/client/extensions/woocommerce/state/sites/coupons/actions.js b/client/extensions/woocommerce/state/sites/coupons/actions.js index 0f7aba91435ab..62a79d496519a 100644 --- a/client/extensions/woocommerce/state/sites/coupons/actions.js +++ b/client/extensions/woocommerce/state/sites/coupons/actions.js @@ -3,8 +3,10 @@ * * @format */ - import { + WOOCOMMERCE_COUPON_CREATE, + WOOCOMMERCE_COUPON_DELETE, + WOOCOMMERCE_COUPON_UPDATE, WOOCOMMERCE_COUPONS_REQUEST, WOOCOMMERCE_COUPONS_UPDATED, } from 'woocommerce/state/action-types'; @@ -19,3 +21,34 @@ export function fetchCoupons( siteId, params ) { export function couponsUpdated( siteId, params, coupons, totalPages, totalCoupons ) { return { type: WOOCOMMERCE_COUPONS_UPDATED, siteId, params, coupons, totalPages, totalCoupons }; } + +export function createCoupon( siteId, coupon, successAction, failureAction ) { + return { + type: WOOCOMMERCE_COUPON_CREATE, + siteId, + coupon, + successAction, + failureAction, + }; +} + +export function updateCoupon( siteId, coupon, successAction, failureAction ) { + return { + type: WOOCOMMERCE_COUPON_UPDATE, + siteId, + coupon, + successAction, + failureAction, + }; +} + +export function deleteCoupon( siteId, couponId, successAction, failureAction ) { + return { + type: WOOCOMMERCE_COUPON_DELETE, + siteId, + couponId, + successAction, + failureAction, + }; +} + diff --git a/client/extensions/woocommerce/state/sites/coupons/handlers.js b/client/extensions/woocommerce/state/sites/coupons/handlers.js index 3f2cf5261232d..749e357ceb082 100644 --- a/client/extensions/woocommerce/state/sites/coupons/handlers.js +++ b/client/extensions/woocommerce/state/sites/coupons/handlers.js @@ -3,7 +3,6 @@ * * @format */ - import { trim } from 'lodash'; /** @@ -12,7 +11,12 @@ import { trim } from 'lodash'; import debugFactory from 'debug'; import { dispatchRequest } from 'state/data-layer/wpcom-http/utils'; import request from 'woocommerce/state/sites/http-request'; -import { WOOCOMMERCE_COUPONS_REQUEST } from 'woocommerce/state/action-types'; +import { + WOOCOMMERCE_COUPON_CREATE, + WOOCOMMERCE_COUPON_DELETE, + WOOCOMMERCE_COUPON_UPDATE, + WOOCOMMERCE_COUPONS_REQUEST, +} from 'woocommerce/state/action-types'; import { couponsUpdated } from './actions'; const debug = debugFactory( 'woocommerce:coupons' ); @@ -21,6 +25,15 @@ export default { [ WOOCOMMERCE_COUPONS_REQUEST ]: [ dispatchRequest( requestCoupons, requestCouponsSuccess, apiError ), ], + [ WOOCOMMERCE_COUPON_CREATE ]: [ + dispatchRequest( couponCreate, couponCreateSuccess, apiError ), + ], + [ WOOCOMMERCE_COUPON_UPDATE ]: [ + dispatchRequest( couponUpdate, couponUpdateSuccess, apiError ), + ], + [ WOOCOMMERCE_COUPON_DELETE ]: [ + dispatchRequest( couponDelete, couponDeleteSuccess, apiError ), + ] }; export function requestCoupons( { dispatch }, action ) { @@ -51,10 +64,54 @@ export function requestCouponsSuccess( { dispatch }, action, { data } ) { dispatch( couponsUpdated( siteId, params, body, totalPages, totalCoupons ) ); } +export function couponCreate( { dispatch }, action ) { + const { siteId, coupon } = action; + const path = 'coupons'; + + dispatch( request( siteId, action ).post( path, coupon ) ); +} + +export function couponCreateSuccess( { dispatch }, action ) { + // TODO: Update local state for this coupon. + if ( action.successAction ) { + dispatch( action.successAction ); + } +} + +export function couponUpdate( { dispatch }, action ) { + const { siteId, coupon } = action; + const path = `coupons/${ coupon.id }`; + + dispatch( request( siteId, action ).put( path, coupon ) ); +} + +export function couponUpdateSuccess( { dispatch }, action ) { + // TODO: Update local state for this coupon. + if ( action.successAction ) { + dispatch( action.successAction ); + } +} + +export function couponDelete( { dispatch }, action ) { + const { siteId, couponId } = action; + const path = `coupons/${ couponId }`; + + dispatch( request( siteId, action ).del( path ) ); +} + +export function couponDeleteSuccess( { dispatch }, action ) { + // TODO: Update local state for this coupon. + if ( action.successAction ) { + dispatch( action.successAction ); + } +} + function apiError( { dispatch }, action, error ) { - // Discard this request. - // TODO: Consider if we need an application error state here. debug( 'API Error: ', error ); + + if ( action.failureAction ) { + dispatch( action.failureAction ); + } } function isValidCouponsArray( coupons ) { diff --git a/client/extensions/woocommerce/state/sites/coupons/test/handlers.js b/client/extensions/woocommerce/state/sites/coupons/test/handlers.js index f4f7e75b2da8e..64fd98eb877b8 100644 --- a/client/extensions/woocommerce/state/sites/coupons/test/handlers.js +++ b/client/extensions/woocommerce/state/sites/coupons/test/handlers.js @@ -9,13 +9,31 @@ import { spy, match } from 'sinon'; /** * Internal dependencies */ -import { fetchCoupons, couponsUpdated } from '../actions'; -import { requestCoupons, requestCouponsSuccess } from '../handlers'; import { WPCOM_HTTP_REQUEST } from 'state/action-types'; +import { + fetchCoupons, + couponsUpdated, + createCoupon, + updateCoupon, + deleteCoupon, +} from '../actions'; +import { + requestCoupons, + requestCouponsSuccess, + couponCreate, + couponCreateSuccess, + couponUpdate, + couponUpdateSuccess, + couponDelete, + couponDeleteSuccess, +} from '../handlers'; describe( 'handlers', () => { const siteId = 123; + const successAction = { type: '%%SUCCESS%%' }; + const failureAction = { type: '%%FAILURE%%' }; + describe( '#requestCoupons', () => { test( 'should dispatch a request', () => { const store = { @@ -96,4 +114,114 @@ describe( 'handlers', () => { expect( store.dispatch ).to.not.have.been.called; } ); } ); + + describe( '#createCoupon', () => { + it( 'should dispatch a request', () => { + const store = { + dispatch: spy(), + }; + + const coupon = { id: { placeholder: 'coupon:5' }, code: '10off', amount: '10', discount_type: 'percent' }; + const action = createCoupon( siteId, coupon, successAction, failureAction ); + + couponCreate( store, action ); + + expect( store.dispatch ).to.have.been.calledWith( match( { + type: WPCOM_HTTP_REQUEST, + body: { + path: '/wc/v3/coupons&_method=POST', + body: JSON.stringify( coupon ), + } + } ) ); + } ); + } ); + + describe( '#couponCreateSuccess', () => { + it( 'should dispatch a success action', () => { + const store = { + dispatch: spy(), + }; + + const coupon = { id: { placeholder: 'coupon:5' }, code: '10off', amount: '10', discount_type: 'percent' }; + const action = createCoupon( siteId, coupon, successAction, failureAction ); + + couponCreateSuccess( store, action, { data: { ...coupon, id: 12 } } ); + expect( store.dispatch ).to.have.been.calledWith( match( { + type: successAction.type, + } ) ); + } ); + } ); + + describe( '#updateCoupon', () => { + it( 'should dispatch a request', () => { + const store = { + dispatch: spy(), + }; + + const coupon = { id: 5, code: '15off', amount: '15', discount_type: 'percent' }; + const action = updateCoupon( siteId, coupon, successAction, failureAction ); + + couponUpdate( store, action ); + + expect( store.dispatch ).to.have.been.calledWith( match( { + type: WPCOM_HTTP_REQUEST, + body: { + path: '/wc/v3/coupons/5&_method=PUT', + body: JSON.stringify( coupon ), + } + } ) ); + } ); + } ); + + describe( '#couponUpdateSuccess', () => { + it( 'should dispatch a success action', () => { + const store = { + dispatch: spy(), + }; + + const coupon = { id: 5, code: '15off', amount: '15', discount_type: 'percent' }; + const action = updateCoupon( siteId, coupon, successAction, failureAction ); + + couponUpdateSuccess( store, action, { data: { ...coupon, id: 12 } } ); + expect( store.dispatch ).to.have.been.calledWith( match( { + type: successAction.type, + } ) ); + } ); + } ); + + describe( '#deleteCoupon', () => { + it( 'should dispatch a request', () => { + const store = { + dispatch: spy(), + }; + + const couponId = 15; + const action = deleteCoupon( siteId, couponId, successAction, failureAction ); + + couponDelete( store, action ); + + expect( store.dispatch ).to.have.been.calledWith( match( { + type: WPCOM_HTTP_REQUEST, + body: { + path: '/wc/v3/coupons/15&_method=DELETE', + } + } ) ); + } ); + } ); + + describe( '#couponDeleteSuccess', () => { + it( 'should dispatch a success action', () => { + const store = { + dispatch: spy(), + }; + + const couponId = 15; + const action = deleteCoupon( siteId, couponId, successAction, failureAction ); + + couponDeleteSuccess( store, action ); + expect( store.dispatch ).to.have.been.calledWith( match( { + type: successAction.type, + } ) ); + } ); + } ); } ); From 35a40dbb997ffb4390570c9d478b29523a5dc90a Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Wed, 4 Oct 2017 06:52:24 -0500 Subject: [PATCH 121/192] Store Promotions: Create/Update/Delete handlers This adds support for creating/updating/deleting promotions via their respective coupon or product API calls. --- .../woocommerce/state/action-types.js | 3 + .../state/sites/promotions/actions.js | 39 +++- .../state/sites/promotions/handlers.js | 60 +++++- .../state/sites/promotions/test/handlers.js | 173 +++++++++++++++++- 4 files changed, 269 insertions(+), 6 deletions(-) diff --git a/client/extensions/woocommerce/state/action-types.js b/client/extensions/woocommerce/state/action-types.js index 13e292ec1a6b0..9949c74a97630 100644 --- a/client/extensions/woocommerce/state/action-types.js +++ b/client/extensions/woocommerce/state/action-types.js @@ -81,8 +81,11 @@ export const WOOCOMMERCE_PRODUCT_VARIATION_EDIT_CLEAR = 'WOOCOMMERCE_PRODUCT_VAR export const WOOCOMMERCE_PRODUCT_VARIATION_UPDATE = 'WOOCOMMERCE_PRODUCT_VARIATION_UPDATE'; export const WOOCOMMERCE_PRODUCT_VARIATION_UPDATED = 'WOOCOMMERCE_PRODUCT_VARIATION_UPDATED'; export const WOOCOMMERCE_PRODUCT_VARIATIONS_REQUEST = 'WOOCOMMERCE_PRODUCT_VARIATIONS_REQUEST'; +export const WOOCOMMERCE_PROMOTION_CREATE = 'WOOCOMMERCE_PROMOTION_CREATE'; +export const WOOCOMMERCE_PROMOTION_DELETE = 'WOOCOMMERCE_PROMOTION_DELETE'; export const WOOCOMMERCE_PROMOTION_EDIT = 'WOOCOMMERCE_PROMOTION_EDIT'; export const WOOCOMMERCE_PROMOTION_EDIT_CLEAR = 'WOOCOMMERCE_PROMOTION_EDIT_CLEAR'; +export const WOOCOMMERCE_PROMOTION_UPDATE = 'WOOCOMMERCE_PROMOTION_UPDATE'; export const WOOCOMMERCE_PROMOTIONS_PAGE_SET = 'WOOCOMMERCE_PROMOTIONS_PAGE_SET'; export const WOOCOMMERCE_PROMOTIONS_REQUEST = 'WOOCOMMERCE_PROMOTIONS_REQUEST'; export const WOOCOMMERCE_PROMOTIONS_UPDATE = 'WOOCOMMERCE_PROMOTIONS_UPDATE'; diff --git a/client/extensions/woocommerce/state/sites/promotions/actions.js b/client/extensions/woocommerce/state/sites/promotions/actions.js index af622409529f5..7eec8e2ba44f9 100644 --- a/client/extensions/woocommerce/state/sites/promotions/actions.js +++ b/client/extensions/woocommerce/state/sites/promotions/actions.js @@ -3,8 +3,12 @@ * * @format */ - -import { WOOCOMMERCE_PROMOTIONS_REQUEST } from 'woocommerce/state/action-types'; +import { + WOOCOMMERCE_PROMOTION_CREATE, + WOOCOMMERCE_PROMOTION_DELETE, + WOOCOMMERCE_PROMOTION_UPDATE, + WOOCOMMERCE_PROMOTIONS_REQUEST, +} from 'woocommerce/state/action-types'; export function fetchPromotions( siteId, perPage = undefined ) { return { @@ -13,3 +17,34 @@ export function fetchPromotions( siteId, perPage = undefined ) { perPage, }; } + +export function createPromotion( siteId, promotion, successAction, failureAction ) { + return { + type: WOOCOMMERCE_PROMOTION_CREATE, + siteId, + promotion, + successAction, + failureAction, + }; +} + +export function updatePromotion( siteId, promotion, successAction, failureAction ) { + return { + type: WOOCOMMERCE_PROMOTION_UPDATE, + siteId, + promotion, + successAction, + failureAction, + }; +} + +export function deletePromotion( siteId, promotion, successAction, failureAction ) { + return { + type: WOOCOMMERCE_PROMOTION_DELETE, + siteId, + promotion, + successAction, + failureAction, + }; +} + diff --git a/client/extensions/woocommerce/state/sites/promotions/handlers.js b/client/extensions/woocommerce/state/sites/promotions/handlers.js index 09f78b6a5df08..b5d5917d5e117 100644 --- a/client/extensions/woocommerce/state/sites/promotions/handlers.js +++ b/client/extensions/woocommerce/state/sites/promotions/handlers.js @@ -9,9 +9,20 @@ import debugFactory from 'debug'; /** * Internal dependencies */ -import { fetchCoupons } from 'woocommerce/state/sites/coupons/actions'; +import { + fetchCoupons, + createCoupon, + updateCoupon, + deleteCoupon, +} from 'woocommerce/state/sites/coupons/actions'; +import { + updateProduct, +} from 'woocommerce/state/sites/products/actions'; import { fetchProducts } from 'woocommerce/state/sites/products/actions'; import { + WOOCOMMERCE_PROMOTION_CREATE, + WOOCOMMERCE_PROMOTION_UPDATE, + WOOCOMMERCE_PROMOTION_DELETE, WOOCOMMERCE_PROMOTIONS_REQUEST, WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, WOOCOMMERCE_COUPONS_UPDATED, @@ -24,6 +35,9 @@ const debug = debugFactory( 'woocommerce:promotions' ); const itemsPerPage = 30; export default { + [ WOOCOMMERCE_PROMOTION_CREATE ]: [ promotionCreate ], + [ WOOCOMMERCE_PROMOTION_UPDATE ]: [ promotionUpdate ], + [ WOOCOMMERCE_PROMOTION_DELETE ]: [ promotionDelete ], [ WOOCOMMERCE_PROMOTIONS_REQUEST ]: [ promotionsRequest ], [ WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS ]: [ productsRequestSuccess ], [ WOOCOMMERCE_COUPONS_UPDATED ]: [ couponsUpdated ], @@ -85,3 +99,47 @@ export function couponsUpdated( { dispatch }, action ) { } } } + +export function promotionCreate( { dispatch }, action ) { + const { siteId, promotion } = action; + + if ( 'coupon' === promotion.type && promotion.coupon ) { + dispatch( createCoupon( siteId, promotion.coupon, action.successAction, action.failureAction ) ); + } + if ( 'product_sale' === promotion.type && promotion.product ) { + dispatch( updateProduct( siteId, promotion.product, action.successAction, action.failureAction ) ); + } +} + +export function promotionUpdate( { dispatch }, action ) { + const { siteId, promotion } = action; + + if ( 'coupon' === promotion.type && promotion.coupon ) { + dispatch( updateCoupon( siteId, promotion.coupon, action.successAction, action.failureAction ) ); + } + if ( 'product_sale' === promotion.type && promotion.product ) { + dispatch( updateProduct( siteId, promotion.product, action.successAction, action.failureAction ) ); + } +} + +export function promotionDelete( { dispatch }, action ) { + const { siteId, promotion } = action; + + if ( 'coupon' === promotion.type && promotion.coupon ) { + dispatch( deleteCoupon( siteId, promotion.coupon.id, action.successAction, action.failureAction ) ); + } + if ( 'product_sale' === promotion.type && promotion.product ) { + // Remove all sale-related fields from the product. + const { + sale_price, + date_on_sale_from, + date_on_sale_from_gmt, + date_on_sale_to, + date_on_sale_to_gmt, + ...productDeletedSale + } = promotion.product; // eslint-disable-line no-unused-vars + + dispatch( updateProduct( siteId, productDeletedSale, action.successAction, action.failureAction ) ); + } +} + diff --git a/client/extensions/woocommerce/state/sites/promotions/test/handlers.js b/client/extensions/woocommerce/state/sites/promotions/test/handlers.js index e586e64201b72..9a83d27e24e49 100644 --- a/client/extensions/woocommerce/state/sites/promotions/test/handlers.js +++ b/client/extensions/woocommerce/state/sites/promotions/test/handlers.js @@ -9,9 +9,26 @@ import { spy, match } from 'sinon'; /** * Internal dependencies */ -import { fetchPromotions } from '../actions'; -import { promotionsRequest, productsRequestSuccess, couponsUpdated } from '../handlers'; -import { coupons1, coupons2, products1, products2 } from './fixtures/promotions'; +import { + coupons1, + coupons2, + products1, + products2, +} from './fixtures/promotions'; +import { + fetchPromotions, + createPromotion, + updatePromotion, + deletePromotion, +} from '../actions'; +import { + promotionsRequest, + productsRequestSuccess, + couponsUpdated, + promotionCreate, + promotionUpdate, + promotionDelete, +} from '../handlers'; import { WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, WOOCOMMERCE_COUPONS_REQUEST, @@ -21,6 +38,9 @@ import { describe( 'handlers', () => { const siteId = 123; + const successAction = { type: '%%SUCCESS%%' }; + const failureAction = { type: '%%FAILURE%%' }; + describe( '#promotionsRequest', () => { test( 'should dispatch the first requests for products and coupons', () => { const store = { @@ -123,4 +143,151 @@ describe( 'handlers', () => { expect( store.dispatch ).to.not.have.been.called; } ); } ); + + describe( '#promotionCreate', () => { + it( 'should dispatch a create coupon action', () => { + const store = { + dispatch: spy(), + }; + + const promotion = { + id: 'coupon:12', + type: 'coupon', + coupon: { + code: '10off', + discount_type: 'percent', + amount: '10' + } + }; + const action = createPromotion( siteId, promotion, successAction, failureAction ); + + promotionCreate( store, action ); + + expect( store.dispatch ).to.have.been.calledWith( match( { + type: 'WOOCOMMERCE_COUPON_CREATE', + coupon: promotion.coupon, + } ) ); + } ); + + it( 'should dispatch an update product action', () => { + const store = { + dispatch: spy(), + }; + + const promotion = { + id: 'product:12', + type: 'product_sale', + product: { + id: 12, + sale_price: '9.99', + } + }; + const action = createPromotion( siteId, promotion, successAction, failureAction ); + + promotionCreate( store, action ); + + expect( store.dispatch ).to.have.been.calledWith( match( { + type: 'WOOCOMMERCE_PRODUCT_UPDATE', + product: promotion.product, + } ) ); + } ); + } ); + + describe( '#promotionUpdate', () => { + it( 'should dispatch an update coupon action', () => { + const store = { + dispatch: spy(), + }; + + const promotion = { + id: 14, + type: 'coupon', + coupon: { + code: '10off', + discount_type: 'percent', + amount: '10' + } + }; + const action = updatePromotion( siteId, promotion, successAction, failureAction ); + + promotionUpdate( store, action ); + + expect( store.dispatch ).to.have.been.calledWith( match( { + type: 'WOOCOMMERCE_COUPON_UPDATE', + coupon: promotion.coupon, + } ) ); + } ); + + it( 'should dispatch an update product action', () => { + const store = { + dispatch: spy(), + }; + + const promotion = { + id: 'product:12', + type: 'product_sale', + product: { + id: 12, + sale_price: '9.99', + } + }; + const action = updatePromotion( siteId, promotion, successAction, failureAction ); + + promotionUpdate( store, action ); + + expect( store.dispatch ).to.have.been.calledWith( match( { + type: 'WOOCOMMERCE_PRODUCT_UPDATE', + product: promotion.product, + } ) ); + } ); + } ); + + describe( '#promotionDelete', () => { + it( 'should dispatch a delete coupon action', () => { + const store = { + dispatch: spy(), + }; + + const promotion = { + id: 14, + type: 'coupon', + coupon: { + code: '10off', + discount_type: 'percent', + amount: '10' + } + }; + const action = deletePromotion( siteId, promotion, successAction, failureAction ); + + promotionDelete( store, action ); + + expect( store.dispatch ).to.have.been.calledWith( match( { + type: 'WOOCOMMERCE_COUPON_DELETE', + couponId: promotion.coupon.id, + } ) ); + } ); + + it( 'should dispatch an update product action', () => { + const store = { + dispatch: spy(), + }; + + const promotion = { + id: 'product:12', + type: 'product_sale', + product: { + id: 12, + sale_price: '10', + } + }; + const action = deletePromotion( siteId, promotion, successAction, failureAction ); + + promotionDelete( store, action ); + + expect( store.dispatch ).to.have.been.calledWith( match( { + type: 'WOOCOMMERCE_PRODUCT_UPDATE', + product: match( { id: 12, sale_price: undefined } ), + } ) ); + } ); + } ); } ); From 3246645da54d3e02e33299e44f96c4f8ae87a7cb Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Mon, 30 Oct 2017 16:13:59 -0500 Subject: [PATCH 122/192] Promotions: Update CRUD handlers This updates the handlers for the refactored synthetic promotion object with the helper functions to export coupon and product updates. --- .../state/sites/promotions/README.md | 9 + .../state/sites/promotions/actions.js | 2 - .../state/sites/promotions/handlers.js | 77 +++++--- .../state/sites/promotions/test/handlers.js | 176 +++++++++++------- 4 files changed, 166 insertions(+), 98 deletions(-) diff --git a/client/extensions/woocommerce/state/sites/promotions/README.md b/client/extensions/woocommerce/state/sites/promotions/README.md index d1597f0486516..3594de209fc35 100644 --- a/client/extensions/woocommerce/state/sites/promotions/README.md +++ b/client/extensions/woocommerce/state/sites/promotions/README.md @@ -45,6 +45,7 @@ As Promotions only exist in memory state on the client at this point, the defini The `appliesTo` object for a promotion is a complex object which describes what all the promotion can be applied to. At this point, exluded products or categories are not supported. #### Example: all products. + ```js { appliesTo: { @@ -102,10 +103,18 @@ There are several helper functions to handle the complexity of promotion objects Creates a promotion object from a product which is on sale. +### `createProductUpdateFromPromotion( promotion: object )` + +Creates an object containing product update data fields derived from a promotion. + ### `createPromotionFromCoupon( coupon: object )` Creates a promotion object from a coupon. +### `createCouponUpdateFromPromotion( promotion: object )` + +Creates an object containing coupon update data fields derived from a promotion. + ### `isCategoryExplicitlySelected( promotion: object, category: object )` Tests if a category is specified in the `appliesTo` property of a promotion. diff --git a/client/extensions/woocommerce/state/sites/promotions/actions.js b/client/extensions/woocommerce/state/sites/promotions/actions.js index 7eec8e2ba44f9..106388e5c2b3b 100644 --- a/client/extensions/woocommerce/state/sites/promotions/actions.js +++ b/client/extensions/woocommerce/state/sites/promotions/actions.js @@ -1,7 +1,5 @@ /** * Internal dependencies - * - * @format */ import { WOOCOMMERCE_PROMOTION_CREATE, diff --git a/client/extensions/woocommerce/state/sites/promotions/handlers.js b/client/extensions/woocommerce/state/sites/promotions/handlers.js index b5d5917d5e117..906c42fad1254 100644 --- a/client/extensions/woocommerce/state/sites/promotions/handlers.js +++ b/client/extensions/woocommerce/state/sites/promotions/handlers.js @@ -9,15 +9,14 @@ import debugFactory from 'debug'; /** * Internal dependencies */ +import { createProductUpdateFromPromotion, createCouponUpdateFromPromotion } from './helpers'; import { fetchCoupons, createCoupon, updateCoupon, deleteCoupon, } from 'woocommerce/state/sites/coupons/actions'; -import { - updateProduct, -} from 'woocommerce/state/sites/products/actions'; +import { updateProduct } from 'woocommerce/state/sites/products/actions'; import { fetchProducts } from 'woocommerce/state/sites/products/actions'; import { WOOCOMMERCE_PROMOTION_CREATE, @@ -103,43 +102,63 @@ export function couponsUpdated( { dispatch }, action ) { export function promotionCreate( { dispatch }, action ) { const { siteId, promotion } = action; - if ( 'coupon' === promotion.type && promotion.coupon ) { - dispatch( createCoupon( siteId, promotion.coupon, action.successAction, action.failureAction ) ); - } - if ( 'product_sale' === promotion.type && promotion.product ) { - dispatch( updateProduct( siteId, promotion.product, action.successAction, action.failureAction ) ); + switch ( promotion.type ) { + case 'product_sale': + const product = createProductUpdateFromPromotion( promotion ); + dispatch( updateProduct( siteId, product, action.successAction, action.failureAction ) ); + break; + case 'fixed_cart': + case 'fixed_product': + case 'percent': + const coupon = createCouponUpdateFromPromotion( promotion ); + dispatch( createCoupon( siteId, coupon, action.successAction, action.failureAction ) ); + break; } } export function promotionUpdate( { dispatch }, action ) { const { siteId, promotion } = action; - if ( 'coupon' === promotion.type && promotion.coupon ) { - dispatch( updateCoupon( siteId, promotion.coupon, action.successAction, action.failureAction ) ); - } - if ( 'product_sale' === promotion.type && promotion.product ) { - dispatch( updateProduct( siteId, promotion.product, action.successAction, action.failureAction ) ); + switch ( promotion.type ) { + case 'product_sale': + const product = createProductUpdateFromPromotion( promotion ); + dispatch( updateProduct( siteId, product, action.successAction, action.failureAction ) ); + break; + case 'fixed_cart': + case 'fixed_product': + case 'percent': + const coupon = createCouponUpdateFromPromotion( promotion ); + dispatch( updateCoupon( siteId, coupon, action.successAction, action.failureAction ) ); + break; } } export function promotionDelete( { dispatch }, action ) { const { siteId, promotion } = action; - if ( 'coupon' === promotion.type && promotion.coupon ) { - dispatch( deleteCoupon( siteId, promotion.coupon.id, action.successAction, action.failureAction ) ); - } - if ( 'product_sale' === promotion.type && promotion.product ) { - // Remove all sale-related fields from the product. - const { - sale_price, - date_on_sale_from, - date_on_sale_from_gmt, - date_on_sale_to, - date_on_sale_to_gmt, - ...productDeletedSale - } = promotion.product; // eslint-disable-line no-unused-vars - - dispatch( updateProduct( siteId, productDeletedSale, action.successAction, action.failureAction ) ); + switch ( promotion.type ) { + case 'product_sale': + const product = createProductUpdateFromPromotion( promotion ); + + const productUpdateData = { + id: product.id, + sale_price: null, + date_on_sale_from: null, + date_on_sale_from_gmt: null, + date_on_sale_to: null, + date_on_sale_to_gmt: null, + }; + + dispatch( + updateProduct( siteId, productUpdateData, action.successAction, action.failureAction ) + ); + break; + case 'fixed_cart': + case 'fixed_product': + case 'percent': + dispatch( + deleteCoupon( siteId, promotion.couponId, action.successAction, action.failureAction ) + ); + break; } } - diff --git a/client/extensions/woocommerce/state/sites/promotions/test/handlers.js b/client/extensions/woocommerce/state/sites/promotions/test/handlers.js index 9a83d27e24e49..abecf2762c736 100644 --- a/client/extensions/woocommerce/state/sites/promotions/test/handlers.js +++ b/client/extensions/woocommerce/state/sites/promotions/test/handlers.js @@ -9,18 +9,8 @@ import { spy, match } from 'sinon'; /** * Internal dependencies */ -import { - coupons1, - coupons2, - products1, - products2, -} from './fixtures/promotions'; -import { - fetchPromotions, - createPromotion, - updatePromotion, - deletePromotion, -} from '../actions'; +import { coupons1, coupons2, products1, products2 } from './fixtures/promotions'; +import { fetchPromotions, createPromotion, updatePromotion, deletePromotion } from '../actions'; import { promotionsRequest, productsRequestSuccess, @@ -152,21 +142,31 @@ describe( 'handlers', () => { const promotion = { id: 'coupon:12', - type: 'coupon', - coupon: { - code: '10off', - discount_type: 'percent', - amount: '10' - } + type: 'percent', + appliesTo: { productIds: [ 1, 2 ] }, + couponCode: '10off', + percentDiscount: '10', + endDate: '2017-12-15T12:15:02', + }; + + const expectedCouponData = { + code: '10off', + discount_type: 'percent', + amount: '10', + product_ids: [ 1, 2 ], + date_expires_gmt: '2017-12-15T12:15:02', }; + const action = createPromotion( siteId, promotion, successAction, failureAction ); promotionCreate( store, action ); - expect( store.dispatch ).to.have.been.calledWith( match( { - type: 'WOOCOMMERCE_COUPON_CREATE', - coupon: promotion.coupon, - } ) ); + expect( store.dispatch ).to.have.been.calledWith( + match( { + type: 'WOOCOMMERCE_COUPON_CREATE', + coupon: expectedCouponData, + } ) + ); } ); it( 'should dispatch an update product action', () => { @@ -177,19 +177,29 @@ describe( 'handlers', () => { const promotion = { id: 'product:12', type: 'product_sale', - product: { - id: 12, - sale_price: '9.99', - } + appliesTo: { productIds: [ 12 ] }, + salePrice: '9.99', + startDate: '2017-10-15T12:15:02', + endDate: '2017-11-15T12:15:02', + }; + + const expectedProductData = { + id: 12, + sale_price: '9.99', + date_on_sale_from_gmt: '2017-10-15T12:15:02', + date_on_sale_to_gmt: '2017-11-15T12:15:02', }; + const action = createPromotion( siteId, promotion, successAction, failureAction ); promotionCreate( store, action ); - expect( store.dispatch ).to.have.been.calledWith( match( { - type: 'WOOCOMMERCE_PRODUCT_UPDATE', - product: promotion.product, - } ) ); + expect( store.dispatch ).to.have.been.calledWith( + match( { + type: 'WOOCOMMERCE_PRODUCT_UPDATE', + product: expectedProductData, + } ) + ); } ); } ); @@ -200,22 +210,35 @@ describe( 'handlers', () => { }; const promotion = { - id: 14, - type: 'coupon', - coupon: { - code: '10off', - discount_type: 'percent', - amount: '10' - } + id: 'coupon:23', + type: 'percent', + couponCode: '10off', + percentDiscount: '10', + appliesTo: { all: true }, + usageLimit: '25', + individualUse: true, + couponId: 27, }; + + const expectedCouponData = { + id: 27, + code: '10off', + discount_type: 'percent', + amount: '10', + individual_use: true, + usage_limit: '25', + }; + const action = updatePromotion( siteId, promotion, successAction, failureAction ); promotionUpdate( store, action ); - expect( store.dispatch ).to.have.been.calledWith( match( { - type: 'WOOCOMMERCE_COUPON_UPDATE', - coupon: promotion.coupon, - } ) ); + expect( store.dispatch ).to.have.been.calledWith( + match( { + type: 'WOOCOMMERCE_COUPON_UPDATE', + coupon: expectedCouponData, + } ) + ); } ); it( 'should dispatch an update product action', () => { @@ -226,19 +249,25 @@ describe( 'handlers', () => { const promotion = { id: 'product:12', type: 'product_sale', - product: { - id: 12, - sale_price: '9.99', - } + salePrice: '9.99', + appliesTo: { productIds: [ 12 ] }, + }; + + const expectedProductData = { + id: 12, + sale_price: '9.99', }; + const action = updatePromotion( siteId, promotion, successAction, failureAction ); promotionUpdate( store, action ); - expect( store.dispatch ).to.have.been.calledWith( match( { - type: 'WOOCOMMERCE_PRODUCT_UPDATE', - product: promotion.product, - } ) ); + expect( store.dispatch ).to.have.been.calledWith( + match( { + type: 'WOOCOMMERCE_PRODUCT_UPDATE', + product: expectedProductData, + } ) + ); } ); } ); @@ -250,21 +279,23 @@ describe( 'handlers', () => { const promotion = { id: 14, - type: 'coupon', - coupon: { - code: '10off', - discount_type: 'percent', - amount: '10' - } + type: 'percent', + couponCode: '10off', + percentDiscount: '10', + appliesTo: { all: true }, + couponId: 25, }; + const action = deletePromotion( siteId, promotion, successAction, failureAction ); promotionDelete( store, action ); - expect( store.dispatch ).to.have.been.calledWith( match( { - type: 'WOOCOMMERCE_COUPON_DELETE', - couponId: promotion.coupon.id, - } ) ); + expect( store.dispatch ).to.have.been.calledWith( + match( { + type: 'WOOCOMMERCE_COUPON_DELETE', + couponId: 25, + } ) + ); } ); it( 'should dispatch an update product action', () => { @@ -275,19 +306,30 @@ describe( 'handlers', () => { const promotion = { id: 'product:12', type: 'product_sale', - product: { - id: 12, - sale_price: '10', - } + appliesTo: { productIds: [ 12 ] }, + salePrice: '10', + endDate: '2017-12-01T05:25:00', + }; + + const expectedProductData = { + id: 12, + date_on_sale_from: null, + date_on_sale_from_gmt: null, + date_on_sale_to: null, + date_on_sale_to_gmt: null, + sale_price: null, }; + const action = deletePromotion( siteId, promotion, successAction, failureAction ); promotionDelete( store, action ); - expect( store.dispatch ).to.have.been.calledWith( match( { - type: 'WOOCOMMERCE_PRODUCT_UPDATE', - product: match( { id: 12, sale_price: undefined } ), - } ) ); + expect( store.dispatch ).to.have.been.calledWith( + match( { + type: 'WOOCOMMERCE_PRODUCT_UPDATE', + product: match( expectedProductData ), + } ) + ); } ); } ); } ); From 17fa0898df45a2ca67f842ee822a89cf941e9f20 Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Mon, 30 Oct 2017 20:55:47 -0500 Subject: [PATCH 123/192] Promotions: Update README for promotions CRUD This adds documentation for the create, update and delete actions. --- .../woocommerce/state/sites/promotions/README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/client/extensions/woocommerce/state/sites/promotions/README.md b/client/extensions/woocommerce/state/sites/promotions/README.md index 3594de209fc35..3d3f235c5c633 100644 --- a/client/extensions/woocommerce/state/sites/promotions/README.md +++ b/client/extensions/woocommerce/state/sites/promotions/README.md @@ -80,6 +80,18 @@ The `appliesTo` object for a promotion is a complex object which describes what In order to compile the sorted list of promotions, it's necessary to fetch all products and coupons because they cannot be fetched and paginated by end date. In the future, when API support is better, it may be possible to paginate this list. +### `createPromotion( siteId: number, promotion: object, successAction: function, failureAction: function ) + +This creates a promotion, which actually translates to either a coupon create or a product update, depending on the type of promotion. + +### `updatePromotion( siteId: number, promotion: object, successAction: function, failureAction: function ) + +This updates a promotion, which actually translates to either a coupon update or a product update, depending on the type of promotion. + +### `deletePromotion( siteId: number, promotion: object ) + +This deletes a promotion, which actually translates to either a coupon delete or a product update, depending on the type of promotion. + ## Reducer From 5ed9eae320be50022c37f06dd58556dcd20c04df Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Sat, 30 Sep 2017 12:57:38 -0500 Subject: [PATCH 124/192] Store Promotions: Add selectors for edit states This adds selectors to get promotion edits, edits overlaid on top of the existing promotions, and the currently editing promotion. --- .../state/selectors/test/promotions.js | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/client/extensions/woocommerce/state/selectors/test/promotions.js b/client/extensions/woocommerce/state/selectors/test/promotions.js index 5ae8254668c8f..51cd5ac0b1368 100644 --- a/client/extensions/woocommerce/state/selectors/test/promotions.js +++ b/client/extensions/woocommerce/state/selectors/test/promotions.js @@ -188,4 +188,86 @@ describe( 'promotions', () => { expect( editedPromotion.type ).to.equal( 'empty33' ); } ); } ); + + describe( '#getCurrentlyEditingPromotionId', () => { + it( 'should return null if nothing is being edited', () => { + const id = getCurrentlyEditingPromotionId( rootState, 123 ); + expect( id ).to.be.null; + } ); + + it( 'should return the id of the last edited promotion', () => { + const editedState = cloneDeep( rootState ); + editedState.extensions.woocommerce.ui.promotions.edits = { + [ 123 ]: { + creates: [ + { id: 'id4', type: 'empty4' }, + ], + currentlyEditingId: 'id4', + } + }; + + const id = getCurrentlyEditingPromotionId( editedState, 123 ); + expect( id ).to.equal( 'id4' ); + } ); + } ); + + describe( '#getPromotionWithLocalEdits', () => { + it( 'should return null if no edits are found for a given id', () => { + const edits = getPromotionEdits( rootState, 'notthere', 123 ); + + expect( edits ).to.be.null; + } ); + + it( 'should return edits for a given id', () => { + const editedState = cloneDeep( rootState ); + editedState.extensions.woocommerce.ui.promotions.edits = { + [ 123 ]: { + updates: [ + { id: 'coupon:3', type: 'empty33' }, + ], + currentlyEditingId: 'coupon:3', + } + }; + + const edits = getPromotionEdits( editedState, 'coupon:3', 123 ); + + expect( edits ).to.exist; + expect( edits.id ).to.equal( 'coupon:3' ); + expect( edits.type ).to.equal( 'empty33' ); + } ); + } ); + + describe( '#getPromotionWithLocalEdits', () => { + it( 'should return null if a promotion is not found by the id provided', () => { + const editedPromotion = getPromotionWithLocalEdits( rootState, 'notthere', 123 ); + + expect( editedPromotion ).to.be.null; + } ); + + it( 'should return an unedited promotion as-is', () => { + const editedPromotion = getPromotionWithLocalEdits( rootState, 'coupon:3', 123 ); + + expect( editedPromotion ).to.exist; + expect( editedPromotion.id ).to.equal( 'coupon:3' ); + expect( editedPromotion.type ).to.equal( 'empty3' ); + } ); + + it( 'should return a promotion with edits overlaid on it', () => { + const editedState = cloneDeep( rootState ); + editedState.extensions.woocommerce.ui.promotions.edits = { + [ 123 ]: { + updates: [ + { id: 'coupon:3', type: 'empty33' }, + ], + currentlyEditingId: 'coupon:3', + } + }; + + const editedPromotion = getPromotionWithLocalEdits( editedState, 'coupon:3', 123 ); + + expect( editedPromotion ).to.exist; + expect( editedPromotion.id ).to.equal( 'coupon:3' ); + expect( editedPromotion.type ).to.equal( 'empty33' ); + } ); + } ); } ); From 60945c961db6eb42caf75d339477fee68b4f6ea3 Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Tue, 3 Oct 2017 22:13:26 -0500 Subject: [PATCH 125/192] Store Promotions: Adjust test code This propagates the test fix up to another PR --- .../state/selectors/test/promotions.js | 82 ------------------- 1 file changed, 82 deletions(-) diff --git a/client/extensions/woocommerce/state/selectors/test/promotions.js b/client/extensions/woocommerce/state/selectors/test/promotions.js index 51cd5ac0b1368..5ae8254668c8f 100644 --- a/client/extensions/woocommerce/state/selectors/test/promotions.js +++ b/client/extensions/woocommerce/state/selectors/test/promotions.js @@ -188,86 +188,4 @@ describe( 'promotions', () => { expect( editedPromotion.type ).to.equal( 'empty33' ); } ); } ); - - describe( '#getCurrentlyEditingPromotionId', () => { - it( 'should return null if nothing is being edited', () => { - const id = getCurrentlyEditingPromotionId( rootState, 123 ); - expect( id ).to.be.null; - } ); - - it( 'should return the id of the last edited promotion', () => { - const editedState = cloneDeep( rootState ); - editedState.extensions.woocommerce.ui.promotions.edits = { - [ 123 ]: { - creates: [ - { id: 'id4', type: 'empty4' }, - ], - currentlyEditingId: 'id4', - } - }; - - const id = getCurrentlyEditingPromotionId( editedState, 123 ); - expect( id ).to.equal( 'id4' ); - } ); - } ); - - describe( '#getPromotionWithLocalEdits', () => { - it( 'should return null if no edits are found for a given id', () => { - const edits = getPromotionEdits( rootState, 'notthere', 123 ); - - expect( edits ).to.be.null; - } ); - - it( 'should return edits for a given id', () => { - const editedState = cloneDeep( rootState ); - editedState.extensions.woocommerce.ui.promotions.edits = { - [ 123 ]: { - updates: [ - { id: 'coupon:3', type: 'empty33' }, - ], - currentlyEditingId: 'coupon:3', - } - }; - - const edits = getPromotionEdits( editedState, 'coupon:3', 123 ); - - expect( edits ).to.exist; - expect( edits.id ).to.equal( 'coupon:3' ); - expect( edits.type ).to.equal( 'empty33' ); - } ); - } ); - - describe( '#getPromotionWithLocalEdits', () => { - it( 'should return null if a promotion is not found by the id provided', () => { - const editedPromotion = getPromotionWithLocalEdits( rootState, 'notthere', 123 ); - - expect( editedPromotion ).to.be.null; - } ); - - it( 'should return an unedited promotion as-is', () => { - const editedPromotion = getPromotionWithLocalEdits( rootState, 'coupon:3', 123 ); - - expect( editedPromotion ).to.exist; - expect( editedPromotion.id ).to.equal( 'coupon:3' ); - expect( editedPromotion.type ).to.equal( 'empty3' ); - } ); - - it( 'should return a promotion with edits overlaid on it', () => { - const editedState = cloneDeep( rootState ); - editedState.extensions.woocommerce.ui.promotions.edits = { - [ 123 ]: { - updates: [ - { id: 'coupon:3', type: 'empty33' }, - ], - currentlyEditingId: 'coupon:3', - } - }; - - const editedPromotion = getPromotionWithLocalEdits( editedState, 'coupon:3', 123 ); - - expect( editedPromotion ).to.exist; - expect( editedPromotion.id ).to.equal( 'coupon:3' ); - expect( editedPromotion.type ).to.equal( 'empty33' ); - } ); - } ); } ); From 8de9eff559c685750ed8dbaf166b5410959393ff Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Sat, 30 Sep 2017 12:57:38 -0500 Subject: [PATCH 126/192] Store Promotions: Add selectors for edit states This adds selectors to get promotion edits, edits overlaid on top of the existing promotions, and the currently editing promotion. --- .../state/selectors/test/promotions.js | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/client/extensions/woocommerce/state/selectors/test/promotions.js b/client/extensions/woocommerce/state/selectors/test/promotions.js index 5ae8254668c8f..51cd5ac0b1368 100644 --- a/client/extensions/woocommerce/state/selectors/test/promotions.js +++ b/client/extensions/woocommerce/state/selectors/test/promotions.js @@ -188,4 +188,86 @@ describe( 'promotions', () => { expect( editedPromotion.type ).to.equal( 'empty33' ); } ); } ); + + describe( '#getCurrentlyEditingPromotionId', () => { + it( 'should return null if nothing is being edited', () => { + const id = getCurrentlyEditingPromotionId( rootState, 123 ); + expect( id ).to.be.null; + } ); + + it( 'should return the id of the last edited promotion', () => { + const editedState = cloneDeep( rootState ); + editedState.extensions.woocommerce.ui.promotions.edits = { + [ 123 ]: { + creates: [ + { id: 'id4', type: 'empty4' }, + ], + currentlyEditingId: 'id4', + } + }; + + const id = getCurrentlyEditingPromotionId( editedState, 123 ); + expect( id ).to.equal( 'id4' ); + } ); + } ); + + describe( '#getPromotionWithLocalEdits', () => { + it( 'should return null if no edits are found for a given id', () => { + const edits = getPromotionEdits( rootState, 'notthere', 123 ); + + expect( edits ).to.be.null; + } ); + + it( 'should return edits for a given id', () => { + const editedState = cloneDeep( rootState ); + editedState.extensions.woocommerce.ui.promotions.edits = { + [ 123 ]: { + updates: [ + { id: 'coupon:3', type: 'empty33' }, + ], + currentlyEditingId: 'coupon:3', + } + }; + + const edits = getPromotionEdits( editedState, 'coupon:3', 123 ); + + expect( edits ).to.exist; + expect( edits.id ).to.equal( 'coupon:3' ); + expect( edits.type ).to.equal( 'empty33' ); + } ); + } ); + + describe( '#getPromotionWithLocalEdits', () => { + it( 'should return null if a promotion is not found by the id provided', () => { + const editedPromotion = getPromotionWithLocalEdits( rootState, 'notthere', 123 ); + + expect( editedPromotion ).to.be.null; + } ); + + it( 'should return an unedited promotion as-is', () => { + const editedPromotion = getPromotionWithLocalEdits( rootState, 'coupon:3', 123 ); + + expect( editedPromotion ).to.exist; + expect( editedPromotion.id ).to.equal( 'coupon:3' ); + expect( editedPromotion.type ).to.equal( 'empty3' ); + } ); + + it( 'should return a promotion with edits overlaid on it', () => { + const editedState = cloneDeep( rootState ); + editedState.extensions.woocommerce.ui.promotions.edits = { + [ 123 ]: { + updates: [ + { id: 'coupon:3', type: 'empty33' }, + ], + currentlyEditingId: 'coupon:3', + } + }; + + const editedPromotion = getPromotionWithLocalEdits( editedState, 'coupon:3', 123 ); + + expect( editedPromotion ).to.exist; + expect( editedPromotion.id ).to.equal( 'coupon:3' ); + expect( editedPromotion.type ).to.equal( 'empty33' ); + } ); + } ); } ); From a69d61a79f92425eb7f519d0667eb6f2a0bbcc02 Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Tue, 3 Oct 2017 22:13:26 -0500 Subject: [PATCH 127/192] Store Promotions: Adjust test code This propagates the test fix up to another PR --- .../state/selectors/test/promotions.js | 82 ------------------- 1 file changed, 82 deletions(-) diff --git a/client/extensions/woocommerce/state/selectors/test/promotions.js b/client/extensions/woocommerce/state/selectors/test/promotions.js index 51cd5ac0b1368..5ae8254668c8f 100644 --- a/client/extensions/woocommerce/state/selectors/test/promotions.js +++ b/client/extensions/woocommerce/state/selectors/test/promotions.js @@ -188,86 +188,4 @@ describe( 'promotions', () => { expect( editedPromotion.type ).to.equal( 'empty33' ); } ); } ); - - describe( '#getCurrentlyEditingPromotionId', () => { - it( 'should return null if nothing is being edited', () => { - const id = getCurrentlyEditingPromotionId( rootState, 123 ); - expect( id ).to.be.null; - } ); - - it( 'should return the id of the last edited promotion', () => { - const editedState = cloneDeep( rootState ); - editedState.extensions.woocommerce.ui.promotions.edits = { - [ 123 ]: { - creates: [ - { id: 'id4', type: 'empty4' }, - ], - currentlyEditingId: 'id4', - } - }; - - const id = getCurrentlyEditingPromotionId( editedState, 123 ); - expect( id ).to.equal( 'id4' ); - } ); - } ); - - describe( '#getPromotionWithLocalEdits', () => { - it( 'should return null if no edits are found for a given id', () => { - const edits = getPromotionEdits( rootState, 'notthere', 123 ); - - expect( edits ).to.be.null; - } ); - - it( 'should return edits for a given id', () => { - const editedState = cloneDeep( rootState ); - editedState.extensions.woocommerce.ui.promotions.edits = { - [ 123 ]: { - updates: [ - { id: 'coupon:3', type: 'empty33' }, - ], - currentlyEditingId: 'coupon:3', - } - }; - - const edits = getPromotionEdits( editedState, 'coupon:3', 123 ); - - expect( edits ).to.exist; - expect( edits.id ).to.equal( 'coupon:3' ); - expect( edits.type ).to.equal( 'empty33' ); - } ); - } ); - - describe( '#getPromotionWithLocalEdits', () => { - it( 'should return null if a promotion is not found by the id provided', () => { - const editedPromotion = getPromotionWithLocalEdits( rootState, 'notthere', 123 ); - - expect( editedPromotion ).to.be.null; - } ); - - it( 'should return an unedited promotion as-is', () => { - const editedPromotion = getPromotionWithLocalEdits( rootState, 'coupon:3', 123 ); - - expect( editedPromotion ).to.exist; - expect( editedPromotion.id ).to.equal( 'coupon:3' ); - expect( editedPromotion.type ).to.equal( 'empty3' ); - } ); - - it( 'should return a promotion with edits overlaid on it', () => { - const editedState = cloneDeep( rootState ); - editedState.extensions.woocommerce.ui.promotions.edits = { - [ 123 ]: { - updates: [ - { id: 'coupon:3', type: 'empty33' }, - ], - currentlyEditingId: 'coupon:3', - } - }; - - const editedPromotion = getPromotionWithLocalEdits( editedState, 'coupon:3', 123 ); - - expect( editedPromotion ).to.exist; - expect( editedPromotion.id ).to.equal( 'coupon:3' ); - expect( editedPromotion.type ).to.equal( 'empty33' ); - } ); - } ); } ); From be3e2ce31d613c0cc1c5a3c3cc1f028f7395087c Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Mon, 30 Oct 2017 19:21:36 -0500 Subject: [PATCH 128/192] Promotions: Update state on API success This code adds support to update coupon, product, and promotions data in the state in response to successful API responses. --- .../woocommerce/state/action-types.js | 2 + .../state/sites/coupons/actions.js | 11 +- .../state/sites/coupons/handlers.js | 20 +-- .../state/sites/coupons/reducer.js | 39 ++++- .../state/sites/coupons/test/handlers.js | 153 +++++++++++++----- .../state/sites/coupons/test/reducer.js | 109 ++++++++++++- .../state/sites/promotions/reducer.js | 53 +++++- .../state/sites/promotions/test/reducer.js | 61 ++++++- 8 files changed, 387 insertions(+), 61 deletions(-) diff --git a/client/extensions/woocommerce/state/action-types.js b/client/extensions/woocommerce/state/action-types.js index 9949c74a97630..8a5acdbd06b2e 100644 --- a/client/extensions/woocommerce/state/action-types.js +++ b/client/extensions/woocommerce/state/action-types.js @@ -6,7 +6,9 @@ export const WOOCOMMERCE_ACTION_LIST_STEP_SUCCESS = 'WOOCOMMERCE_ACTION_LIST_STE export const WOOCOMMERCE_ACTION_LIST_STEP_FAILURE = 'WOOCOMMERCE_ACTION_LIST_STEP_FAILURE'; export const WOOCOMMERCE_COUPON_CREATE = 'WOOCOMMERCE_COUPON_CREATE'; export const WOOCOMMERCE_COUPON_DELETE = 'WOOCOMMERCE_COUPON_DELETE'; +export const WOOCOMMERCE_COUPON_DELETED = 'WOOCOMMERCE_COUPON_DELETED'; export const WOOCOMMERCE_COUPON_UPDATE = 'WOOCOMMERCE_COUPON_UPDATE'; +export const WOOCOMMERCE_COUPON_UPDATED = 'WOOCOMMERCE_COUPON_UPDATED'; export const WOOCOMMERCE_COUPONS_REQUEST = 'WOOCOMMERCE_COUPONS_REQUEST'; export const WOOCOMMERCE_COUPONS_UPDATED = 'WOOCOMMERCE_COUPONS_UPDATED'; export const WOOCOMMERCE_CURRENCIES_REQUEST = 'WOOCOMMERCE_CURRENCIES_REQUEST'; diff --git a/client/extensions/woocommerce/state/sites/coupons/actions.js b/client/extensions/woocommerce/state/sites/coupons/actions.js index 62a79d496519a..4268460828399 100644 --- a/client/extensions/woocommerce/state/sites/coupons/actions.js +++ b/client/extensions/woocommerce/state/sites/coupons/actions.js @@ -6,7 +6,9 @@ import { WOOCOMMERCE_COUPON_CREATE, WOOCOMMERCE_COUPON_DELETE, + WOOCOMMERCE_COUPON_DELETED, WOOCOMMERCE_COUPON_UPDATE, + WOOCOMMERCE_COUPON_UPDATED, WOOCOMMERCE_COUPONS_REQUEST, WOOCOMMERCE_COUPONS_UPDATED, } from 'woocommerce/state/action-types'; @@ -18,6 +20,14 @@ export function fetchCoupons( siteId, params ) { return { type: WOOCOMMERCE_COUPONS_REQUEST, siteId, params }; } +export function couponUpdated( siteId, coupon ) { + return { type: WOOCOMMERCE_COUPON_UPDATED, siteId, coupon }; +} + +export function couponDeleted( siteId, couponId ) { + return { type: WOOCOMMERCE_COUPON_DELETED, siteId, couponId }; +} + export function couponsUpdated( siteId, params, coupons, totalPages, totalCoupons ) { return { type: WOOCOMMERCE_COUPONS_UPDATED, siteId, params, coupons, totalPages, totalCoupons }; } @@ -51,4 +61,3 @@ export function deleteCoupon( siteId, couponId, successAction, failureAction ) { failureAction, }; } - diff --git a/client/extensions/woocommerce/state/sites/coupons/handlers.js b/client/extensions/woocommerce/state/sites/coupons/handlers.js index 749e357ceb082..7a354695cc324 100644 --- a/client/extensions/woocommerce/state/sites/coupons/handlers.js +++ b/client/extensions/woocommerce/state/sites/coupons/handlers.js @@ -17,7 +17,7 @@ import { WOOCOMMERCE_COUPON_UPDATE, WOOCOMMERCE_COUPONS_REQUEST, } from 'woocommerce/state/action-types'; -import { couponsUpdated } from './actions'; +import { couponDeleted, couponUpdated, couponsUpdated } from './actions'; const debug = debugFactory( 'woocommerce:coupons' ); @@ -25,15 +25,9 @@ export default { [ WOOCOMMERCE_COUPONS_REQUEST ]: [ dispatchRequest( requestCoupons, requestCouponsSuccess, apiError ), ], - [ WOOCOMMERCE_COUPON_CREATE ]: [ - dispatchRequest( couponCreate, couponCreateSuccess, apiError ), - ], - [ WOOCOMMERCE_COUPON_UPDATE ]: [ - dispatchRequest( couponUpdate, couponUpdateSuccess, apiError ), - ], - [ WOOCOMMERCE_COUPON_DELETE ]: [ - dispatchRequest( couponDelete, couponDeleteSuccess, apiError ), - ] + [ WOOCOMMERCE_COUPON_CREATE ]: [ dispatchRequest( couponCreate, couponCreateSuccess, apiError ) ], + [ WOOCOMMERCE_COUPON_UPDATE ]: [ dispatchRequest( couponUpdate, couponUpdateSuccess, apiError ) ], + [ WOOCOMMERCE_COUPON_DELETE ]: [ dispatchRequest( couponDelete, couponDeleteSuccess, apiError ) ], }; export function requestCoupons( { dispatch }, action ) { @@ -72,7 +66,7 @@ export function couponCreate( { dispatch }, action ) { } export function couponCreateSuccess( { dispatch }, action ) { - // TODO: Update local state for this coupon. + dispatch( couponUpdated( action.siteId, action.coupon ) ); if ( action.successAction ) { dispatch( action.successAction ); } @@ -86,7 +80,7 @@ export function couponUpdate( { dispatch }, action ) { } export function couponUpdateSuccess( { dispatch }, action ) { - // TODO: Update local state for this coupon. + dispatch( couponUpdated( action.siteId, action.coupon ) ); if ( action.successAction ) { dispatch( action.successAction ); } @@ -100,7 +94,7 @@ export function couponDelete( { dispatch }, action ) { } export function couponDeleteSuccess( { dispatch }, action ) { - // TODO: Update local state for this coupon. + dispatch( couponDeleted( action.siteId, action.couponId ) ); if ( action.successAction ) { dispatch( action.successAction ); } diff --git a/client/extensions/woocommerce/state/sites/coupons/reducer.js b/client/extensions/woocommerce/state/sites/coupons/reducer.js index 423469cfdf152..31daea0abd48d 100644 --- a/client/extensions/woocommerce/state/sites/coupons/reducer.js +++ b/client/extensions/woocommerce/state/sites/coupons/reducer.js @@ -1,19 +1,54 @@ /** - * Internal dependencies + * External dependencies * * @format */ +import { findIndex } from 'lodash'; +/** + * Internal dependencies + */ import { createReducer } from 'state/utils'; -import { WOOCOMMERCE_COUPONS_UPDATED } from 'woocommerce/state/action-types'; +import { + WOOCOMMERCE_COUPON_DELETED, + WOOCOMMERCE_COUPON_UPDATED, + WOOCOMMERCE_COUPONS_UPDATED, +} from 'woocommerce/state/action-types'; export default createReducer( {}, { + [ WOOCOMMERCE_COUPON_DELETED ]: couponDeleted, + [ WOOCOMMERCE_COUPON_UPDATED ]: couponUpdated, [ WOOCOMMERCE_COUPONS_UPDATED ]: pageUpdated, } ); +function couponDeleted( state, action ) { + const { couponId } = action; + const { coupons } = state; + + const newCoupons = coupons.filter( coupon => couponId !== coupon.id ); + + if ( newCoupons.length !== coupons.length ) { + return { ...state, coupons: newCoupons }; + } + return state; +} + +function couponUpdated( state, action ) { + const { coupon } = action; + const { coupons } = state; + const index = findIndex( coupons, { id: coupon.id } ); + + if ( -1 < index ) { + const newCoupons = [ ...coupons ]; + newCoupons[ index ] = coupon; + return { ...state, coupons: newCoupons }; + } + return state; +} + function pageUpdated( state, action ) { const { params, coupons, totalPages, totalCoupons } = action; diff --git a/client/extensions/woocommerce/state/sites/coupons/test/handlers.js b/client/extensions/woocommerce/state/sites/coupons/test/handlers.js index 64fd98eb877b8..6d688f74d0d12 100644 --- a/client/extensions/woocommerce/state/sites/coupons/test/handlers.js +++ b/client/extensions/woocommerce/state/sites/coupons/test/handlers.js @@ -11,12 +11,10 @@ import { spy, match } from 'sinon'; */ import { WPCOM_HTTP_REQUEST } from 'state/action-types'; import { - fetchCoupons, - couponsUpdated, - createCoupon, - updateCoupon, - deleteCoupon, -} from '../actions'; + WOOCOMMERCE_COUPON_UPDATED, + WOOCOMMERCE_COUPON_DELETED, +} from 'woocommerce/state/action-types'; +import { fetchCoupons, couponsUpdated, createCoupon, updateCoupon, deleteCoupon } from '../actions'; import { requestCoupons, requestCouponsSuccess, @@ -121,34 +119,71 @@ describe( 'handlers', () => { dispatch: spy(), }; - const coupon = { id: { placeholder: 'coupon:5' }, code: '10off', amount: '10', discount_type: 'percent' }; + const coupon = { + id: { placeholder: 'coupon:5' }, + code: '10off', + amount: '10', + discount_type: 'percent', + }; const action = createCoupon( siteId, coupon, successAction, failureAction ); couponCreate( store, action ); - expect( store.dispatch ).to.have.been.calledWith( match( { - type: WPCOM_HTTP_REQUEST, - body: { - path: '/wc/v3/coupons&_method=POST', - body: JSON.stringify( coupon ), - } - } ) ); + expect( store.dispatch ).to.have.been.calledWith( + match( { + type: WPCOM_HTTP_REQUEST, + body: { + path: '/wc/v3/coupons&_method=POST', + body: JSON.stringify( coupon ), + }, + } ) + ); } ); } ); describe( '#couponCreateSuccess', () => { + it( 'should dispatch a coupon update action', () => { + const store = { + dispatch: spy(), + }; + + const coupon = { + id: { placeholder: 'coupon:5' }, + code: '10off', + amount: '10', + discount_type: 'percent', + }; + const action = createCoupon( siteId, coupon, successAction, failureAction ); + + couponCreateSuccess( store, action, { data: { ...coupon, id: 12 } } ); + expect( store.dispatch ).to.have.been.calledWith( + match( { + type: WOOCOMMERCE_COUPON_UPDATED, + siteId, + coupon, + } ) + ); + } ); + it( 'should dispatch a success action', () => { const store = { dispatch: spy(), }; - const coupon = { id: { placeholder: 'coupon:5' }, code: '10off', amount: '10', discount_type: 'percent' }; + const coupon = { + id: { placeholder: 'coupon:5' }, + code: '10off', + amount: '10', + discount_type: 'percent', + }; const action = createCoupon( siteId, coupon, successAction, failureAction ); couponCreateSuccess( store, action, { data: { ...coupon, id: 12 } } ); - expect( store.dispatch ).to.have.been.calledWith( match( { - type: successAction.type, - } ) ); + expect( store.dispatch ).to.have.been.calledWith( + match( { + type: successAction.type, + } ) + ); } ); } ); @@ -163,17 +198,37 @@ describe( 'handlers', () => { couponUpdate( store, action ); - expect( store.dispatch ).to.have.been.calledWith( match( { - type: WPCOM_HTTP_REQUEST, - body: { - path: '/wc/v3/coupons/5&_method=PUT', - body: JSON.stringify( coupon ), - } - } ) ); + expect( store.dispatch ).to.have.been.calledWith( + match( { + type: WPCOM_HTTP_REQUEST, + body: { + path: '/wc/v3/coupons/5&_method=PUT', + body: JSON.stringify( coupon ), + }, + } ) + ); } ); } ); describe( '#couponUpdateSuccess', () => { + it( 'should dispatch a coupon update action', () => { + const store = { + dispatch: spy(), + }; + + const coupon = { id: 5, code: '15off', amount: '15', discount_type: 'percent' }; + const action = updateCoupon( siteId, coupon, successAction, failureAction ); + + couponUpdateSuccess( store, action, { data: { ...coupon, id: 12 } } ); + expect( store.dispatch ).to.have.been.calledWith( + match( { + type: WOOCOMMERCE_COUPON_UPDATED, + siteId, + coupon, + } ) + ); + } ); + it( 'should dispatch a success action', () => { const store = { dispatch: spy(), @@ -183,9 +238,11 @@ describe( 'handlers', () => { const action = updateCoupon( siteId, coupon, successAction, failureAction ); couponUpdateSuccess( store, action, { data: { ...coupon, id: 12 } } ); - expect( store.dispatch ).to.have.been.calledWith( match( { - type: successAction.type, - } ) ); + expect( store.dispatch ).to.have.been.calledWith( + match( { + type: successAction.type, + } ) + ); } ); } ); @@ -200,16 +257,36 @@ describe( 'handlers', () => { couponDelete( store, action ); - expect( store.dispatch ).to.have.been.calledWith( match( { - type: WPCOM_HTTP_REQUEST, - body: { - path: '/wc/v3/coupons/15&_method=DELETE', - } - } ) ); + expect( store.dispatch ).to.have.been.calledWith( + match( { + type: WPCOM_HTTP_REQUEST, + body: { + path: '/wc/v3/coupons/15&_method=DELETE', + }, + } ) + ); } ); } ); describe( '#couponDeleteSuccess', () => { + it( 'should dispatch a coupon deleted action', () => { + const store = { + dispatch: spy(), + }; + + const couponId = 15; + const action = deleteCoupon( siteId, couponId, successAction, failureAction ); + + couponDeleteSuccess( store, action ); + expect( store.dispatch ).to.have.been.calledWith( + match( { + type: WOOCOMMERCE_COUPON_DELETED, + siteId, + couponId, + } ) + ); + } ); + it( 'should dispatch a success action', () => { const store = { dispatch: spy(), @@ -219,9 +296,11 @@ describe( 'handlers', () => { const action = deleteCoupon( siteId, couponId, successAction, failureAction ); couponDeleteSuccess( store, action ); - expect( store.dispatch ).to.have.been.calledWith( match( { - type: successAction.type, - } ) ); + expect( store.dispatch ).to.have.been.calledWith( + match( { + type: successAction.type, + } ) + ); } ); } ); } ); diff --git a/client/extensions/woocommerce/state/sites/coupons/test/reducer.js b/client/extensions/woocommerce/state/sites/coupons/test/reducer.js index d9ae88e895e88..1d05e82499532 100644 --- a/client/extensions/woocommerce/state/sites/coupons/test/reducer.js +++ b/client/extensions/woocommerce/state/sites/coupons/test/reducer.js @@ -9,7 +9,11 @@ import { expect } from 'chai'; * Internal dependencies */ import reducer from '../reducer'; -import { WOOCOMMERCE_COUPONS_UPDATED } from 'woocommerce/state/action-types'; +import { + WOOCOMMERCE_COUPON_DELETED, + WOOCOMMERCE_COUPON_UPDATED, + WOOCOMMERCE_COUPONS_UPDATED, +} from 'woocommerce/state/action-types'; describe( 'reducer', () => { const siteId = 123; @@ -64,4 +68,107 @@ describe( 'reducer', () => { const newState = reducer( undefined, action ); expect( newState ).to.be.null; } ); + + test( 'should update a coupon in the current page', () => { + const pageAction = { + type: WOOCOMMERCE_COUPONS_UPDATED, + siteId, + coupons: couponsPage1, + params: { page: 1, per_page: 10 }, + totalPages: 1, + totalCoupons: 3, + }; + + const updatedCoupon = { + id: 2, + code: 'two', + amount: '2.50', + discount_type: 'fixed_product', + }; + + const updateAction = { + type: WOOCOMMERCE_COUPON_UPDATED, + siteId, + coupon: updatedCoupon, + }; + + const state1 = reducer( undefined, pageAction ); + const state2 = reducer( state1, updateAction ); + + expect( state1 ).to.exist; + expect( state1.coupons ).to.exist; + expect( state1.coupons[ 1 ] ).to.equal( couponsPage1[ 1 ] ); + + expect( state2 ).to.exist; + expect( state2.coupons ).to.exist; + expect( state2.coupons[ 1 ] ).to.equal( updatedCoupon ); + } ); + + test( 'should not update a coupon if not in the current page', () => { + const pageAction = { + type: WOOCOMMERCE_COUPONS_UPDATED, + siteId, + coupons: couponsPage1, + params: { page: 1, per_page: 10 }, + totalPages: 1, + totalCoupons: 3, + }; + + const updatedCoupon = { + id: 4, + code: 'four', + amount: '4', + discount_type: 'fixed_product', + }; + + const updateAction = { + type: WOOCOMMERCE_COUPON_UPDATED, + siteId, + coupon: updatedCoupon, + }; + + const state1 = reducer( undefined, pageAction ); + const state2 = reducer( state1, updateAction ); + + expect( state1 ).to.exist; + expect( state1.coupons ).to.exist; + expect( state1.coupons[ 1 ] ).to.equal( couponsPage1[ 1 ] ); + + expect( state2 ).to.exist; + expect( state2.coupons ).to.exist; + expect( state2.coupons[ 1 ] ).to.equal( couponsPage1[ 1 ] ); + } ); + + test( 'should remove a coupon from the current page if it is deleted', () => { + const pageAction = { + type: WOOCOMMERCE_COUPONS_UPDATED, + siteId, + coupons: couponsPage1, + params: { page: 1, per_page: 10 }, + totalPages: 1, + totalCoupons: 3, + }; + + const deleteAction = { + type: WOOCOMMERCE_COUPON_DELETED, + siteId, + couponId: 2, + }; + + const state1 = reducer( undefined, pageAction ); + const state2 = reducer( state1, deleteAction ); + + expect( state1 ).to.exist; + expect( state1.coupons ).to.exist; + expect( state1.coupons.length ).to.equal( 3 ); + expect( state1.coupons[ 0 ] ).to.equal( couponsPage1[ 0 ] ); + expect( state1.coupons[ 1 ] ).to.equal( couponsPage1[ 1 ] ); + expect( state1.coupons[ 2 ] ).to.equal( couponsPage1[ 2 ] ); + + expect( state2 ).to.exist; + expect( state2.coupons ).to.exist; + expect( state2.coupons.length ).to.equal( 2 ); + expect( state2.coupons[ 0 ] ).to.equal( couponsPage1[ 0 ] ); + expect( state2.coupons[ 1 ] ).to.equal( couponsPage1[ 2 ] ); + } ); } ); diff --git a/client/extensions/woocommerce/state/sites/promotions/reducer.js b/client/extensions/woocommerce/state/sites/promotions/reducer.js index 512d4dd272bb7..994cef20961c7 100644 --- a/client/extensions/woocommerce/state/sites/promotions/reducer.js +++ b/client/extensions/woocommerce/state/sites/promotions/reducer.js @@ -3,15 +3,17 @@ * * @format */ - -import { fill } from 'lodash'; +import { fill, findIndex } from 'lodash'; /** * Internal dependencies */ import { createReducer } from 'state/utils'; import { + WOOCOMMERCE_COUPON_DELETED, + WOOCOMMERCE_COUPON_UPDATED, WOOCOMMERCE_COUPONS_UPDATED, + WOOCOMMERCE_PRODUCT_UPDATED, WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, } from 'woocommerce/state/action-types'; import { createPromotionFromProduct, createPromotionFromCoupon } from './helpers'; @@ -23,10 +25,42 @@ const initialState = { }; export default createReducer( initialState, { + [ WOOCOMMERCE_COUPON_DELETED ]: couponDeleted, + [ WOOCOMMERCE_COUPON_UPDATED ]: couponUpdated, [ WOOCOMMERCE_COUPONS_UPDATED ]: couponsUpdated, + [ WOOCOMMERCE_PRODUCT_UPDATED ]: productUpdated, [ WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS ]: productsRequestSuccess, } ); +function couponDeleted( state, action ) { + const { couponId } = action; + const { coupons } = state; + + const newCoupons = coupons.filter( coupon => couponId !== coupon.id ); + + if ( newCoupons.length !== coupons.length ) { + const promotions = calculatePromotions( newCoupons, state.products ); + + return { ...state, coupons: newCoupons, promotions }; + } + return state; +} + +function couponUpdated( state, action ) { + const { coupon } = action; + const { coupons } = state; + const index = findIndex( coupons, { id: coupon.id } ); + + if ( -1 < index ) { + const newCoupons = [ ...coupons ]; + newCoupons[ index ] = coupon; + const promotions = calculatePromotions( newCoupons, state.products ); + + return { ...state, coupons: newCoupons, promotions }; + } + return state; +} + function couponsUpdated( state, action ) { const { params, totalCoupons } = action; @@ -52,6 +86,21 @@ function couponsUpdated( state, action ) { return state; } +function productUpdated( state, action ) { + const { data: product } = action; + const { products } = state; + const index = findIndex( products, { id: product.id } ); + + if ( -1 < index ) { + const newProducts = [ ...products ]; + newProducts[ index ] = product; + const promotions = calculatePromotions( state.coupons, newProducts ); + + return { ...state, products: newProducts, promotions }; + } + return state; +} + function productsRequestSuccess( state, action ) { const { params, totalProducts } = action; diff --git a/client/extensions/woocommerce/state/sites/promotions/test/reducer.js b/client/extensions/woocommerce/state/sites/promotions/test/reducer.js index 773b141d13275..bf3ef3faca395 100644 --- a/client/extensions/woocommerce/state/sites/promotions/test/reducer.js +++ b/client/extensions/woocommerce/state/sites/promotions/test/reducer.js @@ -20,7 +20,10 @@ import { productParams2, } from './fixtures/promotions'; import { + WOOCOMMERCE_COUPON_DELETED, + WOOCOMMERCE_COUPON_UPDATED, WOOCOMMERCE_COUPONS_UPDATED, + WOOCOMMERCE_PRODUCT_UPDATED, WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, } from 'woocommerce/state/action-types'; @@ -45,6 +48,18 @@ describe( 'reducer', () => { totalCoupons: 7, }; + const updateCouponAction1 = { + type: WOOCOMMERCE_COUPON_UPDATED, + siteId, + coupon: { ...coupons1[ 3 ], amount: '111.11' }, + }; + + const deleteCouponAction1 = { + type: WOOCOMMERCE_COUPON_DELETED, + siteId, + couponId: 3, + }; + const productsAction1 = { type: WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, siteId, @@ -63,6 +78,12 @@ describe( 'reducer', () => { totalProducts: 4, }; + const productUpdateAction1 = { + type: WOOCOMMERCE_PRODUCT_UPDATED, + siteId, + data: { id: products1[ 1 ].id, sale_price: '222.22' }, + }; + test( 'should store coupons', () => { const state1 = reducer( undefined, couponsAction1 ); expect( state1.coupons ).to.exist; @@ -78,15 +99,37 @@ describe( 'reducer', () => { const state2 = reducer( state1, couponsAction2 ); expect( state2.coupons ).to.exist; expect( state2.coupons.length ).to.equal( 7 ); - expect( state1.coupons[ 0 ] ).to.equal( coupons1[ 0 ] ); - expect( state1.coupons[ 1 ] ).to.equal( coupons1[ 1 ] ); - expect( state1.coupons[ 2 ] ).to.equal( coupons1[ 2 ] ); - expect( state1.coupons[ 3 ] ).to.equal( coupons1[ 3 ] ); - expect( state1.coupons[ 4 ] ).to.equal( coupons1[ 4 ] ); + expect( state2.coupons[ 0 ] ).to.equal( coupons1[ 0 ] ); + expect( state2.coupons[ 1 ] ).to.equal( coupons1[ 1 ] ); + expect( state2.coupons[ 2 ] ).to.equal( coupons1[ 2 ] ); + expect( state2.coupons[ 3 ] ).to.equal( coupons1[ 3 ] ); + expect( state2.coupons[ 4 ] ).to.equal( coupons1[ 4 ] ); expect( state2.coupons[ 5 ] ).to.equal( coupons2[ 0 ] ); expect( state2.coupons[ 6 ] ).to.equal( coupons2[ 1 ] ); } ); + test( 'should update a coupon in the state', () => { + const state1 = reducer( undefined, couponsAction1 ); + const state2 = reducer( state1, updateCouponAction1 ); + + expect( state2.coupons ).to.exist; + expect( state2.coupons[ 3 ].amount ).to.equal( '111.11' ); + } ); + + test( 'should delete a coupon in the state', () => { + const state1 = reducer( undefined, couponsAction1 ); + const state2 = reducer( state1, couponsAction2 ); + const state3 = reducer( state2, deleteCouponAction1 ); + + expect( state3.coupons ).to.exist; + expect( state3.coupons[ 0 ] ).to.equal( coupons1[ 0 ] ); + expect( state3.coupons[ 1 ] ).to.equal( coupons1[ 1 ] ); + expect( state3.coupons[ 2 ] ).to.equal( coupons1[ 2 ] ); + expect( state3.coupons[ 3 ] ).to.equal( coupons1[ 4 ] ); + expect( state3.coupons[ 4 ] ).to.equal( coupons2[ 0 ] ); + expect( state3.coupons[ 5 ] ).to.equal( coupons2[ 1 ] ); + } ); + test( 'should store products', () => { const state1 = reducer( undefined, productsAction1 ); expect( state1.products ).to.exist; @@ -105,6 +148,14 @@ describe( 'reducer', () => { expect( state2.products[ 3 ] ).to.equal( products2[ 0 ] ); } ); + test( 'should update a product in state', () => { + const state1 = reducer( undefined, productsAction1 ); + const state2 = reducer( state1, productUpdateAction1 ); + + expect( state2.products ).to.exist; + expect( state2.products[ 1 ].sale_price ).to.equal( '222.22' ); + } ); + test( 'should not calculate promotions if coupons are not complete', () => { const state1 = reducer( undefined, couponsAction1 ); const state2 = reducer( state1, productsAction1 ); From 0d33f73ccde2b8bd4cd52ad7aadbbe2e48fdbf68 Mon Sep 17 00:00:00 2001 From: Jarda Snajdr Date: Wed, 1 Nov 2017 10:21:02 +0100 Subject: [PATCH 129/192] Atomic Store: redirect users from ineligible countries to Pressable flow (#19321) If the user comes from an ineligible country (not US or Canada), disable the Atomic Store flow for them and always redirect to Pressable flow. --- .../design-type-with-atomic-store/index.jsx | 54 ++++++++++++++----- 1 file changed, 41 insertions(+), 13 deletions(-) diff --git a/client/signup/steps/design-type-with-atomic-store/index.jsx b/client/signup/steps/design-type-with-atomic-store/index.jsx index 426241fdba2b4..88c4345d41e53 100644 --- a/client/signup/steps/design-type-with-atomic-store/index.jsx +++ b/client/signup/steps/design-type-with-atomic-store/index.jsx @@ -5,11 +5,12 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; import classNames from 'classnames'; -import { invoke } from 'lodash'; +import { includes, invoke } from 'lodash'; /** * Internal dependencies */ +import config from 'config'; import StepWrapper from 'signup/step-wrapper'; import Card from 'components/card'; import { localize } from 'i18n-calypso'; @@ -28,9 +29,15 @@ import SignupProgressStore from 'lib/signup/progress-store'; import { getSignupDependencyStore } from 'state/signup/dependency-store/selectors'; import { DESIGN_TYPE_STORE } from 'signup/constants'; import PressableStoreStep from '../design-type-with-store/pressable-store'; +import QueryGeo from 'components/data/query-geo'; +import { getGeoCountryShort } from 'state/geo/selectors'; +import { getCurrentUserCountryCode } from 'state/current-user/selectors'; class DesignTypeWithAtomicStoreStep extends Component { - state = { showStore: false }; + state = { + showStore: false, + pendingStoreClick: false, + }; setPressableStore = ref => ( this.pressableStore = ref ); getChoices() { @@ -90,13 +97,24 @@ class DesignTypeWithAtomicStoreStep extends Component { }; handleNextStep = designType => { + if ( designType === DESIGN_TYPE_STORE && ! this.props.countryCode ) { + // if we don't know the country code, we can't proceed. Continue after the code arrives + this.setState( { pendingStoreClick: true } ); + return; + } + + this.setState( { pendingStoreClick: false } ); + this.props.setDesignType( designType ); this.props.recordTracksEvent( 'calypso_triforce_select_design', { category: designType } ); + const isCountryAllowed = + includes( [ 'US', 'CA' ], this.props.countryCode ) || config( 'env' ) === 'development'; + if ( - abtest( 'signupPressableStoreFlow' ) === 'pressable' && - designType === DESIGN_TYPE_STORE + designType === DESIGN_TYPE_STORE && + ( abtest( 'signupPressableStoreFlow' ) === 'pressable' || ! isCountryAllowed ) ) { this.scrollUp(); @@ -117,6 +135,10 @@ class DesignTypeWithAtomicStoreStep extends Component { }; renderChoice = choice => { + const buttonClassName = classNames( 'button design-type-with-atomic-store__cta is-compact', { + 'is-busy': choice.type === DESIGN_TYPE_STORE && this.state.pendingStoreClick, + } ); + return (
{ choice.image }
- - { choice.label } - + { choice.label }

{ choice.description }

@@ -139,7 +159,7 @@ class DesignTypeWithAtomicStoreStep extends Component { }; renderChoices() { - const { translate } = this.props; + const { countryCode, translate } = this.props; const disclaimerText = translate( 'Not sure? Pick the closest option. You can always change your settings later.' ); // eslint-disable-line max-len @@ -154,6 +174,7 @@ class DesignTypeWithAtomicStoreStep extends Component { return (
+ { this.state.pendingStoreClick && ! countryCode && }
{ - return { - signupDependencyStore: getSignupDependencyStore( state ), - }; - }, + state => ( { + signupDependencyStore: getSignupDependencyStore( state ), + countryCode: getCurrentUserCountryCode( state ) || getGeoCountryShort( state ), + } ), { recordTracksEvent, setDesignType, From 900c6317974a7270c79f69979d0a0ad9761d09dc Mon Sep 17 00:00:00 2001 From: Marin Atanasov Date: Wed, 1 Nov 2017 11:32:12 +0200 Subject: [PATCH 130/192] Components: Unify Gridicon styles within LoggedOutFormLinkItem (#19346) * Jetpack Connect: Add size to the Happychat button Gridicon * Jetpack Connect: Add size to the help button Gridicon * Jetpack Connect: Add size to the plans skip button Gridicon * Components: Add universal Gridicon styles to LoggedOutFormLinkItem * Jetpack Connect: Cleanup gridicon styles from help button * Signup: Fix alignment in Pressable Store privacy policy link Gridicon * Jetpack Disconnect: Add space to diagnose connection problem link * Jetpack Connect: Cleanup unnecessary footer link gridicon styles * Jetpack Connect: Add space in back link * Update snapshots * Update snapshots --- client/components/logged-out-form/style.scss | 5 +++++ client/jetpack-connect/auth-logged-in-form.jsx | 2 +- client/jetpack-connect/happychat-button.jsx | 2 +- client/jetpack-connect/help-button.jsx | 3 ++- client/jetpack-connect/plans-skip-button.jsx | 2 +- client/jetpack-connect/style.scss | 13 ------------- .../test/__snapshots__/plans-skip-button.jsx.snap | 12 ++++++------ .../site-settings/disconnect-site/troubleshoot.jsx | 4 +--- .../pressable-store/style.scss | 4 ++++ 9 files changed, 21 insertions(+), 26 deletions(-) diff --git a/client/components/logged-out-form/style.scss b/client/components/logged-out-form/style.scss index 2ac97a360b05a..df658162b706e 100644 --- a/client/components/logged-out-form/style.scss +++ b/client/components/logged-out-form/style.scss @@ -50,4 +50,9 @@ &:hover { color: $blue-medium; } + + .gridicon { + position: relative; + top: 3px; + } } diff --git a/client/jetpack-connect/auth-logged-in-form.jsx b/client/jetpack-connect/auth-logged-in-form.jsx index 6a49d91975811..787996e056257 100644 --- a/client/jetpack-connect/auth-logged-in-form.jsx +++ b/client/jetpack-connect/auth-logged-in-form.jsx @@ -451,7 +451,7 @@ class LoggedInForm extends Component { const redirectTo = addQueryArgs( queryObject, window.location.href ); const backToWpAdminLink = ( - + {' '} { translate( 'Return to %(sitename)s', { args: { sitename: decodeEntities( blogname ) }, } ) } diff --git a/client/jetpack-connect/happychat-button.jsx b/client/jetpack-connect/happychat-button.jsx index ab89c4bbcdf7b..3302c9fd4a356 100644 --- a/client/jetpack-connect/happychat-button.jsx +++ b/client/jetpack-connect/happychat-button.jsx @@ -54,7 +54,7 @@ const JetpackConnectHappychatButton = ( { onClick={ getHappyChatButtonClickHandler( eventName ) } > - { label || translate( 'Get help connecting your site' ) } + { label || translate( 'Get help connecting your site' ) } ); }; diff --git a/client/jetpack-connect/help-button.jsx b/client/jetpack-connect/help-button.jsx index 9df5edfa50372..9e251cbc1c283 100644 --- a/client/jetpack-connect/help-button.jsx +++ b/client/jetpack-connect/help-button.jsx @@ -21,7 +21,8 @@ const JetpackConnectHelpButton = ( { label, translate, onClick } ) => { rel="noopener noreferrer" onClick={ onClick } > - { label || translate( 'Get help connecting your site' ) } + {' '} + { label || translate( 'Get help connecting your site' ) } ); }; diff --git a/client/jetpack-connect/plans-skip-button.jsx b/client/jetpack-connect/plans-skip-button.jsx index 469e3b13d579b..f2eaef1c3e52a 100644 --- a/client/jetpack-connect/plans-skip-button.jsx +++ b/client/jetpack-connect/plans-skip-button.jsx @@ -15,7 +15,7 @@ const PlansSkipButton = ( { onClick, isRtl, translate } ) => (
); diff --git a/client/jetpack-connect/style.scss b/client/jetpack-connect/style.scss index 2a01bdb0fea27..be00903bdd40e 100644 --- a/client/jetpack-connect/style.scss +++ b/client/jetpack-connect/style.scss @@ -99,10 +99,6 @@ .button { width: 100%; } - - .logged-out-form__links .gridicon { - top: 2px; - } } .jetpack-connect__back-button { @@ -467,15 +463,6 @@ word-break: break-word; } -.jetpack-connect__help-button { - .gridicon { - width: 18px; - height: 18px; - position: relative; - top: 4px; - } -} - .jetpack-connect__error-details { margin-bottom: 16px; } diff --git a/client/jetpack-connect/test/__snapshots__/plans-skip-button.jsx.snap b/client/jetpack-connect/test/__snapshots__/plans-skip-button.jsx.snap index 0b882f9ebd4d3..15b2bf5b68aa9 100644 --- a/client/jetpack-connect/test/__snapshots__/plans-skip-button.jsx.snap +++ b/client/jetpack-connect/test/__snapshots__/plans-skip-button.jsx.snap @@ -11,11 +11,11 @@ exports[`PlansSkipButton should render 1`] = ` > Start with free @@ -39,11 +39,11 @@ exports[`PlansSkipButton should render arrow-left in rtl mode 1`] = ` > Start with free diff --git a/client/my-sites/site-settings/disconnect-site/troubleshoot.jsx b/client/my-sites/site-settings/disconnect-site/troubleshoot.jsx index 6277e358b1a42..33ac95708cc23 100644 --- a/client/my-sites/site-settings/disconnect-site/troubleshoot.jsx +++ b/client/my-sites/site-settings/disconnect-site/troubleshoot.jsx @@ -23,11 +23,9 @@ const Troubleshoot = ( { siteUrl, trackDebugClick, trackSupportClick, translate - - { translate( 'Diagnose a connection problem' ) } + { translate( 'Diagnose a connection problem' ) } Date: Wed, 1 Nov 2017 10:50:51 +0100 Subject: [PATCH 131/192] Implements connection.init (#19223) - Introduces a new connection.init API that takes a auth promise as parameter, as to decouple the auth mechanism from using the connection.init method. - Substitutes the old actions HAPPYCHAT_CONNECTING, HAPPYCHAT_CONNECT, and HAPPYCHAT_INITIALIZE. --- client/boot/project/wordpress-com.js | 9 +- client/components/happychat/button.jsx | 22 +- client/components/happychat/connection.jsx | 21 +- client/components/happychat/test/button.jsx | 29 ++ .../components/happychat/test/connection.js | 38 ++ client/lib/happychat/connection.js | 55 ++- client/lib/happychat/test/index.js | 404 ++++++++++++------ client/state/action-types.js | 3 - client/state/happychat/README.md | 2 +- client/state/happychat/common.js | 14 - client/state/happychat/connection/actions.js | 9 - client/state/happychat/connection/reducer.js | 4 +- client/state/happychat/middleware.js | 91 +--- client/state/happychat/test/middleware-ng.js | 371 ++++++++++++++++ client/state/happychat/test/middleware.js | 140 ------ client/state/happychat/test/utils.js | 81 ++++ client/state/happychat/utils.js | 62 +++ 17 files changed, 940 insertions(+), 415 deletions(-) create mode 100644 client/components/happychat/test/button.jsx create mode 100644 client/components/happychat/test/connection.js delete mode 100644 client/state/happychat/common.js create mode 100644 client/state/happychat/test/middleware-ng.js create mode 100644 client/state/happychat/test/utils.js create mode 100644 client/state/happychat/utils.js diff --git a/client/boot/project/wordpress-com.js b/client/boot/project/wordpress-com.js index 9343bc6778174..ba88a3eb332c8 100644 --- a/client/boot/project/wordpress-com.js +++ b/client/boot/project/wordpress-com.js @@ -16,7 +16,9 @@ import debugFactory from 'debug'; */ import config from 'config'; import { getSavedVariations } from 'lib/abtest'; // used by error logger -import { initialize as initializeHappychat } from 'state/happychat/connection/actions'; +import { initConnection as initHappychatConnection } from 'state/happychat/connection/actions'; +import { getHappychatAuth } from 'state/happychat/utils'; +import wasHappychatRecentlyActive from 'state/happychat/selectors/was-happychat-recently-active'; import analytics from 'lib/analytics'; import { setReduxStore as setReduxBridgeReduxStore } from 'lib/redux-bridge'; import route from 'lib/route'; @@ -213,7 +215,10 @@ export function setupMiddlewares( currentUser, reduxStore ) { asyncRequire( 'lib/olark', olark => olark.initialize( reduxStore.dispatch ) ); } - reduxStore.dispatch( initializeHappychat() ); + const state = reduxStore.getState(); + if ( wasHappychatRecentlyActive( state ) ) { + reduxStore.dispatch( initHappychatConnection( getHappychatAuth( state )() ) ); + } if ( config.isEnabled( 'keyboard-shortcuts' ) ) { require( 'lib/keyboard-shortcuts/global' )(); diff --git a/client/components/happychat/button.jsx b/client/components/happychat/button.jsx index e8d893797048c..f76b0140d8aab 100644 --- a/client/components/happychat/button.jsx +++ b/client/components/happychat/button.jsx @@ -17,20 +17,24 @@ import classnames from 'classnames'; * Internal dependencies */ import viewport from 'lib/viewport'; +import { getHappychatAuth } from 'state/happychat/utils'; import { hasUnreadMessages } from 'state/happychat/selectors'; import hasActiveHappychatSession from 'state/happychat/selectors/has-active-happychat-session'; import isHappychatAvailable from 'state/happychat/selectors/is-happychat-available'; -import { connectChat } from 'state/happychat/connection/actions'; +import isHappychatConnectionUninitialized from 'state/happychat/selectors/is-happychat-connection-uninitialized'; +import { initConnection } from 'state/happychat/connection/actions'; import { openChat } from 'state/happychat/ui/actions'; import Button from 'components/button'; -class HappychatButton extends Component { +export class HappychatButton extends Component { static propTypes = { allowMobileRedirect: PropTypes.bool, borderless: PropTypes.bool, - connectChat: PropTypes.func, + getAuth: PropTypes.func, + initConnection: PropTypes.func, isChatActive: PropTypes.bool, isChatAvailable: PropTypes.bool, + isConnectionUninitialized: PropTypes.bool, onClick: PropTypes.func, openChat: PropTypes.func, translate: PropTypes.func, @@ -39,9 +43,11 @@ class HappychatButton extends Component { static defaultProps = { allowMobileRedirect: false, borderless: true, - connectChat: noop, + getAuth: noop, + initConnection: noop, isChatActive: false, isChatAvailable: false, + isConnectionUninitialized: false, onClick: noop, openChat: noop, translate: identity, @@ -60,7 +66,9 @@ class HappychatButton extends Component { }; componentDidMount() { - this.props.connectChat(); + if ( this.props.isConnectionUninitialized ) { + this.props.initConnection( this.props.getAuth() ); + } } render() { @@ -100,11 +108,13 @@ class HappychatButton extends Component { export default connect( state => ( { hasUnread: hasUnreadMessages( state ), + getAuth: getHappychatAuth( state ), isChatAvailable: isHappychatAvailable( state ), isChatActive: hasActiveHappychatSession( state ), + isConnectionUninitialized: isHappychatConnectionUninitialized( state ), } ), { openChat, - connectChat, + initConnection, } )( localize( HappychatButton ) ); diff --git a/client/components/happychat/connection.jsx b/client/components/happychat/connection.jsx index c2435d1024b8f..8914b1ddfc0b3 100644 --- a/client/components/happychat/connection.jsx +++ b/client/components/happychat/connection.jsx @@ -11,13 +11,14 @@ import { connect } from 'react-redux'; * Internal dependencies */ import config from 'config'; -import { connectChat } from 'state/happychat/connection/actions'; +import { getHappychatAuth } from 'state/happychat/utils'; import isHappychatConnectionUninitialized from 'state/happychat/selectors/is-happychat-connection-uninitialized'; +import { initConnection } from 'state/happychat/connection/actions'; export class HappychatConnection extends Component { componentDidMount() { - if ( this.props.isEnabled && this.props.isUninitialized ) { - this.props.connectChat(); + if ( this.props.isHappychatEnabled && this.props.isConnectionUninitialized ) { + this.props.initConnection( this.props.getAuth() ); } } @@ -27,15 +28,17 @@ export class HappychatConnection extends Component { } HappychatConnection.propTypes = { - isEnabled: PropTypes.bool, - isUninitialized: PropTypes.bool, - connectChat: PropTypes.func, + getAuth: PropTypes.func, + isConnectionUninitialized: PropTypes.bool, + isHappychatEnabled: PropTypes.bool, + initConnection: PropTypes.func, }; export default connect( state => ( { - isEnabled: config.isEnabled( 'happychat' ), - isUninitialized: isHappychatConnectionUninitialized( state ), + getAuth: getHappychatAuth( state ), + isConnectionUninitialized: isHappychatConnectionUninitialized( state ), + isHappychatEnabled: config.isEnabled( 'happychat' ), } ), - { connectChat } + { initConnection } )( HappychatConnection ); diff --git a/client/components/happychat/test/button.jsx b/client/components/happychat/test/button.jsx new file mode 100644 index 0000000000000..62f15b2fcb952 --- /dev/null +++ b/client/components/happychat/test/button.jsx @@ -0,0 +1,29 @@ +/** @format */ + +/** + * Internal dependencies + */ +import { HappychatButton } from '../button'; + +describe( 'Button', () => { + let component; + beforeEach( () => { + component = new HappychatButton(); + component.props = { + initConnection: jest.fn(), + getAuth: jest.fn(), + }; + } ); + + test( 'initConnection if connection is uninitialized', () => { + component.props.isConnectionUninitialized = true; + component.componentDidMount(); + expect( component.props.initConnection ).toHaveBeenCalled(); + } ); + + test( 'do not initConnection if connection is not uninitialized', () => { + component.props.isConnectionUninitialized = false; + component.componentDidMount(); + expect( component.props.initConnection ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/client/components/happychat/test/connection.js b/client/components/happychat/test/connection.js new file mode 100644 index 0000000000000..580c153ced3d4 --- /dev/null +++ b/client/components/happychat/test/connection.js @@ -0,0 +1,38 @@ +/** @format */ + +/** + * Internal dependencies + */ +import { HappychatConnection } from '../connection'; + +describe( 'Connection', () => { + let component; + beforeEach( () => { + component = new HappychatConnection(); + component.props = { + initConnection: jest.fn(), + getAuth: jest.fn(), + }; + } ); + + test( 'initConnection if connection is uninitialized and happychat is enabled', () => { + component.props.isConnectionUninitialized = true; + component.props.isHappychatEnabled = true; + component.componentDidMount(); + expect( component.props.initConnection ).toHaveBeenCalled(); + } ); + + test( 'do not initConnection if connection is not uninitialized', () => { + component.props.isConnectionUninitialized = false; + component.props.isHappychatEnabled = true; + component.componentDidMount(); + expect( component.props.initConnection ).not.toHaveBeenCalled(); + } ); + + test( 'do not initConnection if happychat is not enabled', () => { + component.props.isConnectionUninitialized = true; + component.props.isHappychatEnabled = false; + component.componentDidMount(); + expect( component.props.initConnection ).not.toHaveBeenCalled(); + } ); +} ); diff --git a/client/lib/happychat/connection.js b/client/lib/happychat/connection.js index 9d59264bd09a2..0706c687a1e69 100644 --- a/client/lib/happychat/connection.js +++ b/client/lib/happychat/connection.js @@ -16,7 +16,6 @@ import { receiveChatEvent, requestChatTranscript, setConnected, - setConnecting, setDisconnected, setHappychatAvailable, setReconnecting, @@ -31,35 +30,45 @@ const buildConnection = socket => : socket; // If socket is not an url, use it directly. Useful for testing. class Connection { - init( url, dispatch, { signer_user_id, jwt, locale, groups, geoLocation } ) { + /** + * Init the SockeIO connection: check user authorization and bind socket events + * + * @param { Function } dispatch Redux dispatch function + * @param { Promise } auth Authentication promise, will return the user info upon fulfillment + * @return { Promise } Fulfilled (returns the opened socket) + * or rejected (returns an error message) + */ + init( dispatch, auth ) { if ( this.openSocket ) { debug( 'socket is already connected' ); return this.openSocket; } + this.dispatch = dispatch; - dispatch( setConnecting() ); - - const socket = buildConnection( url ); this.openSocket = new Promise( ( resolve, reject ) => { - socket - .once( 'connect', () => debug( 'connected' ) ) - .on( 'token', handler => handler( { signer_user_id, jwt, locale, groups } ) ) - .on( 'init', () => { - dispatch( setConnected( { signer_user_id, locale, groups, geoLocation } ) ); - // TODO: There's no need to dispatch a separate action to request a transcript. - // The HAPPYCHAT_CONNECTED action should have its own middleware handler that does this. - dispatch( requestChatTranscript() ); - resolve( socket ); - } ) - .on( 'unauthorized', () => { - socket.close(); - reject( 'user is not authorized' ); + auth + .then( ( { url, user: { signer_user_id, jwt, locale, groups, geoLocation } } ) => { + const socket = buildConnection( url ); + + socket + .once( 'connect', () => debug( 'connected' ) ) + .on( 'token', handler => handler( { signer_user_id, jwt, locale, groups } ) ) + .on( 'init', () => { + dispatch( setConnected( { signer_user_id, locale, groups, geoLocation } ) ); + dispatch( requestChatTranscript() ); + resolve( socket ); + } ) + .on( 'unauthorized', () => { + socket.close(); + reject( 'user is not authorized' ); + } ) + .on( 'disconnect', reason => dispatch( setDisconnected( reason ) ) ) + .on( 'reconnecting', () => dispatch( setReconnecting() ) ) + .on( 'status', status => dispatch( setHappychatChatStatus( status ) ) ) + .on( 'accept', accept => dispatch( setHappychatAvailable( accept ) ) ) + .on( 'message', message => dispatch( receiveChatEvent( message ) ) ); } ) - .on( 'disconnect', reason => dispatch( setDisconnected( reason ) ) ) - .on( 'reconnecting', () => dispatch( setReconnecting() ) ) - .on( 'status', status => dispatch( setHappychatChatStatus( status ) ) ) - .on( 'accept', accept => dispatch( setHappychatAvailable( accept ) ) ) - .on( 'message', message => dispatch( receiveChatEvent( message ) ) ); + .catch( e => reject( e ) ); } ); return this.openSocket; diff --git a/client/lib/happychat/test/index.js b/client/lib/happychat/test/index.js index 5a8a8507c499c..872b95b919c85 100644 --- a/client/lib/happychat/test/index.js +++ b/client/lib/happychat/test/index.js @@ -1,157 +1,319 @@ /** @format */ + /** * External dependencies */ -import { expect } from 'chai'; -import { stub } from 'sinon'; import { EventEmitter } from 'events'; /** * Internal dependencies */ import { - HAPPYCHAT_CONNECTING, - HAPPYCHAT_CONNECTED, - HAPPYCHAT_DISCONNECTED, - HAPPYCHAT_RECEIVE_EVENT, - HAPPYCHAT_RECONNECTING, - HAPPYCHAT_SET_AVAILABLE, - HAPPYCHAT_SET_CHAT_STATUS, - HAPPYCHAT_TRANSCRIPT_REQUEST, -} from 'state/action-types'; - + setConnected, + requestChatTranscript, + setDisconnected, + setReconnecting, + setHappychatAvailable, + receiveChatEvent, +} from 'state/happychat/connection/actions'; +import { setHappychatChatStatus } from 'state/happychat/chat/actions'; import buildConnection from '../connection'; describe( 'connection', () => { - describe( 'should bind socket ', () => { - const signer_user_id = 12; - const jwt = 'jwt'; - const locale = 'locale'; - const groups = 'groups'; - const geoLocation = 'location'; - - let openSocket, socket, dispatch; - beforeEach( () => { - socket = new EventEmitter(); - dispatch = stub(); - const connection = buildConnection(); - openSocket = connection.init( socket, dispatch, { - signer_user_id, - jwt, - locale, - groups, - geoLocation, - } ); - } ); + describe( 'init', () => { + describe( 'should bind SockeIO events upon config promise resolution', () => { + const signer_user_id = 12; + const jwt = 'jwt'; + const locale = 'locale'; + const groups = 'groups'; + const geoLocation = 'location'; - it( 'connect event', done => { - openSocket.then( () => { - // TODO: implement when connect event is used - expect( true ).to.equal( true ); - done(); // tell mocha the promise chain ended + let socket, dispatch, openSocket; + beforeEach( () => { + socket = new EventEmitter(); + dispatch = jest.fn(); + const connection = buildConnection(); + const config = Promise.resolve( { + url: socket, + user: { + signer_user_id, + jwt, + locale, + groups, + geoLocation, + }, + } ); + openSocket = connection.init( dispatch, config ); } ); - socket.emit( 'connect' ); - socket.emit( 'init' ); // force openSocket promise to resolve - } ); - it( 'token event', done => { - const callback = stub(); - openSocket.then( () => { - expect( callback ).to.have.been.calledWithMatch( { signer_user_id, jwt, locale, groups } ); - done(); // tell mocha the promise chain ended + // TODO: to be enabled when corresponding connection changes land + // test( 'connect event', () => { + // socket.emit( 'connect' ); + // expect( dispatch ).toHaveBeenCalledTimes( 1 ); + // expect( dispatch ).toHaveBeenCalledWith( receiveConnect() ); + // } ); + // + // test( 'token event', () => { + // const callback = jest.fn(); + // socket.emit( 'token', callback ); + // expect( dispatch ).toHaveBeenCalledTimes( 1 ); + // expect( dispatch ).toHaveBeenCalledWith( receiveToken() ); + // expect( callback ).toHaveBeenCalledTimes( 1 ); + // expect( callback ).toHaveBeenCalledWith( { signer_user_id, jwt, locale, groups } ); + // } ); + + test( 'init event', () => { + socket.emit( 'init' ); + expect( dispatch ).toHaveBeenCalledTimes( 2 ); + expect( dispatch.mock.calls[ 0 ][ 0 ] ).toEqual( + setConnected( { signer_user_id, locale, groups, geoLocation } ) + ); + expect( dispatch.mock.calls[ 1 ][ 0 ] ).toEqual( requestChatTranscript() ); + return expect( openSocket ).resolves.toBe( socket ); } ); - socket.emit( 'token', callback ); - socket.emit( 'init' ); // force openSocket promise to resolve - } ); - it( 'init event', done => { - openSocket.then( () => { - expect( dispatch.getCall( 0 ) ).to.have.been.calledWithMatch( { - type: HAPPYCHAT_CONNECTING, - } ); - expect( dispatch.getCall( 1 ) ).to.have.been.calledWithMatch( { - type: HAPPYCHAT_CONNECTED, - user: { signer_user_id, locale, groups, geoLocation }, + test( 'unauthorized event', () => { + socket.close = jest.fn(); + openSocket.catch( () => { + // TODO: to be enabled when corresponding connection changes land + // expect( dispatch ).toHaveBeenCalledTimes( 1 ); + // expect( dispatch ).toHaveBeenCalledWith( + // receiveUnauthorized( 'User is not authorized' ) + // ); + expect( socket.close ).toHaveBeenCalled(); } ); - expect( dispatch.getCall( 2 ) ).to.have.been.calledWithMatch( { - type: HAPPYCHAT_TRANSCRIPT_REQUEST, - } ); - done(); // tell mocha the promise chain ended + socket.emit( 'unauthorized' ); } ); - socket.emit( 'init' ); // force openSocket promise to resolve - } ); - it( 'unauthorized event', done => { - socket.close = stub().returns( () => {} ); - openSocket.then( () => { - expect( socket.close ).to.have.been.called; - done(); // tell mocha the promise chain ended + test( 'disconnect event', () => { + const error = 'testing reasons'; + socket.emit( 'disconnect', error ); + expect( dispatch ).toHaveBeenCalledTimes( 1 ); + expect( dispatch ).toHaveBeenCalledWith( setDisconnected( error ) ); } ); - socket.emit( 'init' ); // force openSocket promise to resolve - socket.emit( 'unauthorized' ); - } ); - it( 'disconnect event', done => { - const errorStatus = 'testing reasons'; - openSocket.then( () => { - expect( dispatch.getCall( 3 ) ).to.have.been.calledWithMatch( { - type: HAPPYCHAT_DISCONNECTED, - errorStatus, - } ); - done(); // tell mocha the promise chain ended + test( 'reconnecting event', () => { + socket.emit( 'reconnecting' ); + expect( dispatch ).toHaveBeenCalledTimes( 1 ); + expect( dispatch ).toHaveBeenCalledWith( setReconnecting() ); } ); - socket.emit( 'init' ); // force openSocket promise to resolve - socket.emit( 'disconnect', errorStatus ); - } ); - it( 'reconnecting event', done => { - openSocket.then( () => { - expect( dispatch.getCall( 3 ) ).to.have.been.calledWithMatch( { - type: HAPPYCHAT_RECONNECTING, - } ); - done(); // tell mocha the promise chain ended + test( 'status event', () => { + const status = 'testing status'; + socket.emit( 'status', status ); + expect( dispatch ).toHaveBeenCalledTimes( 1 ); + expect( dispatch ).toHaveBeenCalledWith( setHappychatChatStatus( status ) ); } ); - socket.emit( 'init' ); // force openSocket promise to resolve - socket.emit( 'reconnecting' ); - } ); - it( 'status event', done => { - const status = 'testing status'; - openSocket.then( () => { - expect( dispatch.getCall( 3 ) ).to.have.been.calledWithMatch( { - type: HAPPYCHAT_SET_CHAT_STATUS, - status, - } ); - done(); // tell mocha the promise chain ended + test( 'accept event', () => { + const isAvailable = true; + socket.emit( 'accept', isAvailable ); + expect( dispatch ).toHaveBeenCalledTimes( 1 ); + expect( dispatch ).toHaveBeenCalledWith( setHappychatAvailable( isAvailable ) ); } ); - socket.emit( 'init' ); // force openSocket promise to resolve - socket.emit( 'status', status ); - } ); - it( 'accept event', done => { - const isAvailable = true; - openSocket.then( () => { - expect( dispatch.getCall( 3 ) ).to.have.been.calledWithMatch( { - type: HAPPYCHAT_SET_AVAILABLE, - isAvailable, - } ); - done(); // tell mocha the promise chain ended + test( 'message event', () => { + const message = 'testing msg'; + socket.emit( 'message', message ); + expect( dispatch ).toHaveBeenCalledTimes( 1 ); + expect( dispatch ).toHaveBeenCalledWith( receiveChatEvent( message ) ); } ); - socket.emit( 'init' ); // force openSocket promise to resolve - socket.emit( 'accept', isAvailable ); } ); - it( 'message event', done => { - const event = 'testing msg'; - openSocket.then( () => { - expect( dispatch.getCall( 3 ) ).to.have.been.calledWithMatch( { - type: HAPPYCHAT_RECEIVE_EVENT, - event, - } ); - done(); // tell mocha the promise chain ended + describe( 'should not bind SocketIO events upon config promise rejection', () => { + let connection, socket, dispatch, openSocket; + const rejectMsg = 'no auth'; + beforeEach( () => { + socket = new EventEmitter(); + dispatch = jest.fn(); + connection = buildConnection(); + openSocket = connection.init( dispatch, Promise.reject( rejectMsg ) ); + } ); + + test( 'openSocket Promise has been rejected', () => { + return expect( openSocket ).rejects.toBe( rejectMsg ); + } ); + + test( 'connect event', () => { + socket.emit( 'connect' ); + expect( dispatch ).toHaveBeenCalledTimes( 0 ); + // catch the promise to avoid the UnhandledPromiseRejectionWarning + return expect( openSocket ).rejects.toBe( rejectMsg ); + } ); + + test( 'token event', () => { + const callback = jest.fn(); + socket.emit( 'token', callback ); + expect( dispatch ).toHaveBeenCalledTimes( 0 ); + expect( callback ).toHaveBeenCalledTimes( 0 ); + // catch the promise to avoid the UnhandledPromiseRejectionWarning + return expect( openSocket ).rejects.toBe( rejectMsg ); + } ); + + test( 'init event', () => { + socket.emit( 'init' ); + expect( dispatch ).toHaveBeenCalledTimes( 0 ); + // catch the promise to avoid the UnhandledPromiseRejectionWarning + return expect( openSocket ).rejects.toBe( rejectMsg ); + } ); + + test( 'unauthorized event', () => { + socket.close = jest.fn(); + socket.emit( 'unauthorized' ); + expect( dispatch ).toHaveBeenCalledTimes( 0 ); + // catch the promise to avoid the UnhandledPromiseRejectionWarning + return expect( openSocket ).rejects.toBe( rejectMsg ); + } ); + + test( 'disconnect event', () => { + const error = 'testing reasons'; + socket.emit( 'disconnect', error ); + expect( dispatch ).toHaveBeenCalledTimes( 0 ); + // catch the promise to avoid the UnhandledPromiseRejectionWarning + return expect( openSocket ).rejects.toBe( rejectMsg ); + } ); + + test( 'reconnecting event', () => { + socket.emit( 'reconnecting' ); + expect( dispatch ).toHaveBeenCalledTimes( 0 ); + // catch the promise to avoid the UnhandledPromiseRejectionWarning + return expect( openSocket ).rejects.toBe( rejectMsg ); + } ); + + test( 'status event', () => { + const status = 'testing status'; + socket.emit( 'status', status ); + expect( dispatch ).toHaveBeenCalledTimes( 0 ); + // catch the promise to avoid the UnhandledPromiseRejectionWarning + return expect( openSocket ).rejects.toBe( rejectMsg ); + } ); + + test( 'accept event', () => { + const isAvailable = true; + socket.emit( 'accept', isAvailable ); + expect( dispatch ).toHaveBeenCalledTimes( 0 ); + // catch the promise to avoid the UnhandledPromiseRejectionWarning + return expect( openSocket ).rejects.toBe( rejectMsg ); + } ); + + test( 'message event', () => { + const message = 'testing msg'; + socket.emit( 'message', message ); + expect( dispatch ).toHaveBeenCalledTimes( 0 ); + // catch the promise to avoid the UnhandledPromiseRejectionWarning + return expect( openSocket ).rejects.toBe( rejectMsg ); } ); - socket.emit( 'init' ); // force openSocket promise to resolve - socket.emit( 'message', event ); } ); } ); + + // TODO: to be enabled when corresponding connection changes are merged + // describe( 'when auth promise chain is fulfilled', () => { + // const signer_user_id = 12; + // const jwt = 'jwt'; + // const locale = 'locale'; + // const groups = 'groups'; + // const geoLocation = 'location'; + // + // let socket, dispatch, connection, config; + // beforeEach( () => { + // socket = new EventEmitter(); + // dispatch = jest.fn(); + // connection = buildConnection(); + // config = Promise.resolve( { + // url: socket, + // user: { + // signer_user_id, + // jwt, + // locale, + // groups, + // geoLocation, + // }, + // } ); + // connection.init( dispatch, config ); + // } ); + // + // test( 'connection.send should emit a SocketIO event', () => { + // socket.emit( 'init' ); // resolve internal openSocket promise + // + // socket.emit = jest.fn(); + // const action = sendMessage( 'my msg' ); + // return connection.send( action ).then( () => { + // expect( socket.emit ).toHaveBeenCalledWith( action.event, action.payload ); + // } ); + // } ); + // + // describe( 'connection.request should emit a SocketIO event', () => { + // test( 'and dispatch callbackTimeout if socket did not respond', () => { + // socket.emit( 'init' ); // resolve internal openSocket promise + // + // const action = requestTranscript( null ); + // socket.emit = jest.fn(); + // return connection.request( action, 100 ).catch( error => { + // expect( socket.emit ).toHaveBeenCalled(); + // expect( socket.emit.mock.calls[ 0 ][ 0 ] ).toBe( action.event ); + // expect( socket.emit.mock.calls[ 0 ][ 1 ] ).toBe( action.payload ); + // expect( dispatch ).toHaveBeenCalledWith( action.callbackTimeout() ); + // expect( error.message ).toBe( 'timeout' ); + // } ); + // } ); + // + // test( 'and dispatch callback if socket responded successfully', () => { + // socket.emit( 'init' ); // resolve internal openSocket promise + // + // const action = requestTranscript( null ); + // socket.on( action.event, ( payload, callback ) => { + // const result = { + // messages: [ 'msg1', 'msg2' ], + // timestamp: Date.now(), + // }; + // callback( null, result ); // fake server responded ok + // } ); + // return connection.request( action, 100 ).then( result => { + // expect( dispatch ).toHaveBeenCalledWith( receiveTranscript( result ) ); + // } ); + // } ); + // + // test( 'and dispatch error if socket responded with error', () => { + // socket.emit( 'init' ); // resolve internal openSocket promise + // + // const action = requestTranscript( null ); + // socket.on( action.event, ( payload, callback ) => { + // callback( 'no data', null ); // fake server responded with error + // } ); + // return connection.request( action, 100 ).catch( error => { + // expect( error.message ).toBe( 'no data' ); + // expect( dispatch ).toHaveBeenCalledWith( + // receiveError( action.error + ': ' + error.message ) + // ); + // } ); + // } ); + // } ); + // } ); + // + // describe( 'when auth promise chain is rejected', () => { + // let socket, dispatch, connection, config; + // beforeEach( () => { + // socket = new EventEmitter(); + // dispatch = jest.fn(); + // connection = buildConnection(); + // config = Promise.reject( 'no auth' ); + // connection.init( dispatch, config ); + // } ); + // + // test( 'connection.send should dispatch receiveError action', () => { + // socket.emit = jest.fn(); + // const action = sendMessage( 'content' ); + // return connection.send( action ).catch( e => { + // expect( dispatch ).toHaveBeenCalledWith( receiveError( action.error + ': ' + e ) ); + // } ); + // } ); + // + // test( 'connection.request should dispatch receiveError action', () => { + // socket.emit = jest.fn(); + // const action = requestTranscript( null ); + // return connection.request( action, 100 ).catch( e => { + // expect( dispatch ).toHaveBeenCalledWith( receiveError( action.error + ': ' + e ) ); + // } ); + // } ); + // } ); } ); diff --git a/client/state/action-types.js b/client/state/action-types.js index 27af2c5412f7a..5ad8ee98298da 100644 --- a/client/state/action-types.js +++ b/client/state/action-types.js @@ -207,9 +207,7 @@ export const HAPPINESS_ENGINEERS_FETCH_FAILURE = 'HAPPINESS_ENGINEERS_FETCH_FAIL export const HAPPINESS_ENGINEERS_FETCH_SUCCESS = 'HAPPINESS_ENGINEERS_FETCH_SUCCESS'; export const HAPPINESS_ENGINEERS_RECEIVE = 'HAPPINESS_ENGINEERS_RECEIVE'; export const HAPPYCHAT_BLUR = 'HAPPYCHAT_BLUR'; -export const HAPPYCHAT_CONNECT = 'HAPPYCHAT_CONNECT'; export const HAPPYCHAT_CONNECTED = 'HAPPYCHAT_CONNECTED'; -export const HAPPYCHAT_CONNECTING = 'HAPPYCHAT_CONNECTING'; export const HAPPYCHAT_DISCONNECTED = 'HAPPYCHAT_DISCONNECTED'; export const HAPPYCHAT_FOCUS = 'HAPPYCHAT_FOCUS'; export const HAPPYCHAT_IO_INIT = 'HAPPYCHAT_IO_INIT'; @@ -232,7 +230,6 @@ export const HAPPYCHAT_IO_SEND_MESSAGE_MESSAGE = 'HAPPYCHAT_IO_SEND_MESSAGE_MESS export const HAPPYCHAT_IO_SEND_MESSAGE_USERINFO = 'HAPPYCHAT_IO_SEND_MESSAGE_USERINFO'; export const HAPPYCHAT_IO_SEND_PREFERENCES = 'HAPPYCHAT_IO_SEND_PREFERENCES'; export const HAPPYCHAT_IO_SEND_TYPING = 'HAPPYCHAT_IO_SEND_TYPING'; -export const HAPPYCHAT_INITIALIZE = 'HAPPYCHAT_INITIALIZE'; export const HAPPYCHAT_MINIMIZING = 'HAPPYCHAT_MINIMIZING'; export const HAPPYCHAT_OPEN = 'HAPPYCHAT_OPEN'; export const HAPPYCHAT_RECEIVE_EVENT = 'HAPPYCHAT_RECEIVE_EVENT'; diff --git a/client/state/happychat/README.md b/client/state/happychat/README.md index 96c3120aa679a..2ad798849b52b 100644 --- a/client/state/happychat/README.md +++ b/client/state/happychat/README.md @@ -27,7 +27,7 @@ Happychat state shape: Used in combination with the Redux store instance `dispatch` function, actions can be used in manipulating the current global state. -### `connectChat()` + ### `initConnection()` Opens Happychat Socket.IO client connection. _Note: Most use cases should use the Query Component [``](../../components/happychat/connection.jsx) instead of dispatching diff --git a/client/state/happychat/common.js b/client/state/happychat/common.js deleted file mode 100644 index b864990e658ad..0000000000000 --- a/client/state/happychat/common.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * This is a temporary file to hold common functions while they're being used - * in both actions.js and middleware.js. Once we've finished refactoring - * all side effecting actions into middleware.js, these can be moved there. - * - * @format - */ - -/** - * Internal dependencies - */ -import buildConnection from 'lib/happychat/connection'; - -export const connection = buildConnection(); diff --git a/client/state/happychat/connection/actions.js b/client/state/happychat/connection/actions.js index 31e6820b155e2..3ff578cf05166 100644 --- a/client/state/happychat/connection/actions.js +++ b/client/state/happychat/connection/actions.js @@ -9,11 +9,8 @@ import { v4 as uuid } from 'uuid'; * Internal dependencies */ import { - HAPPYCHAT_CONNECT, HAPPYCHAT_CONNECTED, - HAPPYCHAT_CONNECTING, HAPPYCHAT_DISCONNECTED, - HAPPYCHAT_INITIALIZE, HAPPYCHAT_RECEIVE_EVENT, HAPPYCHAT_RECONNECTING, HAPPYCHAT_SEND_MESSAGE, @@ -45,14 +42,8 @@ import { } from 'state/action-types'; import { HAPPYCHAT_MESSAGE_TYPES } from 'state/happychat/constants'; -export const connectChat = () => ( { type: HAPPYCHAT_CONNECT } ); - -export const initialize = () => ( { type: HAPPYCHAT_INITIALIZE } ); - export const setConnected = user => ( { type: HAPPYCHAT_CONNECTED, user } ); -export const setConnecting = () => ( { type: HAPPYCHAT_CONNECTING } ); - export const setDisconnected = errorStatus => ( { type: HAPPYCHAT_DISCONNECTED, errorStatus } ); export const setReconnecting = () => ( { type: HAPPYCHAT_RECONNECTING } ); diff --git a/client/state/happychat/connection/reducer.js b/client/state/happychat/connection/reducer.js index a93442b327a38..7659029bbaa67 100644 --- a/client/state/happychat/connection/reducer.js +++ b/client/state/happychat/connection/reducer.js @@ -4,9 +4,9 @@ */ import { HAPPYCHAT_SET_AVAILABLE, - HAPPYCHAT_CONNECTING, HAPPYCHAT_CONNECTED, HAPPYCHAT_DISCONNECTED, + HAPPYCHAT_IO_INIT, HAPPYCHAT_RECONNECTING, } from 'state/action-types'; import { @@ -38,7 +38,7 @@ const error = ( state = null, action ) => { */ const status = ( state = HAPPYCHAT_CONNECTION_STATUS_UNINITIALIZED, action ) => { switch ( action.type ) { - case HAPPYCHAT_CONNECTING: + case HAPPYCHAT_IO_INIT: return HAPPYCHAT_CONNECTION_STATUS_CONNECTING; case HAPPYCHAT_CONNECTED: return HAPPYCHAT_CONNECTION_STATUS_CONNECTED; diff --git a/client/state/happychat/middleware.js b/client/state/happychat/middleware.js index 31190eb5e2dd6..b716044cb7ccd 100644 --- a/client/state/happychat/middleware.js +++ b/client/state/happychat/middleware.js @@ -1,21 +1,16 @@ +/** @format */ + /** * External dependencies - * - * @format */ - import moment from 'moment'; import { has, isEmpty, throttle } from 'lodash'; /** * Internal dependencies */ -import config from 'config'; -import wpcom from 'lib/wp'; import { ANALYTICS_EVENT_RECORD, - HAPPYCHAT_CONNECT, - HAPPYCHAT_INITIALIZE, // new happychat action types HAPPYCHAT_IO_INIT, HAPPYCHAT_IO_REQUEST_TRANSCRIPT, @@ -54,10 +49,8 @@ import { getGroups } from './selectors'; import getGeoLocation from 'state/happychat/selectors/get-geolocation'; import isHappychatChatAssigned from 'state/happychat/selectors/is-happychat-chat-assigned'; import isHappychatClientConnected from 'state/happychat/selectors/is-happychat-client-connected'; -import isHappychatConnectionUninitialized from 'state/happychat/selectors/is-happychat-connection-uninitialized'; -import wasHappychatRecentlyActive from 'state/happychat/selectors/was-happychat-recently-active'; import { getCurrentUser, getCurrentUserLocale } from 'state/current-user/selectors'; -import { getHelpSelectedSite } from 'state/help/selectors'; +import buildConnection from 'lib/happychat/connection'; import debugFactory from 'debug'; const debug = debugFactory( 'calypso:happychat:actions' ); @@ -69,30 +62,6 @@ const sendTyping = throttle( { leading: true, trailing: false } ); -// Promise based interface for wpcom.request -const request = ( ...args ) => - new Promise( ( resolve, reject ) => { - wpcom.request( ...args, ( error, response ) => { - if ( error ) { - return reject( error ); - } - resolve( response ); - } ); - } ); - -const sign = payload => - request( { - method: 'POST', - path: '/jwt/sign', - body: { payload: JSON.stringify( payload ) }, - } ); - -const startSession = () => - request( { - method: 'POST', - path: '/happychat/session', - } ); - export const updateChatPreferences = ( connection, { getState }, siteId ) => { const state = getState(); @@ -104,38 +73,6 @@ export const updateChatPreferences = ( connection, { getState }, siteId ) => { } }; -export const connectChat = ( connection, { getState, dispatch } ) => { - const state = getState(); - if ( ! isHappychatConnectionUninitialized( state ) ) { - // If chat has already initialized, do nothing - return; - } - - const url = config( 'happychat_url' ); - - const user = getCurrentUser( state ); - const locale = getCurrentUserLocale( state ); - let groups = getGroups( state ); - const selectedSite = getHelpSelectedSite( state ); - if ( selectedSite && selectedSite.ID ) { - groups = getGroups( state, selectedSite.ID ); - } - - const happychatUser = { - signer_user_id: user.ID, - locale, - groups, - }; - - return startSession() - .then( ( { session_id, geo_location } ) => { - happychatUser.geoLocation = geo_location; - return sign( { user, session_id } ); - } ) - .then( ( { jwt } ) => connection.init( url, dispatch, { jwt, ...happychatUser } ) ) - .catch( e => debug( 'failed to start Happychat session', e, e.stack ) ); -}; - export const requestTranscript = ( connection, { dispatch } ) => { debug( 'requesting current session transcript' ); @@ -204,13 +141,6 @@ export const sendInfo = ( connection, { getState }, action ) => { connection.sendInfo( info ); }; -export const connectIfRecentlyActive = ( connection, store ) => { - if ( wasHappychatRecentlyActive( store.getState() ) ) { - return connectChat( connection, store ); - } - return Promise.resolve(); // for testing purposes we need to return a promise -}; - export const sendRouteSetEventMessage = ( connection, { getState }, action ) => { const state = getState(); const currentUser = getCurrentUser( state ); @@ -320,13 +250,12 @@ export default function( connection = null ) { // Allow a connection object to be specified for // testing. If blank, use a real connection. if ( connection == null ) { - connection = require( './common' ).connection; + connection = buildConnection(); } // This is a placeholder to make sure connectionNG is never used, // but doesn't give a compilation error either. const connectionNG = { - init: () => {}, send: () => {}, request: () => {}, }; @@ -336,14 +265,6 @@ export default function( connection = null ) { sendActionLogsAndEvents( connection, store, action ); switch ( action.type ) { - case HAPPYCHAT_CONNECT: - connectChat( connection, store ); - break; - - case HAPPYCHAT_INITIALIZE: - connectIfRecentlyActive( connection, store ); - break; - case HELP_CONTACT_FORM_SITE_SELECT: updateChatPreferences( connection, store, action.siteId ); break; @@ -368,11 +289,11 @@ export default function( connection = null ) { sendRouteSetEventMessage( connection, store, action ); break; - // NEW SOCKET API SURFACE case HAPPYCHAT_IO_INIT: - connectionNG.init( store.dispatch, action.config ); + connection.init( store.dispatch, action.auth ); break; + // NEW SOCKET API SURFACE - still not in use case HAPPYCHAT_IO_SEND_MESSAGE_EVENT: case HAPPYCHAT_IO_SEND_MESSAGE_LOG: case HAPPYCHAT_IO_SEND_MESSAGE_MESSAGE: diff --git a/client/state/happychat/test/middleware-ng.js b/client/state/happychat/test/middleware-ng.js new file mode 100644 index 0000000000000..f2797d8082fe0 --- /dev/null +++ b/client/state/happychat/test/middleware-ng.js @@ -0,0 +1,371 @@ +/** @format */ + +/** + * External dependencies + */ +// import deepFreeze from 'deep-freeze'; + +/** + * Internal dependencies + */ +// import middleware, { +// sendActionLogsAndEvents, +// sendAnalyticsLogEvent, +// getEventMessageFromTracksData, +// } from '../middleware'; +import middleware from '../middleware'; +import { + initConnection, + // requestTranscript, + // sendEvent, + // sendLog, + // sendMessage, + // sendUserInfo, + // sendPreferences, + // sendTyping, + // sendNotTyping, +} from 'state/happychat/connection/actions'; +// import { selectSiteId } from 'state/help/actions'; +// import { setRoute } from 'state/ui/actions'; +// import { getCurrentUserLocale } from 'state/current-user/selectors'; +// import { getGroups } from 'state/happychat/selectors'; +// import { +// HAPPYCHAT_CHAT_STATUS_ASSIGNED, +// HAPPYCHAT_CHAT_STATUS_DEFAULT, +// HAPPYCHAT_CHAT_STATUS_PENDING, +// HAPPYCHAT_CONNECTION_STATUS_UNINITIALIZED, +// HAPPYCHAT_CONNECTION_STATUS_CONNECTED, +// HAPPYCHAT_CONNECTION_STATUS_DISCONNECTED, +// } from 'state/happychat/constants'; +// import { +// ANALYTICS_EVENT_RECORD, +// HAPPYCHAT_BLUR, +// HAPPYCHAT_IO_SEND_MESSAGE_EVENT, +// HAPPYCHAT_IO_SEND_MESSAGE_LOG, +// } from 'state/action-types'; + +describe( 'middleware', () => { + let actionMiddleware, connection, store; + beforeEach( () => { + connection = { + init: jest.fn(), + // send: jest.fn(), + // request: jest.fn(), + }; + + store = { + getState: jest.fn(), + dispatch: jest.fn(), + }; + + actionMiddleware = middleware( connection )( store )( jest.fn() ); + } ); + + describe( 'connection.init actions are connected', () => { + test( 'HAPPYCHAT_IO_INIT', () => { + const action = initConnection( jest.fn() ); + actionMiddleware( action ); + expect( connection.init ).toHaveBeenCalledWith( store.dispatch, action.auth ); + } ); + } ); + + // TODO: to be enabled when the corresponding changes are merged + // describe( 'connection.send actions are connected', () => { + // test( 'HAPPYCHAT_IO_SEND_MESSAGE_EVENT', () => { + // const action = sendEvent( 'msg' ); + // actionMiddleware( action ); + // expect( connection.send ).toHaveBeenCalledWith( action ); + // } ); + // + // test( 'HAPPYCHAT_IO_SEND_MESSAGE_LOG', () => { + // const action = sendLog( 'msg' ); + // actionMiddleware( action ); + // expect( connection.send ).toHaveBeenCalledWith( action ); + // } ); + // + // test( 'HAPPYCHAT_IO_SEND_MESSAGE_MESSAGE', () => { + // const action = sendMessage( 'msg' ); + // actionMiddleware( action ); + // expect( connection.send ).toHaveBeenCalledWith( action ); + // } ); + // + // test( 'HAPPYCHAT_IO_SEND_MESSAGE_USERINFO', () => { + // const action = sendUserInfo( { user: 'user' } ); + // actionMiddleware( action ); + // expect( connection.send ).toHaveBeenCalledWith( action ); + // } ); + // + // test( 'HAPPYCHAT_IO_SEND_MESSAGE_PREFERENCES', () => { + // const action = sendPreferences( 'locale', [] ); + // actionMiddleware( action ); + // expect( connection.send ).toHaveBeenCalledWith( action ); + // } ); + // + // test( 'HAPPYCHAT_IO_SEND_MESSAGE_TYPING (sendTyping)', () => { + // const action = sendTyping( 'msg' ); + // actionMiddleware( action ); + // expect( connection.send ).toHaveBeenCalledWith( action ); + // } ); + // + // test( 'HAPPYCHAT_IO_SEND_MESSAGE_TYPING (sendNotTyping)', () => { + // const action = sendNotTyping( 'msg' ); + // actionMiddleware( action ); + // expect( connection.send ).toHaveBeenCalledWith( action ); + // } ); + // } ); + // + // describe( 'connection.request actions are connected', () => { + // test( 'HAPPYCHAT_IO_REQUEST_TRANSCRIPT', () => { + // const action = requestTranscript( 20, 30 ); + // actionMiddleware( action ); + // expect( connection.request ).toHaveBeenCalledWith( action, action.timeout ); + // } ); + // } ); + // + // describe( 'Calypso actions are converted to SocketIO actions', () => { + // describe( 'HELP_CONTACT_FORM_SITE_SELECT', () => { + // test( 'should dispatch a sendPreferences action if happychat client is connected', () => { + // const state = { + // happychat: { + // connection: { status: HAPPYCHAT_CONNECTION_STATUS_CONNECTED }, + // }, + // currentUser: { + // locale: 'en', + // capabilities: {}, + // }, + // sites: { + // items: { + // 1: { ID: 1 }, + // }, + // }, + // ui: { + // section: { + // name: 'reader', + // }, + // }, + // }; + // store.getState.mockReturnValue( state ); + // const action = selectSiteId( state.sites.items[ 1 ].ID ); + // actionMiddleware( action ); + // expect( store.dispatch ).toHaveBeenCalledWith( + // sendPreferences( getCurrentUserLocale( state ), getGroups( state, action.siteId ) ) + // ); + // } ); + // + // test( 'should not dispatch a sendPreferences action if there is no happychat connection', () => { + // const state = { + // currentUser: { + // locale: 'en', + // capabilities: {}, + // }, + // sites: { + // items: { + // 1: { ID: 1 }, + // }, + // }, + // }; + // store.getState.mockReturnValue( state ); + // const action = selectSiteId( state.sites.items[ 1 ].ID ); + // actionMiddleware( action ); + // expect( store.dispatch ).not.toHaveBeenCalled(); + // } ); + // } ); + // + // describe( 'ROUTE_SET', () => { + // const action = setRoute( '/me' ); + // + // let state; + // beforeEach( () => { + // state = { + // currentUser: { + // id: '2', + // }, + // users: { + // items: { + // 2: { username: 'Link' }, + // }, + // }, + // happychat: { + // connection: { + // status: HAPPYCHAT_CONNECTION_STATUS_CONNECTED, + // isAvailable: true, + // }, + // chat: { status: HAPPYCHAT_CHAT_STATUS_ASSIGNED }, + // }, + // }; + // + // store.getState.mockReturnValue( state ); + // } ); + // + // test( 'should dispatch a sendEvent action if client connected and chat assigned', () => { + // actionMiddleware( action ); + // expect( store.dispatch.mock.calls[ 0 ][ 0 ].payload.text ).toBe( + // 'Looking at https://wordpress.com/me?support_user=Link' + // ); + // } ); + // + // test( 'should not dispatch a sendEvent action if client is not connected', () => { + // state.happychat.connection.status = HAPPYCHAT_CONNECTION_STATUS_DISCONNECTED; + // actionMiddleware( action ); + // expect( store.dispatch ).not.toHaveBeenCalled(); + // } ); + // + // test( 'should not dispatch a sendEvent action if chat is not assigned', () => { + // state.happychat.chat.status = HAPPYCHAT_CHAT_STATUS_PENDING; + // actionMiddleware( action ); + // expect( store.dispatch ).not.toHaveBeenCalled(); + // } ); + // } ); + // } ); + // + // describe( '#sendAnalyticsLogEvent', () => { + // test( 'should ignore non-tracks analytics recordings', () => { + // const analyticsMeta = [ + // { type: ANALYTICS_EVENT_RECORD, payload: { service: 'ga' } }, + // { type: ANALYTICS_EVENT_RECORD, payload: { service: 'fb' } }, + // { type: ANALYTICS_EVENT_RECORD, payload: { service: 'adwords' } }, + // ]; + // sendAnalyticsLogEvent( store.dispatch, { meta: { analytics: analyticsMeta } } ); + // + // expect( store.dispatch ).not.toHaveBeenCalled(); + // } ); + // + // test( 'should send log events for all listed tracks events', () => { + // const analyticsMeta = [ + // { type: ANALYTICS_EVENT_RECORD, payload: { service: 'ga' } }, + // { type: ANALYTICS_EVENT_RECORD, payload: { service: 'tracks', name: 'abc' } }, + // { type: ANALYTICS_EVENT_RECORD, payload: { service: 'adwords' } }, + // { type: ANALYTICS_EVENT_RECORD, payload: { service: 'tracks', name: 'def' } }, + // ]; + // sendAnalyticsLogEvent( store.dispatch, { meta: { analytics: analyticsMeta } } ); + // + // expect( store.dispatch ).toHaveBeenCalledTimes( 2 ); + // expect( store.dispatch.mock.calls[ 0 ][ 0 ].payload.text ).toBe( 'abc' ); + // expect( store.dispatch.mock.calls[ 1 ][ 0 ].payload.text ).toBe( 'def' ); + // } ); + // + // test( 'should only send a timeline event for whitelisted tracks events', () => { + // const analyticsMeta = [ + // { + // type: ANALYTICS_EVENT_RECORD, + // payload: { service: 'tracks', name: 'calypso_add_new_wordpress_click' }, + // }, + // { type: ANALYTICS_EVENT_RECORD, payload: { service: 'tracks', name: 'abc' } }, + // { + // type: ANALYTICS_EVENT_RECORD, + // payload: { + // service: 'tracks', + // name: 'calypso_themeshowcase_theme_activate', + // properties: {}, + // }, + // }, + // { type: ANALYTICS_EVENT_RECORD, payload: { service: 'tracks', name: 'def' } }, + // ]; + // sendAnalyticsLogEvent( store.dispatch, { meta: { analytics: analyticsMeta } } ); + // + // expect( store.dispatch.mock.calls[ 0 ][ 0 ].type ).toBe( HAPPYCHAT_IO_SEND_MESSAGE_EVENT ); + // expect( store.dispatch.mock.calls[ 1 ][ 0 ].type ).toBe( HAPPYCHAT_IO_SEND_MESSAGE_LOG ); + // expect( store.dispatch.mock.calls[ 2 ][ 0 ].type ).toBe( HAPPYCHAT_IO_SEND_MESSAGE_LOG ); + // expect( store.dispatch.mock.calls[ 3 ][ 0 ].type ).toBe( HAPPYCHAT_IO_SEND_MESSAGE_EVENT ); + // expect( store.dispatch.mock.calls[ 4 ][ 0 ].type ).toBe( HAPPYCHAT_IO_SEND_MESSAGE_LOG ); + // expect( store.dispatch.mock.calls[ 5 ][ 0 ].type ).toBe( HAPPYCHAT_IO_SEND_MESSAGE_LOG ); + // + // expect( store.dispatch ).toHaveBeenCalledTimes( 6 ); + // expect( store.dispatch.mock.calls[ 0 ][ 0 ].payload.text ).toBe( + // getEventMessageFromTracksData( analyticsMeta[ 0 ].payload ) + // ); + // expect( store.dispatch.mock.calls[ 3 ][ 0 ].payload.text ).toBe( + // getEventMessageFromTracksData( analyticsMeta[ 2 ].payload ) + // ); + // } ); + // } ); + // + // describe( '#sendActionLogsAndEvents', () => { + // const assignedState = deepFreeze( { + // happychat: { + // connection: { status: HAPPYCHAT_CONNECTION_STATUS_CONNECTED }, + // chat: { status: HAPPYCHAT_CHAT_STATUS_ASSIGNED }, + // }, + // } ); + // const unassignedState = deepFreeze( { + // happychat: { + // connection: { status: HAPPYCHAT_CONNECTION_STATUS_CONNECTED }, + // chat: { status: HAPPYCHAT_CHAT_STATUS_DEFAULT }, + // }, + // } ); + // const unconnectedState = deepFreeze( { + // happychat: { + // connection: { status: HAPPYCHAT_CONNECTION_STATUS_UNINITIALIZED }, + // chat: { status: HAPPYCHAT_CHAT_STATUS_DEFAULT }, + // }, + // } ); + // + // test( "should not send events if there's no Happychat connection", () => { + // const action = { + // type: HAPPYCHAT_BLUR, + // meta: { + // analytics: [ + // { type: ANALYTICS_EVENT_RECORD, payload: { service: 'tracks', name: 'abc' } }, + // ], + // }, + // }; + // store.getState.mockReturnValue( unconnectedState ); + // sendActionLogsAndEvents( store, action ); + // + // expect( store.dispatch ).not.toHaveBeenCalled(); + // } ); + // + // test( 'should not send log events if the Happychat connection is unassigned', () => { + // const action = { + // type: HAPPYCHAT_BLUR, + // meta: { + // analytics: [ + // { type: ANALYTICS_EVENT_RECORD, payload: { service: 'tracks', name: 'abc' } }, + // ], + // }, + // }; + // store.getState.mockReturnValue( unassignedState ); + // sendActionLogsAndEvents( store, action ); + // + // expect( store.dispatch ).not.toHaveBeenCalled(); + // } ); + // + // test( 'should send matching events when Happychat is connected and assigned', () => { + // const action = { + // type: HAPPYCHAT_BLUR, + // meta: { + // analytics: [ + // { + // type: ANALYTICS_EVENT_RECORD, + // payload: { service: 'tracks', name: 'calypso_add_new_wordpress_click' }, + // }, + // { type: ANALYTICS_EVENT_RECORD, payload: { service: 'tracks', name: 'abc' } }, + // { + // type: ANALYTICS_EVENT_RECORD, + // payload: { + // service: 'tracks', + // name: 'calypso_themeshowcase_theme_activate', + // properties: {}, + // }, + // }, + // { type: ANALYTICS_EVENT_RECORD, payload: { service: 'tracks', name: 'def' } }, + // ], + // }, + // }; + // store.getState.mockReturnValue( assignedState ); + // sendActionLogsAndEvents( store, action ); + // + // // All 4 analytics records will be sent to the "firehose" log + // // The two whitelisted analytics events and the HAPPYCHAT_BLUR action itself + // // will be sent as customer events + // expect( store.dispatch ).toHaveBeenCalledTimes( 7 ); + // expect( store.dispatch.mock.calls[ 0 ][ 0 ].type ).toBe( HAPPYCHAT_IO_SEND_MESSAGE_EVENT ); + // expect( store.dispatch.mock.calls[ 1 ][ 0 ].type ).toBe( HAPPYCHAT_IO_SEND_MESSAGE_LOG ); + // expect( store.dispatch.mock.calls[ 2 ][ 0 ].type ).toBe( HAPPYCHAT_IO_SEND_MESSAGE_LOG ); + // expect( store.dispatch.mock.calls[ 3 ][ 0 ].type ).toBe( HAPPYCHAT_IO_SEND_MESSAGE_EVENT ); + // expect( store.dispatch.mock.calls[ 4 ][ 0 ].type ).toBe( HAPPYCHAT_IO_SEND_MESSAGE_LOG ); + // expect( store.dispatch.mock.calls[ 5 ][ 0 ].type ).toBe( HAPPYCHAT_IO_SEND_MESSAGE_LOG ); + // expect( store.dispatch.mock.calls[ 6 ][ 0 ].type ).toBe( HAPPYCHAT_IO_SEND_MESSAGE_EVENT ); + // } ); + // } ); +} ); diff --git a/client/state/happychat/test/middleware.js b/client/state/happychat/test/middleware.js index ceb5845631cfe..4332cc1c87d8e 100644 --- a/client/state/happychat/test/middleware.js +++ b/client/state/happychat/test/middleware.js @@ -13,8 +13,6 @@ import { spy, stub } from 'sinon'; * Internal dependencies */ import middleware, { - connectChat, - connectIfRecentlyActive, requestTranscript, sendActionLogsAndEvents, sendAnalyticsLogEvent, @@ -28,9 +26,7 @@ import { HAPPYCHAT_CHAT_STATUS_PENDING, HAPPYCHAT_CONNECTION_STATUS_UNINITIALIZED, HAPPYCHAT_CONNECTION_STATUS_CONNECTED, - HAPPYCHAT_CONNECTION_STATUS_CONNECTING, } from '../constants'; -import wpcom from 'lib/wp'; import { ANALYTICS_EVENT_RECORD, HAPPYCHAT_BLUR, @@ -42,61 +38,6 @@ import { import { useSandbox } from 'test/helpers/use-sinon'; describe( 'middleware', () => { - describe( 'HAPPYCHAT_CONNECT action', () => { - // TODO: Add tests for cases outside the happy path - let connection; - let dispatch, getState; - const uninitializedState = deepFreeze( { - currentUser: { id: 1, capabilities: {} }, - happychat: { connection: { status: HAPPYCHAT_CONNECTION_STATUS_UNINITIALIZED } }, - users: { items: { 1: {} } }, - help: { selectedSiteId: 2647731 }, - sites: { - items: { - 2647731: { - ID: 2647731, - name: 'Manual Automattic Updates', - }, - }, - }, - ui: { - section: { - name: 'reader', - }, - }, - } ); - - useSandbox( sandbox => { - connection = { - init: sandbox.stub().returns( Promise.resolve() ), - }; - dispatch = sandbox.stub(); - getState = sandbox.stub(); - sandbox.stub( wpcom, 'request', ( args, callback ) => callback( null, {} ) ); - } ); - - test( 'should not attempt to connect when Happychat has been initialized', () => { - const connectedState = { - happychat: { connection: { status: HAPPYCHAT_CONNECTION_STATUS_CONNECTED } }, - }; - const connectingState = { - happychat: { connection: { status: HAPPYCHAT_CONNECTION_STATUS_CONNECTING } }, - }; - - return Promise.all( [ - connectChat( connection, { dispatch, getState: getState.returns( connectedState ) } ), - connectChat( connection, { dispatch, getState: getState.returns( connectingState ) } ), - ] ).then( () => expect( connection.init ).not.to.have.been.called ); - } ); - - test( 'should attempt to connect when Happychat is uninitialized', () => { - getState.returns( uninitializedState ); - return connectChat( connection, { dispatch, getState } ).then( () => { - expect( connection.init ).to.have.been.calledOnce; - } ); - } ); - } ); - describe( 'HAPPYCHAT_SEND_USER_INFO action', () => { const state = { happychat: { @@ -169,87 +110,6 @@ describe( 'middleware', () => { } ); } ); - describe( 'HAPPYCHAT_INITIALIZE action', () => { - // TODO: This test is only complicated because connectIfRecentlyActive calls - // connectChat directly, and since both are in the same module we can't stub - // connectChat. So we need to build up all the objects to make connectChat execute - // without errors. It may be worth pulling each of these helpers out into their - // own modules, so that we can stub them and simplify our tests. - const recentlyActiveState = deepFreeze( { - currentUser: { id: 1, capabilities: {} }, - happychat: { - connection: { status: HAPPYCHAT_CONNECTION_STATUS_UNINITIALIZED }, - lastActivityTimestamp: Date.now(), - }, - users: { items: { 1: {} } }, - help: { selectedSiteId: 2647731 }, - sites: { - items: { - 2647731: { - ID: 2647731, - name: 'Manual Automattic Updates', - }, - }, - }, - ui: { - section: { - name: 'reader', - }, - }, - } ); - const storeRecentlyActive = { - dispatch: noop, - getState: stub().returns( recentlyActiveState ), - }; - - const notRecentlyActiveState = deepFreeze( { - currentUser: { id: 1, capabilities: {} }, - happychat: { - connection: { status: HAPPYCHAT_CONNECTION_STATUS_UNINITIALIZED }, - lastActivityTimestamp: null, // no record of last activity - }, - users: { items: { 1: {} } }, - help: { selectedSiteId: 2647731 }, - sites: { - items: { - 2647731: { - ID: 2647731, - name: 'Manual Automattic Updates', - }, - }, - }, - ui: { - section: { - name: 'reader', - }, - }, - } ); - const storeNotRecentlyActive = { - dispatch: noop, - getState: stub().returns( notRecentlyActiveState ), - }; - - let connection; - useSandbox( sandbox => { - connection = { - init: sandbox.stub().returns( Promise.resolve() ), - }; - sandbox.stub( wpcom, 'request', ( args, callback ) => callback( null, {} ) ); - } ); - - test( 'should connect the chat if user was recently connected', () => { - connectIfRecentlyActive( connection, storeRecentlyActive ).then( () => { - expect( connection.init ).to.have.been.called; - } ); - } ); - - test( 'should not connect the chat if user was not recently connected', () => { - connectIfRecentlyActive( connection, storeNotRecentlyActive ).then( () => { - expect( connection.init ).to.not.have.been.called; - } ); - } ); - } ); - describe( 'HAPPYCHAT_SEND_MESSAGE action', () => { test( 'should send the message through the connection and send a notTyping signal', () => { const action = { type: HAPPYCHAT_SEND_MESSAGE, message: 'Hello world' }; diff --git a/client/state/happychat/test/utils.js b/client/state/happychat/test/utils.js new file mode 100644 index 0000000000000..d69633b5509f9 --- /dev/null +++ b/client/state/happychat/test/utils.js @@ -0,0 +1,81 @@ +/** @format */ + +/** + * Internal dependencies + */ +import { getHappychatAuth } from '../utils'; +import config from 'config'; +import * as wpcom from 'lib/wp'; +import * as selectedSite from 'state/help/selectors'; + +describe( 'auth promise', () => { + const state = { + currentUser: { + id: 3, + }, + users: { + items: { + 3: { ID: 123456, localeSlug: 'gl' }, + }, + }, + ui: { + section: { + name: 'jetpackConnect', + }, + }, + }; + + describe( 'upon request success', () => { + beforeEach( () => { + wpcom.default.request = jest.fn(); + wpcom.default.request.mockImplementation( ( args, callback ) => + callback( null, { + jwt: 'jwt', + geo_location: { + city: 'Lugo', + }, + } ) + ); + + // mock getHelpSelectedSite to return null + selectedSite.getHelpSelectedSite = jest.fn(); + selectedSite.getHelpSelectedSite.mockReturnValue( null ); + } ); + + test( 'should return a fulfilled Promise', () => { + getHappychatAuth( state )().then( user => { + expect( user ).toMatchObject( { + url: config( 'happychat_url' ), + user: { + signer_user_id: state.users.items[ 3 ].ID, + locale: state.users.items[ 3 ].localeSlug, + groups: [ 'jpop' ], + jwt: 'jwt', + geoLocation: { city: 'Lugo' }, + }, + } ); + } ); + } ); + } ); + + describe( 'upon request failure', () => { + beforeEach( () => { + wpcom.default.request = jest.fn(); + wpcom.default.request.mockImplementation( ( args, callback ) => + callback( 'failed request', {} ) + ); + + // mock getHelpSelectedSite to return null + selectedSite.getHelpSelectedSite = jest.fn(); + selectedSite.getHelpSelectedSite.mockReturnValue( null ); + } ); + + test( 'should return a rejected Promise', () => { + getHappychatAuth( state )().catch( error => { + expect( error ).toBe( + 'Failed to start an authenticated Happychat session: failed request' + ); + } ); + } ); + } ); +} ); diff --git a/client/state/happychat/utils.js b/client/state/happychat/utils.js new file mode 100644 index 0000000000000..6d965e9e953ed --- /dev/null +++ b/client/state/happychat/utils.js @@ -0,0 +1,62 @@ +/** @format */ + +/** + * Internal dependencies + */ +import wpcom from 'lib/wp'; +import config from 'config'; +import { getGroups } from 'state/happychat/selectors'; +import { getCurrentUser, getCurrentUserLocale } from 'state/current-user/selectors'; +import { getHelpSelectedSite } from 'state/help/selectors'; + +// Promise based interface for wpcom.request +const request = ( ...args ) => + new Promise( ( resolve, reject ) => { + wpcom.request( ...args, ( error, response ) => { + if ( error ) { + return reject( error ); + } + resolve( response ); + } ); + } ); + +const sign = payload => + request( { + method: 'POST', + path: '/jwt/sign', + body: { payload: JSON.stringify( payload ) }, + } ); + +const startSession = () => + request( { + method: 'POST', + path: '/happychat/session', + } ); + +export const getHappychatAuth = state => () => { + const url = config( 'happychat_url' ); + + const locale = getCurrentUserLocale( state ); + + let groups = getGroups( state ); + const selectedSite = getHelpSelectedSite( state ); + if ( selectedSite && selectedSite.ID ) { + groups = getGroups( state, selectedSite.ID ); + } + + const user = getCurrentUser( state ); + + const happychatUser = { + signer_user_id: user.ID, + locale, + groups, + }; + + return startSession() + .then( ( { session_id, geo_location } ) => { + happychatUser.geoLocation = geo_location; + return sign( { user, session_id } ); + } ) + .then( ( { jwt } ) => ( { url, user: { jwt, ...happychatUser } } ) ) + .catch( e => Promise.reject( 'Failed to start an authenticated Happychat session: ' + e ) ); +}; From 22b497e6fb3d2325fbbe4366f0264a79ad83e341 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s?= Date: Wed, 1 Nov 2017 11:08:57 +0100 Subject: [PATCH 132/192] Implements connection.request (#19227) --- client/lib/happychat/connection.js | 83 +++++-- client/lib/happychat/test/index.js | 230 ++++++++++--------- client/state/action-types.js | 2 - client/state/happychat/chat/reducer.js | 7 +- client/state/happychat/connection/actions.js | 10 - client/state/happychat/middleware.js | 26 +-- client/state/happychat/test/middleware-ng.js | 22 +- client/state/happychat/test/middleware.js | 29 --- 8 files changed, 202 insertions(+), 207 deletions(-) diff --git a/client/lib/happychat/connection.js b/client/lib/happychat/connection.js index 0706c687a1e69..918036ff06d70 100644 --- a/client/lib/happychat/connection.js +++ b/client/lib/happychat/connection.js @@ -14,7 +14,8 @@ import { isString } from 'lodash'; import { HAPPYCHAT_MESSAGE_TYPES } from 'state/happychat/constants'; import { receiveChatEvent, - requestChatTranscript, + receiveError, + requestTranscript, setConnected, setDisconnected, setHappychatAvailable, @@ -55,7 +56,7 @@ class Connection { .on( 'token', handler => handler( { signer_user_id, jwt, locale, groups } ) ) .on( 'init', () => { dispatch( setConnected( { signer_user_id, locale, groups, geoLocation } ) ); - dispatch( requestChatTranscript() ); + dispatch( requestTranscript() ); resolve( socket ); } ) .on( 'unauthorized', () => { @@ -149,23 +150,67 @@ class Connection { ); } - transcript( timestamp ) { - return this.openSocket.then( socket => - Promise.race( [ - new Promise( ( resolve, reject ) => { - socket.emit( 'transcript', timestamp || null, ( e, result ) => { - if ( e ) { - return reject( new Error( e ) ); - } - resolve( result ); - } ); - } ), - new Promise( ( resolve, reject ) => - setTimeout( () => { - reject( Error( 'timeout' ) ); - }, 10000 ) - ), - ] ) + /** + * + * Given a Redux action and a timeout, emits a SocketIO event that request + * some info to the Happychat server. + * + * The request can have three states, and will dispatch an action accordingly: + * + * - request was succesful: would dispatch action.callback + * - request was unsucessful: would dispatch receiveError + * - request timeout: would dispatch action.callbackTimeout + * + * @param { Object } action A Redux action with props + * { + * event: SocketIO event name, + * payload: contents to be sent, + * callback: a Redux action creator, + * callbackTimeout: a Redux action creator, + * } + * @param { Number } timeout How long (in milliseconds) has the server to respond + * @return { Promise } Fulfilled (returns the transcript response) + * or rejected (returns an error message) + */ + request( action, timeout ) { + if ( ! this.openSocket ) { + return; + } + + return this.openSocket.then( + socket => { + const promiseRace = Promise.race( [ + new Promise( ( resolve, reject ) => { + socket.emit( action.event, action.payload, ( e, result ) => { + if ( e ) { + return reject( new Error( e ) ); // request failed + } + return resolve( result ); // request succesful + } ); + } ), + new Promise( ( resolve, reject ) => + setTimeout( () => { + return reject( new Error( 'timeout' ) ); // request timeout + }, timeout ) + ), + ] ); + + // dispatch the request state upon promise race resolution + promiseRace.then( + result => this.dispatch( action.callback( result ) ), + e => + e.message === 'timeout' + ? this.dispatch( action.callbackTimeout() ) + : this.dispatch( receiveError( action.event + ' request failed: ' + e.message ) ) + ); + + return promiseRace; + }, + e => { + this.dispatch( receiveError( 'failed to send ' + action.event + ': ' + e ) ); + // so we can relay the error message, for testing purposes + return Promise.reject( e ); + } ); } } diff --git a/client/lib/happychat/test/index.js b/client/lib/happychat/test/index.js index 872b95b919c85..ec62cf3a9e9e8 100644 --- a/client/lib/happychat/test/index.js +++ b/client/lib/happychat/test/index.js @@ -9,8 +9,11 @@ import { EventEmitter } from 'events'; * Internal dependencies */ import { + receiveError, + receiveTranscript, + receiveTranscriptTimeout, + requestTranscript, setConnected, - requestChatTranscript, setDisconnected, setReconnecting, setHappychatAvailable, @@ -68,7 +71,7 @@ describe( 'connection', () => { expect( dispatch.mock.calls[ 0 ][ 0 ] ).toEqual( setConnected( { signer_user_id, locale, groups, geoLocation } ) ); - expect( dispatch.mock.calls[ 1 ][ 0 ] ).toEqual( requestChatTranscript() ); + expect( dispatch.mock.calls[ 1 ][ 0 ] ).toEqual( requestTranscript() ); return expect( openSocket ).resolves.toBe( socket ); } ); @@ -206,114 +209,117 @@ describe( 'connection', () => { } ); } ); - // TODO: to be enabled when corresponding connection changes are merged - // describe( 'when auth promise chain is fulfilled', () => { - // const signer_user_id = 12; - // const jwt = 'jwt'; - // const locale = 'locale'; - // const groups = 'groups'; - // const geoLocation = 'location'; - // - // let socket, dispatch, connection, config; - // beforeEach( () => { - // socket = new EventEmitter(); - // dispatch = jest.fn(); - // connection = buildConnection(); - // config = Promise.resolve( { - // url: socket, - // user: { - // signer_user_id, - // jwt, - // locale, - // groups, - // geoLocation, - // }, - // } ); - // connection.init( dispatch, config ); - // } ); - // - // test( 'connection.send should emit a SocketIO event', () => { - // socket.emit( 'init' ); // resolve internal openSocket promise - // - // socket.emit = jest.fn(); - // const action = sendMessage( 'my msg' ); - // return connection.send( action ).then( () => { - // expect( socket.emit ).toHaveBeenCalledWith( action.event, action.payload ); - // } ); - // } ); - // - // describe( 'connection.request should emit a SocketIO event', () => { - // test( 'and dispatch callbackTimeout if socket did not respond', () => { - // socket.emit( 'init' ); // resolve internal openSocket promise - // - // const action = requestTranscript( null ); - // socket.emit = jest.fn(); - // return connection.request( action, 100 ).catch( error => { - // expect( socket.emit ).toHaveBeenCalled(); - // expect( socket.emit.mock.calls[ 0 ][ 0 ] ).toBe( action.event ); - // expect( socket.emit.mock.calls[ 0 ][ 1 ] ).toBe( action.payload ); - // expect( dispatch ).toHaveBeenCalledWith( action.callbackTimeout() ); - // expect( error.message ).toBe( 'timeout' ); - // } ); - // } ); - // - // test( 'and dispatch callback if socket responded successfully', () => { - // socket.emit( 'init' ); // resolve internal openSocket promise - // - // const action = requestTranscript( null ); - // socket.on( action.event, ( payload, callback ) => { - // const result = { - // messages: [ 'msg1', 'msg2' ], - // timestamp: Date.now(), - // }; - // callback( null, result ); // fake server responded ok - // } ); - // return connection.request( action, 100 ).then( result => { - // expect( dispatch ).toHaveBeenCalledWith( receiveTranscript( result ) ); - // } ); - // } ); - // - // test( 'and dispatch error if socket responded with error', () => { - // socket.emit( 'init' ); // resolve internal openSocket promise - // - // const action = requestTranscript( null ); - // socket.on( action.event, ( payload, callback ) => { - // callback( 'no data', null ); // fake server responded with error - // } ); - // return connection.request( action, 100 ).catch( error => { - // expect( error.message ).toBe( 'no data' ); - // expect( dispatch ).toHaveBeenCalledWith( - // receiveError( action.error + ': ' + error.message ) - // ); - // } ); - // } ); - // } ); - // } ); - // - // describe( 'when auth promise chain is rejected', () => { - // let socket, dispatch, connection, config; - // beforeEach( () => { - // socket = new EventEmitter(); - // dispatch = jest.fn(); - // connection = buildConnection(); - // config = Promise.reject( 'no auth' ); - // connection.init( dispatch, config ); - // } ); - // - // test( 'connection.send should dispatch receiveError action', () => { - // socket.emit = jest.fn(); - // const action = sendMessage( 'content' ); - // return connection.send( action ).catch( e => { - // expect( dispatch ).toHaveBeenCalledWith( receiveError( action.error + ': ' + e ) ); - // } ); - // } ); - // - // test( 'connection.request should dispatch receiveError action', () => { - // socket.emit = jest.fn(); - // const action = requestTranscript( null ); - // return connection.request( action, 100 ).catch( e => { - // expect( dispatch ).toHaveBeenCalledWith( receiveError( action.error + ': ' + e ) ); - // } ); - // } ); - // } ); + describe( 'when auth promise chain is fulfilled', () => { + const signer_user_id = 12; + const jwt = 'jwt'; + const locale = 'locale'; + const groups = 'groups'; + const geoLocation = 'location'; + + let socket, dispatch, connection, config; + beforeEach( () => { + socket = new EventEmitter(); + dispatch = jest.fn(); + connection = buildConnection(); + config = Promise.resolve( { + url: socket, + user: { + signer_user_id, + jwt, + locale, + groups, + geoLocation, + }, + } ); + connection.init( dispatch, config ); + } ); + + // TODO: to be enabled when corresponding connection changes land + // test( 'connection.send should emit a SocketIO event', () => { + // socket.emit( 'init' ); // resolve internal openSocket promise + // + // socket.emit = jest.fn(); + // const action = sendMessage( 'my msg' ); + // return connection.send( action ).then( () => { + // expect( socket.emit ).toHaveBeenCalledWith( action.event, action.payload ); + // } ); + // } ); + + describe( 'connection.request should emit a SocketIO event', () => { + test( 'and dispatch callbackTimeout if request ran out of time', () => { + socket.emit( 'init' ); // resolve internal openSocket promise + + const action = requestTranscript( null ); + socket.emit = jest.fn(); + return connection.request( action, 100 ).catch( error => { + expect( socket.emit ).toHaveBeenCalled(); + expect( socket.emit.mock.calls[ 0 ][ 0 ] ).toBe( action.event ); + expect( socket.emit.mock.calls[ 0 ][ 1 ] ).toBe( action.payload ); + expect( dispatch ).toHaveBeenCalledWith( receiveTranscriptTimeout() ); + expect( error.message ).toBe( 'timeout' ); + } ); + } ); + + test( 'and dispatch callback if request responded successfully', () => { + socket.emit( 'init' ); // resolve internal openSocket promise + + const action = requestTranscript( null ); + socket.on( action.event, ( payload, callback ) => { + const result = { + messages: [ 'msg1', 'msg2' ], + timestamp: Date.now(), + }; + callback( null, result ); // fake server responded ok + } ); + return connection.request( action, 100 ).then( result => { + expect( dispatch ).toHaveBeenCalledWith( receiveTranscript( result ) ); + } ); + } ); + + test( 'and dispatch error if request was not successful', () => { + socket.emit( 'init' ); // resolve internal openSocket promise + + const action = requestTranscript( null ); + socket.on( action.event, ( payload, callback ) => { + callback( 'no data', null ); // fake server responded with error + } ); + return connection.request( action, 100 ).catch( error => { + expect( error.message ).toBe( 'no data' ); + expect( dispatch ).toHaveBeenCalledWith( + receiveError( action.event + ' request failed: ' + error.message ) + ); + } ); + } ); + } ); + } ); + + describe( 'when auth promise chain is rejected', () => { + let socket, dispatch, connection, config; + beforeEach( () => { + socket = new EventEmitter(); + dispatch = jest.fn(); + connection = buildConnection(); + config = Promise.reject( 'no auth' ); + connection.init( dispatch, config ); + } ); + + // TODO: to be enabled when corresponding connection changes land + // test( 'connection.send should dispatch receiveError action', () => { + // socket.emit = jest.fn(); + // const action = sendMessage( 'content' ); + // return connection.send( action ).catch( e => { + // expect( dispatch ).toHaveBeenCalledWith( receiveError( action.error + ': ' + e ) ); + // } ); + // } ); + + test( 'connection.request should dispatch receiveError action', () => { + socket.emit = jest.fn(); + const action = requestTranscript( null ); + return connection.request( action, 100 ).catch( e => { + expect( dispatch ).toHaveBeenCalledWith( + receiveError( 'failed to send ' + action.event + ': ' + e ) + ); + } ); + } ); + } ); } ); diff --git a/client/state/action-types.js b/client/state/action-types.js index 5ad8ee98298da..ee20da2ead6e1 100644 --- a/client/state/action-types.js +++ b/client/state/action-types.js @@ -239,8 +239,6 @@ export const HAPPYCHAT_SEND_MESSAGE = 'HAPPYCHAT_SEND_MESSAGE'; export const HAPPYCHAT_SET_AVAILABLE = 'HAPPYCHAT_SET_AVAILABLE'; export const HAPPYCHAT_SET_CHAT_STATUS = 'HAPPYCHAT_SET_CHAT_STATUS'; export const HAPPYCHAT_SET_CURRENT_MESSAGE = 'HAPPYCHAT_SET_CURRENT_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_CONTACT_FORM_SITE_SELECT = 'HELP_CONTACT_FORM_SITE_SELECT'; export const HELP_TICKET_CONFIGURATION_DISMISS_ERROR = 'HELP_TICKET_CONFIGURATION_DISMISS_ERROR'; diff --git a/client/state/happychat/chat/reducer.js b/client/state/happychat/chat/reducer.js index 3ef3345030731..3f6214edeec7b 100644 --- a/client/state/happychat/chat/reducer.js +++ b/client/state/happychat/chat/reducer.js @@ -14,10 +14,11 @@ import validator from 'is-my-json-valid'; import { SERIALIZE, DESERIALIZE, + HAPPYCHAT_IO_REQUEST_TRANSCRIPT_RECEIVE, + HAPPYCHAT_IO_REQUEST_TRANSCRIPT_TIMEOUT, HAPPYCHAT_SEND_MESSAGE, HAPPYCHAT_RECEIVE_EVENT, HAPPYCHAT_SET_CHAT_STATUS, - HAPPYCHAT_TRANSCRIPT_RECEIVE, } from 'state/action-types'; import { HAPPYCHAT_CHAT_STATUS_DEFAULT, @@ -119,7 +120,9 @@ export const timeline = ( state = [], action ) => { const event = timelineEvent( {}, action ); const existing = find( state, ( { id } ) => event.id === id ); return existing ? state : concat( state, [ event ] ); - case HAPPYCHAT_TRANSCRIPT_RECEIVE: + case HAPPYCHAT_IO_REQUEST_TRANSCRIPT_TIMEOUT: + return state; + case HAPPYCHAT_IO_REQUEST_TRANSCRIPT_RECEIVE: const messages = filter( action.messages, message => { if ( ! message.id ) { return false; diff --git a/client/state/happychat/connection/actions.js b/client/state/happychat/connection/actions.js index 3ff578cf05166..854bf0d122d45 100644 --- a/client/state/happychat/connection/actions.js +++ b/client/state/happychat/connection/actions.js @@ -16,8 +16,6 @@ import { HAPPYCHAT_SEND_MESSAGE, HAPPYCHAT_SEND_USER_INFO, HAPPYCHAT_SET_AVAILABLE, - HAPPYCHAT_TRANSCRIPT_RECEIVE, - HAPPYCHAT_TRANSCRIPT_REQUEST, // NEW ACTION TYPES HAPPYCHAT_IO_INIT, HAPPYCHAT_IO_RECEIVE_ACCEPT, @@ -78,14 +76,6 @@ export const sendUserInfo = ( howCanWeHelp, howYouFeel, site ) => { export const receiveChatEvent = event => ( { type: HAPPYCHAT_RECEIVE_EVENT, event } ); -export const requestChatTranscript = () => ( { type: HAPPYCHAT_TRANSCRIPT_REQUEST } ); - -export const receiveChatTranscript = ( messages, timestamp ) => ( { - type: HAPPYCHAT_TRANSCRIPT_RECEIVE, - messages, - timestamp, -} ); - // === NEW ACTION CREATORS ===================================================== // === NEW ACTION CREATORS ===================================================== // === NEW ACTION CREATORS ===================================================== diff --git a/client/state/happychat/middleware.js b/client/state/happychat/middleware.js index b716044cb7ccd..78d75369e8ba2 100644 --- a/client/state/happychat/middleware.js +++ b/client/state/happychat/middleware.js @@ -24,7 +24,6 @@ import { HAPPYCHAT_SEND_USER_INFO, HAPPYCHAT_SEND_MESSAGE, HAPPYCHAT_SET_CURRENT_MESSAGE, - HAPPYCHAT_TRANSCRIPT_REQUEST, HELP_CONTACT_FORM_SITE_SELECT, ROUTE_SET, COMMENTS_CHANGE_STATUS, @@ -44,7 +43,6 @@ import { PURCHASE_REMOVE_COMPLETED, SITE_SETTINGS_SAVE_SUCCESS, } from 'state/action-types'; -import { receiveChatTranscript } from './connection/actions'; import { getGroups } from './selectors'; import getGeoLocation from 'state/happychat/selectors/get-geolocation'; import isHappychatChatAssigned from 'state/happychat/selectors/is-happychat-chat-assigned'; @@ -73,18 +71,6 @@ export const updateChatPreferences = ( connection, { getState }, siteId ) => { } }; -export const requestTranscript = ( connection, { dispatch } ) => { - debug( 'requesting current session transcript' ); - - // passing a null timestamp will request the latest session's transcript - return connection - .transcript( null ) - .then( - result => dispatch( receiveChatTranscript( result.messages, result.timestamp ) ), - e => debug( 'failed to get transcript', e ) - ); -}; - const onMessageChange = ( connection, message ) => { if ( isEmpty( message ) ) { connection.notTyping(); @@ -257,7 +243,6 @@ export default function( connection = null ) { // but doesn't give a compilation error either. const connectionNG = { send: () => {}, - request: () => {}, }; return store => next => action => { @@ -281,10 +266,6 @@ export default function( connection = null ) { onMessageChange( connection, action.message ); break; - case HAPPYCHAT_TRANSCRIPT_REQUEST: - requestTranscript( connection, store ); - break; - case ROUTE_SET: sendRouteSetEventMessage( connection, store, action ); break; @@ -293,6 +274,10 @@ export default function( connection = null ) { connection.init( store.dispatch, action.auth ); break; + case HAPPYCHAT_IO_REQUEST_TRANSCRIPT: + connection.request( action, action.timeout ); + break; + // NEW SOCKET API SURFACE - still not in use case HAPPYCHAT_IO_SEND_MESSAGE_EVENT: case HAPPYCHAT_IO_SEND_MESSAGE_LOG: @@ -303,9 +288,6 @@ export default function( connection = null ) { connectionNG.send( action ); break; - case HAPPYCHAT_IO_REQUEST_TRANSCRIPT: - connectionNG.request( action, action.timeout ); - break; // END OF NEW SOCKET API SURFACE } return next( action ); diff --git a/client/state/happychat/test/middleware-ng.js b/client/state/happychat/test/middleware-ng.js index f2797d8082fe0..0b915c5370213 100644 --- a/client/state/happychat/test/middleware-ng.js +++ b/client/state/happychat/test/middleware-ng.js @@ -16,7 +16,7 @@ import middleware from '../middleware'; import { initConnection, - // requestTranscript, + requestTranscript, // sendEvent, // sendLog, // sendMessage, @@ -50,7 +50,7 @@ describe( 'middleware', () => { connection = { init: jest.fn(), // send: jest.fn(), - // request: jest.fn(), + request: jest.fn(), }; store = { @@ -113,15 +113,15 @@ describe( 'middleware', () => { // expect( connection.send ).toHaveBeenCalledWith( action ); // } ); // } ); - // - // describe( 'connection.request actions are connected', () => { - // test( 'HAPPYCHAT_IO_REQUEST_TRANSCRIPT', () => { - // const action = requestTranscript( 20, 30 ); - // actionMiddleware( action ); - // expect( connection.request ).toHaveBeenCalledWith( action, action.timeout ); - // } ); - // } ); - // + + describe( 'connection.request actions are connected', () => { + test( 'HAPPYCHAT_IO_REQUEST_TRANSCRIPT', () => { + const action = requestTranscript( 20, 30 ); + actionMiddleware( action ); + expect( connection.request ).toHaveBeenCalledWith( action, action.timeout ); + } ); + } ); + // describe( 'Calypso actions are converted to SocketIO actions', () => { // describe( 'HELP_CONTACT_FORM_SITE_SELECT', () => { // test( 'should dispatch a sendPreferences action if happychat client is connected', () => { diff --git a/client/state/happychat/test/middleware.js b/client/state/happychat/test/middleware.js index 4332cc1c87d8e..240675e9fc46a 100644 --- a/client/state/happychat/test/middleware.js +++ b/client/state/happychat/test/middleware.js @@ -13,7 +13,6 @@ import { spy, stub } from 'sinon'; * Internal dependencies */ import middleware, { - requestTranscript, sendActionLogsAndEvents, sendAnalyticsLogEvent, sendRouteSetEventMessage, @@ -33,7 +32,6 @@ import { HAPPYCHAT_SEND_USER_INFO, HAPPYCHAT_SEND_MESSAGE, HAPPYCHAT_SET_CURRENT_MESSAGE, - HAPPYCHAT_TRANSCRIPT_RECEIVE, } from 'state/action-types'; import { useSandbox } from 'test/helpers/use-sinon'; @@ -138,33 +136,6 @@ describe( 'middleware', () => { } ); } ); - describe( 'HAPPYCHAT_TRANSCRIPT_REQUEST action', () => { - test( 'should fetch transcript from connection and dispatch receive action', () => { - const state = deepFreeze( { - happychat: { - chat: { timeline: [] }, - }, - } ); - const response = { - messages: [ { text: 'hello' } ], - timestamp: 100000, - }; - - const connection = { transcript: stub().returns( Promise.resolve( response ) ) }; - const dispatch = stub(); - const getState = stub().returns( state ); - - return requestTranscript( connection, { getState, dispatch } ).then( () => { - expect( connection.transcript ).to.have.been.called; - - expect( dispatch ).to.have.been.calledWith( { - type: HAPPYCHAT_TRANSCRIPT_RECEIVE, - ...response, - } ); - } ); - } ); - } ); - describe( 'HELP_CONTACT_FORM_SITE_SELECT action', () => { test( 'should send the locale and groups through the connection and send a preferences signal', () => { const state = { From b5037c192ba3ea9cd4df95aa5dae178577ab5937 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s?= Date: Wed, 1 Nov 2017 11:21:08 +0100 Subject: [PATCH 133/192] Implements connection.send for typing events (#19231) --- client/components/happychat/composer.jsx | 79 +++++++++------- client/components/happychat/test/composer.jsx | 89 ++++++++++++++++++ client/lib/happychat/connection.js | 34 ++++--- client/lib/happychat/test/index.js | 37 ++++---- client/state/happychat/middleware.js | 29 +----- client/state/happychat/test/middleware-ng.js | 93 ++++++++++--------- client/state/happychat/test/middleware.js | 20 +--- 7 files changed, 232 insertions(+), 149 deletions(-) create mode 100644 client/components/happychat/test/composer.jsx diff --git a/client/components/happychat/composer.jsx b/client/components/happychat/composer.jsx index 9855880de4a63..f87ede3d6c46f 100644 --- a/client/components/happychat/composer.jsx +++ b/client/components/happychat/composer.jsx @@ -8,7 +8,7 @@ import React from 'react'; import createReactClass from 'create-react-class'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { isEmpty } from 'lodash'; +import { get, isEmpty, throttle } from 'lodash'; import { localize } from 'i18n-calypso'; /** @@ -16,16 +16,11 @@ import { localize } from 'i18n-calypso'; */ import { sendChatMessage } from 'state/happychat/connection/actions'; import { setCurrentMessage } from 'state/happychat/ui/actions'; +import { sendTyping, sendNotTyping } from 'state/happychat/connection/actions'; import getCurrentMessage from 'state/happychat/selectors/get-happychat-current-message'; import { canUserSendMessages } from 'state/happychat/selectors'; -import { when, forEach, compose, propEquals, call, prop } from './functional'; import scrollbleed from './scrollbleed'; -// helper function for detecting when a DOM event keycode is pressed -const returnPressed = propEquals( 'which', 13 ); -// helper function that calls prevents default on the DOM event -const preventDefault = call( 'preventDefault' ); - /* * Renders a textarea to be used to comopose a message for the chat. */ @@ -37,23 +32,47 @@ export const Composer = createReactClass( { disabled: PropTypes.bool, message: PropTypes.string, onFocus: PropTypes.func, - onSendChatMessage: PropTypes.func, - onUpdateChatMessage: PropTypes.func, + onSendMessage: PropTypes.func, + onSendTyping: PropTypes.func, + onSendNotTyping: PropTypes.func, + onSetCurrentMessage: PropTypes.func, translate: PropTypes.func, // localize HOC }, + onChange( event ) { + const { onSendTyping, onSendNotTyping, onSetCurrentMessage } = this.props; + + const sendThrottledTyping = throttle( + msg => { + onSendTyping( msg ); + }, + 1000, + { leading: true, trailing: false } + ); + + const msg = get( event, 'target.value' ); + onSetCurrentMessage( msg ); + isEmpty( msg ) ? onSendNotTyping() : sendThrottledTyping( msg ); + }, + + onKeyDown( event ) { + const RETURN_KEYCODE = 13; + if ( get( event, 'which' ) === RETURN_KEYCODE ) { + event.preventDefault(); + this.sendMessage(); + } + }, + + sendMessage() { + const { message, onSendMessage, onSendNotTyping } = this.props; + if ( ! isEmpty( message ) ) { + onSendMessage( message ); + onSendNotTyping(); + } + }, + render() { - const { - disabled, - message, - onFocus, - onSendChatMessage, - onUpdateChatMessage, - translate, - } = this.props; - const sendMessage = when( () => ! isEmpty( message ), () => onSendChatMessage( message ) ); - const onChange = compose( prop( 'target.value' ), onUpdateChatMessage ); - const onKeyDown = when( returnPressed, forEach( preventDefault, sendMessage ) ); + const { disabled, message, onFocus, translate } = this.props; const composerClasses = classNames( 'happychat__composer', { 'is-disabled': disabled, } ); @@ -70,13 +89,13 @@ export const Composer = createReactClass( { onFocus={ onFocus } type="text" placeholder={ translate( 'Type a message …' ) } - onChange={ onChange } - onKeyDown={ onKeyDown } + onChange={ this.onChange } + onKeyDown={ this.onKeyDown } disabled={ disabled } value={ message } />
-
); + case 'ideal': + return ( +
+ iDEAL + { this.getPaymentProviderName( method ) } +
+ ); } return { this.getPaymentProviderName( method ) }; diff --git a/client/my-sites/checkout/checkout/secure-payment-form.jsx b/client/my-sites/checkout/checkout/secure-payment-form.jsx index 9c49ad3ee9334..36cced7ebc069 100644 --- a/client/my-sites/checkout/checkout/secure-payment-form.jsx +++ b/client/my-sites/checkout/checkout/secure-payment-form.jsx @@ -19,6 +19,7 @@ import FreeTrialConfirmationBox from './free-trial-confirmation-box'; import FreeCartPaymentBox from './free-cart-payment-box'; import CreditCardPaymentBox from './credit-card-payment-box'; import PayPalPaymentBox from './paypal-payment-box'; +import SourcePaymentBox from './source-payment-box'; import storeTransactions from 'lib/store-transactions'; import analytics from 'lib/analytics'; import TransactionStepsMixin from './transaction-steps-mixin'; @@ -225,6 +226,26 @@ const SecurePaymentForm = createReactClass( { ); }, + renderSourcePaymentBox( paymentType ) { + return ( + + + + ); + }, + renderGetDotBlogNotice() { const hasProductFromGetDotBlogSignup = find( this.props.cart.products, @@ -278,6 +299,14 @@ const SecurePaymentForm = createReactClass( {
); + case 'ideal': + return ( +
+ { this.renderGreatChoiceHeader() } + { this.renderSourcePaymentBox( visiblePaymentBox ) } +
+ ); + default: debug( 'WARN: %o payment unknown', visiblePaymentBox ); return null; diff --git a/client/my-sites/checkout/checkout/source-payment-box.jsx b/client/my-sites/checkout/checkout/source-payment-box.jsx new file mode 100644 index 0000000000000..40aa301ab8323 --- /dev/null +++ b/client/my-sites/checkout/checkout/source-payment-box.jsx @@ -0,0 +1,231 @@ +/** + * External dependencies + */ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { assign, some, map } from 'lodash'; + +/** + * Internal dependencies + */ +import { localize, translate } from 'i18n-calypso'; +import { abtest } from 'lib/abtest'; +import CartCoupon from 'my-sites/checkout/cart/cart-coupon'; +import PaymentChatButton from './payment-chat-button'; +import config from 'config'; +import { PLAN_BUSINESS } from 'lib/plans/constants'; +import CartToggle from './cart-toggle'; +import TermsOfService from './terms-of-service'; +import Input from 'my-sites/domains/components/form/input'; +import cartValues from 'lib/cart-values'; +import SubscriptionText from './subscription-text'; +import analytics from 'lib/analytics'; +import wpcom from 'lib/wp'; +import notices from 'notices'; +import FormSelect from 'components/forms/form-select'; +import FormLabel from 'components/forms/form-label'; + +class SourcePaymentBox extends PureComponent { + static propTypes = { + paymentType: PropTypes.string.isRequired, + cart: PropTypes.object.isRequired, + transaction: PropTypes.object.isRequired, + redirectTo: PropTypes.func.isRequired, + } + + constructor() { + super(); + this.redirectToPayment = this.redirectToPayment.bind( this ); + this.handleChange = this.handleChange.bind( this ); + } + + getLocationOrigin( l ) { + return l.protocol + '//' + l.hostname + ( l.port ? ':' + l.port : '' ); + } + + handleChange( event ) { + const data = {}; + data[ event.target.name ] = event.target.value; + + this.setState( data ); + } + + setSubmitState( submitState ) { + if ( submitState.error ) { + notices.error( submitState.error ); + } + if ( submitState.info ) { + notices.info( submitState.info ); + } + + this.setState( { + formDisabled: submitState.disabled + } ); + } + + paymentMethodByType( paymentType ) { + if ( paymentType === 'ideal' ) { + return 'WPCOM_Billing_Stripe_Source_Ideal'; + } + return 'WPCOM_Billing_Stripe_Source'; + } + + redirectToPayment( event ) { + const origin = this.getLocationOrigin( location ); + event.preventDefault(); + + this.setSubmitState( { + info: translate( 'Setting up your %(paymentProvider)s payment', { + args: { paymentProvider: this.getPaymentProviderName() } } ), + disabled: true + } ); + + let cancelUrl = origin + '/checkout/'; + + if ( this.props.selectedSite ) { + cancelUrl += this.props.selectedSite.slug; + } else { + cancelUrl += 'no-site'; + } + + const dataForApi = { + payment: assign( {}, this.state, { + paymentMethod: this.paymentMethodByType( this.props.paymentType ), + successUrl: origin + this.props.redirectTo(), + cancelUrl, + } ), + cart: this.props.cart, + domainDetails: this.props.transaction.domainDetails + }; + + // get the redirect URL from rest endpoint + wpcom.undocumented().transactions( 'POST', dataForApi, function( error, result ) { + let errorMessage; + if ( error ) { + if ( error.message ) { + errorMessage = error.message; + } else { + errorMessage = translate( "We've encountered a problem. Please try again later." ); + } + + this.setSubmitState( { + error: errorMessage, + disabled: false + } ); + } else if ( result.redirect_url ) { + this.setSubmitState( { + info: translate( 'Redirecting you to your bank to complete the payment.' ), + disabled: true + } ); + analytics.ga.recordEvent( 'Upgrades', 'Clicked Checkout With Source Payment Button' ); + analytics.tracks.recordEvent( 'calypso_checkout_with_source_' + this.props.paymentType ); + location.href = result.redirect_url; + } + }.bind( this ) ); + } + + renderButtonText() { + if ( cartValues.cartItems.hasRenewalItem( this.props.cart ) ) { + return translate( 'Purchase %(price)s subscription with %(paymentProvider)s', { + args: { price: this.props.cart.total_cost_display, paymentProvider: this.getPaymentProviderName() } + } ); + } + + return translate( 'Pay %(price)s with %(paymentProvider)s', { + args: { price: this.props.cart.total_cost_display, paymentProvider: this.getPaymentProviderName() } + } ); + } + + renderBankOptions() { + // Source https://stripe.com/docs/sources/ideal + const idealBanks = { + abn_amro: 'ABN AMRO', + asn_bank: 'ASN Bank', + bunq: 'Bunq', + ing: 'ING', + knab: 'Knab', + rabobank: 'Rabobank', + regiobank: 'RegioBank', + sns_bank: 'SNS Bank', + triodos_bank: 'Triodos Bank', + van_lanschot: 'Van Lanschot', + }; + + const idealBanksOptions = map( idealBanks, ( text, optionValue ) => ( + + ) ); + + return [ + , + ...idealBanksOptions + ]; + } + + render() { + const hasBusinessPlanInCart = some( this.props.cart.products, { product_slug: PLAN_BUSINESS } ); + const showPaymentChatButton = + config.isEnabled( 'upgrades/presale-chat' ) && + abtest( 'presaleChatButton' ) === 'showChatButton' && + hasBusinessPlanInCart; + + return ( + + +
+ +
+ + { translate( 'Bank' ) } + + + { this.renderBankOptions() } + +
+
+ + + +
+
+ + +
+ + { + showPaymentChatButton && + + } +
+ + + + + + ); + } + + getPaymentProviderName() { + switch ( this.props.paymentType ) { + case 'ideal': + return 'iDEAL'; + } + + return this.props.paymentType; + } +} +SourcePaymentBox.displayName = 'SourcePaymentBox'; + +export default localize( SourcePaymentBox ); diff --git a/client/my-sites/checkout/checkout/style.scss b/client/my-sites/checkout/checkout/style.scss index 06c70346461da..8605b88d5d665 100644 --- a/client/my-sites/checkout/checkout/style.scss +++ b/client/my-sites/checkout/checkout/style.scss @@ -205,7 +205,7 @@ // Floating labels // ----------------------------------- - .checkout-field { + .checkout-field, .checkout__checkout-field { margin-top: 15px; position: relative; @@ -698,6 +698,10 @@ margin-left:8px; } + &.ideal { + margin-left: 1em; + } + @include breakpoint( "<660px" ) { border-bottom: 1px solid lighten( $gray, 30% ); margin: 10px 0 0 0; @@ -900,6 +904,12 @@ width: 80px; } + .checkout__ideal { + width: 26px; + margin-bottom: 2px; + margin-right: 4px; + } + .checkout__credit-card { margin-right: 5px; } diff --git a/client/state/selectors/get-current-user-payment-methods.js b/client/state/selectors/get-current-user-payment-methods.js index c981001ec5831..b5465db3b4925 100644 --- a/client/state/selectors/get-current-user-payment-methods.js +++ b/client/state/selectors/get-current-user-payment-methods.js @@ -29,6 +29,7 @@ const paymentMethods = { byCountry: { US: DEFAULT_PAYMENT_METHODS, + NL: [ 'credit-card', 'ideal', 'paypal' ], }, byWpcomLang: {}, diff --git a/config/development.json b/config/development.json index 5f7b78324e987..2524d4166e7d9 100644 --- a/config/development.json +++ b/config/development.json @@ -176,6 +176,7 @@ "upgrades/credit-cards": true, "upgrades/domain-search": true, "upgrades/in-app-purchase": false, + "upgrades/netherlands-ideal": true, "upgrades/paypal": true, "upgrades/premium-themes": true, "upgrades/removal-survey": true, diff --git a/config/horizon.json b/config/horizon.json index 73ba4b01468d2..7392d5e4bc949 100644 --- a/config/horizon.json +++ b/config/horizon.json @@ -112,6 +112,7 @@ "upgrades/checkout": true, "upgrades/credit-cards": true, "upgrades/in-app-purchase": false, + "upgrades/netherlands-ideal": true, "upgrades/paypal": true, "upgrades/premium-themes": true, "upgrades/removal-survey": true, diff --git a/config/stage.json b/config/stage.json index e9b2ef12f33ea..a347df5763d46 100644 --- a/config/stage.json +++ b/config/stage.json @@ -125,6 +125,7 @@ "upgrades/credit-cards": true, "upgrades/domain-search": true, "upgrades/in-app-purchase": false, + "upgrades/netherlands-ideal": true, "upgrades/paypal": true, "upgrades/premium-themes": true, "upgrades/removal-survey": true, diff --git a/config/wpcalypso.json b/config/wpcalypso.json index c8a4d19b63852..c9bd7b5375f38 100644 --- a/config/wpcalypso.json +++ b/config/wpcalypso.json @@ -139,6 +139,7 @@ "upgrades/domain-search": true, "upgrades/in-app-purchase": false, "upgrades/paypal": true, + "upgrades/netherlands-ideal": true, "upgrades/premium-themes": true, "upgrades/removal-survey": true, "upgrades/precancellation-chat": true, diff --git a/public/images/upgrades/ideal.svg b/public/images/upgrades/ideal.svg new file mode 100644 index 0000000000000..ce4f0942f38ee --- /dev/null +++ b/public/images/upgrades/ideal.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + From 47ec61a927192422b03c0f70719a32bb04cccab2 Mon Sep 17 00:00:00 2001 From: Marin Atanasov Date: Wed, 1 Nov 2017 15:00:00 +0200 Subject: [PATCH 138/192] Components: Cleanup showLinkIndicator prop from Card (#19385) * Reader: Remove unnecessary showLinkIndicator instances * Components: Cleanup showLinkIndicator prop from Card * Update snapshots --- client/blocks/reader-post-card/index.jsx | 6 +--- client/components/card/index.jsx | 21 +++----------- .../card/test/__snapshots__/index.js.snap | 28 ------------------- client/reader/stream/x-post.jsx | 7 +---- 4 files changed, 6 insertions(+), 56 deletions(-) diff --git a/client/blocks/reader-post-card/index.jsx b/client/blocks/reader-post-card/index.jsx index 0b7f26b5443da..9829056537d29 100644 --- a/client/blocks/reader-post-card/index.jsx +++ b/client/blocks/reader-post-card/index.jsx @@ -252,11 +252,7 @@ class ReaderPostCard extends React.Component { const followUrl = feed ? feed.feed_URL : post.site_URL; return ( - + { ! compact && postByline } { showPrimaryFollowButton && followUrl && ( diff --git a/client/components/card/index.jsx b/client/components/card/index.jsx index 3a19720ad7cff..1495a8160d2e0 100644 --- a/client/components/card/index.jsx +++ b/client/components/card/index.jsx @@ -18,26 +18,15 @@ class Card extends Component { compact: PropTypes.bool, children: PropTypes.node, highlight: PropTypes.oneOf( [ false, 'error', 'info', 'success', 'warning' ] ), - showLinkIndicator: PropTypes.bool, }; static defaultProps = { tagName: 'div', highlight: false, - showLinkIndicator: true, }; render() { - const { - children, - compact, - highlight, - href, - onClick, - showLinkIndicator, - tagName, - target, - } = this.props; + const { children, compact, highlight, href, onClick, tagName, target } = this.props; const highlightClass = highlight ? 'is-' + highlight : false; @@ -52,16 +41,14 @@ class Card extends Component { highlightClass ); - const omitProps = [ 'compact', 'highlight', 'tagName', 'showLinkIndicator' ]; + const omitProps = [ 'compact', 'highlight', 'tagName' ]; let linkIndicator; - if ( showLinkIndicator && ( href || onClick ) ) { + if ( href ) { linkIndicator = ( ); - } - - if ( ! href ) { + } else { omitProps.push( 'href', 'target' ); } diff --git a/client/components/card/test/__snapshots__/index.js.snap b/client/components/card/test/__snapshots__/index.js.snap index 3521a0823054c..4434e45ade7a1 100644 --- a/client/components/card/test/__snapshots__/index.js.snap +++ b/client/components/card/test/__snapshots__/index.js.snap @@ -41,7 +41,6 @@ ShallowWrapper { "_currentElement": This is a linked card @@ -56,7 +55,6 @@ ShallowWrapper { "children": "This is a linked card", "highlight": false, "href": "/test", - "showLinkIndicator": true, "tagName": "div", }, "refs": Object {}, @@ -116,7 +114,6 @@ ShallowWrapper { "unrendered": This is a linked card @@ -148,7 +145,6 @@ ShallowWrapper { "_context": Object {}, "_currentElement": , "_debugID": 1, @@ -159,7 +155,6 @@ ShallowWrapper { "context": Object {}, "props": Object { "highlight": false, - "showLinkIndicator": true, "tagName": "div", }, "refs": Object {}, @@ -202,7 +197,6 @@ ShallowWrapper { "root": [Circular], "unrendered": , } @@ -233,7 +227,6 @@ ShallowWrapper { "_currentElement": , "_debugID": 3, @@ -245,7 +238,6 @@ ShallowWrapper { "props": Object { "className": "test__ace", "highlight": false, - "showLinkIndicator": true, "tagName": "div", }, "refs": Object {}, @@ -289,7 +281,6 @@ ShallowWrapper { "unrendered": , } @@ -323,7 +314,6 @@ ShallowWrapper { "_context": Object {}, "_currentElement": This is a card @@ -337,7 +327,6 @@ ShallowWrapper { "props": Object { "children": "This is a card", "highlight": false, - "showLinkIndicator": true, "tagName": "div", }, "refs": Object {}, @@ -384,7 +373,6 @@ ShallowWrapper { "root": [Circular], "unrendered": This is a card @@ -403,14 +391,12 @@ ShallowWrapper { "node": , "nodes": Array [ , ], @@ -451,14 +437,12 @@ ShallowWrapper { "_currentElement": , "_debugID": 10, "_renderedOutput": , }, @@ -487,14 +471,12 @@ ShallowWrapper { "node": , "nodes": Array [ , ], @@ -539,14 +521,12 @@ ShallowWrapper { "_currentElement": , "_debugID": 12, "_renderedOutput": , }, @@ -577,7 +557,6 @@ ShallowWrapper { "node": This is a compact card @@ -586,7 +565,6 @@ ShallowWrapper { This is a compact card @@ -633,7 +611,6 @@ ShallowWrapper { "_currentElement": This is a compact card @@ -642,7 +619,6 @@ ShallowWrapper { "_renderedOutput": This is a compact card @@ -675,14 +651,12 @@ ShallowWrapper { "node": , "nodes": Array [ , ], @@ -723,14 +697,12 @@ ShallowWrapper { "_currentElement": , "_debugID": 16, "_renderedOutput": , }, diff --git a/client/reader/stream/x-post.jsx b/client/reader/stream/x-post.jsx index 049878d9088b3..3c2b9caf35df2 100644 --- a/client/reader/stream/x-post.jsx +++ b/client/reader/stream/x-post.jsx @@ -172,12 +172,7 @@ class CrossPost extends PureComponent { } return ( - + Date: Wed, 1 Nov 2017 14:00:43 +0100 Subject: [PATCH 139/192] Implements remaining HAPPYCHAT_IO_RECEIVE_* actions (#19269) --- client/components/happychat/notices.jsx | 27 ++++++-- client/lib/happychat/connection.js | 35 ++++++---- client/lib/happychat/test/index.js | 65 ++++++++++--------- client/state/action-types.js | 6 -- client/state/audio/middleware.js | 13 ++-- client/state/audio/test/middleware.js | 10 +-- client/state/happychat/chat/actions.js | 17 ----- client/state/happychat/chat/reducer.js | 34 +++++----- client/state/happychat/chat/test/reducer.js | 10 +-- client/state/happychat/connection/actions.js | 27 +------- client/state/happychat/connection/reducer.js | 26 ++++---- .../happychat/connection/test/actions.js | 10 +-- client/state/happychat/user/reducer.js | 5 +- client/state/happychat/user/test/reducer.js | 4 +- 14 files changed, 132 insertions(+), 157 deletions(-) delete mode 100644 client/state/happychat/chat/actions.js diff --git a/client/components/happychat/notices.jsx b/client/components/happychat/notices.jsx index 3877e6d945cee..6a06620d1335e 100644 --- a/client/components/happychat/notices.jsx +++ b/client/components/happychat/notices.jsx @@ -12,10 +12,15 @@ import { get } from 'lodash'; * Internal dependencies */ import { + HAPPYCHAT_CHAT_STATUS_ABANDONED, HAPPYCHAT_CHAT_STATUS_ASSIGNING, - HAPPYCHAT_CHAT_STATUS_PENDING, HAPPYCHAT_CHAT_STATUS_MISSED, - HAPPYCHAT_CHAT_STATUS_ABANDONED, + HAPPYCHAT_CHAT_STATUS_PENDING, + HAPPYCHAT_CONNECTION_STATUS_CONNECTING, + HAPPYCHAT_CONNECTION_STATUS_DISCONNECTED, + HAPPYCHAT_CONNECTION_STATUS_RECONNECTING, + HAPPYCHAT_CONNECTION_STATUS_UNAUTHORIZED, + HAPPYCHAT_CONNECTION_STATUS_UNINITIALIZED, } from 'state/happychat/constants'; import { localize } from 'i18n-calypso'; import getHappychatChatStatus from 'state/happychat/selectors/get-happychat-chat-status'; @@ -36,16 +41,24 @@ export class Notices extends Component { } switch ( connectionStatus ) { - case 'uninitialized': + case HAPPYCHAT_CONNECTION_STATUS_UNINITIALIZED: return translate( 'Waiting to connect you with a Happiness Engineer…' ); - case 'connecting': + case HAPPYCHAT_CONNECTION_STATUS_CONNECTING: return translate( 'Connecting you with a Happiness Engineer…' ); - case 'reconnecting': - // Fall through to the same notice as `disconnected` - case 'disconnected': + case HAPPYCHAT_CONNECTION_STATUS_RECONNECTING: + case HAPPYCHAT_CONNECTION_STATUS_DISCONNECTED: return translate( "We're having trouble connecting to chat. Please bear with us while we try to reconnect…" ); + case HAPPYCHAT_CONNECTION_STATUS_UNAUTHORIZED: + return translate( + 'Chat is not available at the moment. For help, please contact us in {{link}}Support{{/link}}', + { + components: { + link: , + }, + } + ); } const noticeText = { diff --git a/client/lib/happychat/connection.js b/client/lib/happychat/connection.js index 93c4ae703f846..d7e559213d307 100644 --- a/client/lib/happychat/connection.js +++ b/client/lib/happychat/connection.js @@ -10,15 +10,18 @@ import { isString } from 'lodash'; * Internal dependencies */ import { - receiveChatEvent, + receiveAccept, + receiveConnect, + receiveDisconnect, receiveError, + receiveInit, + receiveMessage, + receiveReconnecting, + receiveStatus, + receiveToken, + receiveUnauthorized, requestTranscript, - setConnected, - setDisconnected, - setHappychatAvailable, - setReconnecting, } from 'state/happychat/connection/actions'; -import { setHappychatChatStatus } from 'state/happychat/chat/actions'; const debug = require( 'debug' )( 'calypso:happychat:connection' ); @@ -49,22 +52,26 @@ class Connection { const socket = buildConnection( url ); socket - .once( 'connect', () => debug( 'connected' ) ) - .on( 'token', handler => handler( { signer_user_id, jwt, locale, groups } ) ) + .once( 'connect', () => dispatch( receiveConnect() ) ) + .on( 'token', handler => { + dispatch( receiveToken() ); + handler( { signer_user_id, jwt, locale, groups } ); + } ) .on( 'init', () => { - dispatch( setConnected( { signer_user_id, locale, groups, geoLocation } ) ); + dispatch( receiveInit( { signer_user_id, locale, groups, geoLocation } ) ); dispatch( requestTranscript() ); resolve( socket ); } ) .on( 'unauthorized', () => { socket.close(); + dispatch( receiveUnauthorized( 'User is not authorized' ) ); reject( 'user is not authorized' ); } ) - .on( 'disconnect', reason => dispatch( setDisconnected( reason ) ) ) - .on( 'reconnecting', () => dispatch( setReconnecting() ) ) - .on( 'status', status => dispatch( setHappychatChatStatus( status ) ) ) - .on( 'accept', accept => dispatch( setHappychatAvailable( accept ) ) ) - .on( 'message', message => dispatch( receiveChatEvent( message ) ) ); + .on( 'disconnect', reason => dispatch( receiveDisconnect( reason ) ) ) + .on( 'reconnecting', () => dispatch( receiveReconnecting() ) ) + .on( 'status', status => dispatch( receiveStatus( status ) ) ) + .on( 'accept', accept => dispatch( receiveAccept( accept ) ) ) + .on( 'message', message => dispatch( receiveMessage( message ) ) ); } ) .catch( e => reject( e ) ); } ); diff --git a/client/lib/happychat/test/index.js b/client/lib/happychat/test/index.js index 5a4b7f55bb227..3cfffcf717baa 100644 --- a/client/lib/happychat/test/index.js +++ b/client/lib/happychat/test/index.js @@ -9,18 +9,21 @@ import { EventEmitter } from 'events'; * Internal dependencies */ import { + receiveAccept, + receiveConnect, + receiveDisconnect, receiveError, + receiveInit, + receiveMessage, + receiveReconnecting, + receiveStatus, + receiveToken, receiveTranscript, receiveTranscriptTimeout, + receiveUnauthorized, requestTranscript, sendTyping, - setConnected, - setDisconnected, - setReconnecting, - setHappychatAvailable, - receiveChatEvent, } from 'state/happychat/connection/actions'; -import { setHappychatChatStatus } from 'state/happychat/chat/actions'; import buildConnection from '../connection'; describe( 'connection', () => { @@ -50,27 +53,26 @@ describe( 'connection', () => { openSocket = connection.init( dispatch, config ); } ); - // TODO: to be enabled when corresponding connection changes land - // test( 'connect event', () => { - // socket.emit( 'connect' ); - // expect( dispatch ).toHaveBeenCalledTimes( 1 ); - // expect( dispatch ).toHaveBeenCalledWith( receiveConnect() ); - // } ); - // - // test( 'token event', () => { - // const callback = jest.fn(); - // socket.emit( 'token', callback ); - // expect( dispatch ).toHaveBeenCalledTimes( 1 ); - // expect( dispatch ).toHaveBeenCalledWith( receiveToken() ); - // expect( callback ).toHaveBeenCalledTimes( 1 ); - // expect( callback ).toHaveBeenCalledWith( { signer_user_id, jwt, locale, groups } ); - // } ); + test( 'connect event', () => { + socket.emit( 'connect' ); + expect( dispatch ).toHaveBeenCalledTimes( 1 ); + expect( dispatch ).toHaveBeenCalledWith( receiveConnect() ); + } ); + + test( 'token event', () => { + const callback = jest.fn(); + socket.emit( 'token', callback ); + expect( dispatch ).toHaveBeenCalledTimes( 1 ); + expect( dispatch ).toHaveBeenCalledWith( receiveToken() ); + expect( callback ).toHaveBeenCalledTimes( 1 ); + expect( callback ).toHaveBeenCalledWith( { signer_user_id, jwt, locale, groups } ); + } ); test( 'init event', () => { socket.emit( 'init' ); expect( dispatch ).toHaveBeenCalledTimes( 2 ); expect( dispatch.mock.calls[ 0 ][ 0 ] ).toEqual( - setConnected( { signer_user_id, locale, groups, geoLocation } ) + receiveInit( { signer_user_id, locale, groups, geoLocation } ) ); expect( dispatch.mock.calls[ 1 ][ 0 ] ).toEqual( requestTranscript() ); return expect( openSocket ).resolves.toBe( socket ); @@ -79,11 +81,10 @@ describe( 'connection', () => { test( 'unauthorized event', () => { socket.close = jest.fn(); openSocket.catch( () => { - // TODO: to be enabled when corresponding connection changes land - // expect( dispatch ).toHaveBeenCalledTimes( 1 ); - // expect( dispatch ).toHaveBeenCalledWith( - // receiveUnauthorized( 'User is not authorized' ) - // ); + expect( dispatch ).toHaveBeenCalledTimes( 1 ); + expect( dispatch ).toHaveBeenCalledWith( + receiveUnauthorized( 'User is not authorized' ) + ); expect( socket.close ).toHaveBeenCalled(); } ); socket.emit( 'unauthorized' ); @@ -93,34 +94,34 @@ describe( 'connection', () => { const error = 'testing reasons'; socket.emit( 'disconnect', error ); expect( dispatch ).toHaveBeenCalledTimes( 1 ); - expect( dispatch ).toHaveBeenCalledWith( setDisconnected( error ) ); + expect( dispatch ).toHaveBeenCalledWith( receiveDisconnect( error ) ); } ); test( 'reconnecting event', () => { socket.emit( 'reconnecting' ); expect( dispatch ).toHaveBeenCalledTimes( 1 ); - expect( dispatch ).toHaveBeenCalledWith( setReconnecting() ); + expect( dispatch ).toHaveBeenCalledWith( receiveReconnecting() ); } ); test( 'status event', () => { const status = 'testing status'; socket.emit( 'status', status ); expect( dispatch ).toHaveBeenCalledTimes( 1 ); - expect( dispatch ).toHaveBeenCalledWith( setHappychatChatStatus( status ) ); + expect( dispatch ).toHaveBeenCalledWith( receiveStatus( status ) ); } ); test( 'accept event', () => { const isAvailable = true; socket.emit( 'accept', isAvailable ); expect( dispatch ).toHaveBeenCalledTimes( 1 ); - expect( dispatch ).toHaveBeenCalledWith( setHappychatAvailable( isAvailable ) ); + expect( dispatch ).toHaveBeenCalledWith( receiveAccept( isAvailable ) ); } ); test( 'message event', () => { const message = 'testing msg'; socket.emit( 'message', message ); expect( dispatch ).toHaveBeenCalledTimes( 1 ); - expect( dispatch ).toHaveBeenCalledWith( receiveChatEvent( message ) ); + expect( dispatch ).toHaveBeenCalledWith( receiveMessage( message ) ); } ); } ); diff --git a/client/state/action-types.js b/client/state/action-types.js index 0fb6cbd7fca81..f19a9ff4e5200 100644 --- a/client/state/action-types.js +++ b/client/state/action-types.js @@ -207,8 +207,6 @@ export const HAPPINESS_ENGINEERS_FETCH_FAILURE = 'HAPPINESS_ENGINEERS_FETCH_FAIL export const HAPPINESS_ENGINEERS_FETCH_SUCCESS = 'HAPPINESS_ENGINEERS_FETCH_SUCCESS'; export const HAPPINESS_ENGINEERS_RECEIVE = 'HAPPINESS_ENGINEERS_RECEIVE'; export const HAPPYCHAT_BLUR = 'HAPPYCHAT_BLUR'; -export const HAPPYCHAT_CONNECTED = 'HAPPYCHAT_CONNECTED'; -export const HAPPYCHAT_DISCONNECTED = 'HAPPYCHAT_DISCONNECTED'; export const HAPPYCHAT_FOCUS = 'HAPPYCHAT_FOCUS'; export const HAPPYCHAT_IO_INIT = 'HAPPYCHAT_IO_INIT'; export const HAPPYCHAT_IO_RECEIVE_ACCEPT = 'HAPPYCHAT_IO_RECEIVE_ACCEPT'; @@ -232,10 +230,6 @@ export const HAPPYCHAT_IO_SEND_PREFERENCES = 'HAPPYCHAT_IO_SEND_PREFERENCES'; export const HAPPYCHAT_IO_SEND_TYPING = 'HAPPYCHAT_IO_SEND_TYPING'; export const HAPPYCHAT_MINIMIZING = 'HAPPYCHAT_MINIMIZING'; export const HAPPYCHAT_OPEN = 'HAPPYCHAT_OPEN'; -export const HAPPYCHAT_RECEIVE_EVENT = 'HAPPYCHAT_RECEIVE_EVENT'; -export const HAPPYCHAT_RECONNECTING = 'HAPPYCHAT_RECONNECTING'; -export const HAPPYCHAT_SET_AVAILABLE = 'HAPPYCHAT_SET_AVAILABLE'; -export const HAPPYCHAT_SET_CHAT_STATUS = 'HAPPYCHAT_SET_CHAT_STATUS'; export const HAPPYCHAT_SET_CURRENT_MESSAGE = 'HAPPYCHAT_SET_CURRENT_MESSAGE'; export const HELP_COURSES_RECEIVE = 'HELP_COURSES_RECEIVE'; export const HELP_CONTACT_FORM_SITE_SELECT = 'HELP_CONTACT_FORM_SITE_SELECT'; diff --git a/client/state/audio/middleware.js b/client/state/audio/middleware.js index 3d51165804cfd..c0a6a7acda089 100644 --- a/client/state/audio/middleware.js +++ b/client/state/audio/middleware.js @@ -1,10 +1,9 @@ +/** @format */ + /** * Internal dependencies - * - * @format */ - -import { HAPPYCHAT_RECEIVE_EVENT } from 'state/action-types'; +import { HAPPYCHAT_IO_RECEIVE_MESSAGE } from 'state/action-types'; const isAudioSupported = () => typeof window === 'object' && typeof window.Audio === 'function'; @@ -17,10 +16,10 @@ export const playSound = src => { audioClip.play(); }; -export const playSoundForMessageToCustomer = ( dispatch, { event } ) => { +export const playSoundForMessageToCustomer = ( dispatch, { message } ) => { // If the customer sent the message, there's no // need to play a sound to the customer. - if ( event && event.source === 'customer' ) { + if ( message && message.source === 'customer' ) { return; } @@ -33,7 +32,7 @@ export const playSoundForMessageToCustomer = ( dispatch, { event } ) => { // Initialized this way for performance reasons export const handlers = Object.create( null ); -handlers[ HAPPYCHAT_RECEIVE_EVENT ] = playSoundForMessageToCustomer; +handlers[ HAPPYCHAT_IO_RECEIVE_MESSAGE ] = playSoundForMessageToCustomer; /** * Middleware diff --git a/client/state/audio/test/middleware.js b/client/state/audio/test/middleware.js index 6077980b29f43..5e0c63dc8bd19 100644 --- a/client/state/audio/test/middleware.js +++ b/client/state/audio/test/middleware.js @@ -10,7 +10,7 @@ import { spy } from 'sinon'; * Internal dependencies */ import middleware from '../middleware'; -import { HAPPYCHAT_RECEIVE_EVENT } from 'state/action-types'; +import { HAPPYCHAT_IO_RECEIVE_MESSAGE } from 'state/action-types'; describe( 'Audio Middleware', () => { let next; @@ -50,8 +50,8 @@ describe( 'Audio Middleware', () => { test( 'should not play any sound when no audio support', () => { const action = { - type: HAPPYCHAT_RECEIVE_EVENT, - event: { + type: HAPPYCHAT_IO_RECEIVE_MESSAGE, + message: { source: 'operator', }, }; @@ -67,8 +67,8 @@ describe( 'Audio Middleware', () => { test( 'should play sound when receiving a new message from the operator', () => { const action = { - type: HAPPYCHAT_RECEIVE_EVENT, - event: { + type: HAPPYCHAT_IO_RECEIVE_MESSAGE, + message: { source: 'operator', }, }; diff --git a/client/state/happychat/chat/actions.js b/client/state/happychat/chat/actions.js deleted file mode 100644 index 1abd8ceff9b00..0000000000000 --- a/client/state/happychat/chat/actions.js +++ /dev/null @@ -1,17 +0,0 @@ -/** @format */ - -/** - * Internal dependencies - */ -import { HAPPYCHAT_SET_CHAT_STATUS } from 'state/action-types'; - -/** - * Returns an action object that sets the current chat status - * - * @param { String } status Current status to be set - * @return { Object } Action object - */ -export const setHappychatChatStatus = status => ( { - type: HAPPYCHAT_SET_CHAT_STATUS, - status, -} ); diff --git a/client/state/happychat/chat/reducer.js b/client/state/happychat/chat/reducer.js index dc2336ef13796..9a6f44a3818ac 100644 --- a/client/state/happychat/chat/reducer.js +++ b/client/state/happychat/chat/reducer.js @@ -14,11 +14,11 @@ import validator from 'is-my-json-valid'; import { SERIALIZE, DESERIALIZE, + HAPPYCHAT_IO_RECEIVE_MESSAGE, + HAPPYCHAT_IO_RECEIVE_STATUS, HAPPYCHAT_IO_REQUEST_TRANSCRIPT_RECEIVE, HAPPYCHAT_IO_REQUEST_TRANSCRIPT_TIMEOUT, HAPPYCHAT_IO_SEND_MESSAGE_MESSAGE, - HAPPYCHAT_RECEIVE_EVENT, - HAPPYCHAT_SET_CHAT_STATUS, } from 'state/action-types'; import { HAPPYCHAT_CHAT_STATUS_DEFAULT, @@ -30,7 +30,7 @@ import { timelineSchema } from './schema'; export const lastActivityTimestamp = ( state = null, action ) => { switch ( action.type ) { case HAPPYCHAT_IO_SEND_MESSAGE_MESSAGE: - case HAPPYCHAT_RECEIVE_EVENT: + case HAPPYCHAT_IO_RECEIVE_MESSAGE: return Date.now(); } return state; @@ -55,7 +55,7 @@ lastActivityTimestamp.schema = { type: 'number' }; */ export const status = ( state = HAPPYCHAT_CHAT_STATUS_DEFAULT, action ) => { switch ( action.type ) { - case HAPPYCHAT_SET_CHAT_STATUS: + case HAPPYCHAT_IO_RECEIVE_STATUS: return action.status; } return state; @@ -71,20 +71,20 @@ export const status = ( state = HAPPYCHAT_CHAT_STATUS_DEFAULT, action ) => { */ const timelineEvent = ( state = {}, action ) => { switch ( action.type ) { - case HAPPYCHAT_RECEIVE_EVENT: - const event = action.event; + case HAPPYCHAT_IO_RECEIVE_MESSAGE: + const { message } = action; return Object.assign( {}, { - id: event.id, - source: event.source, - message: event.text, - name: event.user.name, - image: event.user.avatarURL, - timestamp: event.timestamp, - user_id: event.user.id, - type: get( event, 'type', 'message' ), - links: get( event, 'meta.links' ), + id: message.id, + source: message.source, + message: message.text, + name: message.user.name, + image: message.user.avatarURL, + timestamp: message.timestamp, + user_id: message.user.id, + type: get( message, 'type', 'message' ), + links: get( message, 'meta.links' ), } ); } @@ -112,9 +112,9 @@ export const timeline = ( state = [], action ) => { return state; } return []; - case HAPPYCHAT_RECEIVE_EVENT: + case HAPPYCHAT_IO_RECEIVE_MESSAGE: // if meta.forOperator is set, skip so won't show to user - if ( get( action, 'event.meta.forOperator', false ) ) { + if ( get( action, 'message.meta.forOperator', false ) ) { return state; } const event = timelineEvent( {}, action ); diff --git a/client/state/happychat/chat/test/reducer.js b/client/state/happychat/chat/test/reducer.js index 15260268d3ecc..4549dbd5b13cd 100644 --- a/client/state/happychat/chat/test/reducer.js +++ b/client/state/happychat/chat/test/reducer.js @@ -10,9 +10,9 @@ import { expect } from 'chai'; */ import { lastActivityTimestamp } from '../reducer'; import { + HAPPYCHAT_IO_RECEIVE_INIT, + HAPPYCHAT_IO_RECEIVE_MESSAGE, HAPPYCHAT_IO_SEND_MESSAGE_MESSAGE, - HAPPYCHAT_RECEIVE_EVENT, - HAPPYCHAT_CONNECTED, } from 'state/action-types'; // Simulate the time Feb 27, 2017 05:25 UTC @@ -28,8 +28,8 @@ describe( 'reducers', () => { expect( result ).to.be.null; } ); - test( 'should update on HAPPYCHAT_RECEIVE_EVENT', () => { - const result = lastActivityTimestamp( null, { type: HAPPYCHAT_RECEIVE_EVENT } ); + test( 'should update on HAPPYCHAT_IO_RECEIVE_MESSAGE', () => { + const result = lastActivityTimestamp( null, { type: HAPPYCHAT_IO_RECEIVE_MESSAGE } ); expect( result ).to.equal( NOW ); } ); @@ -39,7 +39,7 @@ describe( 'reducers', () => { } ); test( 'should not update on other actions', () => { - const result = lastActivityTimestamp( null, { type: HAPPYCHAT_CONNECTED } ); + const result = lastActivityTimestamp( null, { type: HAPPYCHAT_IO_RECEIVE_INIT } ); expect( result ).to.equal( null ); } ); } ); diff --git a/client/state/happychat/connection/actions.js b/client/state/happychat/connection/actions.js index 0973444e11905..1796eb5ea8016 100644 --- a/client/state/happychat/connection/actions.js +++ b/client/state/happychat/connection/actions.js @@ -9,12 +9,6 @@ import { v4 as uuid } from 'uuid'; * Internal dependencies */ import { - HAPPYCHAT_CONNECTED, - HAPPYCHAT_DISCONNECTED, - HAPPYCHAT_RECEIVE_EVENT, - HAPPYCHAT_RECONNECTING, - HAPPYCHAT_SET_AVAILABLE, - // NEW ACTION TYPES HAPPYCHAT_IO_INIT, HAPPYCHAT_IO_RECEIVE_ACCEPT, HAPPYCHAT_IO_RECEIVE_CONNECT, @@ -26,35 +20,18 @@ import { HAPPYCHAT_IO_RECEIVE_STATUS, HAPPYCHAT_IO_RECEIVE_TOKEN, HAPPYCHAT_IO_RECEIVE_UNAUTHORIZED, - HAPPYCHAT_IO_REQUEST_TRANSCRIPT, HAPPYCHAT_IO_REQUEST_TRANSCRIPT_RECEIVE, HAPPYCHAT_IO_REQUEST_TRANSCRIPT_TIMEOUT, + HAPPYCHAT_IO_REQUEST_TRANSCRIPT, HAPPYCHAT_IO_SEND_MESSAGE_EVENT, - HAPPYCHAT_IO_SEND_MESSAGE_MESSAGE, HAPPYCHAT_IO_SEND_MESSAGE_LOG, + HAPPYCHAT_IO_SEND_MESSAGE_MESSAGE, HAPPYCHAT_IO_SEND_MESSAGE_USERINFO, HAPPYCHAT_IO_SEND_PREFERENCES, HAPPYCHAT_IO_SEND_TYPING, } from 'state/action-types'; import { HAPPYCHAT_MESSAGE_TYPES } from 'state/happychat/constants'; -export const setConnected = user => ( { type: HAPPYCHAT_CONNECTED, user } ); - -export const setDisconnected = errorStatus => ( { type: HAPPYCHAT_DISCONNECTED, errorStatus } ); - -export const setReconnecting = () => ( { type: HAPPYCHAT_RECONNECTING } ); - -export const setHappychatAvailable = isAvailable => ( { - type: HAPPYCHAT_SET_AVAILABLE, - isAvailable, -} ); - -export const receiveChatEvent = event => ( { type: HAPPYCHAT_RECEIVE_EVENT, event } ); - -// === NEW ACTION CREATORS ===================================================== -// === NEW ACTION CREATORS ===================================================== -// === NEW ACTION CREATORS ===================================================== - /** * Returns an action object indicating that the connection is being stablished. * diff --git a/client/state/happychat/connection/reducer.js b/client/state/happychat/connection/reducer.js index 7659029bbaa67..d0f39658dd8ba 100644 --- a/client/state/happychat/connection/reducer.js +++ b/client/state/happychat/connection/reducer.js @@ -3,27 +3,27 @@ * Internal dependencies */ import { - HAPPYCHAT_SET_AVAILABLE, - HAPPYCHAT_CONNECTED, - HAPPYCHAT_DISCONNECTED, HAPPYCHAT_IO_INIT, - HAPPYCHAT_RECONNECTING, + HAPPYCHAT_IO_RECEIVE_ACCEPT, + HAPPYCHAT_IO_RECEIVE_DISCONNECT, + HAPPYCHAT_IO_RECEIVE_INIT, + HAPPYCHAT_IO_RECEIVE_RECONNECTING, } from 'state/action-types'; import { - HAPPYCHAT_CONNECTION_STATUS_UNINITIALIZED, - HAPPYCHAT_CONNECTION_STATUS_CONNECTING, HAPPYCHAT_CONNECTION_STATUS_CONNECTED, + HAPPYCHAT_CONNECTION_STATUS_CONNECTING, HAPPYCHAT_CONNECTION_STATUS_DISCONNECTED, HAPPYCHAT_CONNECTION_STATUS_RECONNECTING, + HAPPYCHAT_CONNECTION_STATUS_UNINITIALIZED, } from 'state/happychat/constants'; import { combineReducers } from 'state/utils'; const error = ( state = null, action ) => { switch ( action.type ) { - case HAPPYCHAT_CONNECTED: + case HAPPYCHAT_IO_RECEIVE_INIT: return null; - case HAPPYCHAT_DISCONNECTED: - return action.errorStatus; + case HAPPYCHAT_IO_RECEIVE_DISCONNECT: + return action.error; } return state; }; @@ -40,11 +40,11 @@ const status = ( state = HAPPYCHAT_CONNECTION_STATUS_UNINITIALIZED, action ) => switch ( action.type ) { case HAPPYCHAT_IO_INIT: return HAPPYCHAT_CONNECTION_STATUS_CONNECTING; - case HAPPYCHAT_CONNECTED: + case HAPPYCHAT_IO_RECEIVE_INIT: return HAPPYCHAT_CONNECTION_STATUS_CONNECTED; - case HAPPYCHAT_DISCONNECTED: + case HAPPYCHAT_IO_RECEIVE_DISCONNECT: return HAPPYCHAT_CONNECTION_STATUS_DISCONNECTED; - case HAPPYCHAT_RECONNECTING: + case HAPPYCHAT_IO_RECEIVE_RECONNECTING: return HAPPYCHAT_CONNECTION_STATUS_RECONNECTING; } return state; @@ -59,7 +59,7 @@ const status = ( state = HAPPYCHAT_CONNECTION_STATUS_UNINITIALIZED, action ) => */ const isAvailable = ( state = false, action ) => { switch ( action.type ) { - case HAPPYCHAT_SET_AVAILABLE: + case HAPPYCHAT_IO_RECEIVE_ACCEPT: return action.isAvailable; } return state; diff --git a/client/state/happychat/connection/test/actions.js b/client/state/happychat/connection/test/actions.js index 2841743c881ca..32e426be2e3c4 100644 --- a/client/state/happychat/connection/test/actions.js +++ b/client/state/happychat/connection/test/actions.js @@ -8,16 +8,16 @@ import { expect } from 'chai'; /** * Internal dependencies */ -import { HAPPYCHAT_CONNECTED } from 'state/action-types'; -import { setConnected } from '../actions'; +import { HAPPYCHAT_IO_RECEIVE_INIT } from 'state/action-types'; +import { receiveInit } from '../actions'; describe( 'actions', () => { - describe( '#setConnected()', () => { + describe( '#receiveInit()', () => { test( 'should return an action object', () => { - const action = setConnected( { geoLocation: { country_long: 'Romania' } } ); + const action = receiveInit( { geoLocation: { country_long: 'Romania' } } ); expect( action ).to.eql( { - type: HAPPYCHAT_CONNECTED, + type: HAPPYCHAT_IO_RECEIVE_INIT, user: { geoLocation: { country_long: 'Romania' } }, } ); } ); diff --git a/client/state/happychat/user/reducer.js b/client/state/happychat/user/reducer.js index 74f184a1e5ee6..d15705762039d 100644 --- a/client/state/happychat/user/reducer.js +++ b/client/state/happychat/user/reducer.js @@ -1,8 +1,9 @@ /** @format */ + /** * Internal dependencies */ -import { HAPPYCHAT_CONNECTED } from 'state/action-types'; +import { HAPPYCHAT_IO_RECEIVE_INIT } from 'state/action-types'; import { combineReducers, createReducer } from 'state/utils'; import { geoLocationSchema } from './schema'; @@ -17,7 +18,7 @@ import { geoLocationSchema } from './schema'; export const geoLocation = createReducer( null, { - [ HAPPYCHAT_CONNECTED ]: ( state, action ) => { + [ HAPPYCHAT_IO_RECEIVE_INIT ]: ( state, action ) => { const { user: { geoLocation: location } } = action; if ( location && location.country_long && location.city ) { return location; diff --git a/client/state/happychat/user/test/reducer.js b/client/state/happychat/user/test/reducer.js index 7bfcba19d9af6..14a6a1da3b6bc 100644 --- a/client/state/happychat/user/test/reducer.js +++ b/client/state/happychat/user/test/reducer.js @@ -8,7 +8,7 @@ import { expect } from 'chai'; /** * Internal dependencies */ -import { HAPPYCHAT_CONNECTED, DESERIALIZE } from 'state/action-types'; +import { HAPPYCHAT_IO_RECEIVE_INIT, DESERIALIZE } from 'state/action-types'; import { geoLocation } from '../reducer'; describe( '#geoLocation()', () => { @@ -20,7 +20,7 @@ describe( '#geoLocation()', () => { test( 'should set the current user geolocation', () => { const state = geoLocation( null, { - type: HAPPYCHAT_CONNECTED, + type: HAPPYCHAT_IO_RECEIVE_INIT, user: { geoLocation: { country_long: 'Romania', From baf85e63f8450a12911d9677ee9fdd1247b306be Mon Sep 17 00:00:00 2001 From: Chris R Date: Wed, 1 Nov 2017 14:15:25 +0000 Subject: [PATCH 140/192] Reader: add conversation follow button block (#19230) * Add components, example and new gridicons * Change selector to isFollowingReaderConversation * Change container component to use new selector * Add to post options menu * Fix naming of following variables * Connect up reducer and data layer * Fix devdocs example * Fix selector JSdoc * Rename variable in follow toggle for clarity * In post options menu, label main follow button 'Follow(ing) Site' when showing Follow Conversation too * Ditch redundant if (prop defaults to noop) * Reformatting in ConversationFollowButton render() * Use a plain notice for conversation unfollow * Fix mute test * Hide conversation follow when not applicable * Widen popover * Remove redundant CSS color variable --- assets/stylesheets/_components.scss | 1 + .../conversation-follow-button/button.jsx | 76 +++++++++++++++++++ .../docs/example.jsx | 31 ++++++++ .../conversation-follow-button/index.jsx | 67 ++++++++++++++++ .../conversation-follow-button/style.scss | 76 +++++++++++++++++++ .../blocks/reader-post-options-menu/index.jsx | 25 +++++- .../reader-post-options-menu/style.scss | 12 ++- client/devdocs/design/blocks.jsx | 2 + client/state/data-layer/wpcom/read/index.js | 12 +-- .../data-layer/wpcom/read/sites/index.js | 9 +++ .../wpcom/read/sites/posts/index.js | 10 +++ .../wpcom/read/sites/posts/mute/index.js | 4 +- .../wpcom/read/sites/posts/mute/test/index.js | 2 +- client/state/reader/reducer.js | 21 ++--- .../get-reader-conversation-follow-status.js | 25 ------ client/state/selectors/index.js | 2 +- .../is-following-reader-conversation.js | 28 +++++++ .../get-reader-conversation-follow-status.js | 36 --------- .../test/is-following-reader-conversation.js | 50 ++++++++++++ 19 files changed, 406 insertions(+), 83 deletions(-) create mode 100644 client/blocks/conversation-follow-button/button.jsx create mode 100644 client/blocks/conversation-follow-button/docs/example.jsx create mode 100644 client/blocks/conversation-follow-button/index.jsx create mode 100644 client/blocks/conversation-follow-button/style.scss create mode 100644 client/state/data-layer/wpcom/read/sites/index.js create mode 100644 client/state/data-layer/wpcom/read/sites/posts/index.js delete mode 100644 client/state/selectors/get-reader-conversation-follow-status.js create mode 100644 client/state/selectors/is-following-reader-conversation.js delete mode 100644 client/state/selectors/test/get-reader-conversation-follow-status.js create mode 100644 client/state/selectors/test/is-following-reader-conversation.js diff --git a/assets/stylesheets/_components.scss b/assets/stylesheets/_components.scss index 4f7f169b8faad..cd06ff879476d 100644 --- a/assets/stylesheets/_components.scss +++ b/assets/stylesheets/_components.scss @@ -32,6 +32,7 @@ @import 'blocks/comment-detail/style'; @import 'blocks/comments/style'; @import 'blocks/conversation-caterpillar/style'; +@import 'blocks/conversation-follow-button/style'; @import 'blocks/conversations/style'; @import 'blocks/daily-post-button/style'; @import 'blocks/disconnect-jetpack/style'; diff --git a/client/blocks/conversation-follow-button/button.jsx b/client/blocks/conversation-follow-button/button.jsx new file mode 100644 index 0000000000000..cbc13d53720db --- /dev/null +++ b/client/blocks/conversation-follow-button/button.jsx @@ -0,0 +1,76 @@ +/** + * @format + */ + +/** + * External dependencies + */ +import PropTypes from 'prop-types'; +import React from 'react'; +import { noop } from 'lodash'; +import { localize } from 'i18n-calypso'; +import Gridicon from 'gridicons'; + +class ConversationFollowButton extends React.Component { + static propTypes = { + isFollowing: PropTypes.bool.isRequired, + onFollowToggle: PropTypes.func, + tagName: PropTypes.oneOfType( [ PropTypes.string, PropTypes.func ] ), + }; + + static defaultProps = { + isFollowing: false, + onFollowToggle: noop, + tagName: 'button', + }; + + toggleFollow = event => { + if ( event ) { + event.preventDefault(); + } + + this.props.onFollowToggle( ! this.props.isFollowing ); + }; + + render() { + const { isFollowing, translate } = this.props; + const buttonClasses = [ + 'button', + 'has-icon', + 'conversation-follow-button', + this.props.className, + ]; + const iconSize = 20; + const label = isFollowing + ? translate( 'Following Conversation' ) + : translate( 'Follow Conversation' ); + + if ( this.props.isFollowing ) { + buttonClasses.push( 'is-following' ); + } + + const followingIcon = ( + + ); + const followIcon = ( + + ); + const followLabelElement = ( + + { label } + + ); + + return React.createElement( + this.props.tagName, + { + onClick: this.toggleFollow, + className: buttonClasses.join( ' ' ), + title: label, + }, + [ followingIcon, followIcon, followLabelElement ] + ); + } +} + +export default localize( ConversationFollowButton ); diff --git a/client/blocks/conversation-follow-button/docs/example.jsx b/client/blocks/conversation-follow-button/docs/example.jsx new file mode 100644 index 0000000000000..cc38cc1c5efe8 --- /dev/null +++ b/client/blocks/conversation-follow-button/docs/example.jsx @@ -0,0 +1,31 @@ +/* + * @format + */ + +/** + * External dependencies + */ +import React from 'react'; + +/** + * Internal dependencies + */ +import ConversationFollowButton from 'blocks/conversation-follow-button/button'; +import Card from 'components/card/compact'; + +export default class ConversationFollowButtonExample extends React.PureComponent { + static displayName = 'ConversationFollowButton'; + + render() { + return ( +
+ + + + + + +
+ ); + } +} diff --git a/client/blocks/conversation-follow-button/index.jsx b/client/blocks/conversation-follow-button/index.jsx new file mode 100644 index 0000000000000..7233bfcdef492 --- /dev/null +++ b/client/blocks/conversation-follow-button/index.jsx @@ -0,0 +1,67 @@ +/** + * @format + */ + +/* + * External dependencies + */ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { noop } from 'lodash'; +import { connect } from 'react-redux'; + +/** + * Internal dependencies + */ +import ConversationFollowButton from './button'; +import { isFollowingReaderConversation } from 'state/selectors'; +import { followConversation, muteConversation } from 'state/reader/conversations/actions'; + +class ConversationFollowButtonContainer extends Component { + static propTypes = { + siteId: PropTypes.number.isRequired, + postId: PropTypes.number.isRequired, + onFollowToggle: PropTypes.func, + tagName: PropTypes.oneOfType( [ PropTypes.string, PropTypes.func ] ), + }; + + static defaultProps = { + onFollowToggle: noop, + }; + + handleFollowToggle = isRequestingFollow => { + const { siteId, postId } = this.props; + + if ( isRequestingFollow ) { + this.props.followConversation( { siteId, postId } ); + } else { + this.props.muteConversation( { siteId, postId } ); + } + + this.props.onFollowToggle( isRequestingFollow ); + }; + + render() { + return ( + + ); + } +} + +export default connect( + ( state, ownProps ) => ( { + isFollowing: isFollowingReaderConversation( state, { + siteId: ownProps.siteId, + postId: ownProps.postId, + } ), + } ), + { + followConversation, + muteConversation, + } +)( ConversationFollowButtonContainer ); diff --git a/client/blocks/conversation-follow-button/style.scss b/client/blocks/conversation-follow-button/style.scss new file mode 100644 index 0000000000000..2009354b9ac01 --- /dev/null +++ b/client/blocks/conversation-follow-button/style.scss @@ -0,0 +1,76 @@ +.conversation-follow-button, +button.conversation-follow-button { + border: 0; + padding: 0; + + .gridicons-reader-follow-conversation { + fill: $blue-medium; + pointer-events: auto; + } + + .conversation-follow-button__label { + color: $blue-medium; + } + + &:hover { + color: $blue-medium; + + .gridicons-reader-follow-conversation { + fill: $blue-medium; + } + } + + &:focus { + box-shadow: none; + } + + // Hides Following icon by default + .gridicons-reader-following-conversation { + display: none; + pointer-events: none; + } + + &.is-following { + + .gridicons-reader-following-conversation { + display: inline-block; + fill: $alert-green; + pointer-events: auto; + } + + .conversation-follow-button__label { + color: $alert-green; + } + + // Hides Follow icon if already following + .gridicons-reader-follow-conversation { + display: none; + pointer-events: none; + } + + &:hover { + color: $alert-green; + + .gridicons-reader-following-conversation { + fill: $alert-green; + } + } + } + + .gridicons-reader-following-conversation { + pointer-events: none; + } + + .gridicon { + height: 18px; + padding-right: 4px; + top: 5px; + width: 18px; + } +} + +.conversation-follow-button__label { + @include breakpoint( "<660px" ) { + display: none; + } +} diff --git a/client/blocks/reader-post-options-menu/index.jsx b/client/blocks/reader-post-options-menu/index.jsx index 974a7523f5bc4..bd6fceb959528 100644 --- a/client/blocks/reader-post-options-menu/index.jsx +++ b/client/blocks/reader-post-options-menu/index.jsx @@ -9,6 +9,7 @@ import page from 'page'; import classnames from 'classnames'; import { connect } from 'react-redux'; import { localize } from 'i18n-calypso'; +import config from 'config'; /** * Internal dependencies @@ -28,6 +29,8 @@ import QueryReaderTeams from 'components/data/query-reader-teams'; import { isAutomatticTeamMember } from 'reader/lib/teams'; import { getReaderTeams } from 'state/selectors'; import ReaderPostOptionsMenuBlogStickers from './blog-stickers'; +import ConversationFollowButton from 'blocks/conversation-follow-button'; +import { shouldShowComments } from 'blocks/comments/helper'; class ReaderPostOptionsMenu extends React.Component { static propTypes = { @@ -120,10 +123,17 @@ class ReaderPostOptionsMenu extends React.Component { render() { const { post, site, feed, teams, translate, position } = this.props; + const { ID: postId, site_ID: siteId } = post; const isEditPossible = PostUtils.userCan( 'edit_post', post ); const isDiscoverPost = DiscoverHelper.isDiscoverPost( post ); const followUrl = this.getFollowUrl(); const isTeamMember = isAutomatticTeamMember( teams ); + const showConversationFollow = + config.isEnabled( 'reader/conversations' ) && + siteId && + ! post.is_external && + shouldShowComments( post ) && + ! isDiscoverPost; let isBlockPossible = false; @@ -157,7 +167,20 @@ class ReaderPostOptionsMenu extends React.Component { { isTeamMember && site && } { this.props.showFollow && ( - + + ) } + + { showConversationFollow && ( + ) } { post.URL && ( diff --git a/client/blocks/reader-post-options-menu/style.scss b/client/blocks/reader-post-options-menu/style.scss index 783b00c790de7..d0d9b713d2696 100644 --- a/client/blocks/reader-post-options-menu/style.scss +++ b/client/blocks/reader-post-options-menu/style.scss @@ -22,8 +22,12 @@ } .reader-post-options-menu__popover { + .popover__menu { + min-width: 212px; + } - .popover__menu .follow-button { + .popover__menu .follow-button, + .popover__menu .conversation-follow-button { color: $blue-medium; padding: 9px 16px; @@ -46,13 +50,15 @@ fill: $white; } - .follow-button__label { + .follow-button__label, + .conversation-follow-button__label { color: $white; } } } - .follow-button__label { + .follow-button__label, + .conversation-follow-button__label { display: inline-block; margin-left: 26px; } diff --git a/client/devdocs/design/blocks.jsx b/client/devdocs/design/blocks.jsx index 2419b4f485260..b9cc8d06bc44e 100644 --- a/client/devdocs/design/blocks.jsx +++ b/client/devdocs/design/blocks.jsx @@ -80,6 +80,7 @@ import UploadImage from 'blocks/upload-image/docs/example'; import ConversationCommentList from 'blocks/conversations/docs/example'; import SimplePaymentsDialog from 'components/tinymce/plugins/simple-payments/dialog/docs/example'; import ConversationCaterpillar from 'blocks/conversation-caterpillar/docs/example'; +import ConversationFollowButton from 'blocks/conversation-follow-button/docs/example'; import ColorSchemePicker from 'blocks/color-scheme-picker/docs/example'; export default class AppComponents extends React.Component { @@ -175,6 +176,7 @@ export default class AppComponents extends React.Component { +
diff --git a/client/state/data-layer/wpcom/read/index.js b/client/state/data-layer/wpcom/read/index.js index 9a8b0094a85c5..3f64bbb308efa 100644 --- a/client/state/data-layer/wpcom/read/index.js +++ b/client/state/data-layer/wpcom/read/index.js @@ -3,11 +3,13 @@ * Internal dependencies */ import { mergeHandlers } from 'state/action-watchers/utils'; -import site from './site'; -import teams from './teams'; -import tags from './tags'; -import followingMine from './following/mine'; + import feed from './feed'; +import followingMine from './following/mine'; import recommendations from './recommendations'; +import site from './site'; +import sites from './sites'; +import tags from './tags'; +import teams from './teams'; -export default mergeHandlers( site, teams, tags, followingMine, feed, recommendations ); +export default mergeHandlers( feed, followingMine, recommendations, site, sites, tags, teams ); diff --git a/client/state/data-layer/wpcom/read/sites/index.js b/client/state/data-layer/wpcom/read/sites/index.js new file mode 100644 index 0000000000000..fb375b36c1bbd --- /dev/null +++ b/client/state/data-layer/wpcom/read/sites/index.js @@ -0,0 +1,9 @@ +/** @format */ +/** + * Internal Dependencies + */ +import { mergeHandlers } from 'state/action-watchers/utils'; + +import posts from './posts'; + +export default mergeHandlers( posts ); diff --git a/client/state/data-layer/wpcom/read/sites/posts/index.js b/client/state/data-layer/wpcom/read/sites/posts/index.js new file mode 100644 index 0000000000000..47bce366daf35 --- /dev/null +++ b/client/state/data-layer/wpcom/read/sites/posts/index.js @@ -0,0 +1,10 @@ +/** @format */ +/** + * Internal Dependencies + */ +import { mergeHandlers } from 'state/action-watchers/utils'; + +import follow from './follow'; +import mute from './mute'; + +export default mergeHandlers( follow, mute ); diff --git a/client/state/data-layer/wpcom/read/sites/posts/mute/index.js b/client/state/data-layer/wpcom/read/sites/posts/mute/index.js index 2d0b15cf7c463..e944d544cf4d9 100644 --- a/client/state/data-layer/wpcom/read/sites/posts/mute/index.js +++ b/client/state/data-layer/wpcom/read/sites/posts/mute/index.js @@ -14,7 +14,7 @@ import { translate } from 'i18n-calypso'; import { READER_CONVERSATION_MUTE } from 'state/action-types'; import { http } from 'state/data-layer/wpcom-http/actions'; import { dispatchRequest } from 'state/data-layer/wpcom-http/utils'; -import { errorNotice, successNotice } from 'state/notices/actions'; +import { errorNotice, plainNotice } from 'state/notices/actions'; import { updateConversationFollowStatus } from 'state/reader/conversations/actions'; import { bypassDataLayer } from 'state/data-layer/utils'; @@ -48,7 +48,7 @@ export function receiveConversationMute( store, action, response ) { } store.dispatch( - successNotice( translate( 'The conversation has been successfully unfollowed.' ), { + plainNotice( translate( 'The conversation has been successfully unfollowed.' ), { duration: 5000, } ) ); diff --git a/client/state/data-layer/wpcom/read/sites/posts/mute/test/index.js b/client/state/data-layer/wpcom/read/sites/posts/mute/test/index.js index a79f9f7a71a49..f9d152e5fe0f4 100644 --- a/client/state/data-layer/wpcom/read/sites/posts/mute/test/index.js +++ b/client/state/data-layer/wpcom/read/sites/posts/mute/test/index.js @@ -56,7 +56,7 @@ describe( 'conversation-mute', () => { expect( dispatch ).toHaveBeenCalledWith( expect.objectContaining( { notice: expect.objectContaining( { - status: 'is-success', + status: 'is-plain', } ), } ) ); diff --git a/client/state/reader/reducer.js b/client/state/reader/reducer.js index e1752bee25743..e4967cd08cf90 100644 --- a/client/state/reader/reducer.js +++ b/client/state/reader/reducer.js @@ -2,31 +2,34 @@ /** * Internal dependencies */ -import lists from './lists/reducer'; import { combineReducers } from 'state/utils'; + +import conversations from './conversations/reducer'; import feeds from './feeds/reducer'; +import feedSearches from './feed-searches/reducer'; import follows from './follows/reducer'; -import sites from './sites/reducer'; +import lists from './lists/reducer'; import posts from './posts/reducer'; +import recommendedSites from './recommended-sites/reducer'; import relatedPosts from './related-posts/reducer'; import siteBlocks from './site-blocks/reducer'; +import sites from './sites/reducer'; import tags from './tags/reducer'; -import thumbnails from './thumbnails/reducer'; import teams from './teams/reducer'; -import feedSearches from './feed-searches/reducer'; -import recommendedSites from './recommended-sites/reducer'; +import thumbnails from './thumbnails/reducer'; export default combineReducers( { + conversations, feeds, + feedSearches, follows, lists, - sites, posts, + recommendedSites, relatedPosts, siteBlocks, + sites, tags, - thumbnails, teams, - feedSearches, - recommendedSites, + thumbnails, } ); diff --git a/client/state/selectors/get-reader-conversation-follow-status.js b/client/state/selectors/get-reader-conversation-follow-status.js deleted file mode 100644 index 53004662a7ced..0000000000000 --- a/client/state/selectors/get-reader-conversation-follow-status.js +++ /dev/null @@ -1,25 +0,0 @@ -/* - * @format - */ - -/** - * External dependencies - */ -import { get } from 'lodash'; - -/** - * Internal dependencies - */ -import { key } from 'state/reader/conversations/utils'; - -/* - * Get the follow status for a given post - * - * @param {Object} state Global state tree - * @param {Number} siteId - * @param {Number} postId - * @return {String} Follow status - */ -export default function getReaderConversationFollowStatus( state, { siteId, postId } ) { - return get( state, [ 'reader', 'conversations', 'items', key( siteId, postId ) ], null ); -} diff --git a/client/state/selectors/index.js b/client/state/selectors/index.js index 8c8d1d73ade2e..f03e7a476ddda 100644 --- a/client/state/selectors/index.js +++ b/client/state/selectors/index.js @@ -91,7 +91,6 @@ export getPublicizeConnection from './get-publicize-connection'; export getPublicSites from './get-public-sites'; export getRawOffsets from './get-raw-offsets'; export getReaderAliasedFollowFeedUrl from './get-reader-aliased-follow-feed-url'; -export getReaderConversationFollowStatus from './get-reader-conversation-follow-status'; export getReaderFeedsCountForQuery from './get-reader-feeds-count-for-query'; export getReaderFeedsForQuery from './get-reader-feeds-for-query'; export getReaderFollowedTags from './get-reader-followed-tags'; @@ -199,6 +198,7 @@ export isFetchingMagicLoginEmail from './is-fetching-magic-login-email'; export isFetchingPublicizeShareActionsPublished from './is-fetching-publicize-share-actions-published'; export isFetchingPublicizeShareActionsScheduled from './is-fetching-publicize-share-actions-scheduled'; export isFollowing from './is-following'; +export isFollowingReaderConversation from './is-following-reader-conversation'; export isHiddenSite from './is-hidden-site'; export isJetpackModuleActive from './is-jetpack-module-active'; export isJetpackModuleUnavailableInDevelopmentMode from './is-jetpack-module-unavailable-in-development-mode'; diff --git a/client/state/selectors/is-following-reader-conversation.js b/client/state/selectors/is-following-reader-conversation.js new file mode 100644 index 0000000000000..b33fcb9965620 --- /dev/null +++ b/client/state/selectors/is-following-reader-conversation.js @@ -0,0 +1,28 @@ +/* + * @format + */ + +/** + * External dependencies + */ +import { get } from 'lodash'; + +/** + * Internal dependencies + */ +import { key } from 'state/reader/conversations/utils'; +import { CONVERSATION_FOLLOW_STATUS_FOLLOWING } from 'state/reader/conversations/follow-status'; + +/* + * Get the conversation following status for a given post + * + * @param {Object} state Global state tree + * @param {Object} params Params including siteId and postId + * @return {Boolean} Is the user following this conversation? + */ +export default function isFollowingReaderConversation( state, { siteId, postId } ) { + return ( + get( state, [ 'reader', 'conversations', 'items', key( siteId, postId ) ] ) === + CONVERSATION_FOLLOW_STATUS_FOLLOWING + ); +} diff --git a/client/state/selectors/test/get-reader-conversation-follow-status.js b/client/state/selectors/test/get-reader-conversation-follow-status.js deleted file mode 100644 index d6ba50c71229e..0000000000000 --- a/client/state/selectors/test/get-reader-conversation-follow-status.js +++ /dev/null @@ -1,36 +0,0 @@ -/** @format */ - -/** - * Internal dependencies - */ -import { getReaderConversationFollowStatus } from '../'; - -describe( 'getReaderConversationFollowStatus()', () => { - test( 'should return follow status for a known post', () => { - const prevState = { - reader: { - conversations: { - items: { - '123-456': 'F', - }, - }, - }, - }; - const nextState = getReaderConversationFollowStatus( prevState, { siteId: 123, postId: 456 } ); - expect( nextState ).toEqual( 'F' ); - } ); - - test( 'should return null for an unknown post', () => { - const prevState = { - reader: { - conversations: { - items: { - '123-456': 'F', - }, - }, - }, - }; - const nextState = getReaderConversationFollowStatus( prevState, { siteId: 234, postId: 456 } ); - expect( nextState ).toEqual( null ); - } ); -} ); diff --git a/client/state/selectors/test/is-following-reader-conversation.js b/client/state/selectors/test/is-following-reader-conversation.js new file mode 100644 index 0000000000000..299e4c1e04d81 --- /dev/null +++ b/client/state/selectors/test/is-following-reader-conversation.js @@ -0,0 +1,50 @@ +/** @format */ + +/** + * Internal dependencies + */ +import { isFollowingReaderConversation } from '../'; + +describe( 'isFollowingReaderConversation()', () => { + test( 'should return true for a known followed post', () => { + const prevState = { + reader: { + conversations: { + items: { + '123-456': 'F', + }, + }, + }, + }; + const nextState = isFollowingReaderConversation( prevState, { siteId: 123, postId: 456 } ); + expect( nextState ).toEqual( true ); + } ); + + test( 'should return false for a muted post', () => { + const prevState = { + reader: { + conversations: { + items: { + '123-456': 'M', + }, + }, + }, + }; + const nextState = isFollowingReaderConversation( prevState, { siteId: 123, postId: 456 } ); + expect( nextState ).toEqual( false ); + } ); + + test( 'should return false for an unknown post', () => { + const prevState = { + reader: { + conversations: { + items: { + '123-456': 'F', + }, + }, + }, + }; + const nextState = isFollowingReaderConversation( prevState, { siteId: 234, postId: 456 } ); + expect( nextState ).toEqual( false ); + } ); +} ); From 2ce1b81d87ff450d8559a7c0af9aa377e0820695 Mon Sep 17 00:00:00 2001 From: Allen Snook Date: Wed, 1 Nov 2017 07:59:51 -0700 Subject: [PATCH 141/192] Store: Add Stripe Connect Flows (#19369) * Store: Add Stripe Connect Flows (#19115) * Add redux for fetching stripe connect account details * Display fetched stripe connect details from redux * Add new unit tests for new redux goodies for fetching acct details * Switch to use plain request instead of http-request to gain the flexibility of dispatching additional actions on success or failure * Add docblocks and get defaults for selectors * Remove account creation reducers (will be delivered in next PR) * Make placeholder dialog a stateless functional component * Do not use the entire site as a prop - just use the id and domain * Store: Stripe Connect: Wire the disconnect link through redux (#19244) * Connect the disconnect link through redux * Update redux to use request technique from base PR, update unit tests, rename everything we can to deauthorize (instead of disconnect) * Use that javascript thing where key is the same as variable with value * Store: Stripe Connect: Add create (deferred) account flow (#19292) * Connect connect UX through to redux; rewrite create redux to use new request approach; add a clear error action creator * We went through the trouble of getting a siteId, we should use it * Store: Stripe Connect: Add OAuth flow (#19300) * Add redux for oauth init * Add redux for the oauth connect endpoint * Fetch and redirect to OAuth URL * Add a dialog to complete the oauth flow and sign in the user * Navigate the user away from the code URL when OAuth fails * Pass the state and code strings under the correct keys * Fetch the account details on success and close the progress dialog * Set oauth_completed in the url to prompt reopening connection dialog on successful oauth * Remove js extension that crept in a few places * Move page import to externals and fix ordering of imports * Correct some docblocks and comments * Added docblocks and fixed one action creator argument list * Also enabling on stage and wpcalypso but not production yet --- .../app/settings/payments/index.js | 13 + .../settings/payments/payment-method-item.js | 2 +- .../payments/payment-method-stripe.js | 105 ++- ...ent-method-stripe-complete-oauth-dialog.js | 164 ++++ .../payment-method-stripe-connect-account.js | 29 +- .../payment-method-stripe-connected-dialog.js | 61 +- .../payment-method-stripe-key-based-dialog.js | 2 +- ...ayment-method-stripe-placeholder-dialog.js | 29 + .../payment-method-stripe-setup-dialog.js | 129 +++- .../stripe/payment-method-stripe-utils.js | 43 +- .../app/settings/payments/stripe/style.scss | 15 + .../extensions/woocommerce/lib/nav-utils.js | 17 + .../woocommerce/state/action-types.js | 18 + .../woocommerce/state/data-layer/index.js | 2 - .../stripe-connect-account/actions.js | 428 ++++++++++- .../stripe-connect-account/handlers.js | 50 -- .../stripe-connect-account/reducer.js | 185 ++++- .../stripe-connect-account/selectors.js | 101 +++ .../stripe-connect-account/test/actions.js | 264 ++++++- .../stripe-connect-account/test/handlers.js | 104 --- .../stripe-connect-account/test/reducer.js | 721 +++++++++++++++++- .../stripe-connect-account/test/selectors.js | 406 ++++++++++ config/stage.json | 2 +- config/wpcalypso.json | 2 +- 24 files changed, 2658 insertions(+), 234 deletions(-) create mode 100644 client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-complete-oauth-dialog.js create mode 100644 client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-placeholder-dialog.js delete mode 100644 client/extensions/woocommerce/state/sites/settings/stripe-connect-account/handlers.js create mode 100644 client/extensions/woocommerce/state/sites/settings/stripe-connect-account/selectors.js delete mode 100644 client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/handlers.js create mode 100644 client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/selectors.js diff --git a/client/extensions/woocommerce/app/settings/payments/index.js b/client/extensions/woocommerce/app/settings/payments/index.js index 4d3317f2444f0..0f236d5ab3933 100644 --- a/client/extensions/woocommerce/app/settings/payments/index.js +++ b/client/extensions/woocommerce/app/settings/payments/index.js @@ -24,6 +24,11 @@ import { getActionList } from 'woocommerce/state/action-list/selectors'; import { getFinishedInitialSetup } from 'woocommerce/state/sites/setup-choices/selectors'; import { getLink } from 'woocommerce/lib/nav-utils'; import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; +import { + hasOAuthParamsInLocation, + hasOAuthCompleteInLocation, +} from './stripe/payment-method-stripe-utils'; +import { openPaymentMethodForEdit } from 'woocommerce/state/ui/payments/methods/actions'; import Main from 'components/main'; import SettingsPaymentsLocationCurrency from './payments-location-currency'; import SettingsNavigation from '../navigation'; @@ -47,6 +52,13 @@ class SettingsPayments extends Component { if ( site && site.ID ) { this.props.fetchSetupChoices( site.ID ); } + + // If we are in the middle of the Stripe Connect OAuth flow + // go ahead and option the Stripe dialog right away so + // we can complete the flow + if ( hasOAuthParamsInLocation() || hasOAuthCompleteInLocation() ) { + this.props.openPaymentMethodForEdit( site.ID, 'stripe' ); + } }; componentWillReceiveProps = newProps => { @@ -121,6 +133,7 @@ function mapDispatchToProps( dispatch ) { { createPaymentSettingsActionList, fetchSetupChoices, + openPaymentMethodForEdit, }, dispatch ); diff --git a/client/extensions/woocommerce/app/settings/payments/payment-method-item.js b/client/extensions/woocommerce/app/settings/payments/payment-method-item.js index 9365c29eab5a2..0444e2c991a95 100644 --- a/client/extensions/woocommerce/app/settings/payments/payment-method-item.js +++ b/client/extensions/woocommerce/app/settings/payments/payment-method-item.js @@ -26,7 +26,7 @@ import { getCurrentlyEditingPaymentMethod } from 'woocommerce/state/ui/payments/ import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; import FormFieldset from 'components/forms/form-fieldset'; import FormLabel from 'components/forms/form-label'; -import { hasStripeKeyPairForMode } from './stripe/payment-method-stripe-utils.js'; +import { hasStripeKeyPairForMode } from './stripe/payment-method-stripe-utils'; import ListItem from 'woocommerce/components/list/list-item'; import ListItemField from 'woocommerce/components/list/list-item-field'; import PaymentMethodEditDialog from './payment-method-edit-dialog'; diff --git a/client/extensions/woocommerce/app/settings/payments/payment-method-stripe.js b/client/extensions/woocommerce/app/settings/payments/payment-method-stripe.js index 59365f08314b0..072e66bdfb891 100644 --- a/client/extensions/woocommerce/app/settings/payments/payment-method-stripe.js +++ b/client/extensions/woocommerce/app/settings/payments/payment-method-stripe.js @@ -4,17 +4,31 @@ * @format */ -import config from 'config'; import React, { Component } from 'react'; -import { hasStripeKeyPairForMode } from './stripe/payment-method-stripe-utils.js'; +import { bindActionCreators } from 'redux'; +import config from 'config'; +import { connect } from 'react-redux'; +import { + getOAuthParamsFromLocation, + hasOAuthParamsInLocation, + hasStripeKeyPairForMode, +} from './stripe/payment-method-stripe-utils'; import { localize } from 'i18n-calypso'; import PropTypes from 'prop-types'; /** * Internal dependencies */ +import { fetchAccountDetails } from 'woocommerce/state/sites/settings/stripe-connect-account/actions'; +import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; +import { + getIsRequesting, + getStripeConnectAccount, +} from 'woocommerce/state/sites/settings/stripe-connect-account/selectors'; import PaymentMethodStripeConnectedDialog from './stripe/payment-method-stripe-connected-dialog'; import PaymentMethodStripeKeyBasedDialog from './stripe/payment-method-stripe-key-based-dialog'; +import PaymentMethodStripeCompleteOAuthDialog from './stripe/payment-method-stripe-complete-oauth-dialog'; +import PaymentMethodStripePlaceholderDialog from './stripe/payment-method-stripe-placeholder-dialog'; import PaymentMethodStripeSetupDialog from './stripe/payment-method-stripe-setup-dialog'; class PaymentMethodStripe extends Component { @@ -42,23 +56,8 @@ class PaymentMethodStripe extends Component { onCancel: PropTypes.func.isRequired, onEditField: PropTypes.func.isRequired, onDone: PropTypes.func.isRequired, - site: PropTypes.shape( { - domain: PropTypes.string.isRequired, - } ), - }; - - //////////////////////////////////////////////////////////////////////////// - // TODO - temporary to facilitate testing - will be removed in a subsequent PR - static defaultProps = { - stripeConnectAccount: { - connectedUserID: '', // e.g. acct_14qyt6Alijdnw0EA - displayName: '', - email: '', - firstName: '', - isActivated: false, - lastName: '', - logo: '', - }, + siteId: PropTypes.number.isRequired, + domain: PropTypes.string.isRequired, }; constructor( props ) { @@ -70,6 +69,22 @@ class PaymentMethodStripe extends Component { }; } + componentDidMount() { + const { siteId } = this.props; + if ( siteId && ! hasOAuthParamsInLocation() ) { + this.props.fetchAccountDetails( siteId ); + } + } + + componentWillReceiveProps( newProps ) { + const { siteId } = this.props; + const newSiteId = newProps.siteId; + + if ( siteId !== newSiteId && ! hasOAuthParamsInLocation() ) { + this.props.fetchAccountDetails( newSiteId ); + } + } + //////////////////////////////////////////////////////////////////////////// // Misc helpers @@ -100,8 +115,9 @@ class PaymentMethodStripe extends Component { // And render brings it all together render() { - const { method, onCancel, onDone, site, stripeConnectAccount } = this.props; + const { domain, isRequesting, method, onCancel, onDone, stripeConnectAccount } = this.props; const { connectedUserID } = stripeConnectAccount; + const oauthParams = getOAuthParamsFromLocation(); const connectFlowsEnabled = config.isEnabled( 'woocommerce/extension-settings-stripe-connect-flows' @@ -126,6 +142,16 @@ class PaymentMethodStripe extends Component { if ( connectedUserID ) { dialog = 'connected'; } + + // But if we are still waiting for account details to arrive, well then you get a placeholder + if ( isRequesting ) { + dialog = 'placeholder'; + } + + // In the middle of OAuth? + if ( hasOAuthParamsInLocation() ) { + dialog = 'oauth'; + } } // Now, render the appropriate dialog @@ -139,7 +165,7 @@ class PaymentMethodStripe extends Component { } else if ( 'connected' === dialog ) { return ( ); + } else if ( 'oauth' === dialog ) { + return ( + + ); + } else if ( 'placeholder' === dialog ) { + return ; } // Key-based dialog by default return ( { + this.props.clearError(); + }; + + componentDidMount = () => { + const { oauthCode, oauthState, siteId, stripeConnectAccount } = this.props; + + // Kick off the last step of the OAuth flow, but only if we don't + // have a connected user ID (to prevent re-entrancy) + const connectedUserID = get( stripeConnectAccount, [ 'connectedUserID' ], '' ); + if ( isEmpty( connectedUserID ) ) { + this.props.oauthConnect( siteId, oauthCode, oauthState ); + } + }; + + componentWillReceiveProps = ( { stripeConnectAccount } ) => { + // Did we receive a connected user ID? Connect must have finished, so + // let's close this dialog + const connectedUserID = get( stripeConnectAccount, [ 'connectedUserID' ], '' ); + if ( ! isEmpty( connectedUserID ) ) { + this.onClose(); + } + }; + + possiblyRenderProgress = () => { + const { error } = this.props; + if ( 0 === error.length ) { + return ; + } + return null; + }; + + possiblyRenderNotice = () => { + const { error } = this.props; + if ( 0 === error.length ) { + return null; + } + return ; + }; + + onClose = () => { + const { error, site } = this.props; + const oauthCompleted = isEmpty( error ); + this.props.clearError(); + + // Important - when the user closes the dialog (which should only happen + // in case of error), let's clear the query params by calling page + // with the payment settings path + const paymentsSettingsLink = getLink( '/store/settings/payments/:site', site ); + const paymentsSettingsQuery = oauthCompleted ? '?oauth_complete=1' : ''; + page( paymentsSettingsLink + paymentsSettingsQuery ); + + // Lastly, in the case of an error, let's make sure state reflects that the dialog is closed + if ( ! oauthCompleted ) { + this.props.onCancel(); + } + }; + + getButtons = () => { + const { isOAuthConnecting, isRequesting, translate } = this.props; + + if ( isOAuthConnecting || isRequesting ) { + return []; + } + + return [ + { + action: 'cancel', + label: translate( 'Close' ), + onClick: this.onClose, + }, + ]; + }; + + render = () => { + const { translate } = this.props; + + return ( + +
+ { translate( 'Completing Your Connection to Stripe' ) } +
+ { this.possiblyRenderProgress() } + { this.possiblyRenderNotice() } +
+ ); + }; +} + +function mapStateToProps( state ) { + const site = getSelectedSiteWithFallback( state ); + const siteId = site.ID || false; + const error = getError( state, siteId ); + const isOAuthConnecting = getIsOAuthConnecting( state, siteId ); + const isRequesting = getIsRequesting( state, siteId ); + const stripeConnectAccount = getStripeConnectAccount( state, siteId ); + + return { + error, + isOAuthConnecting, + isRequesting, + site, + siteId, + stripeConnectAccount, + }; +} + +function mapDispatchToProps( dispatch ) { + return bindActionCreators( + { + clearError, + oauthConnect, + }, + dispatch + ); +} + +export default localize( + connect( mapStateToProps, mapDispatchToProps )( PaymentMethodStripeCompleteOAuthDialog ) +); diff --git a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connect-account.js b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connect-account.js index e3fc5b97e603d..af5ba34777168 100644 --- a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connect-account.js +++ b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connect-account.js @@ -25,7 +25,8 @@ class StripeConnectAccount extends Component { lastName: PropTypes.string, logo: PropTypes.string, } ), - onDisconnect: PropTypes.func, // TODO - require most of these props in subsequent PR + isDeauthorizing: PropTypes.bool.isRequired, + onDeauthorize: PropTypes.func.isRequired, }; renderLogo = () => { @@ -61,19 +62,17 @@ class StripeConnectAccount extends Component { ); }; - // TODO - when we are ready to connect this for-reals, this layer may not be needed - onDisconnect = event => { + onDeauthorize = event => { event.preventDefault(); - if ( this.props.onDisconnect ) { - this.props.onDisconnect(); - } + this.props.onDeauthorize(); }; renderStatus = () => { - const { stripeConnectAccount, translate } = this.props; + const { isDeauthorizing, stripeConnectAccount, translate } = this.props; const { isActivated } = stripeConnectAccount; let status = null; + let deauthorize = null; if ( isActivated ) { status = ( @@ -89,12 +88,22 @@ class StripeConnectAccount extends Component { ); } + if ( isDeauthorizing ) { + deauthorize = ( + { translate( 'Disconnecting' ) } + ); + } else { + deauthorize = ( +
+ { translate( 'Disconnect' ) } + + ); + } + return (
{ status } - - { translate( 'Disconnect' ) } - + { deauthorize }
); }; diff --git a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connected-dialog.js b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connected-dialog.js index afb1afeb456a6..515f4d14a262c 100644 --- a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connected-dialog.js +++ b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-connected-dialog.js @@ -5,6 +5,8 @@ */ import React, { Component } from 'react'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; import { localize } from 'i18n-calypso'; import PropTypes from 'prop-types'; @@ -12,12 +14,18 @@ import PropTypes from 'prop-types'; * Internal dependencies */ import AuthCaptureToggle from 'woocommerce/components/auth-capture-toggle'; +import { deauthorizeAccount } from 'woocommerce/state/sites/settings/stripe-connect-account/actions'; import Dialog from 'components/dialog'; import FormFieldset from 'components/forms/form-fieldset'; import FormLabel from 'components/forms/form-label'; import FormSettingExplanation from 'components/forms/form-setting-explanation'; import FormTextInput from 'components/forms/form-text-input'; -import { getStripeSampleStatementDescriptor } from './payment-method-stripe-utils.js'; +import { + getIsDeauthorizing, + getStripeConnectAccount, +} from 'woocommerce/state/sites/settings/stripe-connect-account/selectors'; +import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; +import { getStripeSampleStatementDescriptor } from './payment-method-stripe-utils'; import PaymentMethodEditFormToggle from '../payment-method-edit-form-toggle'; import StripeConnectAccount from './payment-method-stripe-connect-account'; @@ -68,6 +76,11 @@ class PaymentMethodStripeConnectedDialog extends Component { this.props.onEditField( { target: { name: 'capture', value: 'yes' } } ); }; + onDeauthorize = () => { + const { siteId } = this.props; + this.props.deauthorizeAccount( siteId ); + }; + renderMoreSettings = () => { const { domain, method, onEditField, translate } = this.props; const sampleDescriptor = getStripeSampleStatementDescriptor( domain ); @@ -111,15 +124,23 @@ class PaymentMethodStripeConnectedDialog extends Component { }; getButtons = () => { - const { onCancel, onDone, stripeConnectAccount, translate } = this.props; + const { onCancel, onDone, isDeauthorizing, stripeConnectAccount, translate } = this.props; const buttons = []; + const disabled = isDeauthorizing; + if ( stripeConnectAccount.isActivated ) { - buttons.push( { action: 'cancel', label: translate( 'Cancel' ), onClick: onCancel } ); + buttons.push( { + action: 'cancel', + disabled, + label: translate( 'Cancel' ), + onClick: onCancel, + } ); buttons.push( { action: 'save', + disabled, label: translate( 'Done' ), onClick: onDone, isPrimary: true, @@ -127,6 +148,7 @@ class PaymentMethodStripeConnectedDialog extends Component { } else { buttons.push( { action: 'cancel', + disabled, label: translate( 'Close' ), onClick: onCancel, isPrimary: true, @@ -137,7 +159,7 @@ class PaymentMethodStripeConnectedDialog extends Component { }; render() { - const { stripeConnectAccount, translate } = this.props; + const { isDeauthorizing, stripeConnectAccount, translate } = this.props; return (
{ translate( 'Manage Stripe' ) }
- + { stripeConnectAccount.isActivated && this.renderMoreSettings() }
); } } -export default localize( PaymentMethodStripeConnectedDialog ); +function mapStateToProps( state ) { + const site = getSelectedSiteWithFallback( state ); + const siteId = site.ID || false; + const isDeauthorizing = getIsDeauthorizing( state, siteId ); + const stripeConnectAccount = getStripeConnectAccount( state, siteId ); + return { + isDeauthorizing, + siteId, + stripeConnectAccount, + }; +} + +function mapDispatchToProps( dispatch ) { + return bindActionCreators( + { + deauthorizeAccount, + }, + dispatch + ); +} + +export default localize( + connect( mapStateToProps, mapDispatchToProps )( PaymentMethodStripeConnectedDialog ) +); diff --git a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-key-based-dialog.js b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-key-based-dialog.js index 1066ef541cf90..cd8392cb7e313 100644 --- a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-key-based-dialog.js +++ b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-key-based-dialog.js @@ -21,7 +21,7 @@ import FormTextInput from 'components/forms/form-text-input'; import { getStripeSampleStatementDescriptor, hasStripeKeyPairForMode, -} from './payment-method-stripe-utils.js'; +} from './payment-method-stripe-utils'; import PaymentMethodEditFormToggle from '../payment-method-edit-form-toggle'; import TestLiveToggle from 'woocommerce/components/test-live-toggle'; diff --git a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-placeholder-dialog.js b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-placeholder-dialog.js new file mode 100644 index 0000000000000..2ab2b2737bb7c --- /dev/null +++ b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-placeholder-dialog.js @@ -0,0 +1,29 @@ +/** + * External dependencies + * + * @format + */ + +import React from 'react'; +import { localize } from 'i18n-calypso'; +import { noop } from 'lodash'; + +/** + * Internal dependencies + */ +import Dialog from 'components/dialog'; + +const PaymentMethodStripePlaceholderDialog = ( { translate } ) => { + const buttons = [ + { action: 'cancel', disabled: true, label: translate( 'Cancel' ), onClick: noop }, + ]; + + return ( + +
{ translate( 'Stripe' ) }
+
+
+ ); +}; + +export default localize( PaymentMethodStripePlaceholderDialog ); diff --git a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-setup-dialog.js b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-setup-dialog.js index bda4d55572fa4..73353de00c7be 100644 --- a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-setup-dialog.js +++ b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-setup-dialog.js @@ -5,14 +5,36 @@ */ import React, { Component } from 'react'; +import { bindActionCreators } from 'redux'; +import { connect } from 'react-redux'; import { localize } from 'i18n-calypso'; import PropTypes from 'prop-types'; /** * Internal dependencies */ +import { + areSettingsGeneralLoading, + getStoreLocation, +} from 'woocommerce/state/sites/settings/general/selectors'; +import { + clearError, + createAccount, + oauthInit, +} from 'woocommerce/state/sites/settings/stripe-connect-account/actions'; import Dialog from 'components/dialog'; +import { getCurrentUserEmail } from 'state/current-user/selectors'; +import { + getError, + getIsCreating, + getIsOAuthInitializing, + getOAuthURL, +} from 'woocommerce/state/sites/settings/stripe-connect-account/selectors'; +import { getLink, getOrigin } from 'woocommerce/lib/nav-utils'; +import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; +import Notice from 'components/notice'; import StripeConnectPrompt from './payment-method-stripe-connect-prompt'; +import QuerySettingsGeneral from 'woocommerce/components/query-settings-general'; class PaymentMethodStripeSetupDialog extends Component { static propTypes = { @@ -27,21 +49,56 @@ class PaymentMethodStripeSetupDialog extends Component { }; } + componentWillMount = () => { + this.props.clearError(); + }; + onSelectCreate = () => { + const { isCreating } = this.props; + if ( isCreating ) { + return; + } this.setState( { createSelected: true } ); }; onSelectConnect = () => { + const { isCreating, isOAuthInitializing, oauthUrl, siteId, siteSlug } = this.props; + if ( isCreating ) { + return; + } this.setState( { createSelected: false } ); + + // See if we still need to initialize OAuth, and if so, do so + if ( ! isOAuthInitializing && 0 === oauthUrl.length ) { + const origin = getOrigin(); + const path = getLink( '/store/settings/payments/:site', { slug: siteSlug } ); + const returnUrl = `${ origin }${ path }`; + this.props.oauthInit( siteId, returnUrl ); + } }; onConnect = () => { - // Not yet implemented + const { country, email, oauthUrl, siteId } = this.props; + + if ( this.state.createSelected ) { + this.props.createAccount( siteId, email, country ); + } else { + window.location = oauthUrl; + } }; getButtons = () => { - const { onCancel, onUserRequestsKeyFlow, translate } = this.props; + const { + isCreating, + isLoadingAddress, + isOAuthInitializing, + onCancel, + onUserRequestsKeyFlow, + translate, + } = this.props; + const buttons = []; + const isBusy = isCreating || isLoadingAddress || isOAuthInitializing; // Allow them to switch to key based flow if they want buttons.push( { @@ -52,12 +109,17 @@ class PaymentMethodStripeSetupDialog extends Component { } ); // Always give the user a Cancel button - buttons.push( { action: 'cancel', label: translate( 'Cancel' ), onClick: onCancel } ); + buttons.push( { + action: 'cancel', + disabled: isBusy, + label: translate( 'Cancel' ), + onClick: onCancel, + } ); // And then the connect button itself buttons.push( { action: 'connect', - disabled: true, // TODO: will be enabled in a subsequent PR + disabled: isBusy, isPrimary: true, label: translate( 'Connect' ), onClick: this.onConnect, @@ -66,8 +128,16 @@ class PaymentMethodStripeSetupDialog extends Component { return buttons; }; - render() { - const { translate } = this.props; + possiblyRenderNotice = () => { + const { error } = this.props; + if ( 0 === error.length ) { + return null; + } + return ; + }; + + render = () => { + const { siteId, translate } = this.props; return ( + { this.possiblyRenderNotice() } + ); - } + }; +} + +function mapStateToProps( state ) { + const email = getCurrentUserEmail( state ); + const site = getSelectedSiteWithFallback( state ); + const siteId = site.ID || false; + const siteSlug = site.slug || ''; + + const error = getError( state, siteId ); + const isCreating = getIsCreating( state, siteId ); + const isOAuthInitializing = getIsOAuthInitializing( state, siteId ); + const oauthUrl = getOAuthURL( state, siteId ); + + const isLoadingAddress = areSettingsGeneralLoading( state, siteId ); + const storeLocation = getStoreLocation( state, siteId ); + const country = isLoadingAddress ? '' : storeLocation.country; + + return { + country, + email, + error, + isCreating, + isLoadingAddress, + isOAuthInitializing, + oauthUrl, + siteId, + siteSlug, + }; +} + +function mapDispatchToProps( dispatch ) { + return bindActionCreators( + { + clearError, + createAccount, + oauthInit, + }, + dispatch + ); } -export default localize( PaymentMethodStripeSetupDialog ); +export default localize( + connect( mapStateToProps, mapDispatchToProps )( PaymentMethodStripeSetupDialog ) +); diff --git a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-utils.js b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-utils.js index 99a6cb1faede8..12ca108f75428 100644 --- a/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-utils.js +++ b/client/extensions/woocommerce/app/settings/payments/stripe/payment-method-stripe-utils.js @@ -1,4 +1,15 @@ -/** @format */ +/** + * External dependencies + * + * @format + */ +import { get } from 'lodash'; +import url from 'url'; + +/** + * Internal dependencies + */ + export function hasStripeKeyPairForMode( method ) { const { settings } = method; const isLiveMode = method.settings.testmode.value !== 'yes'; @@ -14,3 +25,33 @@ export function getStripeSampleStatementDescriptor( domain ) { .trim() .toUpperCase(); } + +export function hasOAuthParamsInLocation() { + const oauthParams = getOAuthParamsFromLocation(); + return oauthParams.state.length && oauthParams.code.length; +} + +export function hasOAuthCompleteInLocation() { + try { + const parsedURL = url.parse( window.location.href, true, true ); + return get( parsedURL, [ 'query', 'oauth_complete' ], false ); + } catch ( e ) { + return false; + } +} + +export function getOAuthParamsFromLocation() { + let state = ''; + let code = ''; + + try { + const parsedURL = url.parse( window.location.href, true, true ); + state = get( parsedURL, [ 'query', 'wcs_stripe_state' ], false ); + code = get( parsedURL, [ 'query', 'wcs_stripe_code' ], false ); + } catch ( e ) {} + + return { + state, + code, + }; +} diff --git a/client/extensions/woocommerce/app/settings/payments/stripe/style.scss b/client/extensions/woocommerce/app/settings/payments/stripe/style.scss index 19197650b2d66..c3de8bfdc0999 100644 --- a/client/extensions/woocommerce/app/settings/payments/stripe/style.scss +++ b/client/extensions/woocommerce/app/settings/payments/stripe/style.scss @@ -75,4 +75,19 @@ font-size: 18px; justify-content: space-between; padding: 0 0 16px 0; + + &.placeholder { + margin-bottom: 16px; + width: 30%; + @include placeholder(); + } +} + +.stripe__method-edit-body { + padding: 0 0 16px 0; + + &.placeholder { + height: 60px; + @include placeholder(); + } } diff --git a/client/extensions/woocommerce/lib/nav-utils.js b/client/extensions/woocommerce/lib/nav-utils.js index 47eb5e5380c10..386482fa3fd0c 100644 --- a/client/extensions/woocommerce/lib/nav-utils.js +++ b/client/extensions/woocommerce/lib/nav-utils.js @@ -12,3 +12,20 @@ export const getLink = ( path, site ) => { } return path.replace( ':site', site.slug ); }; + +/* Returns the origin for the current browser window + * + * @return {String} origin for the current browser window, wordpress.com by default + */ +export const getOrigin = () => { + let origin = 'https://wordpress.com'; + if ( 'undefined' !== typeof window && window.location ) { + origin = `${ window.location.protocol }//${ window.location.hostname }`; + } + + if ( window.location.port ) { + origin += `:${ window.location.port }`; + } + + return origin; +}; diff --git a/client/extensions/woocommerce/state/action-types.js b/client/extensions/woocommerce/state/action-types.js index 8a5acdbd06b2e..91aa4736f0b1e 100644 --- a/client/extensions/woocommerce/state/action-types.js +++ b/client/extensions/woocommerce/state/action-types.js @@ -160,10 +160,28 @@ export const WOOCOMMERCE_SETTINGS_GENERAL_RECEIVE = 'WOOCOMMERCE_SETTINGS_GENERA export const WOOCOMMERCE_SETTINGS_PRODUCTS_REQUEST = 'WOOCOMMERCE_SETTINGS_PRODUCTS_REQUEST'; export const WOOCOMMERCE_SETTINGS_PRODUCTS_REQUEST_SUCCESS = 'WOOCOMMERCE_SETTINGS_PRODUCTS_REQUEST_SUCCESS'; +export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR = + 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR'; export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE = 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE'; export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE = 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE'; +export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST = + 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST'; +export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE = + 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE'; +export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE = + 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE'; +export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE = + 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE'; +export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT = + 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_OAUTH_INIT'; +export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE = + 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE'; +export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT = + 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT'; +export const WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE = + 'WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE'; export const WOOCOMMERCE_SETTINGS_TAX_BATCH_REQUEST = 'WOOCOMMERCE_SETTINGS_TAX_BATCH_REQUEST'; export const WOOCOMMERCE_SETTINGS_TAX_BATCH_REQUEST_SUCCESS = 'WOOCOMMERCE_SETTINGS_TAX_BATCH_REQUEST_SUCCESS'; diff --git a/client/extensions/woocommerce/state/data-layer/index.js b/client/extensions/woocommerce/state/data-layer/index.js index 6e246ea7d9dea..0574b529d41d2 100644 --- a/client/extensions/woocommerce/state/data-layer/index.js +++ b/client/extensions/woocommerce/state/data-layer/index.js @@ -20,7 +20,6 @@ import settingsGeneral from '../sites/settings/general/handlers'; import shippingZoneLocations from './shipping-zone-locations'; import shippingZoneMethods from './shipping-zone-methods'; import shippingZones from './shipping-zones'; -import stripeConnectAccount from '../sites/settings/stripe-connect-account/handlers'; import ui from './ui'; import debugFactory from 'debug'; @@ -41,7 +40,6 @@ const handlers = mergeHandlers( shippingZoneLocations, shippingZoneMethods, shippingZones, - stripeConnectAccount, ui ); diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/actions.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/actions.js index 1ecab7459b8c8..4a66a5320dcb4 100644 --- a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/actions.js +++ b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/actions.js @@ -1,16 +1,434 @@ /** - * Internal dependencies + * External dependencies * * @format */ -import { WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE } from 'woocommerce/state/action-types'; +/** + * Internal dependencies + */ +import { + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, +} from 'woocommerce/state/action-types'; +import { getSelectedSiteId } from 'state/ui/selectors'; +import request from '../../request'; -export function createAccount( siteId, email, countryCode ) { - return { +/** + * Action Creator: Clear any error from a previous action. + * + * @param {Number} siteId The id of the site for which to clear errors. + * @return {Object} Action object + */ +export const clearError = siteId => ( dispatch, getState ) => { + const state = getState(); + if ( ! siteId ) { + siteId = getSelectedSiteId( state ); + } + + const clearErrorAction = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR, + siteId, + }; + + dispatch( clearErrorAction ); +}; + +/** + * Action Creator: Create (and connect) a Stripe Connect Account. + * + * @param {Number} siteId The id of the site for which to create an account. + * @param {String} email Email address (i.e. of the logged in WordPress.com user) to pass to Stripe. + * @param {String} country Two character country code to pass to Stripe (e.g. US). + * @param {String} [successAction=undefined] Optional action object to be dispatched upon success. + * @param {String} [failureAction=undefined] Optional action object to be dispatched upon error. + * @return {Object} Action object + */ +export const createAccount = ( + siteId, + email, + country, + successAction = null, + failureAction = null +) => ( dispatch, getState ) => { + const state = getState(); + if ( ! siteId ) { + siteId = getSelectedSiteId( state ); + } + + const createAction = { type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, - countryCode, + country, + email, + siteId, + }; + + dispatch( createAction ); + + return request( siteId ) + .post( 'connect/stripe/account', { email, country }, 'wc/v1' ) + .then( data => { + dispatch( createSuccess( siteId, createAction, data ) ); + if ( successAction ) { + dispatch( successAction( siteId, createAction, data ) ); + } + } ) + .catch( error => { + dispatch( createFailure( siteId, createAction, error ) ); + if ( failureAction ) { + dispatch( failureAction( siteId, createAction, error ) ); + } + } ); +}; + +/** + * Action Creator: Stripe Connect Account creation completed successfully + * + * @param {Number} siteId The id of the site for which to create an account. + * @param {Object} email The email address used to create the account. + * @param {Object} account_id The Stripe Connect Account id created for the site (from the data object). + * @return {Object} Action object + */ +function createSuccess( siteId, { email }, { account_id } ) { + return { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + connectedUserID: account_id, + email, + siteId, + }; +} + +/** + * Action Creator: Stripe Connect Account creation failed + * + * @param {Number} siteId The id of the site for which account creation failed. + * @param {Object} action The action used to attempt to create the account. + * @param {Object} message Error message returned (from the error object). + * @return {Object} Action object + */ +function createFailure( siteId, action, { message } ) { + return { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + error: message, + siteId, + }; +} + +/** + * Action Creator: Fetch Stripe Connect Account Details. + * + * @param {Number} siteId The id of the site for which to fetch connected account details. + * @param {String} [successAction=undefined] Optional action object to be dispatched upon success. + * @param {String} [failureAction=undefined] Optional action object to be dispatched upon error. + * @return {Object} Action object + */ +export const fetchAccountDetails = ( siteId, successAction = null, failureAction = null ) => ( + dispatch, + getState +) => { + const state = getState(); + if ( ! siteId ) { + siteId = getSelectedSiteId( state ); + } + + const fetchAction = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, + siteId, + }; + + dispatch( fetchAction ); + + return request( siteId ) + .get( 'connect/stripe/account', 'wc/v1' ) + .then( data => { + dispatch( fetchSuccess( siteId, fetchAction, data ) ); + if ( successAction ) { + dispatch( successAction( siteId, fetchAction, data ) ); + } + } ) + .catch( error => { + dispatch( fetchFailure( siteId, fetchAction, error ) ); + if ( failureAction ) { + dispatch( failureAction( error ) ); + } + } ); +}; + +/** + * Action Creator: Stripe Connect Account details were fetched successfully + * + * @param {Number} siteId The id of the site for which details were fetched. + * @param {Object} fetchAction The action used to fetch the account details. + * @param {Object} data The entire data object that was returned from the API. + * @return {Object} Action object + */ +function fetchSuccess( siteId, fetchAction, data ) { + const { account_id, display_name, email, business_logo, legal_entity, payouts_enabled } = data; + return { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, + connectedUserID: account_id, + displayName: display_name, email, + firstName: legal_entity.first_name, + isActivated: payouts_enabled, + logo: business_logo, + lastName: legal_entity.last_name, + siteId, + }; +} + +/** + * Action Creator: Stripe Connect Account details were unable to be fetched + * + * @param {Number} siteId The id of the site for which details could not be fetched. + * @param {Object} action The action used to attempt to fetch the account details. + * @param {Object} message Error message returned (from the error object). + * @return {Object} Action object + */ +function fetchFailure( siteId, action, { message } ) { + return { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, + error: message, + siteId, + }; +} + +/** + * Action Creator: Disconnect Account. + * + * @param {Number} siteId The id of the site to disconnect from Stripe Connect. + * @param {String} [successAction=undefined] Optional action object to be dispatched upon success. + * @param {String} [failureAction=undefined] Optional action object to be dispatched upon error. + * @return {Object} Action object + */ +export const deauthorizeAccount = ( siteId, successAction = null, failureAction = null ) => ( + dispatch, + getState +) => { + const state = getState(); + if ( ! siteId ) { + siteId = getSelectedSiteId( state ); + } + + const deauthorizeAction = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, + siteId, + }; + + dispatch( deauthorizeAction ); + + return request( siteId ) + .post( 'connect/stripe/account/deauthorize', {}, 'wc/v1' ) + .then( data => { + dispatch( deauthorizeSuccess( siteId, deauthorizeAction, data ) ); + if ( successAction ) { + dispatch( successAction( siteId, deauthorizeAction, data ) ); + } + } ) + .catch( error => { + dispatch( deauthorizeFailure( siteId, deauthorizeAction, error ) ); + if ( failureAction ) { + dispatch( failureAction( error ) ); + } + } ); +}; + +/** + * Action Creator: The Stripe Connect account was successfully deauthorized from our platform. + * + * @param {Number} siteId The id of the site which had its account deauthorized. + * @param {Object} action The action used to deauthorize the account. + * @param {Object} data The entire data object that was returned from the API. + * @return {Object} Action object + */ +// eslint-disable-next-line no-unused-vars +function deauthorizeSuccess( siteId, action, data ) { + return { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, + siteId, + }; +} + +/** + * Action Creator: The Stripe Connect account was unable to be deauthorized from our platform. + * + * @param {Number} siteId The id of the site which failed to have its account deauthorized. + * @param {Object} action The action used to attempt to deauthorize the account. + * @param {Object} errorMessage Error message returned. + * @return {Object} Action object + */ +function deauthorizeFailure( siteId, action, errorMessage ) { + return { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, + error: errorMessage, + siteId, + }; +} + +/** + * Action Creator: Get the initial OAuth URL for connecting a Stripe Account. + * + * @param {Number} siteId The id of the site for which to create an account. + * @param {String} returnUrl The URL for Stripe to return the user to (to complete the setup) + * @param {String} [successAction=undefined] Optional action object to be dispatched upon success. + * @param {String} [failureAction=undefined] Optional action object to be dispatched upon error. + * @return {Object} Action object + */ +export const oauthInit = ( siteId, returnUrl, successAction = null, failureAction = null ) => ( + dispatch, + getState +) => { + const state = getState(); + if ( ! siteId ) { + siteId = getSelectedSiteId( state ); + } + + const initAction = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, + returnUrl, + siteId, + }; + + dispatch( initAction ); + + return request( siteId ) + .post( 'connect/stripe/oauth/init', { returnUrl }, 'wc/v1' ) + .then( data => { + dispatch( oauthInitSuccess( siteId, initAction, data ) ); + if ( successAction ) { + dispatch( successAction( siteId, initAction, data ) ); + } + } ) + .catch( error => { + dispatch( oauthInitFailure( siteId, initAction, error ) ); + if ( failureAction ) { + dispatch( failureAction( siteId, initAction, error ) ); + } + } ); +}; + +/** + * Action Creator: The Stripe Connect account OAuth flow was successfully initialized. + * + * @param {Number} siteId The id of the site which we're doing OAuth for. + * @param {Object} action The action used to deauthorize the account. + * @param {Object} oauthUrl The URL to which the user needs to navigate to. + * @return {Object} Action object + */ +function oauthInitSuccess( siteId, action, { oauthUrl } ) { + return { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, + oauthUrl, + siteId, + }; +} + +/** + * Action Creator: The Stripe Connect account OAuth flow was unable to be initialized. + * + * @param {Number} siteId The id of the site which we tried doing OAuth for. + * @param {Object} action The action used to attempt to deauthorize the account. + * @param {Object} message Error message returned (from the error object). + * @return {Object} Action object + */ +function oauthInitFailure( siteId, action, { message } ) { + return { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, + error: message, + siteId, + }; +} + +/** + * Action Creator: Complete the OAuth flow and connect the Stripe Account. + * + * @param {Number} siteId The id of the site for which to create an account. + * @param {String} stripeCode The code which Stripe will exchange for the account id. + * @param {String} stripeState An arbitrary string passed throughout the flow as a CSRF protection. + * @param {String} [successAction=undefined] Optional action object to be dispatched upon success. + * @param {String} [failureAction=undefined] Optional action object to be dispatched upon error. + * @return {Object} Action object + */ +export const oauthConnect = ( + siteId, + stripeCode, + stripeState, + successAction = null, + failureAction = null +) => ( dispatch, getState ) => { + const state = getState(); + if ( ! siteId ) { + siteId = getSelectedSiteId( state ); + } + + const connectAction = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, + stripeCode, + stripeState, + siteId, + }; + + dispatch( connectAction ); + + return request( siteId ) + .post( 'connect/stripe/oauth/connect', { code: stripeCode, state: stripeState }, 'wc/v1' ) + .then( data => { + dispatch( oauthConnectSuccess( siteId, connectAction, data ) ); + if ( successAction ) { + dispatch( successAction( siteId, connectAction, data ) ); + } + } ) + .then( () => { + dispatch( fetchAccountDetails( siteId ) ); + } ) + .catch( error => { + dispatch( oauthConnectFailure( siteId, connectAction, error ) ); + if ( failureAction ) { + dispatch( failureAction( siteId, connectAction, error ) ); + } + } ); +}; + +/** + * Action Creator: The Stripe Connect account OAuth flow was successfully completed. + * + * @param {Number} siteId The id of the site which we're doing OAuth for. + * @param {Object} action The action used to complete OAuth for the account. + * @param {Object} account_id The account_id we are now connected to (from the data object) + * @return {Object} Action object + */ +function oauthConnectSuccess( siteId, action, { account_id } ) { + return { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, + connectedUserID: account_id, + siteId, + }; +} + +/** + * Action Creator: The Stripe Connect account OAuth flow was not able to be completed. + * + * @param {Number} siteId The id of the site which we tried doing OAuth for. + * @param {Object} action The action used to try and complete OAuth for the account. + * @param {Object} error Error and message returned (from the error object). + * @return {Object} Action object + */ +// Note: Stripe and WooCommerce Services server errors will be returned in message, but +// message will be empty for errors that the WooCommerce Services client generates itself +// so we need to grab the string from the error field inside the error object for those. +function oauthConnectFailure( siteId, action, { error, message } ) { + return { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, + error: message || error, siteId, }; } diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/handlers.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/handlers.js deleted file mode 100644 index 967ef9a427304..0000000000000 --- a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/handlers.js +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Internal dependencies - * - * @format - */ - -import { dispatchRequest } from 'state/data-layer/wpcom-http/utils'; -import request from 'woocommerce/state/sites/http-request'; -import { - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, - WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, -} from 'woocommerce/state/action-types'; - -export default { - [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE ]: [ - dispatchRequest( handleAccountCreate, handleAccountCreateSuccess, handleAccountCreateFailure ), - ], -}; - -export function handleAccountCreate( { dispatch }, action ) { - const { email, countryCode, siteId } = action; - dispatch( - request( siteId, action, '/wc/v1' ).post( 'connect/stripe/account/', { - email, - country: countryCode, - } ) - ); -} - -export function handleAccountCreateSuccess( store, action, { data } ) { - const { email, siteId } = action; - const { account_id } = data; - - store.dispatch( { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, - connectedUserID: account_id, - email, - siteId, - } ); -} - -export function handleAccountCreateFailure( { dispatch }, action, error ) { - const { email, siteId } = action; - dispatch( { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, - email, - error, - siteId, - } ); -} diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/reducer.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/reducer.js index 7b1cf837a875a..eec0f8e9d9ec4 100644 --- a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/reducer.js +++ b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/reducer.js @@ -1,49 +1,224 @@ /** - * Internal dependencies + * External dependencies * * @format */ +/** + * Internal dependencies + */ import { createReducer } from 'state/utils'; import { + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR, WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, } from 'woocommerce/state/action-types'; /** - * Updates state to indicate account creation request is in progress + * Updates state to clear any error from a previous action + * + * @param {Object} state Current state + * @return {Object} Updated state + */ +function connectAccountClearError( state = {} ) { + return Object.assign( {}, state, { + error: '', + } ); +} + +/** + * Updates state to indicate account creation is in progress * * @param {Object} state Current state - * @param {Object} action Action payload * @return {Object} Updated state */ function connectAccountCreate( state = {} ) { + return Object.assign( {}, state, { + error: '', + isCreating: true, + } ); +} + +/** + * Updates state to reflect account creation completed (or failed with an error) + * + * @param {Object} state Current state + * @param {Object} action Action payload + * @return {Object} Updated state + */ +function connectAccountCreateComplete( state = {}, action ) { + return Object.assign( {}, state, { + connectedUserID: action.connectedUserID || '', + displayName: '', + email: action.email || '', + error: action.error || '', + firstName: '', + isActivated: false, + isCreating: false, + isRequesting: false, + lastName: '', + logo: '', + } ); +} + +/** + * Updates state to indicate account (details) fetch request is in progress + * + * @param {Object} state Current state + * @return {Object} Updated state + */ +function connectAccountFetch( state = {} ) { return Object.assign( {}, state, { connectedUserID: '', + displayName: '', email: '', + error: '', + firstName: '', isActivated: false, + isDeauthorizing: false, isRequesting: true, + lastName: '', + logo: '', } ); } /** - * Updates state with created account details + * Updates state with fetched account details * * @param {Object} state Current state * @param {Object} action Action payload * @return {Object} Updated state */ -function connectAccountCreateComplete( state = {}, action ) { +function connectAccountFetchComplete( state = {}, action ) { return Object.assign( {}, state, { connectedUserID: action.connectedUserID || '', + displayName: action.displayName || '', email: action.email || '', error: action.error || '', + firstName: action.firstName || '', + isActivated: action.isActivated || false, + isDeauthorizing: false, + isRequesting: false, + lastName: action.lastName || '', + logo: action.logo || '', + } ); +} + +/** + * Updates state to indicate account deauthorization request is in progress + * + * @param {Object} state Current state + * @return {Object} Updated state + */ +function connectAccountDeauthorize( state = {} ) { + return Object.assign( {}, state, { + isDeauthorizing: true, + } ); +} + +/** + * Updates state after deauthorization completes + * + * @param {Object} state Current state + * @param {Object} action Action payload + * @return {Object} Updated state + */ +function connectAccountDeauthorizeComplete( state = {}, action ) { + return Object.assign( {}, state, { + connectedUserID: '', + displayName: '', + email: '', + error: action.error || '', + firstName: '', + isActivated: false, + isDeauthorizing: false, + isRequesting: false, + lastName: '', + logo: '', + } ); +} + +/** + * Updates state to indicate oauth initialization request is in progress + * + * @param {Object} state Current state + * @return {Object} Updated state + */ +function connectAccountOAuthInit( state = {} ) { + return Object.assign( {}, state, { + isOAuthInitializing: true, + oauthUrl: '', + } ); +} + +/** + * Updates state after oauth initialization completes + * + * @param {Object} state Current state + * @param {Object} action Action payload + * @return {Object} Updated state + */ +function connectAccountOAuthInitComplete( state = {}, action ) { + return Object.assign( {}, state, { + isOAuthInitializing: false, + error: action.error || '', + oauthUrl: action.oauthUrl || '', + } ); +} + +/** + * Updates state to indicate account creation is in progress + * + * @param {Object} state Current state + * @return {Object} Updated state + */ +function connectAccountOAuthConnect( state = {} ) { + return Object.assign( {}, state, { + error: '', + isOAuthConnecting: true, + } ); +} + +/** + * Updates state to reflect account creation completed (or failed with an error) + * + * @param {Object} state Current state + * @param {Object} action Action payload + * @return {Object} Updated state + */ +function connectAccountOAuthConnectComplete( state = {}, action ) { + return Object.assign( {}, state, { + connectedUserID: action.connectedUserID || '', + email: '', + error: action.error || '', + firstName: '', isActivated: false, + isCreating: false, + isOAuthConnecting: false, isRequesting: false, + lastName: '', + logo: '', } ); } export default createReducer( null, { + [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR ]: connectAccountClearError, [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE ]: connectAccountCreate, [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE ]: connectAccountCreateComplete, + [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE ]: connectAccountDeauthorize, + [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE ]: connectAccountDeauthorizeComplete, + [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST ]: connectAccountFetch, + [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE ]: connectAccountFetchComplete, + [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT ]: connectAccountOAuthInit, + [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE ]: connectAccountOAuthInitComplete, + [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT ]: connectAccountOAuthConnect, + [ WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE ]: connectAccountOAuthConnectComplete, } ); diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/selectors.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/selectors.js new file mode 100644 index 0000000000000..c52d6c95ec6bf --- /dev/null +++ b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/selectors.js @@ -0,0 +1,101 @@ +/** + * External dependencies + * + * @format + */ + +import { get, omit } from 'lodash'; + +/** + * Internal dependencies + */ +import { getSelectedSiteId } from 'state/ui/selectors'; + +const getRawSettings = ( state, siteId ) => { + return get( + state, + [ 'extensions', 'woocommerce', 'sites', siteId, 'settings', 'stripeConnectAccount' ], + {} + ); +}; + +/** + * @param {Object} state Whole Redux state tree + * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used + * @return {boolean} Whether we are presently attempting to create an account + */ +export function getError( state, siteId = getSelectedSiteId( state ) ) { + return get( getRawSettings( state, siteId ), [ 'error' ], '' ); +} + +/** + * @param {Object} state Whole Redux state tree + * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used + * @return {boolean} Whether we are presently attempting to create an account + */ +export function getIsCreating( state, siteId = getSelectedSiteId( state ) ) { + return get( getRawSettings( state, siteId ), [ 'isCreating' ], false ); +} + +/** + * @param {Object} state Whole Redux state tree + * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used + * @return {boolean} Whether we are presently attempting to deauthorize the connected account for the site + */ +export function getIsDeauthorizing( state, siteId = getSelectedSiteId( state ) ) { + return get( getRawSettings( state, siteId ), [ 'isDeauthorizing' ], false ); +} + +/** + * @param {Object} state Whole Redux state tree + * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used + * @return {boolean} Whether we are presently attempting to complete the OAuth connection + */ +export function getIsOAuthConnecting( state, siteId = getSelectedSiteId( state ) ) { + return get( getRawSettings( state, siteId ), [ 'isOAuthConnecting' ], false ); +} + +/** + * @param {Object} state Whole Redux state tree + * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used + * @return {boolean} Whether we are presently requesting oauth initialization + */ +export function getIsOAuthInitializing( state, siteId = getSelectedSiteId( state ) ) { + return get( getRawSettings( state, siteId ), [ 'isOAuthInitializing' ], false ); +} + +/** + * @param {Object} state Whole Redux state tree + * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used + * @return {String} URL to which to navigate to kick off the OAuth flow at Stripe + */ +export function getOAuthURL( state, siteId = getSelectedSiteId( state ) ) { + return get( getRawSettings( state, siteId ), [ 'oauthUrl' ], '' ); +} + +/** + * @param {Object} state Whole Redux state tree + * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used + * @return {boolean} Whether we are presently requesting connect account details from the server + */ +export function getIsRequesting( state, siteId = getSelectedSiteId( state ) ) { + return get( getRawSettings( state, siteId ), [ 'isRequesting' ], false ); +} + +/** + * @param {Object} state Whole Redux state tree + * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used + * @return {Object} The details of the connect account for the site, if any + */ +export function getStripeConnectAccount( state, siteId = getSelectedSiteId( state ) ) { + const rawSettings = getRawSettings( state, siteId ); + return omit( rawSettings, [ + 'error', + 'isCreating', + 'isDeauthorizing', + 'isOAuthConnecting', + 'isOAuthInitializing', + 'isRequesting', + 'oauthUrl', + ] ); +} diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/actions.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/actions.js index 7084549435c43..648e144340577 100644 --- a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/actions.js +++ b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/actions.js @@ -4,25 +4,271 @@ * External dependencies */ import { expect } from 'chai'; +import { spy } from 'sinon'; /** * Internal dependencies */ -import { createAccount } from '../actions'; -import { WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE } from 'woocommerce/state/action-types'; +import useNock from 'test/helpers/use-nock'; +import { + clearError, + createAccount, + deauthorizeAccount, + fetchAccountDetails, + oauthInit, + oauthConnect, +} from '../actions'; +import { + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, +} from 'woocommerce/state/action-types'; describe( 'actions', () => { + describe( '#clearError()', () => { + const siteId = '123'; + + test( 'should dispatch an action', () => { + const getState = () => ( {} ); + const dispatch = spy(); + clearError( siteId )( dispatch, getState ); + expect( dispatch ).to.have.been.calledWith( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR, + siteId, + } ); + } ); + } ); + describe( '#createAccount()', () => { const siteId = '123'; - const email = 'foo@bar.com'; - const countryCode = 'US'; - test( 'should return an action', () => { - const action = createAccount( siteId, email, countryCode ); - expect( action ).to.eql( { + + useNock( nock => { + nock( 'https://public-api.wordpress.com:443' ) + .persist() + .post( '/rest/v1.1/jetpack-blogs/123/rest-api/' ) + .query( { path: '/wc/v1/connect/stripe/account&_method=post', json: true } ) + .reply( 200, { + data: { + success: true, + account_id: 'acct_14qyt6Alijdnw0EA', + }, + } ); + } ); + + test( 'should dispatch an action', () => { + const getState = () => ( {} ); + const dispatch = spy(); + createAccount( siteId, 'foo@bar.com', 'US' )( dispatch, getState ); + expect( dispatch ).to.have.been.calledWith( { type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, + country: 'US', + email: 'foo@bar.com', + siteId, + } ); + } ); + + test( 'should dispatch a success action with account details when the request completes', () => { + const getState = () => ( {} ); + const dispatch = spy(); + const response = createAccount( siteId, 'foo@bar.com', 'US' )( dispatch, getState ); + + return response.then( () => { + expect( dispatch ).to.have.been.calledWith( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + siteId, + connectedUserID: 'acct_14qyt6Alijdnw0EA', + email: 'foo@bar.com', + } ); + } ); + } ); + } ); + + describe( '#fetchAccountDetails()', () => { + const siteId = '123'; + + useNock( nock => { + nock( 'https://public-api.wordpress.com:443' ) + .persist() + .get( '/rest/v1.1/jetpack-blogs/123/rest-api/' ) + .query( { path: '/wc/v1/connect/stripe/account&_method=get', json: true } ) + .reply( 200, { + data: { + success: true, + account_id: 'acct_14qyt6Alijdnw0EA', + display_name: 'Foo Bar', + email: 'foo@bar.com', + legal_entity: { + first_name: 'Foo', + last_name: 'Bar', + }, + payouts_enabled: true, + business_logo: 'https://foo.com/bar.png', + }, + } ); + } ); + + test( 'should dispatch an action', () => { + const getState = () => ( {} ); + const dispatch = spy(); + fetchAccountDetails( siteId )( dispatch, getState ); + expect( dispatch ).to.have.been.calledWith( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, + siteId, + } ); + } ); + + test( 'should dispatch a success action with account details when the request completes', () => { + const getState = () => ( {} ); + const dispatch = spy(); + const response = fetchAccountDetails( siteId )( dispatch, getState ); + + return response.then( () => { + expect( dispatch ).to.have.been.calledWith( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, + siteId, + connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: 'Foo Bar', + email: 'foo@bar.com', + firstName: 'Foo', + isActivated: true, + logo: 'https://foo.com/bar.png', + lastName: 'Bar', + } ); + } ); + } ); + } ); + + describe( '#deauthorizeAccount()', () => { + const siteId = '123'; + + useNock( nock => { + nock( 'https://public-api.wordpress.com:443' ) + .persist() + .post( '/rest/v1.1/jetpack-blogs/123/rest-api/' ) + .query( { path: '/wc/v1/connect/stripe/account/deauthorize&_method=post', json: true } ) + .reply( 200, { + data: { + success: true, + account_id: 'acct_14qyt6Alijdnw0EA', + }, + } ); + } ); + + test( 'should dispatch an action', () => { + const getState = () => ( {} ); + const dispatch = spy(); + deauthorizeAccount( siteId )( dispatch, getState ); + expect( dispatch ).to.have.been.calledWith( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, siteId, - email, - countryCode, + } ); + } ); + + test( 'should dispatch a success action when the request completes', () => { + const getState = () => ( {} ); + const dispatch = spy(); + const response = deauthorizeAccount( siteId )( dispatch, getState ); + + return response.then( () => { + expect( dispatch ).to.have.been.calledWith( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, + siteId, + } ); + } ); + } ); + } ); + + describe( '#oauthInit()', () => { + const siteId = '123'; + + useNock( nock => { + nock( 'https://public-api.wordpress.com:443' ) + .persist() + .post( '/rest/v1.1/jetpack-blogs/123/rest-api/' ) + .query( { path: '/wc/v1/connect/stripe/oauth/init&_method=post', json: true } ) + .reply( 200, { + data: { + success: true, + oauthUrl: + 'https://connect.stripe.com/oauth/authorize?response_type=code&client_id=xxx&scope=read_write&state=yyy', + }, + } ); + } ); + + test( 'should dispatch an action', () => { + const getState = () => ( {} ); + const dispatch = spy(); + oauthInit( siteId, 'https://return.url.com/' )( dispatch, getState ); + expect( dispatch ).to.have.been.calledWith( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, + returnUrl: 'https://return.url.com/', + siteId, + } ); + } ); + + test( 'should dispatch a success action with a Stripe URL when the request completes', () => { + const getState = () => ( {} ); + const dispatch = spy(); + const response = oauthInit( siteId, 'https://return.url.com/' )( dispatch, getState ); + + return response.then( () => { + expect( dispatch ).to.have.been.calledWith( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, + siteId, + oauthUrl: + 'https://connect.stripe.com/oauth/authorize?response_type=code&client_id=xxx&scope=read_write&state=yyy', + } ); + } ); + } ); + } ); + + describe( '#oauthConnect()', () => { + const siteId = '123'; + + useNock( nock => { + nock( 'https://public-api.wordpress.com:443' ) + .persist() + .post( '/rest/v1.1/jetpack-blogs/123/rest-api/' ) + .query( { path: '/wc/v1/connect/stripe/oauth/connect&_method=post', json: true } ) + .reply( 200, { + data: { + success: true, + account_id: 'acct_14qyt6Alijdnw0EA', + }, + } ); + } ); + + test( 'should dispatch an action', () => { + const getState = () => ( {} ); + const dispatch = spy(); + oauthConnect( siteId, 'STRIPECODE', 'STRIPESTATE' )( dispatch, getState ); + expect( dispatch ).to.have.been.calledWith( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, + stripeCode: 'STRIPECODE', + stripeState: 'STRIPESTATE', + siteId, + } ); + } ); + + test( 'should dispatch a success action with a Stripe account when the request completes', () => { + const getState = () => ( {} ); + const dispatch = spy(); + const response = oauthConnect( siteId, 'STRIPECODE', 'STRIPESTATE' )( dispatch, getState ); + + return response.then( () => { + expect( dispatch ).to.have.been.calledWith( { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, + connectedUserID: 'acct_14qyt6Alijdnw0EA', + siteId, + } ); } ); } ); } ); diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/handlers.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/handlers.js deleted file mode 100644 index 149eddf775ebd..0000000000000 --- a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/handlers.js +++ /dev/null @@ -1,104 +0,0 @@ -/** @format */ - -/** - * External dependencies - */ -import { expect } from 'chai'; -import { spy } from 'sinon'; - -/** - * Internal dependencies - */ -import { createAccount } from '../actions.js'; -import { - handleAccountCreate, - handleAccountCreateSuccess, - handleAccountCreateFailure, -} from '../handlers.js'; -import { WPCOM_HTTP_REQUEST } from 'state/action-types'; -import { WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE } from 'woocommerce/state/action-types'; - -describe( 'handlers', () => { - describe( '#handleCreateAccountRequest', () => { - test( 'should dispatch a POST request', () => { - const siteId = '123'; - const email = 'foo@bar.com'; - const country = 'US'; - const dispatch = spy(); - const action = createAccount( siteId, email, country ); - handleAccountCreate( { dispatch }, action ); - expect( dispatch ).to.have.been.calledWithMatch( { - type: WPCOM_HTTP_REQUEST, - body: { - path: '/wc/v1/connect/stripe/account/&_method=POST', - body: JSON.stringify( { email, country } ), - }, - method: 'POST', - path: `/jetpack-blogs/${ siteId }/rest-api/`, - query: { - json: true, - apiVersion: '1.1', - }, - } ); - } ); - } ); - - describe( '#handleAccountCreateSuccess()', () => { - test( 'should dispatch create account receive on success with the account info', () => { - const siteId = '123'; - const email = 'foo@bar.com'; - const countryCode = 'US'; - const store = { - dispatch: spy(), - }; - const response = { - data: { - account_id: 'acct_14qyt6Alijdnw0EA', - success: true, - }, - }; - - const action = createAccount( siteId, email, countryCode ); - handleAccountCreateSuccess( store, action, response ); - - expect( store.dispatch ).calledWith( { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, - connectedUserID: response.data.account_id, - email, - siteId, - } ); - } ); - } ); - - describe( '#handleAccountCreateFailure()', () => { - test( 'should dispatch create account error', () => { - const siteId = '123'; - const email = 'foo@bar.com'; - const countryCode = 'US'; - const store = { - dispatch: spy(), - }; - const response = { - data: { - body: { - data: { - message: 'An account using that email address already exists.', - }, - success: false, - }, - status: 400, - }, - }; - - const action = createAccount( siteId, email, countryCode ); - handleAccountCreateFailure( store, action, response.data.body.data.message ); - - expect( store.dispatch ).calledWith( { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, - email, - error: response.data.body.data.message, - siteId, - } ); - } ); - } ); -} ); diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/reducer.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/reducer.js index fcf6acb81cc7c..050bbc99b17b7 100644 --- a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/reducer.js +++ b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/reducer.js @@ -10,8 +10,17 @@ import { expect } from 'chai'; */ import stripeConnectAccountReducer from '../reducer'; import { + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR, WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, + WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, } from 'woocommerce/state/action-types'; import sitesReducer from 'woocommerce/state/sites/reducer'; @@ -23,6 +32,17 @@ describe( 'reducer', () => { } ); } ); + describe( 'clearError', () => { + test( 'should reset error in state', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CLEAR_ERROR, + siteId: 123, + }; + const newState = stripeConnectAccountReducer( { error: 'My error message' }, action ); + expect( newState.error ).to.eql( '' ); + } ); + } ); + describe( 'connectAccountCreate', () => { test( 'should update state to show request in progress', () => { const action = { @@ -30,7 +50,7 @@ describe( 'reducer', () => { siteId: 123, }; const newState = stripeConnectAccountReducer( undefined, action ); - expect( newState.isRequesting ).to.eql( true ); + expect( newState.isCreating ).to.eql( true ); } ); test( 'should only update the request in progress flag for the appropriate siteId', () => { @@ -38,6 +58,154 @@ describe( 'reducer', () => { type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE, siteId: 123, }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + isCreating: false, + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + isCreating: false, + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.isCreating ).to.eql( true ); + expect( newState[ 456 ].settings.stripeConnectAccount.isCreating ).to.eql( false ); + } ); + } ); + + describe( 'connectAccountCreateComplete', () => { + test( 'should update state with the received account details', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + connectedUserID: 'acct_14qyt6Alijdnw0EA', + email: 'foo@bar.com', + siteId: 123, + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState ).to.eql( { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: '', + email: 'foo@bar.com', + error: '', + firstName: '', + isActivated: false, + isCreating: false, + isRequesting: false, + lastName: '', + logo: '', + } ); + } ); + + test( 'should leave other sites state unchanged', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + connectedUserID: 'acct_14qyt6Alijdnw0EA', + email: 'foo@bar.com', + siteId: 123, + }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + email: '', + isActivated: false, + isCreating: true, + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + email: '', + isActivated: false, + isCreating: true, + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.isCreating ).to.eql( false ); + expect( newState[ 123 ].settings.stripeConnectAccount.connectedUserID ).to.eql( + 'acct_14qyt6Alijdnw0EA' + ); + expect( newState[ 123 ].settings.stripeConnectAccount.email ).to.eql( 'foo@bar.com' ); + expect( newState[ 456 ].settings.stripeConnectAccount.isCreating ).to.eql( true ); + } ); + } ); + + describe( 'connectAccountCreateError', () => { + test( 'should reset the isCreating flag in state and store the email and error', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + siteId: 123, + email: 'foo@bar.com', + error: 'My error', + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState.error ).to.eql( 'My error' ); + expect( newState.email ).to.eql( 'foo@bar.com' ); + expect( newState.isCreating ).to.eql( false ); + } ); + + test( 'should leave other sites state unchanged', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + siteId: 123, + email: 'foo@bar.com', + error: 'My error', + }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + isCreating: true, + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + isCreating: true, + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.isCreating ).to.eql( false ); + expect( newState[ 456 ].settings.stripeConnectAccount.isCreating ).to.eql( true ); + } ); + } ); + + describe( 'connectAccountFetch', () => { + test( 'should update state to show request in progress', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, + siteId: 123, + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState.isRequesting ).to.eql( true ); + } ); + + test( 'should only update the request in progress flag for the appropriate siteId', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_REQUEST, + siteId: 123, + }; const newState = sitesReducer( { 123: { @@ -68,27 +236,37 @@ describe( 'reducer', () => { } ); } ); - describe( 'connectAccountCreateComplete', () => { + describe( 'connectAccountFetchComplete', () => { test( 'should update state with the received account details', () => { const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: 'Foo Bar', email: 'foo@bar.com', + firstName: 'Foo', + isActivated: false, + lastName: 'Bar', + logo: 'http://bar.com/foo.png', siteId: 123, }; const newState = stripeConnectAccountReducer( undefined, action ); expect( newState ).to.eql( { connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: 'Foo Bar', email: 'foo@bar.com', error: '', + firstName: 'Foo', isActivated: false, + isDeauthorizing: false, isRequesting: false, + lastName: 'Bar', + logo: 'http://bar.com/foo.png', } ); } ); test( 'should leave other sites state unchanged', () => { const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, connectedUserID: 'acct_14qyt6Alijdnw0EA', email: 'foo@bar.com', siteId: 123, @@ -127,10 +305,10 @@ describe( 'reducer', () => { } ); } ); - describe( 'receivingAccountCreationError', () => { + describe( 'connectAccountFetchError', () => { test( 'should reset the isRequesting flag in state', () => { const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, siteId: 123, email: 'foo@bar.com', error: 'My error', @@ -143,7 +321,7 @@ describe( 'reducer', () => { test( 'should leave other sites state unchanged', () => { const action = { - type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_CREATE_COMPLETE, + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DETAILS_UPDATE, siteId: 123, email: 'foo@bar.com', error: 'My error', @@ -177,4 +355,533 @@ describe( 'reducer', () => { expect( newState[ 456 ].settings.stripeConnectAccount.isRequesting ).to.eql( true ); } ); } ); + + describe( 'connectAccountDeauthorize', () => { + test( 'should update state to show request in progress', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, + siteId: 123, + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState.isDeauthorizing ).to.eql( true ); + } ); + + test( 'should only update the request in progress flag for the appropriate siteId', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE, + siteId: 123, + }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + isDeauthorizing: false, + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + isDeauthorizing: false, + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.isDeauthorizing ).to.eql( true ); + expect( newState[ 456 ].settings.stripeConnectAccount.isDeauthorizing ).to.eql( false ); + } ); + } ); + + describe( 'connectAccountDeauthorizeComplete Success', () => { + test( 'should update state', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, + siteId: 123, + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState ).to.eql( { + connectedUserID: '', + displayName: '', + email: '', + error: '', + firstName: '', + isActivated: false, + isDeauthorizing: false, + isRequesting: false, + lastName: '', + logo: '', + } ); + } ); + + test( 'should leave other sites state unchanged', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, + siteId: 123, + }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: 'acct_25rzu7Alijdnw0FB', + displayName: 'Bar Foo', + email: 'bar@foo.com', + error: '', + firstName: 'Bar', + isActivated: false, + isDeauthorizing: true, + isRequesting: false, + lastName: 'Foo', + logo: '', + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: 'Foo Bar', + email: 'foo@bar.com', + error: '', + firstName: 'Foo', + isActivated: false, + isDeauthorizing: true, + isRequesting: false, + lastName: 'Bar', + logo: '', + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.isDeauthorizing ).to.eql( false ); + expect( newState[ 123 ].settings.stripeConnectAccount.connectedUserID ).to.eql( '' ); + expect( newState[ 456 ].settings.stripeConnectAccount.isDeauthorizing ).to.eql( true ); + expect( newState[ 456 ].settings.stripeConnectAccount.connectedUserID ).to.eql( + 'acct_14qyt6Alijdnw0EA' + ); + } ); + } ); + + describe( 'connectAccountDeauthorizeComplete w/ Error', () => { + test( 'should set the error in state', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, + siteId: 123, + error: 'My error message', + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState.error ).to.eql( 'My error message' ); + } ); + + test( 'should leave other sites state unchanged', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_DEAUTHORIZE_COMPLETE, + siteId: 123, + error: 'My error message', + }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: 'Foo Bar', + email: 'foo@bar.com', + error: '', + firstName: 'Foo', + isActivated: false, + isDeauthorizing: true, + isRequesting: false, + lastName: 'Bar', + logo: '', + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: 'Foo Bar', + email: 'foo@bar.com', + error: '', + firstName: 'Foo', + isActivated: false, + isDeauthorizing: true, + isRequesting: false, + lastName: 'Bar', + logo: '', + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.error ).to.eql( 'My error message' ); + expect( newState[ 123 ].settings.stripeConnectAccount.isDeauthorizing ).to.eql( false ); + expect( newState[ 456 ].settings.stripeConnectAccount.error ).to.eql( '' ); + expect( newState[ 456 ].settings.stripeConnectAccount.isDeauthorizing ).to.eql( true ); + } ); + } ); + + describe( 'connectAccountOAuthInit', () => { + test( 'should update state to show request in progress', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, + siteId: 123, + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState.isOAuthInitializing ).to.eql( true ); + } ); + + test( 'should only update the request in progress flag for the appropriate siteId', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT, + siteId: 123, + }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + isOAuthInitializing: false, + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + isOAuthInitializing: false, + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.isOAuthInitializing ).to.eql( true ); + expect( newState[ 456 ].settings.stripeConnectAccount.isOAuthInitializing ).to.eql( false ); + } ); + } ); + + describe( 'connectAccountOAuthInitComplete Success', () => { + test( 'should update state', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, + oauthUrl: 'https://connect.stripe.com/oauth/authorize', + siteId: 123, + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState ).to.eql( { + error: '', + isOAuthInitializing: false, + oauthUrl: 'https://connect.stripe.com/oauth/authorize', + } ); + } ); + + test( 'should leave other sites state unchanged', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, + oauthUrl: 'https://connect.stripe.com/oauth/authorize', + siteId: 123, + }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: 'acct_25rzu7Alijdnw0FB', + displayName: 'Bar Foo', + email: 'bar@foo.com', + error: '', + firstName: 'Bar', + isActivated: false, + isDeauthorizing: false, + isOAuthInitializing: true, + isRequesting: false, + lastName: 'Foo', + logo: '', + oauthUrl: 'https://connect.stripe.com/oauth/authorize', + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: 'Foo Bar', + email: 'foo@bar.com', + error: '', + firstName: 'Foo', + isActivated: false, + isDeauthorizing: true, + isOAuthInitializing: false, + isRequesting: false, + lastName: 'Bar', + logo: '', + oauthUrl: '', + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.isOAuthInitializing ).to.eql( false ); + expect( newState[ 123 ].settings.stripeConnectAccount.oauthUrl ).to.eql( + 'https://connect.stripe.com/oauth/authorize' + ); + expect( newState[ 456 ].settings.stripeConnectAccount.isOAuthInitializing ).to.eql( false ); + expect( newState[ 456 ].settings.stripeConnectAccount.oauthUrl ).to.eql( '' ); + } ); + } ); + + describe( 'connectAccountOAuthInitComplete w/ Error', () => { + test( 'should set the error in state', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, + siteId: 123, + error: 'My error message', + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState.error ).to.eql( 'My error message' ); + } ); + + test( 'should leave other sites state unchanged', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_INIT_COMPLETE, + siteId: 123, + error: 'My error message', + }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: 'Foo Bar', + email: 'foo@bar.com', + error: '', + firstName: 'Foo', + isActivated: false, + isDeauthorizing: false, + isOAuthInitializing: true, + isRequesting: false, + lastName: 'Bar', + logo: '', + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: 'Foo Bar', + email: 'foo@bar.com', + error: '', + firstName: 'Foo', + isActivated: false, + isDeauthorizing: false, + isOAuthInitializing: true, + isRequesting: false, + lastName: 'Bar', + logo: '', + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.error ).to.eql( 'My error message' ); + expect( newState[ 123 ].settings.stripeConnectAccount.isOAuthInitializing ).to.eql( false ); + expect( newState[ 456 ].settings.stripeConnectAccount.error ).to.eql( '' ); + expect( newState[ 456 ].settings.stripeConnectAccount.isOAuthInitializing ).to.eql( true ); + } ); + } ); + + describe( 'connectAccountOAuthConnect', () => { + test( 'should update state to show request in progress', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, + siteId: 123, + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState.isOAuthConnecting ).to.eql( true ); + } ); + + test( 'should only update the request in progress flag for the appropriate siteId', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT, + siteId: 123, + }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + isOAuthConnecting: false, + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + isOAuthConnecting: false, + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.isOAuthConnecting ).to.eql( true ); + expect( newState[ 456 ].settings.stripeConnectAccount.isOAuthConnecting ).to.eql( false ); + } ); + } ); + + describe( 'connectAccountOAuthConnectComplete Success', () => { + test( 'should update state', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, + connectedUserID: 'acct_14qyt6Alijdnw0EA', + siteId: 123, + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState ).to.eql( { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + email: '', + error: '', + firstName: '', + isActivated: false, + isCreating: false, + isOAuthConnecting: false, + isRequesting: false, + lastName: '', + logo: '', + } ); + } ); + + test( 'should leave other sites state unchanged', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, + connectedUserID: 'acct_14qyt6Alijdnw0EA', + siteId: 123, + }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + displayName: '', + email: '', + error: '', + firstName: '', + isActivated: false, + isDeauthorizing: false, + isOAuthConnecting: true, + isOAuthInitializing: false, + isRequesting: false, + lastName: '', + logo: '', + oauthUrl: '', + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + displayName: '', + email: '', + error: '', + firstName: '', + isActivated: false, + isDeauthorizing: false, + isOAuthConnecting: true, + isOAuthInitializing: false, + isRequesting: false, + lastName: '', + logo: '', + oauthUrl: '', + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.isOAuthConnecting ).to.eql( false ); + expect( newState[ 123 ].settings.stripeConnectAccount.connectedUserID ).to.eql( + 'acct_14qyt6Alijdnw0EA' + ); + expect( newState[ 456 ].settings.stripeConnectAccount.isOAuthConnecting ).to.eql( true ); + expect( newState[ 456 ].settings.stripeConnectAccount.connectedUserID ).to.eql( '' ); + } ); + } ); + + describe( 'connectAccountOAuthConnectComplete w/ Error', () => { + test( 'should set the error in state', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, + siteId: 123, + error: 'My error message', + }; + const newState = stripeConnectAccountReducer( undefined, action ); + expect( newState.error ).to.eql( 'My error message' ); + } ); + + test( 'should leave other sites state unchanged', () => { + const action = { + type: WOOCOMMERCE_SETTINGS_STRIPE_CONNECT_ACCOUNT_OAUTH_CONNECT_COMPLETE, + siteId: 123, + error: 'My error message', + }; + const newState = sitesReducer( + { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + displayName: 'Foo Bar', + email: 'foo@bar.com', + error: '', + firstName: 'Foo', + isActivated: false, + isDeauthorizing: false, + isOAuthConnecting: true, + isOAuthInitializing: false, + isRequesting: false, + lastName: 'Bar', + logo: '', + }, + }, + }, + 456: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + displayName: 'Foo Bar', + email: 'foo@bar.com', + error: '', + firstName: 'Foo', + isActivated: false, + isDeauthorizing: false, + isOAuthConnecting: true, + isOAuthInitializing: false, + isRequesting: false, + lastName: 'Bar', + logo: '', + }, + }, + }, + }, + action + ); + expect( newState[ 123 ].settings.stripeConnectAccount.error ).to.eql( 'My error message' ); + expect( newState[ 123 ].settings.stripeConnectAccount.isOAuthConnecting ).to.eql( false ); + expect( newState[ 456 ].settings.stripeConnectAccount.error ).to.eql( '' ); + expect( newState[ 456 ].settings.stripeConnectAccount.isOAuthConnecting ).to.eql( true ); + } ); + } ); } ); diff --git a/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/selectors.js b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/selectors.js new file mode 100644 index 0000000000000..25d617ce25e7f --- /dev/null +++ b/client/extensions/woocommerce/state/sites/settings/stripe-connect-account/test/selectors.js @@ -0,0 +1,406 @@ +/** @format */ + +/** + * External dependencies + */ +import { expect } from 'chai'; + +/** + * Internal dependencies + */ +import { + getError, + getIsCreating, + getIsDeauthorizing, + getIsOAuthConnecting, + getIsOAuthInitializing, + getIsRequesting, + getOAuthURL, + getStripeConnectAccount, +} from '../selectors'; + +const uninitializedState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: {}, + }, + }, + }, + }, + }, +}; + +const creatingState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: { + isCreating: true, + }, + }, + }, + }, + }, + }, +}; + +const createdState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + email: 'foo@bar.com', + isCreating: false, + }, + }, + }, + }, + }, + }, +}; + +const errorState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: { + error: 'My error message', + }, + }, + }, + }, + }, + }, +}; + +const fetchingState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + displayName: '', + email: '', + firstName: '', + isActivated: false, + isDeauthorizing: false, + isRequesting: true, + lastName: '', + logo: '', + }, + }, + }, + }, + }, + }, +}; + +const fetchedState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: 'Foo Bar', + email: 'foo@bar.com', + firstName: 'Foo', + isActivated: true, + isDeauthorizing: false, + isRequesting: false, + lastName: 'Bar', + logo: 'https://foo.com/bar.png', + }, + }, + }, + }, + }, + }, +}; + +const deauthorizingState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + displayName: '', + email: '', + firstName: '', + isActivated: false, + isDeauthorizing: true, + isRequesting: false, + logo: '', + lastName: '', + }, + }, + }, + }, + }, + }, +}; + +const deauthorizedState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + displayName: '', + email: '', + firstName: '', + isActivated: false, + isDeauthorizing: false, + isRequesting: false, + logo: '', + lastName: '', + }, + }, + }, + }, + }, + }, +}; + +const oauthInitializingState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + displayName: '', + email: '', + firstName: '', + isActivated: false, + isDeauthorizing: false, + isOAuthInitializing: true, + isRequesting: false, + logo: '', + lastName: '', + oauthUrl: '', + }, + }, + }, + }, + }, + }, +}; + +const oauthConnectingState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + displayName: '', + email: '', + firstName: '', + isActivated: false, + isDeauthorizing: false, + isOAuthInitializing: false, + isOAuthConnecting: true, + isRequesting: false, + logo: '', + lastName: '', + oauthUrl: '', + }, + }, + }, + }, + }, + }, +}; + +const oauthConnectedState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: '', + email: '', + firstName: '', + isActivated: false, + isDeauthorizing: false, + isOAuthInitializing: false, + isOAuthConnecting: false, + isRequesting: false, + logo: '', + lastName: '', + oauthUrl: '', + }, + }, + }, + }, + }, + }, +}; + +const oauthInitializedState = { + extensions: { + woocommerce: { + sites: { + 123: { + settings: { + stripeConnectAccount: { + connectedUserID: '', + displayName: '', + email: '', + firstName: '', + isActivated: false, + isDeauthorizing: false, + isOAuthInitializing: false, + isRequesting: false, + logo: '', + lastName: '', + oauthUrl: 'https://connect.stripe.com/oauth/authorize', + }, + }, + }, + }, + }, + }, +}; + +describe( 'selectors', () => { + describe( '#getIsCreating', () => { + test( 'should be false when state is uninitialized.', () => { + expect( getIsCreating( uninitializedState, 123 ) ).to.be.false; + } ); + + test( 'should be true when attempting to create an account.', () => { + expect( getIsCreating( creatingState, 123 ) ).to.be.true; + } ); + + test( 'should be false after creating an account.', () => { + expect( getIsCreating( createdState, 123 ) ).to.be.false; + } ); + } ); + + describe( '#getError', () => { + test( 'should return error when present.', () => { + expect( getError( errorState, 123 ) ).to.eql( 'My error message' ); + } ); + + test( 'should return empty string when not.', () => { + expect( getError( createdState, 123 ) ).to.eql( '' ); + } ); + } ); + + describe( '#getIsRequesting', () => { + test( 'should be false when state is uninitialized.', () => { + expect( getIsRequesting( uninitializedState, 123 ) ).to.be.false; + } ); + + test( 'should be true when fetching account details.', () => { + expect( getIsRequesting( fetchingState, 123 ) ).to.be.true; + } ); + + test( 'should be false when not fetching account details.', () => { + expect( getIsRequesting( fetchedState, 123 ) ).to.be.false; + } ); + } ); + + describe( '#getStripeConnectAccount', () => { + test( 'should be empty when state is uninitialized.', () => { + expect( getStripeConnectAccount( uninitializedState, 123 ) ).to.eql( {} ); + } ); + + test( 'should return account details when they are available in state.', () => { + expect( getStripeConnectAccount( fetchedState, 123 ) ).to.eql( { + connectedUserID: 'acct_14qyt6Alijdnw0EA', + displayName: 'Foo Bar', + email: 'foo@bar.com', + firstName: 'Foo', + isActivated: true, + lastName: 'Bar', + logo: 'https://foo.com/bar.png', + } ); + } ); + } ); + + describe( '#getIsDeauthorizing', () => { + test( 'should be false when woocommerce state is not available.', () => { + expect( getIsDeauthorizing( uninitializedState, 123 ) ).to.be.false; + } ); + + test( 'should be false when connected.', () => { + expect( getIsDeauthorizing( fetchedState, 123 ) ).to.be.false; + } ); + + test( 'should be true when deauthorizing.', () => { + expect( getIsDeauthorizing( deauthorizingState, 123 ) ).to.be.true; + } ); + + test( 'should be false when deauthorization has completed.', () => { + expect( getIsDeauthorizing( deauthorizedState, 123 ) ).to.be.false; + } ); + } ); + + describe( '#getIsOAuthInitializing', () => { + test( 'should be false when woocommerce state is not available.', () => { + expect( getIsOAuthInitializing( uninitializedState, 123 ) ).to.be.false; + } ); + + test( 'should be true when initializing.', () => { + expect( getIsOAuthInitializing( oauthInitializingState, 123 ) ).to.be.true; + } ); + + test( 'should be false when initialization has completed.', () => { + expect( getIsOAuthInitializing( oauthInitializedState, 123 ) ).to.be.false; + } ); + } ); + + describe( '#getIsOAuthConnecting', () => { + test( 'should be false when woocommerce state is not available.', () => { + expect( getIsOAuthConnecting( uninitializedState, 123 ) ).to.be.false; + } ); + + test( 'should be true when connecting.', () => { + expect( getIsOAuthConnecting( oauthConnectingState, 123 ) ).to.be.true; + } ); + + test( 'should be false when connection has completed.', () => { + expect( getIsOAuthConnecting( oauthConnectedState, 123 ) ).to.be.false; + } ); + } ); + + describe( '#getOAuthURL', () => { + test( 'should be empty when woocommerce state is not available.', () => { + expect( getOAuthURL( uninitializedState, 123 ) ).to.eql( '' ); + } ); + + test( 'should be empty when initializing.', () => { + expect( getOAuthURL( oauthInitializingState, 123 ) ).to.be.eql( '' ); + } ); + + test( 'should have a URL when initialization has completed.', () => { + expect( getOAuthURL( oauthInitializedState, 123 ) ).to.eql( + 'https://connect.stripe.com/oauth/authorize' + ); + } ); + } ); +} ); diff --git a/config/stage.json b/config/stage.json index a347df5763d46..feb40e91167fe 100644 --- a/config/stage.json +++ b/config/stage.json @@ -142,7 +142,7 @@ "woocommerce/extension-settings-email": true, "woocommerce/extension-settings-payments": true, "woocommerce/extension-settings-shipping": true, - "woocommerce/extension-settings-stripe-connect-flows": false, + "woocommerce/extension-settings-stripe-connect-flows": true, "woocommerce/extension-settings-tax": true, "woocommerce/extension-wcservices": true, "woocommerce/store-on-non-atomic-sites": true, diff --git a/config/wpcalypso.json b/config/wpcalypso.json index c9bd7b5375f38..3d1525ef41610 100644 --- a/config/wpcalypso.json +++ b/config/wpcalypso.json @@ -155,7 +155,7 @@ "woocommerce/extension-settings-email": true, "woocommerce/extension-settings-payments": true, "woocommerce/extension-settings-shipping": true, - "woocommerce/extension-settings-stripe-connect-flows": false, + "woocommerce/extension-settings-stripe-connect-flows": true, "woocommerce/extension-settings-tax": true, "woocommerce/extension-wcservices": true, "woocommerce/store-on-non-atomic-sites": true, From 5607d3bcaf9a5b6fb5b9241f3222f025eec11b87 Mon Sep 17 00:00:00 2001 From: Jarda Snajdr Date: Wed, 1 Nov 2017 11:42:09 +0100 Subject: [PATCH 142/192] Simple Payments: fix data layer warnings about missing `onError` handler Bug introduced in #18733: the new `dispatchRequestEx` function checks if the `onError` handler was passed and issues a console warning if it wasn't. This PR adds a dummy `onError: noop` handlers to the refactored Simple Payments handlers. --- .../state/data-layer/wpcom/sites/simple-payments/index.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/state/data-layer/wpcom/sites/simple-payments/index.js b/client/state/data-layer/wpcom/sites/simple-payments/index.js index 532f496e76387..f396e2e13f6a6 100644 --- a/client/state/data-layer/wpcom/sites/simple-payments/index.js +++ b/client/state/data-layer/wpcom/sites/simple-payments/index.js @@ -4,7 +4,7 @@ * @format */ -import { get, toPairs } from 'lodash'; +import { get, noop, toPairs } from 'lodash'; /** * Internal dependencies @@ -155,6 +155,7 @@ export const handleProductGet = dispatchRequestEx( { ), fromApi: customPostToProduct, onSuccess: addOrUpdateProduct, + onError: noop, } ); export const handleProductList = dispatchRequestEx( { @@ -172,6 +173,7 @@ export const handleProductList = dispatchRequestEx( { ), fromApi: customPostsToProducts, onSuccess: replaceProductList, + onError: noop, } ); export const handleProductListAdd = dispatchRequestEx( { @@ -186,6 +188,7 @@ export const handleProductListAdd = dispatchRequestEx( { ), fromApi: customPostToProduct, onSuccess: addOrUpdateProduct, + onError: noop, } ); export const handleProductListEdit = dispatchRequestEx( { @@ -200,6 +203,7 @@ export const handleProductListEdit = dispatchRequestEx( { ), fromApi: customPostToProduct, onSuccess: addOrUpdateProduct, + onError: noop, } ); export const handleProductListDelete = dispatchRequestEx( { @@ -212,6 +216,7 @@ export const handleProductListDelete = dispatchRequestEx( { action ), onSuccess: deleteProduct, + onError: noop, } ); export default { From 0c4380f389256766b911da9368d720b80b893dd7 Mon Sep 17 00:00:00 2001 From: Allen Snook Date: Wed, 1 Nov 2017 09:01:26 -0700 Subject: [PATCH 143/192] Store: Enable Stripe Connect on production (#19391) --- config/production.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/production.json b/config/production.json index f7a523250b68c..3ae89a4c28fbf 100644 --- a/config/production.json +++ b/config/production.json @@ -134,7 +134,7 @@ "woocommerce/extension-settings-email": true, "woocommerce/extension-settings-payments": true, "woocommerce/extension-settings-shipping": true, - "woocommerce/extension-settings-stripe-connect-flows": false, + "woocommerce/extension-settings-stripe-connect-flows": true, "woocommerce/extension-settings-tax": true, "woocommerce/extension-wcservices": true, "wpcom-user-bootstrap": true From 503c63241570714b8fa5788e4ab3eba4d78ef12e Mon Sep 17 00:00:00 2001 From: Kerry Liu Date: Wed, 1 Nov 2017 09:30:50 -0700 Subject: [PATCH 144/192] Build: update Node to 6.11.5 (#19361) --- .nvmrc | 2 +- Dockerfile | 2 +- circle.yml | 2 +- npm-shrinkwrap.json | 6 +++--- package.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.nvmrc b/.nvmrc index dbadd72fe8cac..f0fcab7c9b936 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v6.11.2 +v6.11.5 diff --git a/Dockerfile b/Dockerfile index 6780681fb34a3..3b1082b839b66 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:6.11.2 +FROM node:6.11.5 MAINTAINER Automattic WORKDIR /calypso diff --git a/circle.yml b/circle.yml index 17b5b632334ff..6c649281b6a5d 100644 --- a/circle.yml +++ b/circle.yml @@ -1,6 +1,6 @@ machine: node: - version: 6.11.2 + version: 6.11.5 test: pre: - ? | diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index 500b8f254e1d8..11e9f6375cd46 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -13,7 +13,7 @@ "dev": true, "dependencies": { "ast-types": { - "version": "0.9.12", + "version": "0.9.14", "dev": true }, "esprima": { @@ -25,7 +25,7 @@ "dev": true }, "recast": { - "version": "0.12.7", + "version": "0.12.8", "dev": true }, "source-map": { @@ -185,7 +185,7 @@ "version": "0.2.3" }, "asn1.js": { - "version": "4.9.1" + "version": "4.9.2" }, "assert": { "version": "1.4.1" diff --git a/package.json b/package.json index 7c2c5ae220894..0821317666fc8 100644 --- a/package.json +++ b/package.json @@ -162,7 +162,7 @@ "wpcom-xhr-request": "1.1.1" }, "engines": { - "node": "6.11.2", + "node": "6.11.5", "npm": "3.10.10" }, "scripts": { From d0400e511b9ade768bf99be2823d1a5bcfbcab7c Mon Sep 17 00:00:00 2001 From: "Michael P. Pfeiffer" Date: Wed, 1 Nov 2017 12:20:39 -0500 Subject: [PATCH 145/192] Change appearance of write button based on context (#19304) --- assets/stylesheets/shared/_color-schemes.scss | 15 +++++++++++++++ client/layout/masterbar/style.scss | 10 +++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/assets/stylesheets/shared/_color-schemes.scss b/assets/stylesheets/shared/_color-schemes.scss index 110e8d209406c..9d68a3aafa4e5 100644 --- a/assets/stylesheets/shared/_color-schemes.scss +++ b/assets/stylesheets/shared/_color-schemes.scss @@ -6,6 +6,11 @@ --masterbar-item-hover-background: lighten( $blue-wordpress, 5% ); --masterbar-item-active-background: darken( $blue-wordpress, 17% ); --masterbar-item-new-color: $blue-wordpress; + --masterbar-item-new-editor-background: darken( $blue-wordpress, 17% ); + --masterbar-item-new-editor-hover-background: darken( $blue-wordpress, 13% ); + --masterbar-toggle-drafts-editor-background: darken( $blue-wordpress, 12% ); + --masterbar-toggle-drafts-editor-border: darken( $blue-wordpress, 5% ); + --masterbar-toggle-drafts-editor-hover-background: darken( $blue-wordpress, 17% ); } //additional color schemes @@ -17,6 +22,11 @@ --masterbar-item-hover-background: lighten( $gray, 30% ); --masterbar-item-active-background: lighten( $gray, 10% ); --masterbar-item-new-color: $gray-text; + --masterbar-item-new-editor-background: darken( $gray, 20% ); + --masterbar-item-new-editor-hover-background: darken( $gray, 10% ); + --masterbar-toggle-drafts-editor-background: darken( $gray, 10% ); + --masterbar-toggle-drafts-editor-border: lighten( $gray, 20% ); + --masterbar-toggle-drafts-editor-hover-background: darken( $gray, 10% ); } &.is-dark { @@ -26,5 +36,10 @@ --masterbar-item-hover-background: darken( $gray, 10% ); --masterbar-item-active-background: $gray-text-min; --masterbar-item-new-color: $gray-dark; + --masterbar-item-new-editor-background: $gray-text-min; + --masterbar-item-new-editor-hover-background: lighten( $gray-text-min, 5% ); + --masterbar-toggle-drafts-editor-background: darken( $gray, 10% ); + --masterbar-toggle-drafts-editor-border: $gray-dark; + --masterbar-toggle-drafts-editor-hover-background: lighten( $gray-text-min, 5% ); } } diff --git a/client/layout/masterbar/style.scss b/client/layout/masterbar/style.scss index eac10f34eb991..30e5e99a4a082 100644 --- a/client/layout/masterbar/style.scss +++ b/client/layout/masterbar/style.scss @@ -212,7 +212,7 @@ $autobar-height: 20px; // active state when editing .is-group-editor & { - background: darken( $masterbar-color, 17% ); + background: var( --masterbar-item-new-editor-background ); color: $white; } @@ -222,7 +222,7 @@ $autobar-height: 20px; } .is-group-editor &:hover { - background: darken( $masterbar-color, 13% ); + background: var( --masterbar-item-new-editor-hover-background ); } } @@ -391,8 +391,8 @@ $autobar-height: 20px; } .is-group-editor & { - background: darken( $masterbar-color, 12% ); - border-left: 1px solid darken( $masterbar-color, 5% ); + background: var( --masterbar-toggle-drafts-editor-background ); + border-left: 1px solid var( --masterbar-toggle-drafts-editor-border ); .count { color: $gray-light; @@ -400,7 +400,7 @@ $autobar-height: 20px; } .is-group-editor &:hover { - background: darken( $masterbar-color, 17% ); + background: var( --masterbar-toggle-drafts-editor-hover-background ); .count { color: $white; From 87d84fabaf22fa43d88379fadaa4ecd753d49d20 Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Wed, 1 Nov 2017 12:47:24 -0500 Subject: [PATCH 146/192] Store Promotions: Create/Update from edit state (#18525) * Store Promotions: Add selectors for edit states This adds selectors to get promotion edits, edits overlaid on top of the existing promotions, and the currently editing promotion. * Store Promotions: Add selectors for edit states This adds selectors to get promotion edits, edits overlaid on top of the existing promotions, and the currently editing promotion. * Store Promotions: Add selectors for edit states This adds selectors to get promotion edits, edits overlaid on top of the existing promotions, and the currently editing promotion. * Store Promotions: Adjust test code This propagates the test fix up to another PR * Store Promotions: Adjust test code This propagates the test fix up to another PR * Store Promotions: Adjust test code This propagates the test fix up to another PR * Store Promotions: Create/Update from edit state This hooks up the save actions to create/update/delete promotion elements. * Promotions: Adjust code for create/update/list This updates several things: 1. Sets a derivitive promotion id, p for products, c for coupons. This makes finding promotions easier between page loads. 2. Changes all timestamps to local instead of GMT. The WooCommerce API doesn't allow the _gmt timestamps to be set directly, so we have to use local time. 3. Allows product sales to be moved to another product. It didn't take much and was easier than disabling the list. 4. Disables changing an existing promotion from a coupon to a sale or vice versa. 5. Adds an extra `json: true` parameter to body for POST requests through the wpcom proxy (needed for coupon operations) 6. Smaller miscellanous fixes * Promotions: Force coupon deletes When I try to not force a delete, and try to allow trashing, I get this error: `Error: The shop_coupon does not support trashing.` even though coupons can be trashed via the wp-admin page. So for now, we delete permanently. * Promotion edit: Add boolean checks for form type This adds actual boolean checks to ensure the right options are enabled for the selector on an existing promotion. * Promotions update/create: Address PR comments This addresses a few small PR comments, removes a TODO, adds a propType, etc. --- .../app/promotions/fields/currency-field.js | 4 +- .../app/promotions/fields/date-field.js | 9 +- .../app/promotions/fields/number-field.js | 4 +- .../woocommerce/app/promotions/helpers.js | 26 +++ .../app/promotions/promotion-create.js | 67 +++++-- .../promotions/promotion-form-type-card.js | 19 +- .../app/promotions/promotion-header.js | 2 +- .../app/promotions/promotion-models.js | 4 - .../app/promotions/promotion-update.js | 188 +++++++++++++++++- .../app/promotions/promotions-list-row.js | 19 +- .../state/sites/coupons/handlers.js | 7 +- .../state/sites/coupons/test/handlers.js | 2 +- .../woocommerce/state/sites/http-request.js | 1 + .../state/sites/promotions/handlers.js | 28 +-- .../state/sites/promotions/helpers.js | 57 ++++-- .../promotions/test/fixtures/promotions.js | 44 ++-- .../state/sites/promotions/test/handlers.js | 11 +- .../state/sites/promotions/test/helpers.js | 14 +- .../state/sites/promotions/test/reducer.js | 3 +- 19 files changed, 395 insertions(+), 114 deletions(-) create mode 100644 client/extensions/woocommerce/app/promotions/helpers.js diff --git a/client/extensions/woocommerce/app/promotions/fields/currency-field.js b/client/extensions/woocommerce/app/promotions/fields/currency-field.js index 83a2b5555853b..f491b31b53fe4 100644 --- a/client/extensions/woocommerce/app/promotions/fields/currency-field.js +++ b/client/extensions/woocommerce/app/promotions/fields/currency-field.js @@ -13,7 +13,7 @@ import FormField from './form-field'; const CurrencyField = ( props ) => { const { fieldName, explanationText, placeholderText, value, edit, currency } = props; - const renderedValue = ( 'undefined' !== typeof value ? value : '' ); + const renderedValue = ( 'undefined' !== typeof value && null !== value ? value : '' ); const onChange = ( e ) => { const newValue = e.target.value; @@ -25,7 +25,7 @@ const CurrencyField = ( props ) => { const numberValue = Number( newValue ); if ( 0 <= Number( newValue ) ) { const formattedValue = getCurrencyFormatDecimal( numberValue, currency ); - edit( fieldName, formattedValue ); + edit( fieldName, String( formattedValue ) ); } }; diff --git a/client/extensions/woocommerce/app/promotions/fields/date-field.js b/client/extensions/woocommerce/app/promotions/fields/date-field.js index 28111e8784bd2..be01ff3264a38 100644 --- a/client/extensions/woocommerce/app/promotions/fields/date-field.js +++ b/client/extensions/woocommerce/app/promotions/fields/date-field.js @@ -3,6 +3,7 @@ */ import React from 'react'; import PropTypes from 'prop-types'; +import { localize } from 'i18n-calypso'; /** * Internal dependencies @@ -11,18 +12,18 @@ import DatePicker from 'components/date-picker'; import FormField from './form-field'; const DateField = ( props ) => { - const { fieldName, explanationText, value, edit } = props; + const { fieldName, explanationText, value, edit, moment } = props; const selectedDay = ( value ? new Date( value ) : new Date() ); const onSelectDay = ( day ) => { - edit( fieldName, day.toISOString() ); + edit( fieldName, moment( day ).format( 'YYYY-MM-DDTHH:mm:ss' ) ); }; return ( @@ -37,5 +38,5 @@ DateField.PropTypes = { edit: PropTypes.func.isRequired, }; -export default DateField; +export default localize( DateField ); diff --git a/client/extensions/woocommerce/app/promotions/fields/number-field.js b/client/extensions/woocommerce/app/promotions/fields/number-field.js index c9b922264c4b4..f088bbc50db98 100644 --- a/client/extensions/woocommerce/app/promotions/fields/number-field.js +++ b/client/extensions/woocommerce/app/promotions/fields/number-field.js @@ -12,7 +12,7 @@ import FormField from './form-field'; const NumberField = ( props ) => { const { fieldName, explanationText, placeholderText, value, edit, minValue, maxValue } = props; - const renderedValue = ( 'undefined' !== typeof value ? value : '' ); + const renderedValue = ( 'undefined' !== typeof value && null !== value ? value : '' ); const onChange = ( e ) => { const newValue = e.target.value; @@ -24,7 +24,7 @@ const NumberField = ( props ) => { return; } - edit( fieldName, newValue ); + edit( fieldName, String( newValue ) ); }; return ( diff --git a/client/extensions/woocommerce/app/promotions/helpers.js b/client/extensions/woocommerce/app/promotions/helpers.js new file mode 100644 index 0000000000000..13f0005d84f84 --- /dev/null +++ b/client/extensions/woocommerce/app/promotions/helpers.js @@ -0,0 +1,26 @@ + +export function isValidPromotion( promotion ) { + if ( ! promotion || ! promotion.type ) { + return false; + } + + const validCouponCode = promotion.couponCode && promotion.couponCode.length > 0; + + switch ( promotion.type ) { + case 'product_sale': + const { productIds } = promotion.appliesTo || {}; + const validProductTarget = ( productIds && productIds.length === 1 ); + const validSalePrice = promotion.salePrice && promotion.salePrice.length > 0; + return validProductTarget && validSalePrice; + case 'percent': + const validPercent = promotion.percentDiscount && promotion.percentDiscount > 0; + return validCouponCode && validPercent; + case 'fixed_cart': + case 'fixed_product': + const validFixedDiscount = promotion.fixedDiscount && promotion.fixedDiscount > 0; + return validCouponCode && validFixedDiscount; + default: + return false; + } +} + diff --git a/client/extensions/woocommerce/app/promotions/promotion-create.js b/client/extensions/woocommerce/app/promotions/promotion-create.js index e08c9cffc5ae7..62f14fef32c35 100644 --- a/client/extensions/woocommerce/app/promotions/promotion-create.js +++ b/client/extensions/woocommerce/app/promotions/promotion-create.js @@ -9,27 +9,32 @@ import PropTypes from 'prop-types'; import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; import { localize } from 'i18n-calypso'; +import page from 'page'; /** * Internal dependencies */ import Main from 'components/main'; import { editPromotion, clearPromotionEdits } from 'woocommerce/state/ui/promotions/actions'; +import { getLink } from 'woocommerce/lib/nav-utils'; import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; import { fetchProductCategories } from 'woocommerce/state/sites/product-categories/actions'; -import { fetchPromotions } from 'woocommerce/state/sites/promotions/actions'; +import { fetchPromotions, createPromotion } from 'woocommerce/state/sites/promotions/actions'; import { fetchSettingsGeneral } from 'woocommerce/state/sites/settings/general/actions'; import { getPaymentCurrencySettings } from 'woocommerce/state/sites/settings/general/selectors'; import { getCurrentlyEditingPromotionId, getPromotionWithLocalEdits, } from 'woocommerce/state/selectors/promotions'; +import { isValidPromotion } from './helpers'; import PromotionHeader from './promotion-header'; import PromotionForm from './promotion-form'; +import { successNotice, errorNotice } from 'state/notices/actions'; class PromotionCreate extends React.Component { static propTypes = { className: PropTypes.string, + currency: PropTypes.string, site: PropTypes.shape( { ID: PropTypes.number, slug: PropTypes.string, @@ -39,11 +44,20 @@ class PromotionCreate extends React.Component { } ), editPromotion: PropTypes.func.isRequired, clearPromotionEdits: PropTypes.func.isRequired, + createPromotion: PropTypes.func.isRequired, fetchProductCategories: PropTypes.func.isRequired, fetchPromotions: PropTypes.func.isRequired, fetchSettingsGeneral: PropTypes.func.isRequired, }; + constructor( props ) { + super( props ); + + this.state = { + busy: false, + }; + } + componentDidMount() { const { site } = this.props; @@ -73,23 +87,47 @@ class PromotionCreate extends React.Component { } onSave = () => { - // TODO: Add action to save promotion. + const { site, promotion, translate } = this.props; + + this.setState( () => ( { busy: true } ) ); + + const getSuccessNotice = () => { + return successNotice( + translate( '%(promotion)s promotion successfully created.', { + args: { promotion: promotion.name }, + } ), + { + displayOnNextPage: true, + duration: 8000, + } + ); + }; + + const successAction = dispatch => { + dispatch( getSuccessNotice( promotion ) ); + page.redirect( getLink( '/store/promotions/:site', site ) ); + }; + + const failureAction = dispatch => { + dispatch( + errorNotice( + translate( 'There was a problem saving the %(promotion)s promotion. Please try again.', { + args: { promotion: promotion.name }, + } ) + ) + ); + this.setState( () => ( { busy: false } ) ); + }; + + this.props.createPromotion( site.ID, promotion, successAction, failureAction ); }; - isPromotionValid() { - const { promotion } = this.props; - - // TODO: Update with real info. - return promotion && promotion.id; - } - render() { const { site, currency, className, promotion } = this.props; + const { busy } = this.state; - // TODO: Update with real info. - const isValid = 'undefined' !== typeof site && this.isPromotionValid(); - const isBusy = false; - const saveEnabled = isValid && ! isBusy; + const isValid = 'undefined' !== typeof site && isValidPromotion( promotion ); + const saveEnabled = isValid && ! busy; return (
@@ -97,7 +135,7 @@ class PromotionCreate extends React.Component { site={ site } promotion={ promotion } onSave={ saveEnabled ? this.onSave : false } - isBusy={ isBusy } + isBusy={ busy } /> { const promotionType = ( promotion && promotion.type ? promotion.type : '' ); + const productTypesDisabled = Boolean( promotion.couponId ); + const couponTypesDisabled = Boolean( promotion.productId ); + const onTypeSelect = ( e ) => { const type = e.target.value; editPromotion( siteId, promotion, { type } ); @@ -44,10 +47,18 @@ const PromotionFormTypeCard = ( { - - - - + + + + { getExplanation( promotionType, translate ) } diff --git a/client/extensions/woocommerce/app/promotions/promotion-header.js b/client/extensions/woocommerce/app/promotions/promotion-header.js index 9998ef489b834..7781ef694638e 100644 --- a/client/extensions/woocommerce/app/promotions/promotion-header.js +++ b/client/extensions/woocommerce/app/promotions/promotion-header.js @@ -24,7 +24,7 @@ function renderTrashButton( onTrash, promotion, isBusy, translate ) { } function renderSaveButton( onSave, promotion, isBusy, translate ) { - if ( 'undefined' !== typeof onSave ) { + if ( 'undefined' === typeof onSave ) { // 'Save' not allowed here. return null; } diff --git a/client/extensions/woocommerce/app/promotions/promotion-models.js b/client/extensions/woocommerce/app/promotions/promotion-models.js index 59dfa8c4bd824..dac7a63864575 100644 --- a/client/extensions/woocommerce/app/promotions/promotion-models.js +++ b/client/extensions/woocommerce/app/promotions/promotion-models.js @@ -119,26 +119,22 @@ const couponConditions = { component: CurrencyField, labelText: translate( 'This promotion requires a minimum purchase' ), isEnableable: true, - defaultValue: 10, }, maximumAmount: { component: CurrencyField, labelText: translate( 'Don\'t apply this promotion if the order value exceeds a specific amount' ), isEnableable: true, - defaultValue: 100, }, usageLimit: { component: NumberField, labelText: translate( 'Limit number of times this promotion can be used in total' ), isEnableable: true, - defaultValue: 10, minValue: 0, }, usageLimitPerUser: { component: NumberField, labelText: translate( 'Limit total times each customer can use this promotion' ), isEnableable: true, - defaultValue: 1, minValue: 0, }, individualUse: { diff --git a/client/extensions/woocommerce/app/promotions/promotion-update.js b/client/extensions/woocommerce/app/promotions/promotion-update.js index 43797fa686ad7..f7ec3564cc31c 100644 --- a/client/extensions/woocommerce/app/promotions/promotion-update.js +++ b/client/extensions/woocommerce/app/promotions/promotion-update.js @@ -6,36 +6,214 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { bindActionCreators } from 'redux'; import { connect } from 'react-redux'; import { localize } from 'i18n-calypso'; +import page from 'page'; +import { debounce } from 'lodash'; /** * Internal dependencies */ import Main from 'components/main'; +import accept from 'lib/accept'; +import { successNotice, errorNotice } from 'state/notices/actions'; +import { getLink } from 'woocommerce/lib/nav-utils'; +import { + fetchPromotions, + updatePromotion, + deletePromotion, +} from 'woocommerce/state/sites/promotions/actions'; +import { fetchProductCategories } from 'woocommerce/state/sites/product-categories/actions'; +import { getProductCategories } from 'woocommerce/state/sites/product-categories/selectors'; +import { editPromotion, clearPromotionEdits } from 'woocommerce/state/ui/promotions/actions'; import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; +import { fetchSettingsGeneral } from 'woocommerce/state/sites/settings/general/actions'; +import { getPaymentCurrencySettings } from 'woocommerce/state/sites/settings/general/selectors'; +import { + getPromotionWithLocalEdits, + getPromotionableProducts, +} from 'woocommerce/state/selectors/promotions'; +import { isValidPromotion } from './helpers'; +import PromotionHeader from './promotion-header'; +import PromotionForm from './promotion-form'; class PromotionUpdate extends React.Component { static propTypes = { + className: PropTypes.string, + currency: PropTypes.string, + products: PropTypes.array, + productCategories: PropTypes.array, site: PropTypes.shape( { ID: PropTypes.number, + slug: PropTypes.string, } ), - className: PropTypes.string, + promotion: PropTypes.shape( { + id: PropTypes.isRequired, + } ), + editPromotion: PropTypes.func.isRequired, + clearPromotionEdits: PropTypes.func.isRequired, + fetchSettingsGeneral: PropTypes.func.isRequired, + fetchPromotions: PropTypes.func.isRequired, + fetchProductCategories: PropTypes.func.isRequired, + updatePromotion: PropTypes.func.isRequired, + deletePromotion: PropTypes.func.isRequired, + }; + + constructor( props ) { + super( props ); + + this.state = { + busy: false, + }; + } + + componentDidMount() { + const { site } = this.props; + + if ( site && site.ID ) { + this.props.fetchProductCategories( site.ID ); + this.props.fetchPromotions( site.ID ); + this.props.fetchSettingsGeneral( site.ID ); + } + } + + componentWillReceiveProps( newProps ) { + const { site } = this.props; + const newSiteId = ( newProps.site && newProps.site.ID ) || null; + const oldSiteId = ( site && site.ID ) || null; + if ( oldSiteId !== newSiteId ) { + this.props.fetchProductCategories( newSiteId ); + this.props.fetchPromotions( newSiteId ); + this.props.fetchSettingsGeneral( newSiteId ); + } + } + + componentWillUnmount() { + const { site } = this.props; + if ( site ) { + this.props.clearPromotionEdits( site.ID ); + } + } + + onTrash = () => { + const { translate, site, promotion, deletePromotion: dispatchDelete } = this.props; + const areYouSure = translate( 'Are you sure you want to delete this promotion?' ); + accept( areYouSure, function( accepted ) { + if ( ! accepted ) { + return; + } + const successAction = dispatch => { + dispatch( successNotice( translate( 'Promotion successfully deleted.' ) ) ); + debounce( () => { + page.redirect( getLink( '/store/promotions/:site/', site ) ); + }, 1000 )(); + }; + const failureAction = () => { + return errorNotice( + translate( 'There was a problem deleting the promotion. Please try again.' ) + ); + }; + dispatchDelete( site.ID, promotion, successAction, failureAction ); + } ); + }; + + onSave = () => { + const { site, promotion, translate } = this.props; + + this.setState( () => ( { busy: true } ) ); + + const getSuccessNotice = () => { + return successNotice( + translate( '%(promotion)s promotion successfully updated.', { + args: { promotion: promotion.name }, + } ), + { + displayOnNextPage: true, + duration: 8000, + } + ); + }; + + const successAction = dispatch => { + dispatch( getSuccessNotice( promotion ) ); + page.redirect( getLink( '/store/promotions/:site', site ) ); + }; + + const failureAction = dispatch => { + dispatch( + errorNotice( + translate( 'There was a problem saving the %(promotion)s promotion. Please try again.', { + args: { promotion: promotion.name }, + } ) + ) + ); + this.setState( () => ( { busy: false } ) ); + }; + + this.props.updatePromotion( site.ID, promotion, successAction, failureAction ); }; render() { - const { className } = this.props; + const { site, currency, className, promotion, products, productCategories } = this.props; + const { busy } = this.state; - return
; + const isValid = 'undefined' !== typeof site && isValidPromotion( promotion ); + const saveEnabled = isValid && ! busy; + + return ( +
+ + +
+ ); } } -function mapStateToProps( state ) { +function mapStateToProps( state, ownProps ) { const site = getSelectedSiteWithFallback( state ); + const currencySettings = getPaymentCurrencySettings( state ); + const currency = currencySettings ? currencySettings.value : null; + const promotionId = ownProps.params.promotion; + const promotion = promotionId ? getPromotionWithLocalEdits( state, promotionId, site.ID ) : null; + const products = getPromotionableProducts( state, site.ID ); + const productCategories = getProductCategories( state, site.ID ); return { site, + promotion, + currency, + products, + productCategories, }; } -export default connect( mapStateToProps )( localize( PromotionUpdate ) ); +function mapDispatchToProps( dispatch ) { + return bindActionCreators( + { + editPromotion, + clearPromotionEdits, + fetchSettingsGeneral, + fetchPromotions, + fetchProductCategories, + updatePromotion, + deletePromotion, + }, + dispatch + ); +} + +export default connect( mapStateToProps, mapDispatchToProps )( localize( PromotionUpdate ) ); diff --git a/client/extensions/woocommerce/app/promotions/promotions-list-row.js b/client/extensions/woocommerce/app/promotions/promotions-list-row.js index d0444936d602b..5b39691d26840 100644 --- a/client/extensions/woocommerce/app/promotions/promotions-list-row.js +++ b/client/extensions/woocommerce/app/promotions/promotions-list-row.js @@ -19,8 +19,12 @@ function getPromotionTypeText( promotionType, translate ) { switch ( promotionType ) { case 'product_sale': return translate( 'Product Sale' ); - case 'coupon': - return translate( 'Coupon' ); + case 'fixed_cart': + return translate( 'Cart Discount' ); + case 'fixed_product': + return translate( 'Product Discount' ); + case 'percent': + return translate( 'Percent Discount' ); } } @@ -30,22 +34,22 @@ function getTimeframeText( promotion, translate, moment ) { if ( promotion.startDate && promotion.endDate ) { return translate( '%(startDate)s - %(endDate)s', { args: { - startDate: moment( promotion.startDate + 'Z' ).format( 'll' ), - endDate: moment( promotion.endDate + 'Z' ).format( 'll' ), + startDate: moment( promotion.startDate ).format( 'll' ), + endDate: moment( promotion.endDate ).format( 'll' ), }, } ); } if ( promotion.endDate ) { return translate( 'Ends on %(endDate)s', { args: { - endDate: moment( promotion.endDate + 'Z' ).format( 'll' ), + endDate: moment( promotion.endDate ).format( 'll' ), }, } ); } if ( promotion.startDate ) { return translate( '%(startDate)s - No expiration date', { args: { - startDate: moment( promotion.startDate + 'Z' ).format( 'll' ), + startDate: moment( promotion.startDate ).format( 'll' ), }, } ); } @@ -54,8 +58,7 @@ function getTimeframeText( promotion, translate, moment ) { const PromotionsListRow = ( { site, promotion, translate, moment } ) => { return ( - // TODO: Replace with individual update link for promotion. - + { promotion.name } diff --git a/client/extensions/woocommerce/state/sites/coupons/handlers.js b/client/extensions/woocommerce/state/sites/coupons/handlers.js index 7a354695cc324..032345463fcb6 100644 --- a/client/extensions/woocommerce/state/sites/coupons/handlers.js +++ b/client/extensions/woocommerce/state/sites/coupons/handlers.js @@ -4,11 +4,12 @@ * @format */ import { trim } from 'lodash'; +import warn from 'lib/warn'; +import debugFactory from 'debug'; /** * Internal dependencies */ -import debugFactory from 'debug'; import { dispatchRequest } from 'state/data-layer/wpcom-http/utils'; import request from 'woocommerce/state/sites/http-request'; import { @@ -88,7 +89,7 @@ export function couponUpdateSuccess( { dispatch }, action ) { export function couponDelete( { dispatch }, action ) { const { siteId, couponId } = action; - const path = `coupons/${ couponId }`; + const path = `coupons/${ couponId }?force=true`; dispatch( request( siteId, action ).del( path ) ); } @@ -101,7 +102,7 @@ export function couponDeleteSuccess( { dispatch }, action ) { } function apiError( { dispatch }, action, error ) { - debug( 'API Error: ', error ); + warn( 'Coupon API Error: ', error ); if ( action.failureAction ) { dispatch( action.failureAction ); diff --git a/client/extensions/woocommerce/state/sites/coupons/test/handlers.js b/client/extensions/woocommerce/state/sites/coupons/test/handlers.js index 6d688f74d0d12..8d7d3a63c402c 100644 --- a/client/extensions/woocommerce/state/sites/coupons/test/handlers.js +++ b/client/extensions/woocommerce/state/sites/coupons/test/handlers.js @@ -261,7 +261,7 @@ describe( 'handlers', () => { match( { type: WPCOM_HTTP_REQUEST, body: { - path: '/wc/v3/coupons/15&_method=DELETE', + path: '/wc/v3/coupons/15&force=true&_method=DELETE', }, } ) ); diff --git a/client/extensions/woocommerce/state/sites/http-request.js b/client/extensions/woocommerce/state/sites/http-request.js index 14b2e9f59c000..6bba129bb6f52 100644 --- a/client/extensions/woocommerce/state/sites/http-request.js +++ b/client/extensions/woocommerce/state/sites/http-request.js @@ -54,6 +54,7 @@ const _request = ( method, path, siteId, body, action, namespace ) => { requestBody = { path, body: body && JSON.stringify( body ), + json: true, }; } diff --git a/client/extensions/woocommerce/state/sites/promotions/handlers.js b/client/extensions/woocommerce/state/sites/promotions/handlers.js index 906c42fad1254..8f5926c962898 100644 --- a/client/extensions/woocommerce/state/sites/promotions/handlers.js +++ b/client/extensions/woocommerce/state/sites/promotions/handlers.js @@ -122,6 +122,10 @@ export function promotionUpdate( { dispatch }, action ) { switch ( promotion.type ) { case 'product_sale': const product = createProductUpdateFromPromotion( promotion ); + if ( product.id !== promotion.productId ) { + // This product sale is changing product, so remove it from the previous one. + dispatch( clearProductSale( siteId, promotion.productId, null, action.failureAction ) ); + } dispatch( updateProduct( siteId, product, action.successAction, action.failureAction ) ); break; case 'fixed_cart': @@ -138,19 +142,8 @@ export function promotionDelete( { dispatch }, action ) { switch ( promotion.type ) { case 'product_sale': - const product = createProductUpdateFromPromotion( promotion ); - - const productUpdateData = { - id: product.id, - sale_price: null, - date_on_sale_from: null, - date_on_sale_from_gmt: null, - date_on_sale_to: null, - date_on_sale_to_gmt: null, - }; - dispatch( - updateProduct( siteId, productUpdateData, action.successAction, action.failureAction ) + clearProductSale( siteId, promotion.productId, action.successAction, action.failureAction ) ); break; case 'fixed_cart': @@ -162,3 +155,14 @@ export function promotionDelete( { dispatch }, action ) { break; } } + +function clearProductSale( siteId, productId, successAction, failureAction ) { + const productUpdateData = { + id: productId, + sale_price: '', + date_on_sale_from: null, + date_on_sale_to: null, + }; + + return updateProduct( siteId, productUpdateData, successAction, failureAction ); +} diff --git a/client/extensions/woocommerce/state/sites/promotions/helpers.js b/client/extensions/woocommerce/state/sites/promotions/helpers.js index 2e537c79f1bd6..2574f9c5cf7da 100644 --- a/client/extensions/woocommerce/state/sites/promotions/helpers.js +++ b/client/extensions/woocommerce/state/sites/promotions/helpers.js @@ -1,17 +1,23 @@ /** * External dependencies */ -import { find, uniqueId } from 'lodash'; +import { find } from 'lodash'; export function createPromotionFromProduct( product ) { + const salePrice = product.sale_price; + const startDate = product.date_on_sale_from || undefined; + const endDate = product.date_on_sale_to || undefined; + const productId = product.id; + return { - id: uniqueId( 'promotion:' ), + id: 'p' + product.id, name: product.name, type: 'product_sale', appliesTo: { productIds: [ product.id ] }, - salePrice: product.sale_price, - startDate: product.date_on_sale_from_gmt, - endDate: product.date_on_sale_to_gmt, + salePrice, + startDate, + endDate, + productId, }; } @@ -26,26 +32,41 @@ export function createProductUpdateFromPromotion( promotion ) { return { id, sale_price: promotion.salePrice, - date_on_sale_from_gmt: promotion.startDate, - date_on_sale_to_gmt: promotion.endDate, + date_on_sale_from: promotion.startDate, + date_on_sale_to: promotion.endDate, }; } export function createPromotionFromCoupon( coupon ) { + const couponCode = coupon.code; + const startDate = coupon.date_created; + const endDate = coupon.date_expires || undefined; + const individualUse = coupon.individual_use || undefined; + const usageLimit = coupon.usage_limit || undefined; + const usageLimitPerUser = coupon.usage_limit_per_user || undefined; + const freeShipping = coupon.free_shipping || undefined; + const minimumAmount = ( + ( '0.00' !== coupon.minimum_amount ) ? coupon.minimum_amount : undefined + ); + const maximumAmount = ( + ( '0.00' !== coupon.maximum_amount ) ? coupon.maximum_amount : undefined + ); + const promotion = { - id: uniqueId( 'promotion:' ), + id: 'c' + coupon.id, name: coupon.code, type: coupon.discount_type, appliesTo: calculateCouponAppliesTo( coupon ), - couponCode: coupon.code, - startDate: coupon.date_created_gmt, - endDate: coupon.date_expires_gmt, - individualUse: coupon.individual_use, - usageLimit: coupon.usage_limit, - usageLimitPerUser: coupon.usage_limit_per_user, - freeShipping: coupon.free_shipping, - minimumAmount: coupon.minimum_amount, - maximumAmount: coupon.maximum_amount, + couponCode, + startDate, + endDate, + individualUse, + usageLimit, + usageLimitPerUser, + freeShipping, + minimumAmount, + maximumAmount, + couponId: coupon.id, }; switch ( coupon.discount_type ) { @@ -80,7 +101,7 @@ export function createCouponUpdateFromPromotion( promotion ) { discount_type: promotion.type, code: promotion.couponCode, amount: amount, - date_expires_gmt: promotion.endDate, + date_expires: promotion.endDate, individual_use: promotion.individualUse, usage_limit: promotion.usageLimit, usage_limit_per_user: promotion.usageLimitPerUser, diff --git a/client/extensions/woocommerce/state/sites/promotions/test/fixtures/promotions.js b/client/extensions/woocommerce/state/sites/promotions/test/fixtures/promotions.js index 4e74a14dca0af..8bab4ab1c84cc 100644 --- a/client/extensions/woocommerce/state/sites/promotions/test/fixtures/promotions.js +++ b/client/extensions/woocommerce/state/sites/promotions/test/fixtures/promotions.js @@ -7,40 +7,40 @@ export const coupons1 = [ code: 'two', amount: '2', discount_type: 'fixed_cart', - date_created_gmt: '2017-09-07T12:50:50', - date_expires_gmt: '2017-10-20T12:50:50', + date_created: '2017-09-07T12:50:50', + date_expires: '2017-10-20T12:50:50', }, { id: 1, code: 'one', amount: '1', discount_type: 'fixed_cart', - date_created_gmt: '2017-09-06T13:54:50', - date_expires_gmt: '2017-10-15T13:54:50', + date_created: '2017-09-06T13:54:50', + date_expires: '2017-10-15T13:54:50', }, { id: 4, code: 'four', amount: '4', discount_type: 'percent', - date_created_gmt: '2017-09-09T09:50:50', - date_expires_gmt: '2017-10-28T09:50:50', + date_created: '2017-09-09T09:50:50', + date_expires: '2017-10-28T09:50:50', }, { id: 3, code: 'three', amount: '3', discount_type: 'percent', - date_created_gmt: '2017-09-08T10:50:50', - date_expires_gmt: '2017-10-25T10:50:50', + date_created: '2017-09-08T10:50:50', + date_expires: '2017-10-25T10:50:50', }, { id: 5, code: 'five', amount: '5', discount_type: 'percent', - date_created_gmt: '2017-09-10T10:50:50', - date_expires_gmt: undefined, + date_created: '2017-09-10T10:50:50', + date_expires: undefined, }, ]; @@ -51,16 +51,16 @@ export const coupons2 = [ code: 'six', amount: '6', discount_type: 'fixed_cart', - date_created_gmt: '2017-09-11T12:50:50', - date_expires_gmt: undefined, + date_created: '2017-09-11T12:50:50', + date_expires: undefined, }, { id: 7, code: 'seven', amount: '7', discount_type: 'percent', - date_created_gmt: '2017-09-12T04:54:50', - date_expires_gmt: '2017-11-05T04:54:50', + date_created: '2017-09-12T04:54:50', + date_expires: '2017-11-05T04:54:50', }, ]; @@ -72,8 +72,8 @@ export const products1 = [ slug: 'two', regular_price: '2.00', sale_price: '1.20', - date_on_sale_from_gmt: undefined, - date_on_sale_to_gmt: undefined, + date_on_sale_from: undefined, + date_on_sale_to: undefined, }, { id: 4, @@ -81,8 +81,8 @@ export const products1 = [ slug: 'four', regular_price: '4.00', sale_price: '3.40', - date_on_sale_from_gmt: '2017-10-20T04:01:10', - date_on_sale_to_gmt: '2017-10-20T04:01:10', + date_on_sale_from: '2017-10-20T04:01:10', + date_on_sale_to: '2017-10-20T04:01:10', }, { id: 3, @@ -90,8 +90,8 @@ export const products1 = [ slug: 'three', regular_price: '3.00', sale_price: '', - date_on_sale_from_gmt: undefined, - date_on_sale_to_gmt: undefined, + date_on_sale_from: undefined, + date_on_sale_to: undefined, }, ]; @@ -103,7 +103,7 @@ export const products2 = [ slug: 'one', regular_price: '1.00', sale_price: '0.10', - date_on_sale_from_gmt: '2017-09-01T01:01:10', - date_on_sale_to_gmt: '2017-09-10T01:01:10', + date_on_sale_from: '2017-09-01T01:01:10', + date_on_sale_to: '2017-09-10T01:01:10', }, ]; diff --git a/client/extensions/woocommerce/state/sites/promotions/test/handlers.js b/client/extensions/woocommerce/state/sites/promotions/test/handlers.js index abecf2762c736..89d341251a57d 100644 --- a/client/extensions/woocommerce/state/sites/promotions/test/handlers.js +++ b/client/extensions/woocommerce/state/sites/promotions/test/handlers.js @@ -154,7 +154,7 @@ describe( 'handlers', () => { discount_type: 'percent', amount: '10', product_ids: [ 1, 2 ], - date_expires_gmt: '2017-12-15T12:15:02', + date_expires: '2017-12-15T12:15:02', }; const action = createPromotion( siteId, promotion, successAction, failureAction ); @@ -186,8 +186,8 @@ describe( 'handlers', () => { const expectedProductData = { id: 12, sale_price: '9.99', - date_on_sale_from_gmt: '2017-10-15T12:15:02', - date_on_sale_to_gmt: '2017-11-15T12:15:02', + date_on_sale_from: '2017-10-15T12:15:02', + date_on_sale_to: '2017-11-15T12:15:02', }; const action = createPromotion( siteId, promotion, successAction, failureAction ); @@ -309,15 +309,14 @@ describe( 'handlers', () => { appliesTo: { productIds: [ 12 ] }, salePrice: '10', endDate: '2017-12-01T05:25:00', + productId: 12, }; const expectedProductData = { id: 12, date_on_sale_from: null, - date_on_sale_from_gmt: null, date_on_sale_to: null, - date_on_sale_to_gmt: null, - sale_price: null, + sale_price: '', }; const action = deletePromotion( siteId, promotion, successAction, failureAction ); diff --git a/client/extensions/woocommerce/state/sites/promotions/test/helpers.js b/client/extensions/woocommerce/state/sites/promotions/test/helpers.js index efcae1c847d52..a504b6584ab3d 100644 --- a/client/extensions/woocommerce/state/sites/promotions/test/helpers.js +++ b/client/extensions/woocommerce/state/sites/promotions/test/helpers.js @@ -28,8 +28,8 @@ describe( 'helpers', () => { expect( promotion.type ).to.equal( 'product_sale' ); expect( promotion.name ).to.equal( product1.name ); expect( promotion.salePrice ).to.equal( product1.sale_price ); - expect( promotion.startDate ).to.equal( product1.date_on_sale_from_gmt ); - expect( promotion.endDate ).to.equal( product1.date_on_sale_to_gmt ); + expect( promotion.startDate ).to.equal( product1.date_on_sale_from ); + expect( promotion.endDate ).to.equal( product1.date_on_sale_to ); } ); test( 'should set appliesTo.productIds', () => { @@ -58,8 +58,8 @@ describe( 'helpers', () => { expect( productData ).to.exist; expect( productData.id ).to.equal( 52 ); - expect( productData.date_on_sale_from_gmt ).to.equal( promotion.startDate ); - expect( productData.date_on_sale_to_gmt ).to.equal( promotion.endDate ); + expect( productData.date_on_sale_from ).to.equal( promotion.startDate ); + expect( productData.date_on_sale_to ).to.equal( promotion.endDate ); } ); test( 'should throw if promotion does not apply to product', () => { @@ -85,8 +85,8 @@ describe( 'helpers', () => { expect( promotion.name ).to.equal( coupon1.code ); expect( promotion.type ).to.equal( 'percent' ); expect( promotion.couponCode ).to.equal( coupon1.code ); - expect( promotion.startDate ).to.equal( coupon1.date_created_gmt ); - expect( promotion.endDate ).to.equal( coupon1.date_expires_gmt ); + expect( promotion.startDate ).to.equal( coupon1.date_created ); + expect( promotion.endDate ).to.equal( coupon1.date_expires ); } ); test( 'should set cart percent discount', () => { @@ -250,7 +250,7 @@ describe( 'helpers', () => { expect( couponData ).to.exist; expect( couponData.id ).to.equal( 25 ); - expect( couponData.date_expires_gmt ).to.equal( promotion.endDate ); + expect( couponData.date_expires ).to.equal( promotion.endDate ); expect( couponData.individual_use ).to.equal( promotion.individualUse ); expect( couponData.usage_limit ).to.equal( promotion.usageLimit ); expect( couponData.usage_limit_per_user ).to.equal( promotion.usageLimitPerUser ); diff --git a/client/extensions/woocommerce/state/sites/promotions/test/reducer.js b/client/extensions/woocommerce/state/sites/promotions/test/reducer.js index bf3ef3faca395..c1cf65f1da960 100644 --- a/client/extensions/woocommerce/state/sites/promotions/test/reducer.js +++ b/client/extensions/woocommerce/state/sites/promotions/test/reducer.js @@ -179,8 +179,9 @@ describe( 'reducer', () => { const state4 = reducer( state3, productsAction2 ); expect( state4.promotions ).to.exist; + expect( state4.promotions.length ).to.equal( 10 ); expect( state4.promotions[ 0 ].type ).to.equal( 'product_sale' ); - expect( state4.promotions[ 0 ].id ).to.exist; + expect( state4.promotions[ 0 ].id ).to.equal( 'p2' ); expect( state4.promotions[ 0 ].name ).to.equal( products1[ 0 ].name ); expect( state4.promotions[ 1 ].type ).to.equal( 'percent' ); expect( state4.promotions[ 1 ].id ).to.exist; From f2d0c3d017d392021b952f23ccd15b4349ec0ea0 Mon Sep 17 00:00:00 2001 From: Timmy Crawford Date: Wed, 1 Nov 2017 10:55:26 -0700 Subject: [PATCH 147/192] Store: Add getProductsSettingValue selector. (#19367) --- .../sites/settings/products/selectors.js | 14 ++++ .../sites/settings/products/test/selectors.js | 79 ++++++++++++++++++- 2 files changed, 92 insertions(+), 1 deletion(-) diff --git a/client/extensions/woocommerce/state/sites/settings/products/selectors.js b/client/extensions/woocommerce/state/sites/settings/products/selectors.js index 7927dcd091bc6..868500564e16e 100644 --- a/client/extensions/woocommerce/state/sites/settings/products/selectors.js +++ b/client/extensions/woocommerce/state/sites/settings/products/selectors.js @@ -59,3 +59,17 @@ export function getDimensionsUnitSetting( state, siteId = getSelectedSiteId( sta const unit = find( productsSettings, item => item.id === 'woocommerce_dimension_unit' ); return unit || {}; } + +/** + * Gets an arbitrary product setting value from API data. + * + * @param {Object} state Global state tree + * @param {String} id setting name / id of the products setting you would like the value of + * @param {Number} siteId wpcom site id. If not provided, the Site ID selected in the UI will be used + * @return {mixed} value for the products setting returned from the API + */ +export function getProductsSettingValue( state, id, siteId = getSelectedSiteId( state ) ) { + const productsSettings = getRawProductsSettings( state, siteId ); + const setting = find( productsSettings, item => item.id === id ); + return setting ? setting.value : null; +} diff --git a/client/extensions/woocommerce/state/sites/settings/products/test/selectors.js b/client/extensions/woocommerce/state/sites/settings/products/test/selectors.js index 88e50fe30ba72..613e48f308a44 100644 --- a/client/extensions/woocommerce/state/sites/settings/products/test/selectors.js +++ b/client/extensions/woocommerce/state/sites/settings/products/test/selectors.js @@ -13,6 +13,7 @@ import { areSettingsProductsLoading, getWeightUnitSetting, getDimensionsUnitSetting, + getProductsSettingValue, } from '../selectors'; import { LOADING } from 'woocommerce/state/constants'; @@ -56,13 +57,53 @@ const dimensionsUnitSetting = { default: 'cm', value: 'in', }; + +const notifyLowStockSetting = { + id: 'woocommerce_notify_low_stock', + description: 'Enable low stock notifications', + label: 'Notifications', + type: 'checkbox', + value: 'yes', +}; + +const lowStockAmountSetting = { + id: 'woocommerce_notify_low_stock_amount', + description: 'When product stock reaches this amount you will be notified via email.', + label: 'Low stock threshold', + type: 'number', + value: '2', +}; + +const notifyNoStockSetting = { + id: 'woocommerce_notify_no_stock', + description: 'Enable out of stock notifications', + label: '', + type: 'checkbox', + value: 'no', +}; + +const manageStockSetting = { + id: 'woocommerce_manage_stock', + description: 'Enable stock management', + label: 'Manage stock', + type: 'checkbox', + value: 'yes', +}; + const loadedState = { extensions: { woocommerce: { sites: { 123: { settings: { - products: [ weightUnitSetting, dimensionsUnitSetting ], + products: [ + dimensionsUnitSetting, + lowStockAmountSetting, + manageStockSetting, + notifyLowStockSetting, + notifyNoStockSetting, + weightUnitSetting, + ], }, }, }, @@ -137,4 +178,40 @@ describe( 'selectors', () => { expect( getDimensionsUnitSetting( loadedStateWithUi ) ).to.eql( dimensionsUnitSetting ); } ); } ); + + describe( '#getProductsSettingValue', () => { + test( 'should be null when woocommerce state is not available', () => { + expect( + getProductsSettingValue( preInitializedState, 'woocommerce_notify_low_stock_amount', 123 ) + ).to.be.null; + } ); + + test( 'should be null when woocommerce state is available but setting key is invalid', () => { + expect( getProductsSettingValue( loadedState, 'not-a-valid-key', 123 ) ).to.be.null; + } ); + + test( 'should return correct woocommerce_notify_low_stock_amount', () => { + expect( + getProductsSettingValue( loadedState, 'woocommerce_notify_low_stock_amount', 123 ) + ).to.equal( '2' ); + } ); + + test( 'should return correct woocommerce_notify_low_stock', () => { + expect( + getProductsSettingValue( loadedStateWithUi, 'woocommerce_notify_low_stock' ) + ).to.equal( 'yes' ); + } ); + + test( 'should return correct woocommerce_notify_no_stock', () => { + expect( getProductsSettingValue( loadedState, 'woocommerce_notify_no_stock', 123 ) ).to.equal( + 'no' + ); + } ); + + test( 'should return correct woocommerce_manage_stock', () => { + expect( getProductsSettingValue( loadedStateWithUi, 'woocommerce_manage_stock' ) ).to.equal( + 'yes' + ); + } ); + } ); } ); From 8a54a885239a61d5be44c0b4aa4762de14aac65c Mon Sep 17 00:00:00 2001 From: "Michael P. Pfeiffer" Date: Wed, 1 Nov 2017 13:13:42 -0500 Subject: [PATCH 148/192] Make custom property use more predictable (#19310) --- assets/stylesheets/shared/_color-schemes.scss | 12 ++++++------ client/layout/masterbar/style.scss | 4 ++-- client/layout/style.scss | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/assets/stylesheets/shared/_color-schemes.scss b/assets/stylesheets/shared/_color-schemes.scss index 9d68a3aafa4e5..e68e2d6b078c9 100644 --- a/assets/stylesheets/shared/_color-schemes.scss +++ b/assets/stylesheets/shared/_color-schemes.scss @@ -1,7 +1,7 @@ //default color scheme :root { --masterbar-color: $white; - --masterbar-background-color: $blue-wordpress; + --masterbar-background: $blue-wordpress; --masterbar-border-color: darken( $blue-wordpress, 4% ); --masterbar-item-hover-background: lighten( $blue-wordpress, 5% ); --masterbar-item-active-background: darken( $blue-wordpress, 17% ); @@ -9,7 +9,7 @@ --masterbar-item-new-editor-background: darken( $blue-wordpress, 17% ); --masterbar-item-new-editor-hover-background: darken( $blue-wordpress, 13% ); --masterbar-toggle-drafts-editor-background: darken( $blue-wordpress, 12% ); - --masterbar-toggle-drafts-editor-border: darken( $blue-wordpress, 5% ); + --masterbar-toggle-drafts-editor-border-color: darken( $blue-wordpress, 5% ); --masterbar-toggle-drafts-editor-hover-background: darken( $blue-wordpress, 17% ); } @@ -17,7 +17,7 @@ .color-scheme { &.is-light { --masterbar-color: $gray-text; - --masterbar-background-color: lighten( $gray, 20% ); + --masterbar-background: lighten( $gray, 20% ); --masterbar-border-color: lighten( $gray, 10% ); --masterbar-item-hover-background: lighten( $gray, 30% ); --masterbar-item-active-background: lighten( $gray, 10% ); @@ -25,13 +25,13 @@ --masterbar-item-new-editor-background: darken( $gray, 20% ); --masterbar-item-new-editor-hover-background: darken( $gray, 10% ); --masterbar-toggle-drafts-editor-background: darken( $gray, 10% ); - --masterbar-toggle-drafts-editor-border: lighten( $gray, 20% ); + --masterbar-toggle-drafts-editor-border-color: lighten( $gray, 20% ); --masterbar-toggle-drafts-editor-hover-background: darken( $gray, 10% ); } &.is-dark { --masterbar-color: $white; - --masterbar-background-color: $gray-dark; + --masterbar-background: $gray-dark; --masterbar-border-color: darken( $gray, 10% ); --masterbar-item-hover-background: darken( $gray, 10% ); --masterbar-item-active-background: $gray-text-min; @@ -39,7 +39,7 @@ --masterbar-item-new-editor-background: $gray-text-min; --masterbar-item-new-editor-hover-background: lighten( $gray-text-min, 5% ); --masterbar-toggle-drafts-editor-background: darken( $gray, 10% ); - --masterbar-toggle-drafts-editor-border: $gray-dark; + --masterbar-toggle-drafts-editor-border-color: $gray-dark; --masterbar-toggle-drafts-editor-hover-background: lighten( $gray-text-min, 5% ); } } diff --git a/client/layout/masterbar/style.scss b/client/layout/masterbar/style.scss index 30e5e99a4a082..83451d73a9f45 100644 --- a/client/layout/masterbar/style.scss +++ b/client/layout/masterbar/style.scss @@ -3,7 +3,7 @@ $autobar-height: 20px; // The WordPress.com Masterbar .masterbar { - background: var( --masterbar-background-color ); + background: var( --masterbar-background ); border-bottom: 1px solid var( --masterbar-border-color ); color: var( --masterbar-color ); font-size: 16px; @@ -392,7 +392,7 @@ $autobar-height: 20px; .is-group-editor & { background: var( --masterbar-toggle-drafts-editor-background ); - border-left: 1px solid var( --masterbar-toggle-drafts-editor-border ); + border-left: 1px solid var( --masterbar-toggle-drafts-editor-border-color ); .count { color: $gray-light; diff --git a/client/layout/style.scss b/client/layout/style.scss index bc04110a49d66..f18b5af455997 100644 --- a/client/layout/style.scss +++ b/client/layout/style.scss @@ -191,7 +191,7 @@ transition-delay: 0.4s; @include breakpoint( "<480px" ) { - background: var( --masterbar-background-color ); + background: var( --masterbar-background ); } } From 12f7c0dbc064d706d964326851969d16d2ce9f9b Mon Sep 17 00:00:00 2001 From: Chris R Date: Wed, 1 Nov 2017 18:35:32 +0000 Subject: [PATCH 149/192] Allow unmoderated comments to be displayed but not fetched (#19393) --- client/blocks/comments/comments-filters.js | 2 ++ client/blocks/comments/post-comment-list.jsx | 7 ++++++- client/blocks/reader-full-post/index.jsx | 6 ++++-- 3 files changed, 12 insertions(+), 3 deletions(-) create mode 100644 client/blocks/comments/comments-filters.js diff --git a/client/blocks/comments/comments-filters.js b/client/blocks/comments/comments-filters.js new file mode 100644 index 0000000000000..ffa072203aa70 --- /dev/null +++ b/client/blocks/comments/comments-filters.js @@ -0,0 +1,2 @@ +/** @format */ +export const COMMENTS_FILTER_ALL = 'all'; diff --git a/client/blocks/comments/post-comment-list.jsx b/client/blocks/comments/post-comment-list.jsx index da52142b02167..f29fb6e14b36e 100644 --- a/client/blocks/comments/post-comment-list.jsx +++ b/client/blocks/comments/post-comment-list.jsx @@ -55,6 +55,11 @@ class PostCommentList extends React.Component { commentCount: PropTypes.number, maxDepth: PropTypes.number, showNestingReplyArrow: PropTypes.bool, + commentsFilter: PropTypes.string, + + // To display comments with a different status but not fetch them + // e.g. Reader full post view showing unapproved comments made to a moderated site + commentsFilterDisplay: PropTypes.string, // connect()ed props: commentsTree: PropTypes.object, @@ -453,7 +458,7 @@ export default connect( state, ownProps.post.site_ID, ownProps.post.ID, - ownProps.commentsFilter + ownProps.commentsFilterDisplay ? ownProps.commentsFilterDisplay : ownProps.commentsFilter ), commentsFetchingStatus: commentsFetchingStatus( state, diff --git a/client/blocks/reader-full-post/index.jsx b/client/blocks/reader-full-post/index.jsx index 6b96a9e977bf2..36ec92ad73a16 100644 --- a/client/blocks/reader-full-post/index.jsx +++ b/client/blocks/reader-full-post/index.jsx @@ -70,6 +70,7 @@ import { getLastStore } from 'reader/controller-helper'; import { showSelectedPost } from 'reader/utils'; import Emojify from 'components/emojify'; import config from 'config'; +import { COMMENTS_FILTER_ALL } from 'blocks/comments/comments-filters'; export class FullPostView extends React.Component { static propTypes = { @@ -439,7 +440,7 @@ export class FullPostView extends React.Component { ) }
- { shouldShowComments( post ) ? ( + { shouldShowComments( post ) && ( - ) : null } + ) }
{ showRelatedPosts && ( From 68ef763e4ac61ecb2ad544b51d5e54b6b0dd2269 Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Tue, 31 Oct 2017 15:14:27 -0500 Subject: [PATCH 150/192] Promotions list: Update empty content page. This updates the empty content page to bring it up a level so the search component doesn't show, and fixes the link for the button. --- .../woocommerce/app/promotions/index.js | 35 +++++++++++++++++-- .../app/promotions/promotions-list.js | 26 ++------------ 2 files changed, 34 insertions(+), 27 deletions(-) diff --git a/client/extensions/woocommerce/app/promotions/index.js b/client/extensions/woocommerce/app/promotions/index.js index c8d7cf614e7e1..307539d7157f8 100644 --- a/client/extensions/woocommerce/app/promotions/index.js +++ b/client/extensions/woocommerce/app/promotions/index.js @@ -15,6 +15,7 @@ import { noop } from 'lodash'; /** * Internal dependencies */ +import EmptyContent from 'components/empty-content'; import { fetchPromotions } from 'woocommerce/state/sites/promotions/actions'; import { getPromotions } from 'woocommerce/state/selectors/promotions'; import ActionHeader from 'woocommerce/components/action-header'; @@ -67,9 +68,38 @@ class Promotions extends Component { ); } + renderEmptyContent() { + const { site, translate } = this.props; + + const emptyContentAction = ( + + ); + + return ( + + ); + } + + renderContent() { + return ( +
+ { this.renderSearchCard() } + +
+ ); + } + render() { - const { site, className, translate } = this.props; + const { site, className, promotions, translate } = this.props; const classes = classNames( 'promotions__list', className ); + const isEmpty = site && promotions && 0 === promotions.length; + + const content = isEmpty ? this.renderEmptyContent() : this.renderContent(); return (
@@ -79,8 +109,7 @@ class Promotions extends Component { { translate( 'Add promotion' ) } - { this.renderSearchCard() } - + { content }
); } diff --git a/client/extensions/woocommerce/app/promotions/promotions-list.js b/client/extensions/woocommerce/app/promotions/promotions-list.js index fe0e0461dd8bc..4662794cb8229 100644 --- a/client/extensions/woocommerce/app/promotions/promotions-list.js +++ b/client/extensions/woocommerce/app/promotions/promotions-list.js @@ -8,14 +8,10 @@ import React from 'react'; import { bindActionCreators } from 'redux'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; -import { localize } from 'i18n-calypso'; /** * Internal dependencies */ -import Button from 'components/button'; -import EmptyContent from 'components/empty-content'; -import { getLink } from 'woocommerce/lib/nav-utils'; import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; import { getPromotions, @@ -28,21 +24,7 @@ import PromotionsListPagination from './promotions-list-pagination'; import { setPromotionsPage } from 'woocommerce/state/ui/promotions/actions'; const PromotionsList = props => { - const { site, translate, promotions, promotionsPage, currentPage, perPage } = props; - - const renderEmptyContent = () => { - const emptyContentAction = ( - - ); - return ( - - ); - }; + const { site, promotions, promotionsPage, currentPage, perPage } = props; const switchPage = index => { if ( site ) { @@ -50,10 +32,6 @@ const PromotionsList = props => { } }; - if ( promotions && promotions.length === 0 ) { - return renderEmptyContent(); - } - return (
@@ -102,4 +80,4 @@ function mapDispatchToProps( dispatch ) { ); } -export default connect( mapStateToProps, mapDispatchToProps )( localize( PromotionsList ) ); +export default connect( mapStateToProps, mapDispatchToProps )( PromotionsList ); From 58f02dbcc5b46a046c886faf0651420ba38b0885 Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Wed, 1 Nov 2017 00:17:47 -0500 Subject: [PATCH 151/192] Promotions edit: arrow function for delete success There was a report of a problem accessing `this` within the onSuccess handler of the delete operation. While I couldn't reproduce the issue directly, I did spot a function call instead of an arrow function that could be the issue. This commit converts to use an arrow function instead. (see: https://github.com/Automattic/wp-calypso/pull/19363#issuecomment-340944332) --- .../extensions/woocommerce/app/promotions/promotion-update.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/extensions/woocommerce/app/promotions/promotion-update.js b/client/extensions/woocommerce/app/promotions/promotion-update.js index f7ec3564cc31c..4f3c64b0b5f4d 100644 --- a/client/extensions/woocommerce/app/promotions/promotion-update.js +++ b/client/extensions/woocommerce/app/promotions/promotion-update.js @@ -99,7 +99,7 @@ class PromotionUpdate extends React.Component { onTrash = () => { const { translate, site, promotion, deletePromotion: dispatchDelete } = this.props; const areYouSure = translate( 'Are you sure you want to delete this promotion?' ); - accept( areYouSure, function( accepted ) { + accept( areYouSure, accepted => { if ( ! accepted ) { return; } From 2c3e6af5e04c2cea4816a890f9fa82bba00d8587 Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Tue, 31 Oct 2017 18:23:07 -0500 Subject: [PATCH 152/192] Promotion edit: Protect form from navigating away This adds a protect guard while edits are present. It also updates the delete notification to be more explicit that it is a permanent delete. --- .../app/promotions/promotion-create.js | 11 +++++++-- .../app/promotions/promotion-update.js | 24 ++++++++++++++++--- 2 files changed, 30 insertions(+), 5 deletions(-) diff --git a/client/extensions/woocommerce/app/promotions/promotion-create.js b/client/extensions/woocommerce/app/promotions/promotion-create.js index 62f14fef32c35..f6d5b58c08660 100644 --- a/client/extensions/woocommerce/app/promotions/promotion-create.js +++ b/client/extensions/woocommerce/app/promotions/promotion-create.js @@ -24,11 +24,13 @@ import { fetchSettingsGeneral } from 'woocommerce/state/sites/settings/general/a import { getPaymentCurrencySettings } from 'woocommerce/state/sites/settings/general/selectors'; import { getCurrentlyEditingPromotionId, + getPromotionEdits, getPromotionWithLocalEdits, } from 'woocommerce/state/selectors/promotions'; import { isValidPromotion } from './helpers'; import PromotionHeader from './promotion-header'; import PromotionForm from './promotion-form'; +import { ProtectFormGuard } from 'lib/protect-form'; import { successNotice, errorNotice } from 'state/notices/actions'; class PromotionCreate extends React.Component { @@ -104,6 +106,8 @@ class PromotionCreate extends React.Component { }; const successAction = dispatch => { + this.props.clearPromotionEdits( site.ID ); + dispatch( getSuccessNotice( promotion ) ); page.redirect( getLink( '/store/promotions/:site', site ) ); }; @@ -123,11 +127,11 @@ class PromotionCreate extends React.Component { }; render() { - const { site, currency, className, promotion } = this.props; + const { site, currency, className, promotion, hasEdits } = this.props; const { busy } = this.state; const isValid = 'undefined' !== typeof site && isValidPromotion( promotion ); - const saveEnabled = isValid && ! busy; + const saveEnabled = isValid && ! busy && hasEdits; return (
@@ -137,6 +141,7 @@ class PromotionCreate extends React.Component { onSave={ saveEnabled ? this.onSave : false } isBusy={ busy } /> + { const { translate, site, promotion, deletePromotion: dispatchDelete } = this.props; - const areYouSure = translate( 'Are you sure you want to delete this promotion?' ); + const areYouSure = translate( 'Are you sure you want to permanently delete this promotion?' ); accept( areYouSure, accepted => { if ( ! accepted ) { return; } + const successAction = dispatch => { + this.props.clearPromotionEdits( site.ID ); + dispatch( successNotice( translate( 'Promotion successfully deleted.' ) ) ); debounce( () => { page.redirect( getLink( '/store/promotions/:site/', site ) ); @@ -136,6 +141,8 @@ class PromotionUpdate extends React.Component { }; const successAction = dispatch => { + this.props.clearPromotionEdits( site.ID ); + dispatch( getSuccessNotice( promotion ) ); page.redirect( getLink( '/store/promotions/:site', site ) ); }; @@ -155,11 +162,19 @@ class PromotionUpdate extends React.Component { }; render() { - const { site, currency, className, promotion, products, productCategories } = this.props; + const { + site, + currency, + className, + promotion, + products, + productCategories, + hasEdits, + } = this.props; const { busy } = this.state; const isValid = 'undefined' !== typeof site && isValidPromotion( promotion ); - const saveEnabled = isValid && ! busy; + const saveEnabled = isValid && ! busy && hasEdits; return (
@@ -170,6 +185,7 @@ class PromotionUpdate extends React.Component { onSave={ saveEnabled ? this.onSave : false } isBusy={ busy } /> + Date: Wed, 1 Nov 2017 12:42:30 -0500 Subject: [PATCH 153/192] Promotion edits: Add hasEdits to propTypes This adds hasEdits to propTypes for create and update components. --- client/extensions/woocommerce/app/promotions/promotion-create.js | 1 + client/extensions/woocommerce/app/promotions/promotion-update.js | 1 + 2 files changed, 2 insertions(+) diff --git a/client/extensions/woocommerce/app/promotions/promotion-create.js b/client/extensions/woocommerce/app/promotions/promotion-create.js index f6d5b58c08660..2a561e72626db 100644 --- a/client/extensions/woocommerce/app/promotions/promotion-create.js +++ b/client/extensions/woocommerce/app/promotions/promotion-create.js @@ -37,6 +37,7 @@ class PromotionCreate extends React.Component { static propTypes = { className: PropTypes.string, currency: PropTypes.string, + hasEdits: PropTypes.bool.isRequired, site: PropTypes.shape( { ID: PropTypes.number, slug: PropTypes.string, diff --git a/client/extensions/woocommerce/app/promotions/promotion-update.js b/client/extensions/woocommerce/app/promotions/promotion-update.js index ab6e586e4e0f2..5183cce5d96fc 100644 --- a/client/extensions/woocommerce/app/promotions/promotion-update.js +++ b/client/extensions/woocommerce/app/promotions/promotion-update.js @@ -44,6 +44,7 @@ class PromotionUpdate extends React.Component { static propTypes = { className: PropTypes.string, currency: PropTypes.string, + hasEdits: PropTypes.bool.isRequired, products: PropTypes.array, productCategories: PropTypes.array, site: PropTypes.shape( { From 13ecdf07dd2600c45db373c8a0e808d44ce1343d Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Tue, 31 Oct 2017 17:28:22 -0500 Subject: [PATCH 154/192] Promotions edit: Update promotion name during edit This adds code to update the promotion name while editing to allow for a correct notifiction when it's been saved. --- .../app/promotions/promotion-create.js | 18 ++++++++++- .../app/promotions/promotion-form.js | 30 +++++++++++++++++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/client/extensions/woocommerce/app/promotions/promotion-create.js b/client/extensions/woocommerce/app/promotions/promotion-create.js index 2a561e72626db..7a169757708c7 100644 --- a/client/extensions/woocommerce/app/promotions/promotion-create.js +++ b/client/extensions/woocommerce/app/promotions/promotion-create.js @@ -22,9 +22,11 @@ import { fetchProductCategories } from 'woocommerce/state/sites/product-categori import { fetchPromotions, createPromotion } from 'woocommerce/state/sites/promotions/actions'; import { fetchSettingsGeneral } from 'woocommerce/state/sites/settings/general/actions'; import { getPaymentCurrencySettings } from 'woocommerce/state/sites/settings/general/selectors'; +import { getProductCategories } from 'woocommerce/state/sites/product-categories/selectors'; import { getCurrentlyEditingPromotionId, getPromotionEdits, + getPromotionableProducts, getPromotionWithLocalEdits, } from 'woocommerce/state/selectors/promotions'; import { isValidPromotion } from './helpers'; @@ -128,7 +130,15 @@ class PromotionCreate extends React.Component { }; render() { - const { site, currency, className, promotion, hasEdits } = this.props; + const { + site, + currency, + className, + promotion, + hasEdits, + products, + productCategories + } = this.props; const { busy } = this.state; const isValid = 'undefined' !== typeof site && isValidPromotion( promotion ); @@ -148,6 +158,8 @@ class PromotionCreate extends React.Component { currency={ currency } promotion={ promotion } editPromotion={ this.props.editPromotion } + products={ products } + productCategories={ productCategories } />
); @@ -161,12 +173,16 @@ function mapStateToProps( state ) { const promotionId = getCurrentlyEditingPromotionId( state, site.ID ); const promotion = promotionId ? getPromotionWithLocalEdits( state, promotionId, site.ID ) : null; const hasEdits = Boolean( getPromotionEdits( state, promotionId, site.ID ) ); + const products = getPromotionableProducts( state, site.ID ); + const productCategories = getProductCategories( state, site.ID ); return { hasEdits, site, promotion, currency, + products, + productCategories, }; } diff --git a/client/extensions/woocommerce/app/promotions/promotion-form.js b/client/extensions/woocommerce/app/promotions/promotion-form.js index 6594af9bdcba0..aecfe5072e77a 100644 --- a/client/extensions/woocommerce/app/promotions/promotion-form.js +++ b/client/extensions/woocommerce/app/promotions/promotion-form.js @@ -4,7 +4,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; -import { uniqueId } from 'lodash'; +import { uniqueId, get, find } from 'lodash'; import warn from 'lib/warn'; /** @@ -34,11 +34,37 @@ export default class PromotionForm extends React.PureComponent { id: PropTypes.isRequired, } ), editPromotion: PropTypes.func.isRequired, + products: PropTypes.array, + productCategories: PropTypes.array, }; + calculatePromotionName = ( promotion ) => { + const { products } = this.props; + + switch ( promotion.type ) { + case 'fixed_discount': + case 'fixed_cart': + case 'percent': + return promotion.couponCode; + case 'product_sale': + const productIds = get( promotion, [ 'appliesTo', 'productIds' ], [] ); + const productId = ( productIds.length > 0 ? productIds[ 0 ] : null ); + const product = productId && find( products, { id: productId } ); + return ( product ? product.name : '' ); + } + } + + editPromotionWithNameUpdate = ( siteId, promotion, data ) => { + const name = this.calculatePromotionName( { ...promotion, ...data } ); + const adjustedData = { ...data, name }; + + return this.props.editPromotion( siteId, promotion, adjustedData ); + } + renderFormCards( promotion ) { - const { siteId, currency, editPromotion } = this.props; + const { siteId, currency } = this.props; const model = promotionModels[ promotion.type ]; + const editPromotion = this.editPromotionWithNameUpdate; if ( ! model ) { warn( 'No model found for promotion type: ' + promotion.type ); From a1c65ccc231500e22f35e1ac1deec43ba26a0b63 Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Wed, 1 Nov 2017 00:41:19 -0500 Subject: [PATCH 155/192] Promotion update: Stay on page after save. This updates the code to not go back to the list, but stay on the edit page. --- .../extensions/woocommerce/app/promotions/promotion-update.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/client/extensions/woocommerce/app/promotions/promotion-update.js b/client/extensions/woocommerce/app/promotions/promotion-update.js index 5183cce5d96fc..4bbcf35fccfd1 100644 --- a/client/extensions/woocommerce/app/promotions/promotion-update.js +++ b/client/extensions/woocommerce/app/promotions/promotion-update.js @@ -135,7 +135,6 @@ class PromotionUpdate extends React.Component { args: { promotion: promotion.name }, } ), { - displayOnNextPage: true, duration: 8000, } ); @@ -143,9 +142,8 @@ class PromotionUpdate extends React.Component { const successAction = dispatch => { this.props.clearPromotionEdits( site.ID ); - dispatch( getSuccessNotice( promotion ) ); - page.redirect( getLink( '/store/promotions/:site', site ) ); + this.setState( () => ( { busy: false } ) ); }; const failureAction = dispatch => { From 9d90293c20322c66a08da5c120f44680c9f13ef6 Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Wed, 1 Nov 2017 13:20:31 -0500 Subject: [PATCH 156/192] Promotion edit: Remove currency field formatting (#19379) * Store Promotions: Add selectors for edit states This adds selectors to get promotion edits, edits overlaid on top of the existing promotions, and the currently editing promotion. * Store Promotions: Add selectors for edit states This adds selectors to get promotion edits, edits overlaid on top of the existing promotions, and the currently editing promotion. * Store Promotions: Adjust test code This propagates the test fix up to another PR * Store Promotions: Adjust test code This propagates the test fix up to another PR * Promotion edit: Remove currency field formatting The formatting for the currency field isn't working correctly and is cutting off trailing zeroes. This removes the formatting for now until we can work through how it should operate. --- .../app/promotions/fields/currency-field.js | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/client/extensions/woocommerce/app/promotions/fields/currency-field.js b/client/extensions/woocommerce/app/promotions/fields/currency-field.js index f491b31b53fe4..880d8780f1188 100644 --- a/client/extensions/woocommerce/app/promotions/fields/currency-field.js +++ b/client/extensions/woocommerce/app/promotions/fields/currency-field.js @@ -7,7 +7,6 @@ import PropTypes from 'prop-types'; /** * Internal dependencies */ -import { getCurrencyFormatDecimal } from 'woocommerce/lib/currency'; import PriceInput from 'woocommerce/components/price-input'; import FormField from './form-field'; @@ -17,21 +16,14 @@ const CurrencyField = ( props ) => { const onChange = ( e ) => { const newValue = e.target.value; - if ( 0 === newValue.length ) { - edit( fieldName, '' ); - return; - } - - const numberValue = Number( newValue ); - if ( 0 <= Number( newValue ) ) { - const formattedValue = getCurrencyFormatDecimal( numberValue, currency ); - edit( fieldName, String( formattedValue ) ); - } + edit( fieldName, String( newValue ) ); }; return ( Date: Wed, 1 Nov 2017 12:10:19 -0700 Subject: [PATCH 157/192] Allow both approving and unapproving in bulk edit for Approved and (#19350) --- client/my-sites/comments/comment-navigation/index.jsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/my-sites/comments/comment-navigation/index.jsx b/client/my-sites/comments/comment-navigation/index.jsx index 93da518c04c8a..1fa32d8cbc61f 100644 --- a/client/my-sites/comments/comment-navigation/index.jsx +++ b/client/my-sites/comments/comment-navigation/index.jsx @@ -32,8 +32,8 @@ import { getSiteComment } from 'state/selectors'; import { NEWEST_FIRST, OLDEST_FIRST } from '../constants'; const bulkActions = { - unapproved: [ 'approve', 'spam', 'trash' ], - approved: [ 'unapprove', 'spam', 'trash' ], + unapproved: [ 'approve', 'unapprove', 'spam', 'trash' ], + approved: [ 'approve', 'unapprove', 'spam', 'trash' ], spam: [ 'approve', 'delete' ], trash: [ 'approve', 'spam', 'delete' ], all: [ 'approve', 'unapprove', 'spam', 'trash' ], From f00ed271f40fcf8da0662a94d43836564abf30cc Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Wed, 1 Nov 2017 01:22:40 -0500 Subject: [PATCH 158/192] Promotions list: Implement search The search box has been at the top, but nonfunctional. This adds code to implement the list. It also adds code to replace "expiration date" copy with "end date" copy. --- .../woocommerce/app/promotions/index.js | 20 ++++++++++--- .../app/promotions/promotions-list-row.js | 4 +-- .../app/promotions/promotions-list.js | 28 +++++++++++++++---- .../woocommerce/state/selectors/promotions.js | 4 +-- .../state/selectors/test/promotions.js | 24 +++++++--------- 5 files changed, 52 insertions(+), 28 deletions(-) diff --git a/client/extensions/woocommerce/app/promotions/index.js b/client/extensions/woocommerce/app/promotions/index.js index 307539d7157f8..9fe15741a0c55 100644 --- a/client/extensions/woocommerce/app/promotions/index.js +++ b/client/extensions/woocommerce/app/promotions/index.js @@ -10,7 +10,6 @@ import PropTypes from 'prop-types'; import classNames from 'classnames'; import { connect } from 'react-redux'; import { localize } from 'i18n-calypso'; -import { noop } from 'lodash'; /** * Internal dependencies @@ -36,6 +35,14 @@ class Promotions extends Component { fetchPromotions: PropTypes.func.isRequired, }; + constructor( props ) { + super( props ); + + this.state = { + searchFilter: '', + }; + } + componentDidMount() { const { site } = this.props; if ( site && site.ID ) { @@ -53,13 +60,16 @@ class Promotions extends Component { } } + onSearch = searchFilter => { + this.setState( () => ( { searchFilter } ) ); + }; + renderSearchCard() { const { site, promotions, translate } = this.props; - // TODO: Implement onSearch return ( { this.renderSearchCard() } - +
); } diff --git a/client/extensions/woocommerce/app/promotions/promotions-list-row.js b/client/extensions/woocommerce/app/promotions/promotions-list-row.js index 5b39691d26840..c30db63d0e65f 100644 --- a/client/extensions/woocommerce/app/promotions/promotions-list-row.js +++ b/client/extensions/woocommerce/app/promotions/promotions-list-row.js @@ -47,13 +47,13 @@ function getTimeframeText( promotion, translate, moment ) { } ); } if ( promotion.startDate ) { - return translate( '%(startDate)s - No expiration date', { + return translate( '%(startDate)s - No end date', { args: { startDate: moment( promotion.startDate ).format( 'll' ), }, } ); } - return translate( 'No expiration date' ); + return translate( 'No end date' ); } const PromotionsListRow = ( { site, promotion, translate, moment } ) => { diff --git a/client/extensions/woocommerce/app/promotions/promotions-list.js b/client/extensions/woocommerce/app/promotions/promotions-list.js index 4662794cb8229..6a980d1d8fba1 100644 --- a/client/extensions/woocommerce/app/promotions/promotions-list.js +++ b/client/extensions/woocommerce/app/promotions/promotions-list.js @@ -23,8 +23,18 @@ import PromotionsListTable from './promotions-list-table'; import PromotionsListPagination from './promotions-list-pagination'; import { setPromotionsPage } from 'woocommerce/state/ui/promotions/actions'; +function promotionContainsString( promotion, textString ) { + const matchString = textString.trim().toLocaleLowerCase(); + + if ( -1 < promotion.name.toLocaleLowerCase().indexOf( matchString ) ) { + // found in promotion name + return true; + } + return false; +} + const PromotionsList = props => { - const { site, promotions, promotionsPage, currentPage, perPage } = props; + const { site, filteredPromotions, promotionsPage, currentPage, perPage } = props; const switchPage = index => { if ( site ) { @@ -37,8 +47,8 @@ const PromotionsList = props => { = 0 } - totalPromotions={ promotions && promotions.length } + promotionsLoaded={ filteredPromotions && filteredPromotions.length >= 0 } + totalPromotions={ filteredPromotions && filteredPromotions.length } currentPage={ currentPage } perPage={ perPage } onSwitchPage={ switchPage } @@ -48,23 +58,31 @@ const PromotionsList = props => { }; PromotionsList.propTypes = { + searchFilter: PropTypes.string, site: PropTypes.object, promotions: PropTypes.array, + filteredPromotions: PropTypes.array, currentPage: PropTypes.number, perPage: PropTypes.number, promotionsPage: PropTypes.array, }; -function mapStateToProps( state ) { +function mapStateToProps( state, ownProps ) { const site = getSelectedSiteWithFallback( state ); const currentPage = site && getPromotionsCurrentPage( state ); const perPage = site && getPromotionsPerPage( state ); const promotions = site && getPromotions( state, site.ID ); - const promotionsPage = site && getPromotionsPage( state, site.ID, currentPage, perPage ); + const filteredPromotions = + promotions && + promotions.filter( promotion => { + return promotionContainsString( promotion, ownProps.searchFilter ); + } ); + const promotionsPage = site && getPromotionsPage( filteredPromotions, currentPage, perPage ); return { site, promotions, + filteredPromotions, promotionsPage, currentPage, perPage, diff --git a/client/extensions/woocommerce/state/selectors/promotions.js b/client/extensions/woocommerce/state/selectors/promotions.js index 601a1bd44a7f9..841c34f67b8db 100644 --- a/client/extensions/woocommerce/state/selectors/promotions.js +++ b/client/extensions/woocommerce/state/selectors/promotions.js @@ -26,13 +26,11 @@ export function getPromotion( } export function getPromotionsPage( - rootState, - siteId = getSelectedSiteWithFallback( rootState ), + promotions, page, perPage ) { const offset = ( page - 1 ) * perPage; - const promotions = getPromotions( rootState, siteId ); return promotions ? promotions.slice( offset, offset + perPage ) : null; } diff --git a/client/extensions/woocommerce/state/selectors/test/promotions.js b/client/extensions/woocommerce/state/selectors/test/promotions.js index 5ae8254668c8f..4c317be1f926e 100644 --- a/client/extensions/woocommerce/state/selectors/test/promotions.js +++ b/client/extensions/woocommerce/state/selectors/test/promotions.js @@ -76,7 +76,8 @@ describe( 'promotions', () => { describe( '#getPromotionsPage', () => { test( 'should return only promotions for a given page.', () => { - const page = getPromotionsPage( rootState, 123, 1, 2 ); + const promotions = getPromotions( rootState, 123 ); + const page = getPromotionsPage( promotions, 1, 2 ); expect( page ).to.exist; expect( page.length ).to.equal( 2 ); @@ -85,7 +86,8 @@ describe( 'promotions', () => { } ); test( 'should advance the offset for pages > 1.', () => { - const page = getPromotionsPage( rootState, 123, 2, 2 ); + const promotions = getPromotions( rootState, 123 ); + const page = getPromotionsPage( promotions, 2, 2 ); expect( page ).to.exist; expect( page.length ).to.equal( 1 ); @@ -117,11 +119,9 @@ describe( 'promotions', () => { const editedState = cloneDeep( rootState ); editedState.extensions.woocommerce.ui.promotions.edits = { [ 123 ]: { - creates: [ - { id: 'coupon:4', type: 'empty4' }, - ], + creates: [ { id: 'coupon:4', type: 'empty4' } ], currentlyEditingId: 'coupon:4', - } + }, }; const id = getCurrentlyEditingPromotionId( editedState, 123 ); @@ -140,11 +140,9 @@ describe( 'promotions', () => { const editedState = cloneDeep( rootState ); editedState.extensions.woocommerce.ui.promotions.edits = { [ 123 ]: { - updates: [ - { id: 'coupon:3', type: 'empty33' }, - ], + updates: [ { id: 'coupon:3', type: 'empty33' } ], currentlyEditingId: 'coupon:3', - } + }, }; const edits = getPromotionEdits( editedState, 'coupon:3', 123 ); @@ -174,11 +172,9 @@ describe( 'promotions', () => { const editedState = cloneDeep( rootState ); editedState.extensions.woocommerce.ui.promotions.edits = { [ 123 ]: { - updates: [ - { id: 'coupon:3', type: 'empty33' }, - ], + updates: [ { id: 'coupon:3', type: 'empty33' } ], currentlyEditingId: 'coupon:3', - } + }, }; const editedPromotion = getPromotionWithLocalEdits( editedState, 'coupon:3', 123 ); From 79d0b8a9b7159a3d706e111707e363bb77d40227 Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Wed, 1 Nov 2017 01:35:23 -0500 Subject: [PATCH 159/192] Promotion edit: Adjust promotion type text Addresses #19331 This adjusts the names of the promotion types as they appear in the selector on the edit form, and in the table under the Type column. --- .../app/promotions/promotion-form-type-card.js | 12 ++++++------ .../woocommerce/app/promotions/promotion-form.js | 2 +- .../app/promotions/promotions-list-row.js | 12 ++++++------ 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/client/extensions/woocommerce/app/promotions/promotion-form-type-card.js b/client/extensions/woocommerce/app/promotions/promotion-form-type-card.js index 9131ca6e84937..ee9c3652a80e9 100644 --- a/client/extensions/woocommerce/app/promotions/promotion-form-type-card.js +++ b/client/extensions/woocommerce/app/promotions/promotion-form-type-card.js @@ -47,17 +47,17 @@ const PromotionFormTypeCard = ( { - + diff --git a/client/extensions/woocommerce/app/promotions/promotion-form.js b/client/extensions/woocommerce/app/promotions/promotion-form.js index aecfe5072e77a..e6b0d18a038bb 100644 --- a/client/extensions/woocommerce/app/promotions/promotion-form.js +++ b/client/extensions/woocommerce/app/promotions/promotion-form.js @@ -93,7 +93,7 @@ export default class PromotionForm extends React.PureComponent { } const promotion = this.props.promotion || - { id: { placeholder: uniqueId( 'promotion_' ) }, type: 'percent' }; + { id: { placeholder: uniqueId( 'promotion_' ) }, type: 'fixed_product' }; return (
diff --git a/client/extensions/woocommerce/app/promotions/promotions-list-row.js b/client/extensions/woocommerce/app/promotions/promotions-list-row.js index c30db63d0e65f..12f096bdf8669 100644 --- a/client/extensions/woocommerce/app/promotions/promotions-list-row.js +++ b/client/extensions/woocommerce/app/promotions/promotions-list-row.js @@ -17,14 +17,14 @@ import TableItem from 'woocommerce/components/table/table-item'; function getPromotionTypeText( promotionType, translate ) { switch ( promotionType ) { - case 'product_sale': - return translate( 'Product Sale' ); - case 'fixed_cart': - return translate( 'Cart Discount' ); case 'fixed_product': - return translate( 'Product Discount' ); + return translate( 'Product Discount Coupon' ); + case 'fixed_cart': + return translate( 'Cart Discount Coupon' ); case 'percent': - return translate( 'Percent Discount' ); + return translate( 'Percent Cart Discount Coupon' ); + case 'product_sale': + return translate( 'Specific Product Sale' ); } } From 20ebf751ef366e6bd8265c0c998c338164c2ce70 Mon Sep 17 00:00:00 2001 From: kellychoffman Date: Wed, 1 Nov 2017 08:50:02 -0600 Subject: [PATCH 160/192] copy updates --- .../app/promotions/promotion-form-type-card.js | 11 +++++------ .../woocommerce/app/promotions/promotion-models.js | 4 ++-- .../woocommerce/app/promotions/promotions-list-row.js | 8 ++++---- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/client/extensions/woocommerce/app/promotions/promotion-form-type-card.js b/client/extensions/woocommerce/app/promotions/promotion-form-type-card.js index ee9c3652a80e9..3c84473637126 100644 --- a/client/extensions/woocommerce/app/promotions/promotion-form-type-card.js +++ b/client/extensions/woocommerce/app/promotions/promotion-form-type-card.js @@ -16,7 +16,7 @@ import FormSettingExplanation from 'components/forms/form-setting-explanation'; function getExplanation( promotionType, translate ) { switch ( promotionType ) { case 'product_sale': - return translate( 'Put a product on sale for all customers.' ); + return translate( 'Place a single product on sale for all customers.' ); case 'fixed_product': return translate( 'Issue a coupon with a discount for one or more products.' ); case 'fixed_cart': @@ -48,16 +48,16 @@ const PromotionFormTypeCard = ( { @@ -80,4 +80,3 @@ PromotionFormTypeCard.PropTypes = { }; export default localize( PromotionFormTypeCard ); - diff --git a/client/extensions/woocommerce/app/promotions/promotion-models.js b/client/extensions/woocommerce/app/promotions/promotion-models.js index dac7a63864575..0374408abb8ff 100644 --- a/client/extensions/woocommerce/app/promotions/promotion-models.js +++ b/client/extensions/woocommerce/app/promotions/promotion-models.js @@ -77,12 +77,12 @@ const endDate = { */ const productSaleModel = { productAndSalePrice: { - labelText: translate( 'Product & Sale Price' ), + labelText: translate( 'Product & sale price' ), cssClass: 'promotions__promotion-form-card-primary', fields: { salePrice: { component: CurrencyField, - labelText: translate( 'Product Sale Price' ), + labelText: translate( 'Product sale price' ), isRequired: true, }, appliesTo: { diff --git a/client/extensions/woocommerce/app/promotions/promotions-list-row.js b/client/extensions/woocommerce/app/promotions/promotions-list-row.js index 12f096bdf8669..182e30dfecaa6 100644 --- a/client/extensions/woocommerce/app/promotions/promotions-list-row.js +++ b/client/extensions/woocommerce/app/promotions/promotions-list-row.js @@ -18,13 +18,13 @@ import TableItem from 'woocommerce/components/table/table-item'; function getPromotionTypeText( promotionType, translate ) { switch ( promotionType ) { case 'fixed_product': - return translate( 'Product Discount Coupon' ); + return translate( 'Product discount coupon' ); case 'fixed_cart': - return translate( 'Cart Discount Coupon' ); + return translate( 'Cart discount coupon' ); case 'percent': - return translate( 'Percent Cart Discount Coupon' ); + return translate( 'Percent cart discount coupon' ); case 'product_sale': - return translate( 'Specific Product Sale' ); + return translate( 'Individual product sale' ); } } From 0e47facb085c703fd5e2f8de38eccb180be5a0d2 Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Wed, 1 Nov 2017 02:02:06 -0500 Subject: [PATCH 161/192] Promotions edit: Adjust appliesTo in its own card This moves appliesTo to its own card, and removes the label for the selector. --- .../app/promotions/fields/form-field.js | 14 ++++-- .../app/promotions/promotion-models.js | 48 +++++++++++++------ 2 files changed, 43 insertions(+), 19 deletions(-) diff --git a/client/extensions/woocommerce/app/promotions/fields/form-field.js b/client/extensions/woocommerce/app/promotions/fields/form-field.js index 6d0bce0a13df5..288998eaa4ad0 100644 --- a/client/extensions/woocommerce/app/promotions/fields/form-field.js +++ b/client/extensions/woocommerce/app/promotions/fields/form-field.js @@ -52,12 +52,16 @@ const FormField = ( { { 'fields__fieldset-children-enableable': enableCheckbox } ); + const formLabel = ( enableCheckbox || labelText ) && ( + + { enableCheckbox } + { labelText } + + ); + return ( - - { enableCheckbox } - { labelText } - + { formLabel }
{ showChildren && children } { explanation } @@ -68,7 +72,7 @@ const FormField = ( { FormField.PropTypes = { fieldName: PropTypes.string.isRequired, - labelText: PropTypes.string.isRequired, + labelText: PropTypes.string, explanationText: PropTypes.string, isRequired: PropTypes.bool, isEnableable: PropTypes.bool, diff --git a/client/extensions/woocommerce/app/promotions/promotion-models.js b/client/extensions/woocommerce/app/promotions/promotion-models.js index 0374408abb8ff..7004a788b28fd 100644 --- a/client/extensions/woocommerce/app/promotions/promotion-models.js +++ b/client/extensions/woocommerce/app/promotions/promotion-models.js @@ -41,8 +41,6 @@ const appliesToCouponField = { ] } /> ), - labelText: translate( 'Applies to' ), - isRequired: true, }; /** @@ -76,15 +74,10 @@ const endDate = { * Promotion Type: Product Sale (e.g. $5 off the "I <3 Robots" t-shirt) */ const productSaleModel = { - productAndSalePrice: { - labelText: translate( 'Product & sale price' ), - cssClass: 'promotions__promotion-form-card-primary', + appliesTo: { + labelText: translate( 'Applies to product' ), + cssClass: 'promotions__promotion-form-card-applies-to', fields: { - salePrice: { - component: CurrencyField, - labelText: translate( 'Product sale price' ), - isRequired: true, - }, appliesTo: { component: ( ), - labelText: translate( 'Applies to product' ), + }, + }, + }, + productAndSalePrice: { + labelText: translate( 'Product & Sale Price' ), + cssClass: 'promotions__promotion-form-card-primary', + fields: { + salePrice: { + component: CurrencyField, + labelText: translate( 'Product Sale Price' ), isRequired: true, }, } @@ -150,6 +152,13 @@ const couponConditions = { * Promotion Type: Fixed Product Discount (e.g. $5 off any t-shirt) */ const fixedProductModel = { + appliesTo: { + labelText: translate( 'Applies to' ), + cssClass: 'promotions__promotion-form-card-applies-to', + fields: { + appliesTo: appliesToCouponField, + }, + }, couponCodeAndDiscount: { labelText: translate( 'Coupon Code & Discount' ), cssClass: 'promotions__promotion-form-card-primary', @@ -159,7 +168,6 @@ const fixedProductModel = { ...fixedDiscountField, labelText: translate( 'Product Discount', { context: 'noun' } ) }, - appliesTo: appliesToCouponField, }, }, conditions: couponConditions, @@ -169,6 +177,13 @@ const fixedProductModel = { * Promotion Type: Fixed Cart Discount (e.g. $10 off my cart) */ const fixedCartModel = { + appliesTo: { + labelText: translate( 'Applies to' ), + cssClass: 'promotions__promotion-form-card-applies-to', + fields: { + appliesTo: appliesToCouponField, + }, + }, couponCodeAndDiscount: { labelText: translate( 'Coupon Code & Discount' ), cssClass: 'promotions__promotion-form-card-primary', @@ -178,7 +193,6 @@ const fixedCartModel = { ...fixedDiscountField, labelText: translate( 'Cart Discount', { context: 'noun' } ), }, - appliesTo: appliesToCouponField, }, }, conditions: couponConditions, @@ -188,6 +202,13 @@ const fixedCartModel = { * Promotion Type: Percentage Cart Discount (e.g. 10% off my cart) */ const percentCartModel = { + appliesTo: { + labelText: translate( 'Applies to' ), + cssClass: 'promotions__promotion-form-card-applies-to', + fields: { + appliesTo: appliesToCouponField, + }, + }, couponCodeAndDiscount: { labelText: translate( 'Coupon Code & Discount' ), cssClass: 'promotions__promotion-form-card-primary', @@ -198,7 +219,6 @@ const percentCartModel = { labelText: translate( 'Percent Cart Discount', { context: 'noun' } ), isRequired: true, }, - appliesTo: appliesToCouponField, }, }, conditions: couponConditions, From 541d948e48d555e2423adcc41cecb134a37a3819 Mon Sep 17 00:00:00 2001 From: James Koster Date: Wed, 1 Nov 2017 13:47:22 +0000 Subject: [PATCH 162/192] 'Applies to' spacing --- .../extensions/woocommerce/app/promotions/fields/style.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/extensions/woocommerce/app/promotions/fields/style.scss b/client/extensions/woocommerce/app/promotions/fields/style.scss index 18b635c8b9cc9..037dbbe26fc3e 100644 --- a/client/extensions/woocommerce/app/promotions/fields/style.scss +++ b/client/extensions/woocommerce/app/promotions/fields/style.scss @@ -75,6 +75,10 @@ select + .promotion-applies-to-field__filtered-list { margin-top: 16px; + + &:empty { + margin-top: 0; + } } .promotion-applies-to-field__list { From 8540304da06ef703922b316d9e0000dd76ad8eda Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Wed, 1 Nov 2017 15:04:27 -0500 Subject: [PATCH 163/192] Promotion edit: Change Applies To -> Applies When This is a wording change specifically for coupons that apply to the cart, for better understanding of what's going on. --- .../app/promotions/promotion-models.js | 82 +++++++++++-------- 1 file changed, 50 insertions(+), 32 deletions(-) diff --git a/client/extensions/woocommerce/app/promotions/promotion-models.js b/client/extensions/woocommerce/app/promotions/promotion-models.js index 7004a788b28fd..296d921764f84 100644 --- a/client/extensions/woocommerce/app/promotions/promotion-models.js +++ b/client/extensions/woocommerce/app/promotions/promotion-models.js @@ -29,18 +29,54 @@ const couponCodeField = { }; /** - * "Applies to" field reused for all coupon promotion types. + * Coupon "Applies to" card. */ -const appliesToCouponField = { - component: ( - - ), +const appliesToCoupon = { + labelText: translate( 'Applies to' ), + cssClass: 'promotions__promotion-form-card-applies-to', + fields: { + appliesTo: { + component: ( + + ), + }, + }, +}; + +/** + * Coupon "Applies when" card. + */ +const appliesWhenCoupon = { + labelText: translate( 'Applies when' ), + cssClass: 'promotions__promotion-form-card-applies-to', + fields: { + appliesTo: { + component: ( + + ), + }, + }, }; /** @@ -152,13 +188,7 @@ const couponConditions = { * Promotion Type: Fixed Product Discount (e.g. $5 off any t-shirt) */ const fixedProductModel = { - appliesTo: { - labelText: translate( 'Applies to' ), - cssClass: 'promotions__promotion-form-card-applies-to', - fields: { - appliesTo: appliesToCouponField, - }, - }, + appliesToCoupon, couponCodeAndDiscount: { labelText: translate( 'Coupon Code & Discount' ), cssClass: 'promotions__promotion-form-card-primary', @@ -177,13 +207,7 @@ const fixedProductModel = { * Promotion Type: Fixed Cart Discount (e.g. $10 off my cart) */ const fixedCartModel = { - appliesTo: { - labelText: translate( 'Applies to' ), - cssClass: 'promotions__promotion-form-card-applies-to', - fields: { - appliesTo: appliesToCouponField, - }, - }, + appliesWhenCoupon, couponCodeAndDiscount: { labelText: translate( 'Coupon Code & Discount' ), cssClass: 'promotions__promotion-form-card-primary', @@ -202,13 +226,7 @@ const fixedCartModel = { * Promotion Type: Percentage Cart Discount (e.g. 10% off my cart) */ const percentCartModel = { - appliesTo: { - labelText: translate( 'Applies to' ), - cssClass: 'promotions__promotion-form-card-applies-to', - fields: { - appliesTo: appliesToCouponField, - }, - }, + appliesWhenCoupon, couponCodeAndDiscount: { labelText: translate( 'Coupon Code & Discount' ), cssClass: 'promotions__promotion-form-card-primary', From f4dfb19e8db71d300605dd22061c312614d66f13 Mon Sep 17 00:00:00 2001 From: Kirk Wight Date: Wed, 1 Nov 2017 14:29:51 -0700 Subject: [PATCH 164/192] Comments: Link the post list comments button to the comments post view. (#19245) --- client/blocks/post-actions/index.jsx | 33 ++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/client/blocks/post-actions/index.jsx b/client/blocks/post-actions/index.jsx index b1f96a1793502..d65f30a0b0bc9 100644 --- a/client/blocks/post-actions/index.jsx +++ b/client/blocks/post-actions/index.jsx @@ -15,13 +15,14 @@ import { localize } from 'i18n-calypso'; /** * Internal dependencies */ +import config from 'config'; import { recordGoogleEvent } from 'state/analytics/actions'; import PostRelativeTimeStatus from 'my-sites/post-relative-time-status'; import CommentButton from 'blocks/comment-button'; import LikeButton from 'my-sites/post-like-button'; import PostTotalViews from 'my-sites/posts/post-total-views'; import { canCurrentUser } from 'state/selectors'; -import { isJetpackModuleActive, isJetpackSite } from 'state/sites/selectors'; +import { isJetpackModuleActive, isJetpackSite, getSiteSlug } from 'state/sites/selectors'; import { getEditorPath } from 'state/ui/editor/selectors'; const getContentLink = ( state, siteId, post ) => { @@ -47,6 +48,7 @@ const PostActions = ( { showComments, showLikes, showStats, + siteSlug, toggleComments, trackRelativeTimeStatusOnClick, trackTotalViewsOnClick, @@ -67,14 +69,25 @@ const PostActions = ( { { ! isDraft && showComments && (
  • - + { config.isEnabled( 'comments/management/post-view' ) ? ( + + ) : ( + + ) }
  • ) } { ! isDraft && @@ -109,6 +122,7 @@ PostActions.propTypes = { const mapStateToProps = ( state, { siteId, post } ) => { const isJetpack = isJetpackSite( state, siteId ); + const siteSlug = getSiteSlug( state, siteId ); // TODO: Maybe add dedicated selectors for the following. const showComments = @@ -125,6 +139,7 @@ const mapStateToProps = ( state, { siteId, post } ) => { showComments, showLikes, showStats, + siteSlug, }; }; From 044336ccec8528f6592b6317777f283c3f267370 Mon Sep 17 00:00:00 2001 From: Kirk Wight Date: Wed, 1 Nov 2017 14:41:46 -0700 Subject: [PATCH 165/192] Boot: Remove XSS console warning message from the development environment. (#19355) --- server/pages/index.jade | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/pages/index.jade b/server/pages/index.jade index 14ad003192cb9..6d59e5c3674c5 100644 --- a/server/pages/index.jade +++ b/server/pages/index.jade @@ -240,7 +240,7 @@ html(lang=lang, dir=isRTL ? 'rtl' : 'ltr', class=isFluidWidth ? 'is-fluid-width' script. (function() { - if ( window.console ) { + if ( window.console && window.configData && 'development' !== window.configData.env ) { console.log( "%cSTOP!", "color:#f00;font-size:xx-large" ); console.log( "%cWait! This browser feature runs code that can alter your website or its security, " + From b8096f4887c59e2ecfe85a9aaae2c09b477d01e4 Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Wed, 1 Nov 2017 17:42:57 -0500 Subject: [PATCH 166/192] Promotions edit: Exclude variable products This excludes variable products from being directly selected in a promotion until we can properly support the variations. --- .../applies-to-filtered-list.js | 7 ++- .../app/promotions/promotion-models.js | 47 +++++++++++++------ 2 files changed, 39 insertions(+), 15 deletions(-) diff --git a/client/extensions/woocommerce/app/promotions/fields/promotion-applies-to-field/applies-to-filtered-list.js b/client/extensions/woocommerce/app/promotions/fields/promotion-applies-to-field/applies-to-filtered-list.js index c39a599d3b823..59df6db70ea52 100644 --- a/client/extensions/woocommerce/app/promotions/fields/promotion-applies-to-field/applies-to-filtered-list.js +++ b/client/extensions/woocommerce/app/promotions/fields/promotion-applies-to-field/applies-to-filtered-list.js @@ -287,8 +287,13 @@ function mapStateToProps( state ) { const products = getPromotionableProducts( state, siteId ); const productCategories = getProductCategories( state, siteId ); + // TODO: This is temporary until we can support variable products. + const nonVariableProducts = products && products.filter( + ( product ) => 'variable' !== product.type + ); + return { - products, + products: nonVariableProducts, productCategories, }; } diff --git a/client/extensions/woocommerce/app/promotions/promotion-models.js b/client/extensions/woocommerce/app/promotions/promotion-models.js index 296d921764f84..28916849bbcef 100644 --- a/client/extensions/woocommerce/app/promotions/promotion-models.js +++ b/client/extensions/woocommerce/app/promotions/promotion-models.js @@ -45,6 +45,11 @@ const appliesToCoupon = { ] } /> ), + // TODO: Remove this text after variable products are supported. + explanationText: translate( + 'Note: Variable products cannot be selected directly, ' + + 'only via Product Categories or All Products.' + ), }, }, }; @@ -75,6 +80,33 @@ const appliesWhenCoupon = { ] } /> ), + // TODO: Remove this text after variable products are supported. + explanationText: translate( + 'Note: Variable products cannot be selected directly, ' + + 'only via Product Categories or All Products.' + ), + }, + }, +}; + +/** + * Product sale "Applies to" card. + */ +const appliesToProductSale = { + labelText: translate( 'Applies to product' ), + cssClass: 'promotions__promotion-form-card-applies-to', + fields: { + appliesTo: { + component: ( + + ), + // TODO: Remove this text after variable products are supported. + explanationText: translate( + 'Note: Variable products cannot be selected.' + ), }, }, }; @@ -110,20 +142,7 @@ const endDate = { * Promotion Type: Product Sale (e.g. $5 off the "I <3 Robots" t-shirt) */ const productSaleModel = { - appliesTo: { - labelText: translate( 'Applies to product' ), - cssClass: 'promotions__promotion-form-card-applies-to', - fields: { - appliesTo: { - component: ( - - ), - }, - }, - }, + appliesToProductSale, productAndSalePrice: { labelText: translate( 'Product & Sale Price' ), cssClass: 'promotions__promotion-form-card-primary', From 0b0fa9d47babfc8b91a7e21662bc2b09fd5e3d24 Mon Sep 17 00:00:00 2001 From: Tugdual de Kerviler Date: Thu, 2 Nov 2017 10:03:28 +0100 Subject: [PATCH 167/192] Do not rewrite context.path for external urls --- client/boot/common.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/client/boot/common.js b/client/boot/common.js index 7aaf7bd236804..f65c7fbbdc7de 100644 --- a/client/boot/common.js +++ b/client/boot/common.js @@ -40,7 +40,12 @@ const setupContextMiddleware = reduxStore => { const parsed = url.parse( context.canonicalPath, true ); context.pathname = parsed.pathname; context.prevPath = parsed.path === context.path ? false : parsed.path; - context.path = parsed.path; + + // allow external urls to pass through, by not rewriting context.path in this case + if ( ! startsWith( context.path, 'http' ) ) { + context.path = parsed.path; + } + context.query = parsed.query; context.hashstring = ( parsed.hash && parsed.hash.substring( 1 ) ) || ''; From d97d505fc3d211b7626d0ea4031909241f0b6671 Mon Sep 17 00:00:00 2001 From: Tugdual de Kerviler Date: Thu, 2 Nov 2017 10:14:11 +0100 Subject: [PATCH 168/192] Make it safer by testint the whole scheme part --- client/boot/common.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/boot/common.js b/client/boot/common.js index f65c7fbbdc7de..18982e66547fe 100644 --- a/client/boot/common.js +++ b/client/boot/common.js @@ -42,7 +42,7 @@ const setupContextMiddleware = reduxStore => { context.prevPath = parsed.path === context.path ? false : parsed.path; // allow external urls to pass through, by not rewriting context.path in this case - if ( ! startsWith( context.path, 'http' ) ) { + if ( /^(?!https?:\/\/)/.test( context.path ) ) { context.path = parsed.path; } From 4ecfbeccc45e8fb101bbff0f0ccb2d3ffce0046c Mon Sep 17 00:00:00 2001 From: Yoav Farhi Date: Thu, 2 Nov 2017 12:22:45 +0200 Subject: [PATCH 169/192] Checkout: actually launch iDEAL in production (#19386) * iDEAL: launch in production * add front end safeguard to make sure ideal is only shown on euro payments --- client/lib/cart-values/index.js | 3 ++- config/production.json | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/client/lib/cart-values/index.js b/client/lib/cart-values/index.js index b70b74bf4174a..a88f8c5dd073f 100644 --- a/client/lib/cart-values/index.js +++ b/client/lib/cart-values/index.js @@ -165,7 +165,8 @@ function isPayPalExpressEnabled( cart ) { function isNetherlandsIdealEnabled( cart ) { return ( config.isEnabled( 'upgrades/netherlands-ideal' ) && - cart.allowed_payment_methods.indexOf( 'WPCOM_Billing_Stripe_Source_Ideal' ) >= 0 + cart.allowed_payment_methods.indexOf( 'WPCOM_Billing_Stripe_Source_Ideal' ) >= 0 && + 'EUR' === cart.currency ); } diff --git a/config/production.json b/config/production.json index 3ae89a4c28fbf..5bc310da8b2bb 100644 --- a/config/production.json +++ b/config/production.json @@ -118,6 +118,7 @@ "upgrades/credit-cards": true, "upgrades/domain-search": true, "upgrades/in-app-purchase": false, + "upgrades/netherlands-ideal": true, "upgrades/paypal": true, "upgrades/premium-themes": true, "upgrades/removal-survey": true, From 58e636a3774daa3650152504a022719e88290eaf Mon Sep 17 00:00:00 2001 From: Bernie Reiter Date: Thu, 2 Nov 2017 11:35:31 +0100 Subject: [PATCH 170/192] Components: Remove isMounted() from `lib/accept` (#19000) And turn into React.Component. `AcceptDialog`'s `onClose` prop is [provided by](https://github.com/Automattic/wp-calypso/blob/60ea236ee15be699b2e7a20fb668e5f920208dc5/client/lib/accept/index.js#L19-L29) `lib/accept/index.js` and unmounts this component, so it shouldn't come as a surprise that after this line has run, the component is no longer mounted. Moving `setState` before unmounting should fix this. --- client/lib/accept/dialog.jsx | 38 ++++++++++++++---------------------- 1 file changed, 15 insertions(+), 23 deletions(-) diff --git a/client/lib/accept/dialog.jsx b/client/lib/accept/dialog.jsx index dfef3b07785ef..af77a48338ca3 100644 --- a/client/lib/accept/dialog.jsx +++ b/client/lib/accept/dialog.jsx @@ -1,12 +1,9 @@ +/** @format */ /** * External dependencies - * - * @format */ - +import React, { Component } from 'react'; import PropTypes from 'prop-types'; -import React from 'react'; -import createReactClass from 'create-react-class'; import { localize } from 'i18n-calypso'; import classnames from 'classnames'; @@ -15,31 +12,26 @@ import classnames from 'classnames'; */ import Dialog from 'components/dialog'; -const AcceptDialog = createReactClass( { - displayName: 'AcceptDialog', +class AcceptDialog extends Component { + static displayName = 'AcceptDialog'; - propTypes: { + static propTypes = { translate: PropTypes.func, message: PropTypes.node, onClose: PropTypes.func.isRequired, confirmButtonText: PropTypes.node, cancelButtonText: PropTypes.node, options: PropTypes.object, - }, + }; - getInitialState: function() { - return { isVisible: true }; - }, + state = { isVisible: true }; - onClose: function( action ) { + onClose = action => { + this.setState( { isVisible: false } ); this.props.onClose( 'accept' === action ); + }; - if ( this.isMounted() ) { - this.setState( { isVisible: false } ); - } - }, - - getActionButtons: function() { + getActionButtons = () => { const { options } = this.props; const isScary = options && options.isScary; const additionalClassNames = classnames( { 'is-scary': isScary } ); @@ -59,9 +51,9 @@ const AcceptDialog = createReactClass( { additionalClassNames, }, ]; - }, + }; - render: function() { + render() { if ( ! this.state.isVisible ) { return null; } @@ -76,7 +68,7 @@ const AcceptDialog = createReactClass( { { this.props.message } ); - }, -} ); + } +} export default localize( AcceptDialog ); From 26df7b68a78096bbd50b555d5cad61e89e1e28c0 Mon Sep 17 00:00:00 2001 From: Mikael Korpela Date: Wed, 1 Nov 2017 22:47:33 +0200 Subject: [PATCH 171/192] Codemods: add howto instructions to readme - Instructs where to put new codemod files - Gives a stub to start with - Links to brilliant astexplorer.net --- bin/codemods/README.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/bin/codemods/README.md b/bin/codemods/README.md index 1e0a0336222df..9f35ae1a62c1c 100644 --- a/bin/codemods/README.md +++ b/bin/codemods/README.md @@ -4,6 +4,30 @@ Code modification scripts, also known as codemods, are transformation scripts that can simultaneously modify multiple files with precision and reliability. Codemods were popularized by [Facebook's engineering team](https://medium.com/@cpojer/effective-javascript-codemods-5a6686bb46fb) and depends greatly on Facebook's [jscodeshift](https://github.com/facebook/jscodeshift) library, which wraps over a library named [recast](https://github.com/benjamn/recast) (author of which is associated with the [Meteor](https://www.meteor.com/) project). +## How to write codemods + +Place your codemod under `src` folder: +```bash +touch ./bin/codemods/src/your-transformation-name.js +``` + +Here's a stub to begin with: +```js +const config = require( './config' ); + +export default function transformer( file, api ) { + const j = api.jscodeshift; + const root = j( file.source ); + + // Modify file's AST (Abstract Syntax Tree) structure here + + return root.toSource( config.recastOptions ); +} +``` + +A nifty tool to explore AST structures is [AST explorer](https://astexplorer.net/). +You can choose "esprima" as a parser as that's what Recast uses internally. + ## How to run our codemods It's easy! Our codemod script uses the following CLI: From 84dcb4019fdd1cd16a7986e6eb9deba82e418909 Mon Sep 17 00:00:00 2001 From: Mikael Korpela Date: Wed, 1 Nov 2017 23:10:38 +0200 Subject: [PATCH 172/192] Instruct to use recast and jscodeshift from AST Explorer --- bin/codemods/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/codemods/README.md b/bin/codemods/README.md index 9f35ae1a62c1c..1507953af68db 100644 --- a/bin/codemods/README.md +++ b/bin/codemods/README.md @@ -26,7 +26,7 @@ export default function transformer( file, api ) { ``` A nifty tool to explore AST structures is [AST explorer](https://astexplorer.net/). -You can choose "esprima" as a parser as that's what Recast uses internally. +You can choose "recast" as a parser and "jscodeshift" from "Transform" menu. ## How to run our codemods From 1570162a79c57482b041acc2e6cc62387ad135d9 Mon Sep 17 00:00:00 2001 From: Mikael Korpela Date: Thu, 2 Nov 2017 12:57:28 +0200 Subject: [PATCH 173/192] Fix codemods config export Fixes importing `recastOptions` from `config.js` in codemods. Pretty much all codemods do this currently: ``` const config = require( './config' ); root.toSource( config.recastOptions ) ``` But in above code `config.recastOptions` would be `undefined`, causing Recast use its default options for printing out code. By adding `recastOptions` to exports, codemods are able to pick it up. --- bin/codemods/src/config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/codemods/src/config.js b/bin/codemods/src/config.js index 0f16ec4006b53..875544f4991b3 100644 --- a/bin/codemods/src/config.js +++ b/bin/codemods/src/config.js @@ -62,4 +62,5 @@ const codemodArgs = { module.exports = { codemodArgs, jscodeshiftArgs, + recastOptions, }; From a481180e2985e303897291002c9105f838dc3789 Mon Sep 17 00:00:00 2001 From: Tugdual de Kerviler Date: Thu, 2 Nov 2017 14:32:18 +0100 Subject: [PATCH 174/192] Use page.js version of path and pathname for the context --- client/boot/common.js | 7 ------- 1 file changed, 7 deletions(-) diff --git a/client/boot/common.js b/client/boot/common.js index 18982e66547fe..ce4de923195c5 100644 --- a/client/boot/common.js +++ b/client/boot/common.js @@ -38,14 +38,7 @@ const setupContextMiddleware = reduxStore => { page( '*', ( context, next ) => { // page.js url parsing is broken so we had to disable it with `decodeURLComponents: false` const parsed = url.parse( context.canonicalPath, true ); - context.pathname = parsed.pathname; context.prevPath = parsed.path === context.path ? false : parsed.path; - - // allow external urls to pass through, by not rewriting context.path in this case - if ( /^(?!https?:\/\/)/.test( context.path ) ) { - context.path = parsed.path; - } - context.query = parsed.query; context.hashstring = ( parsed.hash && parsed.hash.substring( 1 ) ) || ''; From d07773c463a7bebc78bd437c69cc22fb05125194 Mon Sep 17 00:00:00 2001 From: Elio Rivero Date: Thu, 2 Nov 2017 10:44:52 -0300 Subject: [PATCH 175/192] Activity Log: fix positioning of last step tooltip so it's properly aligned below ellipsis menu (#19347) --- client/layout/guided-tours/tours/activity-log-tour.js | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/client/layout/guided-tours/tours/activity-log-tour.js b/client/layout/guided-tours/tours/activity-log-tour.js index fc8d26f91b8fc..e161169d6756c 100644 --- a/client/layout/guided-tours/tours/activity-log-tour.js +++ b/client/layout/guided-tours/tours/activity-log-tour.js @@ -86,8 +86,12 @@ export const ActivityLogTour = makeTour(

    { translate( From 52ad4d2c88dc51b030389259c5e2fc0d03c21078 Mon Sep 17 00:00:00 2001 From: Kelly Dwan Date: Thu, 2 Nov 2017 10:00:25 -0400 Subject: [PATCH 176/192] Store Products: Give the variation price a little more space (#19288) --- client/extensions/woocommerce/app/products/product-form.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/client/extensions/woocommerce/app/products/product-form.scss b/client/extensions/woocommerce/app/products/product-form.scss index a50ea0515527d..7ed4495756782 100644 --- a/client/extensions/woocommerce/app/products/product-form.scss +++ b/client/extensions/woocommerce/app/products/product-form.scss @@ -447,6 +447,10 @@ min-width: 80px; } +.products__product-form-variation-table-wrapper .form-text-input-with-affixes .form-currency-input { + min-width: 100px; +} + .products__product-form-variation-table-wrapper { .form-dimensions-input__length, .form-dimensions-input__width, From 80ef0d648b4b61537cca506e3cf1e3beebfdd363 Mon Sep 17 00:00:00 2001 From: Michael Arestad Date: Thu, 2 Nov 2017 08:50:41 -0600 Subject: [PATCH 177/192] Activity Log - added discarded styles and fixed up other bugs (#19411) This is a bit of a doozy for style changes. I tackled a bunch while I was in there since I had to shuffle some things around anyway. #### Notes * removed unused styles * refactored for simpler styling * removed unused div * added classes to be more explicit with selectors * added discarded styles: Fixes https://github.com/Automattic/wp-calypso/issues/19132 https://github.com/Automattic/wp-calypso/issues/18759 * rebuilt timelines to be friendlier to work with (and use fewer elements) * converted em to span in the rewind button * aligned items to their icons better * made text colors a bit more accessible * made sure text sizes matched our standards * changed color of Jetpack icon: fixes https://github.com/Automattic/wp-calypso/issues/18862 * Removed overflowing line: Fixes https://github.com/Automattic/wp-calypso/issues/17065 --- .../my-sites/stats/activity-log-day/index.jsx | 33 ++--- .../stats/activity-log-day/style.scss | 133 ++---------------- .../stats/activity-log-item/style.scss | 130 +++++++++-------- client/my-sites/stats/activity-log/style.scss | 32 +++-- 4 files changed, 123 insertions(+), 205 deletions(-) diff --git a/client/my-sites/stats/activity-log-day/index.jsx b/client/my-sites/stats/activity-log-day/index.jsx index e1f49f10d8ffb..428c62e399009 100644 --- a/client/my-sites/stats/activity-log-day/index.jsx +++ b/client/my-sites/stats/activity-log-day/index.jsx @@ -157,8 +157,8 @@ class ActivityLogDay extends Component { primary={ 'primary' === type } > {' '} - { this.props.translate( 'Rewind {{em}}to this day{{/em}}', { - components: { em: }, + { this.props.translate( 'Rewind {{span}}to this day{{/span}}', { + components: { span: }, } ) } ); @@ -243,26 +243,23 @@ class ActivityLogDay extends Component { ); return ( -

    - - { newer.map( log => ) } - { above && } - { older.length > 0 && rewindConfirmDialog } - { older.map( log => ) } - -
    + { newer.map( log => ) } + { above && } + { older.length > 0 && rewindConfirmDialog } + { older.map( log => ) } + ); } } diff --git a/client/my-sites/stats/activity-log-day/style.scss b/client/my-sites/stats/activity-log-day/style.scss index 0ebaa7232a553..5ba8eef7e09a7 100644 --- a/client/my-sites/stats/activity-log-day/style.scss +++ b/client/my-sites/stats/activity-log-day/style.scss @@ -1,77 +1,21 @@ // Activity Log .activity-log-day { - position: relative; - z-index: 2; + background: none; + box-shadow: none; - .card { - background: transparent; - - .foldable-card__content { - padding: 0 0 16px; - background: transparent; - } - } - - &.is-empty { - .card { - box-shadow: none; - } - - .foldable-card__header { - background: $gray-light; - min-height: initial; - padding-bottom: 4px; - padding-top: 4px; - } - } - - .foldable-card__header { + > .foldable-card__header { background: $white; - - .foldable-card__secondary { - flex: 2; - } - } - - .foldable-card__main { - @include breakpoint( "<480px" ) { - flex: 3 1; - } - } - - .foldable-card.card.is-expanded { - box-shadow: none; - margin: 0; - - .foldable-card__header { - box-shadow: 0 0 0 1px transparentize( lighten( $gray, 20% ), .5 ), + box-shadow: 0 0 0 1px transparentize( lighten( $gray, 20% ), .5 ), 0 1px 2px lighten( $gray, 30% ); - } } -} -.activity-log-day__day { - font-weight: 600; -} - -.activity-log-day__rewind-button { - em { - font-style: normal; - - @include breakpoint( "<960px" ) { - display: none; - } + > .foldable-card__content.foldable-card__content { // Sad panda specificity override + padding: 24px 0 16px; + background: transparent; } } -.activity-log-day__events { - font-size: 12px; - color: $gray; -} - .activity-log-day__placeholder { - @extend .activity-log-day; - .activity-log-day__day, .activity-log-day__events { @include placeholder(); @@ -87,64 +31,17 @@ } } -.activity-log-day, .activity-log-item { - position: relative; - - &:before { - content: ""; - position: absolute; - top: 73px; - height: 16px; - left: 33px; - width: 2px; - border-left: 2px solid lighten( $gray, 20% ); - - @include breakpoint( "<480px" ) { - left: 29px; - } - } -} - -.activity-log-item.is-discarded:before { - border-left-style: dotted; -} - -.activity-log-day.is-empty:before { - top: 48px; -} - -.activity-log-day:last-of-type:before { - width: 0; +.activity-log-day__day { + font-weight: 600; } -.activity-log-item:before { - bottom: -16px; - height: auto; - - @include breakpoint( "<480px" ) { - left: 21px; +.activity-log-day__rewind-button-extra-text { + @include breakpoint( "<960px" ) { + display: none; } } -.activity-log-item__restore-confirm:before { - bottom: 0; - height: auto; - top: 57px; -} - -.activity-log-item.is-before-dialog:before { - content: none; -} -.has-rewind-dialog .activity-log-item__restore-confirm:first-child:after { - content: " "; - position: absolute; - top: -24px; - left: 33px; - height: 20px; - width: 4px; - background: $gray-light; - - @include breakpoint( "<480px" ) { - left: 21px; - } +.activity-log-day__events { + font-size: 12px; + color: $gray-text-min; } diff --git a/client/my-sites/stats/activity-log-item/style.scss b/client/my-sites/stats/activity-log-item/style.scss index f2582ed47cba7..931e3af521693 100644 --- a/client/my-sites/stats/activity-log-item/style.scss +++ b/client/my-sites/stats/activity-log-item/style.scss @@ -1,39 +1,18 @@ .activity-log-item { display: flex; - flex-direction: row; - flex-wrap: wrap; - justify-content: flex-start; - align-content: flex-start; - align-items: center; position: relative; @include breakpoint( "<480px" ) { margin-left: 8px; } - // TODO: Remove when foldable cards become "expandable" - .foldable-card__header.is-clickable { - cursor: default; - } - - &:first-of-type { - margin-top: 24px; - } - .card { flex: 1; + margin-top: 8px; margin-bottom: 16px; - background: $white; - } - - .activity-log-item__card .foldable-card__content { - padding: 16px; - font-size: 14px; - color: darken( $gray, 20% ); } .foldable-card { - .foldable-card__expand .gridicon { transform: none; } @@ -51,18 +30,42 @@ @include breakpoint( "<480px" ) { padding: 12px; } + + // TODO: Remove when foldable cards become "expandable" + .is-clickable { + cursor: default; + } } - .foldable-card__main { - flex: 5 1; + .foldable-card__secondary { + flex-grow: 0; } &.is-discarded { margin-left: 80px; - } - &.is-discarded * { - background-color: unset; + @include breakpoint( "<480px" ) { + margin-left: 60px; + } + + &:before { + content: ''; + position: absolute; + top: 0; + left: 33px; + height: 100%; + border-left: 2px dotted lighten( $gray, 20% ); + + @include breakpoint( "<480px" ) { + left: 22px; + } + } + + &:last-of-type { + &:before { + display: none; + } + } } } @@ -72,6 +75,7 @@ text-align: center; margin: -2px 22px 0 10px; padding: 4px 0 6px; + z-index: 1; @include breakpoint( "<480px" ) { margin: -2px 8px 0 0px; @@ -80,7 +84,7 @@ .activity-log-item__time { font-size: 11px; - color: $gray; + color: $gray-text-min; text-transform: uppercase; white-space: nowrap; @@ -116,12 +120,31 @@ &.is-error { background: $alert-red; } + + .is-discarded & { + color: $gray-text; + background: none; + border: 1px solid transparentize( lighten( $gray, 20% ), .5 ); + } +} + +.activity-log-item__card { + .foldable-card__content { + padding: 16px; + font-size: 14px; + color: darken( $gray, 20% ); + } + + .is-discarded & { + background: none; + box-shadow: 0 0 0 1px transparentize( lighten( $gray, 20% ), .5 ); + } } .activity-log-item__card-header { display: flex; - flex: 1; - align-items: center; + flex-wrap: wrap; + margin-right: 32px; @include breakpoint( "<960px" ) { flex-direction: column; @@ -131,28 +154,34 @@ .activity-log-item__actor { display: flex; - flex: 3 1; - align-items: center; + flex-shrink: 0; margin-right: 32px; - @include breakpoint( "<960px" ) { - order: 2; - } - .gravatar, .jetpack-logo { float: left; + width: 40px; + height: 40px; margin-right: 16px; + fill: darken( $gray, 10% ); @include breakpoint( "<960px" ) { - width: 24px; - height: 24px; + width: 32px; + height: 32px; margin-right: 8px; } } - .jetpack-logo path { - fill: $green-jetpack; + .gravatar { + .is-discarded & { + filter: grayscale(1); + } + } + + .jetpack-logo { + .is-discarded & { + opacity: .75; + } } } @@ -160,34 +189,23 @@ font-size: 14px; @include breakpoint( "<960px" ) { - font-size: 13px; + margin-top: 2px; + line-height: 1; } } .activity-log-item__actor-role { text-transform: capitalize; font-size: 12px; - color: $gray; + color: $gray-text-min; } .activity-log-item__title { - flex: 4 1; + flex: 1 1 50%; font-size: 14px; word-break: break-word; @include breakpoint( "<960px" ) { - margin-bottom: 16px; + margin-top: 8px; } } - -.activity-log-item__action { - flex-basis: 25%; - text-align:right; -} - -.activity-log-item__id { - margin-top: 8px; - font-size: 11px; - font-style: italic; - color: lighten( $gray, 20% ); -} diff --git a/client/my-sites/stats/activity-log/style.scss b/client/my-sites/stats/activity-log/style.scss index 770f645cb37a4..04c225a4d7658 100644 --- a/client/my-sites/stats/activity-log/style.scss +++ b/client/my-sites/stats/activity-log/style.scss @@ -1,5 +1,23 @@ // Activity Log +.activity-log__wrapper { + position: relative; + + &:before { + content: ''; + position: absolute; + top: 0; + left: 33px; + height: 100%; + width: 2px; + background: lighten( $gray, 20% ); + + @include breakpoint( "<480px" ) { + left: 29px; + } + } +} + .activity-log__rewind-toggle { margin-bottom: 18px; } @@ -11,18 +29,6 @@ } } -.rewind-requested .activity-log-day:not(.has-rewind-dialog), -.has-rewind-dialog .activity-log-item, -.has-rewind-dialog > .foldable-card > .foldable-card__header { - opacity: .5; -} - -.has-rewind-dialog .activity-log-item__restore-confirm, -.activity-log-item__restore-confirm ~ .activity-log-item, -.rewind-requested .has-rewind-dialog ~ .activity-log-day { - opacity: 1; -} - .activity-log__empty-day { background: $gray-light; width: 100%; @@ -38,5 +44,5 @@ .activity-log__empty-day-events { font-size: 12px; - color: $gray; + color: $gray-text-min; } From e488a6d0b59c35d4ad5aa1cc10a33ff3746306d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9s?= Date: Thu, 2 Nov 2017 15:56:49 +0100 Subject: [PATCH 178/192] Fixes #922 - Dispatch eventMessage only when necessary (#19417) Dispatch only if client is connected and chat is already assigned. --- client/state/happychat/middleware.js | 15 +++- client/state/happychat/test/middleware.js | 101 ++++++++++++++++++++++ 2 files changed, 115 insertions(+), 1 deletion(-) diff --git a/client/state/happychat/middleware.js b/client/state/happychat/middleware.js index 15a770530fb7f..1485378e351fa 100644 --- a/client/state/happychat/middleware.js +++ b/client/state/happychat/middleware.js @@ -1,5 +1,10 @@ /** @format */ +/** + * External dependencies + */ +import { noop } from 'lodash'; + /** * Internal dependencies */ @@ -17,6 +22,8 @@ import { } from 'state/action-types'; import { sendEvent } from 'state/happychat/connection/actions'; import buildConnection from 'lib/happychat/connection'; +import isHappychatClientConnected from 'state/happychat/selectors/is-happychat-client-connected'; +import isHappychatChatAssigned from 'state/happychat/selectors/is-happychat-chat-assigned'; const eventMessage = { HAPPYCHAT_BLUR: 'Stopped looking at Happychat', @@ -51,9 +58,15 @@ export const socketMiddleware = ( connection = null ) => { case HAPPYCHAT_BLUR: case HAPPYCHAT_FOCUS: - store.dispatch( sendEvent( eventMessage[ action.type ] ) ); + const state = store.getState(); + isHappychatClientConnected( state ) && + isHappychatChatAssigned( state ) && + eventMessage[ action.type ] + ? store.dispatch( sendEvent( eventMessage[ action.type ] ) ) + : noop; break; } + return next( action ); }; }; diff --git a/client/state/happychat/test/middleware.js b/client/state/happychat/test/middleware.js index 8fb745cd639c7..1ea83015dd939 100644 --- a/client/state/happychat/test/middleware.js +++ b/client/state/happychat/test/middleware.js @@ -15,6 +15,24 @@ import { sendTyping, sendNotTyping, } from 'state/happychat/connection/actions'; +import { blur, focus } from 'state/happychat/ui/actions'; +import { + HAPPYCHAT_CHAT_STATUS_ABANDONED, + HAPPYCHAT_CHAT_STATUS_ASSIGNED, + HAPPYCHAT_CHAT_STATUS_ASSIGNING, + HAPPYCHAT_CHAT_STATUS_BLOCKED, + HAPPYCHAT_CHAT_STATUS_CLOSED, + HAPPYCHAT_CHAT_STATUS_DEFAULT, + HAPPYCHAT_CHAT_STATUS_MISSED, + HAPPYCHAT_CHAT_STATUS_NEW, + HAPPYCHAT_CHAT_STATUS_PENDING, + HAPPYCHAT_CONNECTION_STATUS_CONNECTED, + HAPPYCHAT_CONNECTION_STATUS_CONNECTING, + HAPPYCHAT_CONNECTION_STATUS_DISCONNECTED, + HAPPYCHAT_CONNECTION_STATUS_RECONNECTING, + HAPPYCHAT_CONNECTION_STATUS_UNAUTHORIZED, + HAPPYCHAT_CONNECTION_STATUS_UNINITIALIZED, +} from 'state/happychat/constants'; describe( 'middleware', () => { let actionMiddleware, connection, store; @@ -92,4 +110,87 @@ describe( 'middleware', () => { expect( connection.request ).toHaveBeenCalledWith( action, action.timeout ); } ); } ); + + describe( 'eventMessage', () => { + const state = { + happychat: { + connection: { + status: HAPPYCHAT_CONNECTION_STATUS_CONNECTED, + }, + chat: { + status: HAPPYCHAT_CHAT_STATUS_ASSIGNED, + }, + }, + }; + + describe( 'is dispatched if client is connected, chat is assigned, and there is a message for the action', () => { + test( 'for HAPPYCHAT_BLUR', () => { + store.getState.mockReturnValue( state ); + const action = blur(); + actionMiddleware( action ); + + expect( store.dispatch.mock.calls[ 0 ][ 0 ].event ).toBe( 'message' ); + expect( store.dispatch.mock.calls[ 0 ][ 0 ].payload.text ).toBe( + 'Stopped looking at Happychat' + ); + } ); + + test( 'for HAPPYCHAT_FOCUS', () => { + store.getState.mockReturnValue( state ); + const action = focus(); + actionMiddleware( action ); + + expect( store.dispatch.mock.calls[ 0 ][ 0 ].event ).toBe( 'message' ); + expect( store.dispatch.mock.calls[ 0 ][ 0 ].payload.text ).toBe( + 'Started looking at Happychat' + ); + } ); + } ); + + test( 'is not dispatched if client is not connected', () => { + [ + HAPPYCHAT_CONNECTION_STATUS_CONNECTING, + HAPPYCHAT_CONNECTION_STATUS_DISCONNECTED, + HAPPYCHAT_CONNECTION_STATUS_RECONNECTING, + HAPPYCHAT_CONNECTION_STATUS_UNAUTHORIZED, + HAPPYCHAT_CONNECTION_STATUS_UNINITIALIZED, + ].forEach( connectionStatus => { + store.getState.mockReturnValue( + Object.assign( state, { happychat: { connection: { status: connectionStatus } } } ) + ); + const action = blur(); + actionMiddleware( action ); + + expect( store.dispatch ).not.toHaveBeenCalled(); + } ); + } ); + + test( 'is not dispatched if chat is not assigned', () => { + [ + HAPPYCHAT_CHAT_STATUS_ABANDONED, + HAPPYCHAT_CHAT_STATUS_ASSIGNING, + HAPPYCHAT_CHAT_STATUS_BLOCKED, + HAPPYCHAT_CHAT_STATUS_CLOSED, + HAPPYCHAT_CHAT_STATUS_DEFAULT, + HAPPYCHAT_CHAT_STATUS_NEW, + HAPPYCHAT_CHAT_STATUS_MISSED, + HAPPYCHAT_CHAT_STATUS_PENDING, + ].forEach( chatStatus => { + store.getState.mockReturnValue( + Object.assign( state, { happychat: { chat: { status: chatStatus } } } ) + ); + const action = blur(); + actionMiddleware( action ); + + expect( store.dispatch ).not.toHaveBeenCalled(); + } ); + } ); + + test( 'is not dispatched if there is no message defined', () => { + store.getState.mockReturnValue( state ); + const action = { type: 'HAPPYCHAT_ACTION_WITHOUT_EVENT_MESSAGE_DEFINED' }; + actionMiddleware( action ); + expect( store.dispatch ).not.toHaveBeenCalled(); + } ); + } ); } ); From 6b877cc4c23f2c7080724aaafe25f80ebcf538c2 Mon Sep 17 00:00:00 2001 From: Jacopo Tomasone Date: Thu, 2 Nov 2017 15:50:16 +0000 Subject: [PATCH 179/192] Comments Redesign: Add missing forceWpcom to QueryComment (#19422) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring the `forceWpcom` prop from the old `CommentDetail` onto the new `Comment` component, in order to force requesting comments data via the WPCOM API even on JP sites. --- client/my-sites/comments/comment/index.jsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/client/my-sites/comments/comment/index.jsx b/client/my-sites/comments/comment/index.jsx index 65d910b8b7869..a2f0b3e039eb3 100644 --- a/client/my-sites/comments/comment/index.jsx +++ b/client/my-sites/comments/comment/index.jsx @@ -97,7 +97,9 @@ export class Comment extends Component { ref={ this.storeCardRef } tabIndex="0" > - { refreshCommentData && } + { refreshCommentData && ( + + ) } { ! isEditMode && (
    From 65965445a81cb20ee954534a49dea4fb8c01f77c Mon Sep 17 00:00:00 2001 From: Rob Landers Date: Thu, 2 Nov 2017 12:21:18 -0400 Subject: [PATCH 180/192] Enable jitms in staging (#19295) --- config/horizon.json | 2 +- config/stage.json | 2 +- config/wpcalypso.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/horizon.json b/config/horizon.json index 7392d5e4bc949..fbfa18055b6ba 100644 --- a/config/horizon.json +++ b/config/horizon.json @@ -38,7 +38,7 @@ "jetpack/api-cache": true, "jetpack/happychat": true, "jetpack_core_inline_update": true, - "jitms": false, + "jitms": true, "keyboard-shortcuts": true, "login/native-login-links": true, "login/wp-login": true, diff --git a/config/stage.json b/config/stage.json index feb40e91167fe..43d2513c6cf1c 100644 --- a/config/stage.json +++ b/config/stage.json @@ -43,7 +43,7 @@ "jetpack/google-analytics-anonymize-ip": true, "jetpack/google-analytics-for-stores": true, "jetpack_core_inline_update": true, - "jitms": false, + "jitms": true, "login/magic-login": true, "login/native-login-links": true, "login/wp-login": true, diff --git a/config/wpcalypso.json b/config/wpcalypso.json index 3d1525ef41610..5e9b759227021 100644 --- a/config/wpcalypso.json +++ b/config/wpcalypso.json @@ -43,7 +43,7 @@ "jetpack/google-analytics-anonymize-ip": true, "jetpack/google-analytics-for-stores": true, "jetpack_core_inline_update": true, - "jitms": false, + "jitms": true, "login/magic-login": true, "login/native-login-links": true, "login/wp-login": true, From 13ed152040dd08b1206f4dfef9491c8a4720e579 Mon Sep 17 00:00:00 2001 From: Andrei Demian Date: Thu, 2 Nov 2017 18:43:21 +0200 Subject: [PATCH 181/192] Start happychat connection only after we get sites info (#17028) --- client/me/help/help-contact/index.jsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/client/me/help/help-contact/index.jsx b/client/me/help/help-contact/index.jsx index c4dac4199ce0e..8bca8da97db39 100644 --- a/client/me/help/help-contact/index.jsx +++ b/client/me/help/help-contact/index.jsx @@ -732,7 +732,7 @@ class HelpContact extends React.Component { > { this.getView() } - + { this.props.shouldStartHappychatConnection && } @@ -760,6 +760,7 @@ export default connect( ticketSupportEligible: isTicketSupportEligible( state ), ticketSupportRequestError: getTicketSupportRequestError( state ), hasMoreThanOneSite: getCurrentUserSiteCount( state ) > 1, + shouldStartHappychatConnection: ! isRequestingSites( state ) && helpSelectedSiteId, isRequestingSites: isRequestingSites( state ), isSelectedHelpSiteOnPaidPlan: isCurrentPlanPaid( state, helpSelectedSiteId ), selectedSitePlanSlug: selectedSitePlan && selectedSitePlan.product_slug, From fa7d4c162a34fd0a7427356d3f5f2b7a8a476303 Mon Sep 17 00:00:00 2001 From: Jarda Snajdr Date: Thu, 2 Nov 2017 18:40:53 +0100 Subject: [PATCH 182/192] Atomic Store: Wire the store-nux flow into production signup (#19425) * Atomic Store: Wire the store-nux flow into production signup This patch does the following two things: **Offer the `store` design type on all flows that have the `design-type` step** Depending on the `signup/atomic-store-flow` feature flag, replace the `design-type` step with either `design-type-with-store` or `design-type-with-store-nux`. Before this patch, store was offered only for new users with EN locale or for users who explicitly came to the `/start/store` URL. We're removing these limiting conditions. For the `store` and `pressable` flows, the design type steps continues to be `design-type-with-store`. That means always Pressable and never Atomic Store, no matter what the `signup/atomic-store-flow` flag says. **If `store` design type is chosen, redirect to the `store-nux` flow** When the `store` design type is chosen in the `design-type-with-store-nux` step, we force a redirect to the `store-nux` flow. The old flow name is forgotten. We achieve that by adding a new optional `flowName` or `nextFlowName` parameter to the `Signup.goToNextStep` and `Signup.goToStep` methods. * Atomic Store: save the flow name to local storage after redirect to another flow If I start with the `main` flow, select `store` and get redirected to `store-nux`, the new flow name should be saved to local storage key `signupFlowName`. Until now, the flow name was saved only when the `Signup` component is first mounted, in the `SignupFlowController` constructor. We need to save it on every flow change. * Atomic Store: make the flow-redirecting code clearer * Atomic Store: if Atomic Store is not enabled, keep the old behavior of showing store only to new EN users * Atomic Store: use store-nux in the 'store' flow, too * Atomic Store: code cleanup --- client/lib/signup/flow-controller.js | 6 +++ client/signup/config/flows.js | 41 +++++++++++++------ client/signup/main.jsx | 21 ++++++---- .../design-type-with-atomic-store/index.jsx | 5 ++- 4 files changed, 53 insertions(+), 20 deletions(-) diff --git a/client/lib/signup/flow-controller.js b/client/lib/signup/flow-controller.js index a08f28efc499d..39f67c8a4ab0d 100644 --- a/client/lib/signup/flow-controller.js +++ b/client/lib/signup/flow-controller.js @@ -306,6 +306,12 @@ assign( SignupFlowController.prototype, { SignupProgressStore.reset(); SignupDependencyStore.reset(); }, + + changeFlowName( flowName ) { + this._flowName = flowName; + this._flow = flows.getFlow( flowName ); + store.set( STORAGE_KEY, flowName ); + }, } ); export default SignupFlowController; diff --git a/client/signup/config/flows.js b/client/signup/config/flows.js index 35ba9a270c973..04ca47e70c14d 100644 --- a/client/signup/config/flows.js +++ b/client/signup/config/flows.js @@ -302,20 +302,39 @@ function removeUserStepFromFlow( flow ) { } ); } -function filterDesignTypeInFlow( flow ) { +function replaceStepInFlow( flow, oldStepName, newStepName ) { + // no change + if ( ! includes( flow.steps, oldStepName ) ) { + return flow; + } + + return assign( {}, flow, { + steps: flow.steps.map( stepName => ( stepName === oldStepName ? newStepName : stepName ) ), + } ); +} + +function filterDesignTypeInFlow( flowName, flow ) { if ( ! flow ) { return; } - if ( ! includes( flow.steps, 'design-type' ) ) { - return flow; + if ( config.isEnabled( 'signup/atomic-store-flow' ) ) { + // If Atomic Store is enabled, replace 'design-type-with-store' with + // 'design-type-with-store-nux' in flows other than 'pressable'. + if ( flowName !== 'pressable' && includes( flow.steps, 'design-type-with-store' ) ) { + return replaceStepInFlow( flow, 'design-type-with-store', 'design-type-with-store-nux' ); + } + + // Show store option to everyone if Atomic Store is enabled + return replaceStepInFlow( flow, 'design-type', 'design-type-with-store-nux' ); } - return assign( {}, flow, { - steps: flow.steps.map( - stepName => ( stepName === 'design-type' ? 'design-type-with-store' : stepName ) - ), - } ); + // Show design type with store option only to new users with EN locale + if ( ! user.get() && 'en' === i18n.getLocaleSlug() ) { + return replaceStepInFlow( flow, 'design-type', 'design-type-with-store' ); + } + + return flow; } /** @@ -378,10 +397,8 @@ const Flows = { flow = removeUserStepFromFlow( flow ); } - // Show design type with store option only to new users with EN locale. - if ( ! user.get() && 'en' === i18n.getLocaleSlug() ) { - flow = filterDesignTypeInFlow( flow ); - } + // Maybe modify the design type step to a variant with store + flow = filterDesignTypeInFlow( flowName, flow ); Flows.preloadABTestVariationsForStep( flowName, currentStepName ); diff --git a/client/signup/main.jsx b/client/signup/main.jsx index 590c04c5c59b3..e9d7c770d530f 100644 --- a/client/signup/main.jsx +++ b/client/signup/main.jsx @@ -348,7 +348,9 @@ class Signup extends React.Component { ); }; - goToStep = ( stepName, stepSectionName ) => { + // `flowName` is an optional parameter used to redirect to another flow, i.e., from `main` + // to `store-nux`. If not specified, the current flow (`this.props.flowName`) continues. + goToStep = ( stepName, stepSectionName, flowName = this.props.flowName ) => { if ( this.state.scrolling ) { return; } @@ -370,23 +372,28 @@ class Signup extends React.Component { // redirect the user to the next step scrollPromise.then( () => { if ( ! this.isEveryStepSubmitted() ) { - page( - utils.getStepUrl( this.props.flowName, stepName, stepSectionName, this.props.locale ) - ); + if ( flowName !== this.props.flowName ) { + // if flow is being changed, tell SignupFlowController about the change and save + // a new value of `signupFlowName` to local storage. + this.signupFlowController.changeFlowName( flowName ); + } + page( utils.getStepUrl( flowName, stepName, stepSectionName, this.props.locale ) ); } else if ( this.isEveryStepSubmitted() ) { this.goToFirstInvalidStep(); } } ); }; - goToNextStep = () => { - const flowSteps = flows.getFlow( this.props.flowName, this.props.stepName ).steps, + // `nextFlowName` is an optional parameter used to redirect to another flow, i.e., from `main` + // to `store-nux`. If not specified, the current flow (`this.props.flowName`) continues. + goToNextStep = ( nextFlowName = this.props.flowName ) => { + const flowSteps = flows.getFlow( nextFlowName, this.props.stepName ).steps, currentStepIndex = indexOf( flowSteps, this.props.stepName ), nextStepName = flowSteps[ currentStepIndex + 1 ], nextProgressItem = this.state.progress[ currentStepIndex + 1 ], nextStepSection = ( nextProgressItem && nextProgressItem.stepSectionName ) || ''; - this.goToStep( nextStepName, nextStepSection ); + this.goToStep( nextStepName, nextStepSection, nextFlowName ); }; goToFirstInvalidStep = () => { diff --git a/client/signup/steps/design-type-with-atomic-store/index.jsx b/client/signup/steps/design-type-with-atomic-store/index.jsx index 88c4345d41e53..33073dd5b7d05 100644 --- a/client/signup/steps/design-type-with-atomic-store/index.jsx +++ b/client/signup/steps/design-type-with-atomic-store/index.jsx @@ -131,7 +131,10 @@ class DesignTypeWithAtomicStoreStep extends Component { designType, } ); - this.props.goToNextStep(); + // If the user chooses `store` as design type, redirect to the `store-nux` flow. + // For other choices, continue with the current flow. + const nextFlowName = designType === DESIGN_TYPE_STORE ? 'store-nux' : this.props.flowName; + this.props.goToNextStep( nextFlowName ); }; renderChoice = choice => { From 3920d110c25205efbcfe843b4854d52fdc2c8db5 Mon Sep 17 00:00:00 2001 From: Kevin Killingsworth Date: Wed, 1 Nov 2017 01:51:42 -0500 Subject: [PATCH 183/192] Promotions edit: Add product price to appliesTo This adds the product price next to each product so the user can better see what prices each product has when they choose a sale price or a discount. --- .../applies-to-filtered-list.js | 23 ++++++++++++------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/client/extensions/woocommerce/app/promotions/fields/promotion-applies-to-field/applies-to-filtered-list.js b/client/extensions/woocommerce/app/promotions/fields/promotion-applies-to-field/applies-to-filtered-list.js index 59df6db70ea52..c1036d626fd37 100644 --- a/client/extensions/woocommerce/app/promotions/fields/promotion-applies-to-field/applies-to-filtered-list.js +++ b/client/extensions/woocommerce/app/promotions/fields/promotion-applies-to-field/applies-to-filtered-list.js @@ -10,6 +10,7 @@ import warn from 'lib/warn'; /** * Internal dependencies */ +import formatCurrency from 'lib/format-currency'; import FormLabel from 'components/forms/form-label'; import FormRadio from 'components/forms/form-radio'; import FormCheckbox from 'components/forms/form-checkbox'; @@ -118,6 +119,7 @@ class AppliesToFilteredList extends React.Component { edit: PropTypes.func.isRequired, products: PropTypes.array, productCategories: PropTypes.array, + currency: PropTypes.string, }; constructor( props ) { @@ -196,9 +198,12 @@ class AppliesToFilteredList extends React.Component { ); } - renderProductList( singular ) { + renderProductList( singular, currency ) { const filteredProducts = this.getFilteredProducts() || []; - const renderFunc = ( singular ? this.renderProductRadio : this.renderProductCheckbox ); + const renderFunc = ( singular + ? this.renderProductRadio( currency ) + : this.renderProductCheckbox( currency ) + ); return (
    @@ -216,24 +221,26 @@ class AppliesToFilteredList extends React.Component { return renderRow( FormCheckbox, name, id, imageSrc, selected, this.onCategoryCheckbox ); } - renderProductCheckbox = ( product ) => { + renderProductCheckbox = ( currency ) => ( product ) => { const { value } = this.props; - const { name, id, images } = product; + const { name, regular_price, id, images } = product; + const nameWithPrice = name + ' - ' + formatCurrency( regular_price, currency ); const selected = isProductSelected( value, id ); const image = images && images[ 0 ]; const imageSrc = image && image.src; - return renderRow( FormCheckbox, name, id, imageSrc, selected, this.onProductCheckbox ); + return renderRow( FormCheckbox, nameWithPrice, id, imageSrc, selected, this.onProductCheckbox ); } - renderProductRadio = ( product ) => { + renderProductRadio = ( currency ) => ( product ) => { const { value } = this.props; - const { name, id, images } = product; + const { name, regular_price, id, images } = product; + const nameWithPrice = name + ' - ' + formatCurrency( regular_price, currency ); const selected = isProductSelected( value, product.id ); const image = images && images[ 0 ]; const imageSrc = image && image.src; - return renderRow( FormRadio, name, id, imageSrc, selected, this.onProductRadio ); + return renderRow( FormRadio, nameWithPrice, id, imageSrc, selected, this.onProductRadio ); } onSearch = ( searchFilter ) => { From 2c60af4632886dcca7440bd35b7301cebaba6807 Mon Sep 17 00:00:00 2001 From: James Koster Date: Thu, 2 Nov 2017 10:33:06 +0000 Subject: [PATCH 184/192] Less extreme z-index value to stop the search bar overlaying the action bar --- client/extensions/woocommerce/app/promotions/fields/style.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/extensions/woocommerce/app/promotions/fields/style.scss b/client/extensions/woocommerce/app/promotions/fields/style.scss index 037dbbe26fc3e..0e8e6033dbcbd 100644 --- a/client/extensions/woocommerce/app/promotions/fields/style.scss +++ b/client/extensions/woocommerce/app/promotions/fields/style.scss @@ -66,7 +66,7 @@ border: 1px solid lighten( $gray, 20% ); z-index: 0; // Keeps search from overlapping header above. box-sizing: border-box; - z-index: 99; + z-index: 9; } .form-fieldset { From 46b8f4ebf662ba21d787562b15a1a9f39522f7ce Mon Sep 17 00:00:00 2001 From: Chris R Date: Thu, 2 Nov 2017 18:08:41 +0000 Subject: [PATCH 185/192] Fix long word wrapping in WebKit browsers (#19400) --- client/blocks/comments/style.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/client/blocks/comments/style.scss b/client/blocks/comments/style.scss index 70ad2d4f75100..21a4f3a0c4d17 100644 --- a/client/blocks/comments/style.scss +++ b/client/blocks/comments/style.scss @@ -52,6 +52,7 @@ white-space: pre-wrap; word-wrap: break-word; + word-break: break-word; } textarea { From 8ffed2d68415c5daf40ef408c221b217c08add73 Mon Sep 17 00:00:00 2001 From: jonathansadowski Date: Thu, 2 Nov 2017 15:01:51 -0400 Subject: [PATCH 186/192] Show end-of-list marker for PostTypeList (#19025) * Show end-of-list marker for PostTypeList * Update strings for better i18n * More consistent props * Roll isTruncatedResults into renderMaxPagesNotice * Do not query more than 10 pages in all sites * Update comment * Split `lastPage` prop into `lastPageToRequest` and `totalPageCount` When deciding which page to request, we may need to cap the maximum page number in all-sites mode. When deciding whether the list has been truncated, we need the full, un-capped number of pages. Remove the now-unneeded `renderQueryPosts` method. * Rewrite `maybeLoadNextPage` to avoid unnecessary scroll calculations * Formatting changes only (calypso-prettier) --- client/my-sites/post-type-list/index.jsx | 80 ++++++++++++++----- .../post-type-list/max-pages-notice.jsx | 67 ++++++++++++++++ client/my-sites/post-type-list/style.scss | 14 ++++ 3 files changed, 141 insertions(+), 20 deletions(-) create mode 100644 client/my-sites/post-type-list/max-pages-notice.jsx diff --git a/client/my-sites/post-type-list/index.jsx b/client/my-sites/post-type-list/index.jsx index b20b1c23f6998..5cb0c69998842 100644 --- a/client/my-sites/post-type-list/index.jsx +++ b/client/my-sites/post-type-list/index.jsx @@ -19,10 +19,13 @@ import { getSelectedSiteId } from 'state/ui/selectors'; import { isRequestingSitePostsForQueryIgnoringPage, getSitePostsForQueryIgnoringPage, + getSitePostsFoundForQuery, getSitePostsLastPageForQuery, } from 'state/posts/selectors'; +import ListEnd from 'components/list-end'; import PostItem from 'blocks/post-item'; import PostTypeListEmptyContent from './empty-content'; +import PostTypeListMaxPagesNotice from './max-pages-notice'; /** * Constants @@ -30,6 +33,9 @@ import PostTypeListEmptyContent from './empty-content'; // When this many pixels or less are below the viewport, begin loading the next // page of items. const LOAD_NEXT_PAGE_THRESHOLD_PIXELS = 400; +// The maximum number of pages of results that can be displayed in "All My +// Sites" (API endpoint limitation). +const MAX_ALL_SITES_PAGES = 10; class PostTypeList extends Component { static propTypes = { @@ -43,7 +49,9 @@ class PostTypeList extends Component { siteId: PropTypes.number, posts: PropTypes.array, isRequestingPosts: PropTypes.bool, - lastPage: PropTypes.number, + totalPostCount: PropTypes.number, + totalPageCount: PropTypes.number, + lastPageToRequest: PropTypes.number, }; constructor() { @@ -103,14 +111,18 @@ class PostTypeList extends Component { return 1; } - const query = this.props.query || {}; - const postsPerPage = query.number || DEFAULT_POST_QUERY.number; + const postsPerPage = this.getPostsPerPageCount(); const pageCount = Math.ceil( posts.length / postsPerPage ); // Avoid making more than 5 concurrent requests on page load. return Math.min( pageCount, 5 ); } + getPostsPerPageCount() { + const query = this.props.query || {}; + return query.number || DEFAULT_POST_QUERY.number; + } + getScrollTop() { const { scrollContainer } = this.props; if ( ! scrollContainer ) { @@ -122,31 +134,53 @@ class PostTypeList extends Component { return scrollContainer.scrollTop; } + hasListFullyLoaded() { + const { lastPageToRequest, isRequestingPosts } = this.props; + const { maxRequestedPage } = this.state; + + return ! isRequestingPosts && maxRequestedPage >= lastPageToRequest; + } + maybeLoadNextPage() { - const { scrollContainer, lastPage, isRequestingPosts } = this.props; - if ( ! scrollContainer ) { + const { scrollContainer, lastPageToRequest, isRequestingPosts } = this.props; + const { maxRequestedPage } = this.state; + if ( ! scrollContainer || isRequestingPosts || maxRequestedPage >= lastPageToRequest ) { return; } + const scrollTop = this.getScrollTop(); const { scrollHeight, clientHeight } = scrollContainer; + const pixelsBelowViewport = scrollHeight - scrollTop - clientHeight; if ( typeof scrollTop !== 'number' || typeof scrollHeight !== 'number' || - typeof clientHeight !== 'number' + typeof clientHeight !== 'number' || + pixelsBelowViewport > LOAD_NEXT_PAGE_THRESHOLD_PIXELS ) { return; } - const pixelsBelowViewport = scrollHeight - scrollTop - clientHeight; - const { maxRequestedPage } = this.state; - if ( - pixelsBelowViewport <= LOAD_NEXT_PAGE_THRESHOLD_PIXELS && - maxRequestedPage < lastPage && - ! isRequestingPosts - ) { - this.setState( { - maxRequestedPage: maxRequestedPage + 1, - } ); + + this.setState( { maxRequestedPage: maxRequestedPage + 1 } ); + } + + renderListEnd() { + return ; + } + + renderMaxPagesNotice() { + const { siteId, totalPageCount, totalPostCount } = this.props; + const isTruncated = + null === siteId && this.hasListFullyLoaded() && totalPageCount > MAX_ALL_SITES_PAGES; + + if ( ! isTruncated ) { + return null; } + + const displayedPosts = this.getPostsPerPageCount() * MAX_ALL_SITES_PAGES; + + return ( + + ); } renderPlaceholder() { @@ -169,7 +203,7 @@ class PostTypeList extends Component { } render() { - const { query, siteId, posts, isRequestingPosts, lastPage } = this.props; + const { query, siteId, posts, isRequestingPosts } = this.props; const { maxRequestedPage } = this.state; const isLoadedAndEmpty = query && posts && ! posts.length && ! isRequestingPosts; const classes = classnames( 'post-type-list', { @@ -186,7 +220,8 @@ class PostTypeList extends Component { { isLoadedAndEmpty && ( ) } - { ( maxRequestedPage < lastPage || isRequestingPosts ) && this.renderPlaceholder() } + { this.renderMaxPagesNotice() } + { this.hasListFullyLoaded() ? this.renderListEnd() : this.renderPlaceholder() }
    ); } @@ -194,12 +229,17 @@ class PostTypeList extends Component { export default connect( ( state, ownProps ) => { const siteId = getSelectedSiteId( state ); - const lastPage = getSitePostsLastPageForQuery( state, siteId, ownProps.query ); + + const totalPageCount = getSitePostsLastPageForQuery( state, siteId, ownProps.query ); + const lastPageToRequest = + siteId === null ? Math.min( MAX_ALL_SITES_PAGES, totalPageCount ) : totalPageCount; return { siteId, posts: getSitePostsForQueryIgnoringPage( state, siteId, ownProps.query ), isRequestingPosts: isRequestingSitePostsForQueryIgnoringPage( state, siteId, ownProps.query ), - lastPage, + totalPostCount: getSitePostsFoundForQuery( state, siteId, ownProps.query ), + totalPageCount, + lastPageToRequest, }; } )( PostTypeList ); diff --git a/client/my-sites/post-type-list/max-pages-notice.jsx b/client/my-sites/post-type-list/max-pages-notice.jsx new file mode 100644 index 0000000000000..fd35f41c77f82 --- /dev/null +++ b/client/my-sites/post-type-list/max-pages-notice.jsx @@ -0,0 +1,67 @@ +/** + * External dependencies + * + * @format + */ + +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { localize } from 'i18n-calypso'; + +/** + * Internal dependencies + */ +import { setLayoutFocus } from 'state/ui/layout-focus/actions'; +import { recordTracksEvent } from 'state/analytics/actions'; + +class PostTypeListMaxPagesNotice extends Component { + static propTypes = { + displayedPosts: PropTypes.number, + totalPosts: PropTypes.number, + }; + + componentWillMount() { + this.props.recordTracksEvent( 'calypso_post_type_list_max_pages_view' ); + } + + focusSiteSelector = event => { + event.preventDefault(); + + this.props.setLayoutFocus( 'sites' ); + }; + + render() { + const { displayedPosts, totalPosts, translate } = this.props; + + return ( +
    + { translate( + 'Showing %(displayedPosts)d post of %(totalPosts)d.', + 'Showing %(displayedPosts)d posts of %(totalPosts)d.', + { + args: { + displayedPosts, + totalPosts, + }, + } + ) } +
    + { translate( 'To view more posts, {{a}}switch to a specific site{{/a}}.', { + components: { + a: ( + + ), + }, + } ) } +
    + ); + } +} + +export default connect( null, { recordTracksEvent, setLayoutFocus } )( + localize( PostTypeListMaxPagesNotice ) +); diff --git a/client/my-sites/post-type-list/style.scss b/client/my-sites/post-type-list/style.scss index 6bab3e0645ec6..9dcb3577594c9 100644 --- a/client/my-sites/post-type-list/style.scss +++ b/client/my-sites/post-type-list/style.scss @@ -46,3 +46,17 @@ max-height: 100%; max-width: none; } + + +.post-type-list__max-pages-notice { + text-align: center; + margin: 16px 0; + font-size: 14px; + color: $gray-text-min; +} + +a.post-type-list__max-pages-notice-link { + cursor: pointer; + color: $gray-text-min; + text-decoration: underline; +} From fd462e14352a2c829e39747c2c4231c1a13d4346 Mon Sep 17 00:00:00 2001 From: Jason Johnston Date: Thu, 2 Nov 2017 12:18:27 -0700 Subject: [PATCH 187/192] Store NUX: fix redirect at setup store setup step (#19172) * check if the site is pending transfer * update from feedback - destructure import - split out pending transfer status --- client/extensions/woocommerce/app/index.js | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/client/extensions/woocommerce/app/index.js b/client/extensions/woocommerce/app/index.js index 795410b4fedaa..f8f1cf96ab560 100644 --- a/client/extensions/woocommerce/app/index.js +++ b/client/extensions/woocommerce/app/index.js @@ -17,7 +17,7 @@ import { canCurrentUser } from 'state/selectors'; import config from 'config'; import DocumentHead from 'components/data/document-head'; import { getSelectedSiteId } from 'state/ui/selectors'; -import { isSiteAutomatedTransfer } from 'state/selectors'; +import { isSiteAutomatedTransfer, hasSitePendingAutomatedTransfer } from 'state/selectors'; import route from 'lib/route'; class App extends Component { @@ -27,6 +27,7 @@ class App extends Component { canUserManageOptions: PropTypes.bool.isRequired, currentRoute: PropTypes.string.isRequired, isAtomicSite: PropTypes.bool.isRequired, + hasPendingAutomatedTransfer: PropTypes.bool.isRequired, children: PropTypes.element.isRequired, }; @@ -46,6 +47,7 @@ class App extends Component { children, canUserManageOptions, isAtomicSite, + hasPendingAutomatedTransfer, currentRoute, translate, } = this.props; @@ -62,7 +64,11 @@ class App extends Component { return null; } - if ( ! isAtomicSite && ! config.isEnabled( 'woocommerce/store-on-non-atomic-sites' ) ) { + if ( + ! isAtomicSite && + ! hasPendingAutomatedTransfer && + ! config.isEnabled( 'woocommerce/store-on-non-atomic-sites' ) + ) { this.redirect(); return null; } @@ -89,10 +95,14 @@ function mapStateToProps( state ) { const canUserManageOptions = ( siteId && canCurrentUser( state, siteId, 'manage_options' ) ) || false; const isAtomicSite = ( siteId && !! isSiteAutomatedTransfer( state, siteId ) ) || false; + const hasPendingAutomatedTransfer = + ( siteId && !! hasSitePendingAutomatedTransfer( state, siteId ) ) || false; + return { siteId, canUserManageOptions, isAtomicSite, + hasPendingAutomatedTransfer, currentRoute: page.current, }; } From c62d6ab21db2ec83cf9782189de1972f4594c921 Mon Sep 17 00:00:00 2001 From: Jarda Snajdr Date: Thu, 2 Nov 2017 21:24:44 +0100 Subject: [PATCH 188/192] Atomic Store: disable on all environments until debugged --- config/development.json | 2 +- config/horizon.json | 2 +- config/wpcalypso.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/config/development.json b/config/development.json index 2524d4166e7d9..a25279d43af0b 100644 --- a/config/development.json +++ b/config/development.json @@ -161,7 +161,7 @@ "signup/domain-first-flow": true, "signup/social": true, "signup/social-management": true, - "signup/atomic-store-flow": true, + "signup/atomic-store-flow": false, "signup/wpcc": true, "simple-payments": true, "standalone-site-preview": true, diff --git a/config/horizon.json b/config/horizon.json index fbfa18055b6ba..863ef7cf24f53 100644 --- a/config/horizon.json +++ b/config/horizon.json @@ -102,7 +102,7 @@ "signup/domain-first-flow": true, "signup/social": true, "signup/social-management": true, - "signup/atomic-store-flow": true, + "signup/atomic-store-flow": false, "signup/wpcc": true, "simple-payments": true, "standalone-site-preview": true, diff --git a/config/wpcalypso.json b/config/wpcalypso.json index 5e9b759227021..a671a4b3252ef 100644 --- a/config/wpcalypso.json +++ b/config/wpcalypso.json @@ -123,7 +123,7 @@ "signup/domain-first-flow": true, "signup/social": true, "signup/social-management": true, - "signup/atomic-store-flow": true, + "signup/atomic-store-flow": false, "signup/wpcc": true, "simple-payments": true, "standalone-site-preview": true, From 4bdc430d140f45650322e93fc439c4408151b533 Mon Sep 17 00:00:00 2001 From: Paul Dechov Date: Thu, 2 Nov 2017 16:42:13 -0400 Subject: [PATCH 189/192] Store: Move all shipping label dialog buttons to fixed footer (#19250) * Standardize label dialogs to use 's 'buttons' prop * Remove
    from label refund dialog --- .../components/action-buttons/index.js | 43 ------------------- .../state/shipping-label/reducer.js | 1 - .../shipping-label/label-details-modal.js | 16 +++---- .../packages-step/add-item.js | 22 +++++----- .../packages-step/move-item.js | 22 +++++----- .../shipping-label/label-refund-modal.js | 31 +++++++------ .../shipping-label/label-reprint-modal.js | 27 ++++++------ 7 files changed, 59 insertions(+), 103 deletions(-) delete mode 100644 client/extensions/woocommerce/woocommerce-services/components/action-buttons/index.js diff --git a/client/extensions/woocommerce/woocommerce-services/components/action-buttons/index.js b/client/extensions/woocommerce/woocommerce-services/components/action-buttons/index.js deleted file mode 100644 index f41d68af6c3d8..0000000000000 --- a/client/extensions/woocommerce/woocommerce-services/components/action-buttons/index.js +++ /dev/null @@ -1,43 +0,0 @@ -/** - * External dependencies - */ -import React from 'react'; -import PropTypes from 'prop-types'; - -/** - * Internal dependencies - */ -import FormButton from 'components/forms/form-button'; -import FormButtonsBar from 'components/forms/form-buttons-bar'; - -const ActionButtons = ( { buttons, className } ) => { - return ( - - { buttons.map( ( button, idx ) => ( - - { button.label } - - ) ) } - - ); -}; - -ActionButtons.propTypes = { - buttons: PropTypes.arrayOf( - PropTypes.shape( { - label: PropTypes.node.isRequired, - onClick: PropTypes.func.isRequired, - isPrimary: PropTypes.bool, - isDisabled: PropTypes.bool, - } ) - ).isRequired, - className: PropTypes.string, -}; - -export default ActionButtons; diff --git a/client/extensions/woocommerce/woocommerce-services/state/shipping-label/reducer.js b/client/extensions/woocommerce/woocommerce-services/state/shipping-label/reducer.js index f1fce6741093d..ae52eeeb3fe49 100644 --- a/client/extensions/woocommerce/woocommerce-services/state/shipping-label/reducer.js +++ b/client/extensions/woocommerce/woocommerce-services/state/shipping-label/reducer.js @@ -718,7 +718,6 @@ reducers[ WOOCOMMERCE_SERVICES_SHIPPING_LABEL_REFUND_RESPONSE ] = ( state, { res refundDialog: null, labels: [ ...state.labels ], }; - newState.refundDialog = null; newState.labels[ labelIndex ] = labelData; return newState; diff --git a/client/extensions/woocommerce/woocommerce-services/views/shipping-label/label-details-modal.js b/client/extensions/woocommerce/woocommerce-services/views/shipping-label/label-details-modal.js index d28af9b88366f..7525241230d82 100644 --- a/client/extensions/woocommerce/woocommerce-services/views/shipping-label/label-details-modal.js +++ b/client/extensions/woocommerce/woocommerce-services/views/shipping-label/label-details-modal.js @@ -11,7 +11,6 @@ import { localize } from 'i18n-calypso'; * Internal dependencies */ import Dialog from 'components/dialog'; -import ActionButtons from 'woocommerce/woocommerce-services/components/action-buttons'; import FormSectionHeading from 'components/forms/form-section-heading'; import { closeDetailsDialog } from 'woocommerce/woocommerce-services/state/shipping-label/actions'; import { isLoaded, getShippingLabel } from 'woocommerce/woocommerce-services/state/shipping-label/selectors'; @@ -20,11 +19,16 @@ const DetailsDialog = ( props ) => { const { orderId, siteId, isVisible, labelIndex, serviceName, packageName, productNames, translate } = props; const onClose = () => props.closeDetailsDialog( orderId, siteId ); + const buttons = [ + { action: 'close', label: translate( 'Close' ), onClick: onClose }, + ]; + return ( + onClose={ onClose } + buttons={ buttons }> { translate( 'Label #%(labelIndex)s details', { args: { labelIndex: labelIndex + 1 } } ) } @@ -38,16 +42,10 @@ const DetailsDialog = ( props ) => {
    { translate( 'Items' ) }
      - { productNames.map( productName =>
    • { productName }
    • ) } + { productNames.map( ( productName, i ) =>
    • { productName }
    • ) }
    -
    ); }; diff --git a/client/extensions/woocommerce/woocommerce-services/views/shipping-label/label-purchase-modal/packages-step/add-item.js b/client/extensions/woocommerce/woocommerce-services/views/shipping-label/label-purchase-modal/packages-step/add-item.js index d82e517113285..2eecc51450fe5 100644 --- a/client/extensions/woocommerce/woocommerce-services/views/shipping-label/label-purchase-modal/packages-step/add-item.js +++ b/client/extensions/woocommerce/woocommerce-services/views/shipping-label/label-purchase-modal/packages-step/add-item.js @@ -14,7 +14,6 @@ import { includes, size, some } from 'lodash'; import Dialog from 'components/dialog'; import FormCheckbox from 'components/forms/form-checkbox'; import FormLabel from 'components/forms/form-label'; -import ActionButtons from 'woocommerce/woocommerce-services/components/action-buttons'; import getPackageDescriptions from './get-package-descriptions'; import FormSectionHeading from 'components/forms/form-section-heading'; import { closeAddItem, setAddedItem, addItems } from 'woocommerce/woocommerce-services/state/shipping-label/actions'; @@ -74,11 +73,23 @@ const AddItemDialog = ( props ) => { const onClose = () => props.closeAddItem( orderId, siteId ); + const buttons = [ + { action: 'close', label: translate( 'Close' ), onClick: onClose }, + { + action: 'add', + label: translate( 'Add' ), + isPrimary: true, + disabled: ! some( addedItems, size ), + onClick: () => props.addItems( orderId, siteId, openedPackageId ), + }, + ]; + return ( { translate( 'Add item' ) }
    @@ -91,15 +102,6 @@ const AddItemDialog = ( props ) => {

    { itemOptions }
    - props.addItems( orderId, siteId, openedPackageId ), - }, - { label: translate( 'Close' ), onClick: onClose }, - ] } />
    ); }; diff --git a/client/extensions/woocommerce/woocommerce-services/views/shipping-label/label-purchase-modal/packages-step/move-item.js b/client/extensions/woocommerce/woocommerce-services/views/shipping-label/label-purchase-modal/packages-step/move-item.js index 0562e341ef084..029a305562076 100644 --- a/client/extensions/woocommerce/woocommerce-services/views/shipping-label/label-purchase-modal/packages-step/move-item.js +++ b/client/extensions/woocommerce/woocommerce-services/views/shipping-label/label-purchase-modal/packages-step/move-item.js @@ -13,7 +13,6 @@ import { localize } from 'i18n-calypso'; import Dialog from 'components/dialog'; import FormRadio from 'components/forms/form-radio'; import FormLabel from 'components/forms/form-label'; -import ActionButtons from 'woocommerce/woocommerce-services/components/action-buttons'; import getPackageDescriptions from './get-package-descriptions'; import FormSectionHeading from 'components/forms/form-section-heading'; import { getLink } from 'woocommerce/lib/nav-utils'; @@ -104,11 +103,23 @@ const MoveItemDialog = ( props ) => { const onClose = () => props.closeItemMove( orderId, siteId ); + const buttons = [ + { action: 'cancel', label: translate( 'Cancel' ), onClick: onClose }, + { + action: 'move', + label: translate( 'Move' ), + isPrimary: true, + disabled: targetPackageId === openedPackageId, // Result of targetPackageId initialization + onClick: () => props.moveItem( orderId, siteId, openedPackageId, movedItemIndex, targetPackageId ), + }, + ]; + return ( { translate( 'Move item' ) }
    @@ -118,15 +129,6 @@ const MoveItemDialog = ( props ) => { { renderNewPackageOption() } { renderIndividualOption() }
    - props.moveItem( orderId, siteId, openedPackageId, movedItemIndex, targetPackageId ), - }, - { label: translate( 'Cancel' ), onClick: onClose }, - ] } />
    ); }; diff --git a/client/extensions/woocommerce/woocommerce-services/views/shipping-label/label-refund-modal.js b/client/extensions/woocommerce/woocommerce-services/views/shipping-label/label-refund-modal.js index fd68d37a2cfd7..ec06de909a5ae 100644 --- a/client/extensions/woocommerce/woocommerce-services/views/shipping-label/label-refund-modal.js +++ b/client/extensions/woocommerce/woocommerce-services/views/shipping-label/label-refund-modal.js @@ -11,7 +11,6 @@ import { localize } from 'i18n-calypso'; * Internal dependencies */ import Dialog from 'components/dialog'; -import ActionButtons from 'woocommerce/woocommerce-services/components/action-buttons'; import FormSectionHeading from 'components/forms/form-section-heading'; import { closeRefundDialog, confirmRefund } from 'woocommerce/woocommerce-services/state/shipping-label/actions'; import { isLoaded, getShippingLabel } from 'woocommerce/woocommerce-services/state/shipping-label/selectors'; @@ -26,11 +25,25 @@ const RefundDialog = ( props ) => { const onClose = () => props.closeRefundDialog( orderId, siteId ); const onConfirm = () => props.confirmRefund( orderId, siteId ); + + const buttons = [ + { action: 'cancel', label: translate( 'Cancel' ), onClick: onClose }, + { + action: 'confirm', + onClick: onConfirm, + isPrimary: true, + disabled: refundDialog && refundDialog.isSubmitting, + additionalClassNames: refundDialog && refundDialog.isSubmitting ? 'is-busy' : '', + label: translate( 'Refund label (-%(amount)s)', { args: { amount: getRefundableAmount() } } ), + }, + ]; + return ( + onClose={ onClose } + buttons={ buttons }> { translate( 'Request a refund' ) } @@ -38,7 +51,6 @@ const RefundDialog = ( props ) => { { translate( 'You can request a refund for a shipping label that has not been used to ship a package. ' + 'It will take at least 14 days to process.' ) }

    -
    { translate( 'Purchase date' ) }
    { moment( created ).format( 'MMMM Do YYYY, h:mm a' ) }
    @@ -46,19 +58,6 @@ const RefundDialog = ( props ) => {
    { translate( 'Amount eligible for refund' ) }
    { getRefundableAmount() }
    -
    ); }; diff --git a/client/extensions/woocommerce/woocommerce-services/views/shipping-label/label-reprint-modal.js b/client/extensions/woocommerce/woocommerce-services/views/shipping-label/label-reprint-modal.js index 9c7b0cc13e3d4..a66cd768a2c12 100644 --- a/client/extensions/woocommerce/woocommerce-services/views/shipping-label/label-reprint-modal.js +++ b/client/extensions/woocommerce/woocommerce-services/views/shipping-label/label-reprint-modal.js @@ -11,7 +11,6 @@ import { localize } from 'i18n-calypso'; * Internal dependencies */ import Dialog from 'components/dialog'; -import ActionButtons from 'woocommerce/woocommerce-services/components/action-buttons'; import Dropdown from 'woocommerce/woocommerce-services/components/dropdown'; import { getPaperSizes } from 'woocommerce/woocommerce-services/lib/pdf-label-utils'; import FormSectionHeading from 'components/forms/form-section-heading'; @@ -25,10 +24,23 @@ const ReprintDialog = ( props ) => { const onConfirm = () => props.confirmReprint( orderId, siteId ); const onPaperSizeChange = ( value ) => props.updatePaperSize( orderId, siteId, value ); + const buttons = [ + { action: 'cancel', label: translate( 'Cancel' ), onClick: onClose }, + { + action: 'confirm', + onClick: onConfirm, + isPrimary: true, + disabled: reprintDialog && reprintDialog.isFetching, + additionalClassNames: reprintDialog && reprintDialog.isFetching ? 'is-busy' : '', + label: translate( 'Print' ), + }, + ]; + return ( { translate( 'Reprint shipping label' ) } @@ -46,19 +58,6 @@ const ReprintDialog = ( props ) => { title={ translate( 'Paper size' ) } value={ paperSize } updateValue={ onPaperSizeChange } /> - ); }; From 92714ba2728db04892d6e0a51ed4ac10fe9b2a6b Mon Sep 17 00:00:00 2001 From: Elio Rivero Date: Thu, 2 Nov 2017 18:13:51 -0300 Subject: [PATCH 190/192] Activity Log: expand today, don't show rewind button in day header (#19447) --- client/my-sites/stats/activity-log-day/index.jsx | 4 +--- client/my-sites/stats/activity-log/index.jsx | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/client/my-sites/stats/activity-log-day/index.jsx b/client/my-sites/stats/activity-log-day/index.jsx index 428c62e399009..6ccd54046e28b 100644 --- a/client/my-sites/stats/activity-log-day/index.jsx +++ b/client/my-sites/stats/activity-log-day/index.jsx @@ -265,10 +265,8 @@ class ActivityLogDay extends Component { } export default connect( - ( state, { tsEndOfSiteDay, siteId } ) => { - const now = Date.now(); + ( state, { siteId } ) => { return { - isToday: now <= tsEndOfSiteDay && tsEndOfSiteDay - DAY_IN_MILLISECONDS <= now, requestedRewind: getRequestedRewind( state, siteId ), }; }, diff --git a/client/my-sites/stats/activity-log/index.jsx b/client/my-sites/stats/activity-log/index.jsx index fd17621018c45..11719d9ceab27 100644 --- a/client/my-sites/stats/activity-log/index.jsx +++ b/client/my-sites/stats/activity-log/index.jsx @@ -533,6 +533,7 @@ class ActivityLog extends Component { requestRestore={ this.handleRequestRestore } siteId={ siteId } tsEndOfSiteDay={ start.valueOf() } + isToday={ isToday } /> ); } From 1c89b0a4ae4d6527dba105878eb6c708a4748975 Mon Sep 17 00:00:00 2001 From: Kelly Dwan Date: Thu, 2 Nov 2017 17:22:12 -0400 Subject: [PATCH 191/192] Store Products State: Merge search state into list state (#19397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Merge search reducer into general list reducer * Merge search action with general fetch action * Merge search selectors into general product fetching * Add `getProducts` selector * Fix format & eslint-disable comments * Add `queries` to track which products belong to whic request * Change the reducer shape to map the serialized parameters at a higher level (makes more sense when looking at state structure) * Merge search reducer into list, and remove product IDs list from UI (it exists in sites now) * Combine selectors to handle new merged list/search reducers * Fix selectors now that state has been reorganized * Update the reducer to set the default query params if they aren’t set in the action * Update components to use updated actions & selectors * Fix some documentation * Remove deleted products from the recorded query results (to prevent the UI from trying to show nonexistent products) * Address PR feedback --- .../woocommerce/app/products/index.js | 29 +- .../app/products/products-list-pagination.js | 7 +- .../products/products-list-search-results.js | 42 +-- .../app/products/products-list-table.js | 9 +- .../woocommerce/app/products/products-list.js | 26 +- .../components/product-search/index.js | 22 +- .../components/product-search/results.js | 14 +- .../state/sites/products/README.md | 48 ++- .../state/sites/products/actions.js | 91 +----- .../state/sites/products/reducer.js | 190 ++++++------ .../state/sites/products/selectors.js | 146 +++------ .../state/sites/products/test/actions.js | 187 ++++-------- .../sites/products/test/fixtures/product.js | 7 +- .../sites/products/test/fixtures/products.js | 7 +- .../state/sites/products/test/reducer.js | 288 +++++++++--------- .../state/sites/products/test/selectors.js | 192 +++++------- .../woocommerce/state/sites/products/utils.js | 35 +++ .../woocommerce/state/ui/products/README.md | 56 ++++ .../state/ui/products/list-reducer.js | 42 +-- .../woocommerce/state/ui/products/reducer.js | 6 +- .../state/ui/products/search-reducer.js | 52 ---- .../state/ui/products/selectors.js | 74 +---- .../state/ui/products/test/list-reducer.js | 68 +++-- .../state/ui/products/test/search-reducer.js | 73 ----- .../state/ui/products/test/selectors.js | 149 +++------ 25 files changed, 737 insertions(+), 1123 deletions(-) create mode 100644 client/extensions/woocommerce/state/sites/products/utils.js create mode 100644 client/extensions/woocommerce/state/ui/products/README.md delete mode 100644 client/extensions/woocommerce/state/ui/products/search-reducer.js delete mode 100644 client/extensions/woocommerce/state/ui/products/test/search-reducer.js diff --git a/client/extensions/woocommerce/app/products/index.js b/client/extensions/woocommerce/app/products/index.js index 0ce988b131778..24f1af3445c9a 100644 --- a/client/extensions/woocommerce/app/products/index.js +++ b/client/extensions/woocommerce/app/products/index.js @@ -17,11 +17,7 @@ import { trim } from 'lodash'; */ import ActionHeader from 'woocommerce/components/action-header'; import Button from 'components/button'; -import { - fetchProducts, - fetchProductSearchResults, - clearProductSearch, -} from 'woocommerce/state/sites/products/actions'; +import { fetchProducts } from 'woocommerce/state/sites/products/actions'; import { getLink } from 'woocommerce/lib/nav-utils'; import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; import { @@ -41,8 +37,6 @@ class Products extends Component { slug: PropTypes.string, } ), fetchProducts: PropTypes.func.isRequired, - fetchProductSearchResults: PropTypes.func.isRequired, - clearProductSearch: PropTypes.func.isRequired, }; state = { @@ -69,7 +63,7 @@ class Products extends Component { switchPage = page => { const { site } = this.props; if ( trim( this.state.query ) !== '' ) { - this.props.fetchProductSearchResults( site.ID, page ); + this.props.fetchProducts( site.ID, { page, search: this.state.query } ); } else { this.props.fetchProducts( site.ID, { page } ); } @@ -80,12 +74,11 @@ class Products extends Component { if ( trim( query ) === '' ) { this.setState( { query: '' } ); - this.props.clearProductSearch( site.ID ); return; } this.setState( { query } ); - this.props.fetchProductSearchResults( site.ID, 1, query ); + this.props.fetchProducts( site.ID, { search: query } ); }; render() { @@ -140,9 +133,10 @@ class Products extends Component { function mapStateToProps( state ) { const site = getSelectedSiteWithFallback( state ); - const productsLoaded = site && areProductsLoaded( state, { page: 1, per_page: 10 }, site.ID ); - const totalProducts = site && getTotalProducts( state, site.ID ); - const productsLoading = site && areProductsLoading( state, { page: 1, per_page: 10 }, site.ID ); + const defaultParams = {}; + const productsLoaded = site && areProductsLoaded( state, defaultParams, site.ID ); + const totalProducts = site && getTotalProducts( state, defaultParams, site.ID ); + const productsLoading = site && areProductsLoading( state, defaultParams, site.ID ); return { site, productsLoaded, @@ -152,14 +146,7 @@ function mapStateToProps( state ) { } function mapDispatchToProps( dispatch ) { - return bindActionCreators( - { - fetchProducts, - fetchProductSearchResults, - clearProductSearch, - }, - dispatch - ); + return bindActionCreators( { fetchProducts }, dispatch ); } export default connect( mapStateToProps, mapDispatchToProps )( localize( Products ) ); diff --git a/client/extensions/woocommerce/app/products/products-list-pagination.js b/client/extensions/woocommerce/app/products/products-list-pagination.js index f9e2a816ff8b3..8458fb8d14655 100644 --- a/client/extensions/woocommerce/app/products/products-list-pagination.js +++ b/client/extensions/woocommerce/app/products/products-list-pagination.js @@ -11,6 +11,7 @@ import PropTypes from 'prop-types'; * Internal dependencies */ import Pagination from 'components/pagination'; +import { DEFAULT_QUERY } from 'woocommerce/state/sites/products/utils'; const ProductsListPagination = ( { site, @@ -20,9 +21,7 @@ const ProductsListPagination = ( { requestedPage, onSwitchPage, } ) => { - const perPage = 10; - - if ( totalProducts && totalProducts < perPage + 1 ) { + if ( totalProducts && totalProducts < DEFAULT_QUERY.per_page + 1 ) { return null; } @@ -34,7 +33,7 @@ const ProductsListPagination = ( { return ( diff --git a/client/extensions/woocommerce/app/products/products-list-search-results.js b/client/extensions/woocommerce/app/products/products-list-search-results.js index 27d092da3a51c..8167a6c269fbc 100644 --- a/client/extensions/woocommerce/app/products/products-list-search-results.js +++ b/client/extensions/woocommerce/app/products/products-list-search-results.js @@ -14,15 +14,16 @@ import { localize } from 'i18n-calypso'; */ import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; import { - getTotalProductSearchResults, - areProductSearchResultsLoaded, - getProductSearchQuery, + getTotalProducts, + areProductsLoaded, + getProducts, } from 'woocommerce/state/sites/products/selectors'; import { - getProductSearchCurrentPage, - getProductSearchResults, - getProductSearchRequestedPage, + getProductsCurrentPage, + getProductsCurrentSearch, + getProductsRequestedPage, } from 'woocommerce/state/ui/products/selectors'; + import ProductsListPagination from './products-list-pagination'; import ProductsListTable from './products-list-table'; @@ -82,27 +83,16 @@ ProductsListSearchResults.propTypes = { function mapStateToProps( state ) { const site = getSelectedSiteWithFallback( state ); - const query = site && getProductSearchQuery( state, site.ID ); - const currentPage = site && getProductSearchCurrentPage( state, site.ID ); + const search = site && getProductsCurrentSearch( state, site.ID ); + const currentPage = site && getProductsCurrentPage( state, site.ID ); + const currentQuery = { page: currentPage, search }; const currentPageLoaded = - site && - currentPage && - areProductSearchResultsLoaded( - state, - { page: currentPage, per_page: 10, search: query }, - site.ID - ); - const requestedPage = site && getProductSearchRequestedPage( state, site.ID ); + site && currentPage && areProductsLoaded( state, currentQuery, site.ID ); + const requestedPage = site && getProductsRequestedPage( state, site.ID ); const requestedPageLoaded = - site && - requestedPage && - areProductSearchResultsLoaded( - state, - { page: requestedPage, per_page: 10, search: query }, - site.ID - ); - const totalProducts = site && getTotalProductSearchResults( state, site.ID ); - const products = site && getProductSearchResults( state, site.ID ); + site && requestedPage && areProductsLoaded( state, { page: requestedPage, search }, site.ID ); + const totalProducts = site && getTotalProducts( state, currentQuery, site.ID ); + const products = site && getProducts( state, currentQuery, site.ID ); return { site, @@ -112,7 +102,7 @@ function mapStateToProps( state ) { requestedPageLoaded, products, totalProducts, - query, + query: search, }; } diff --git a/client/extensions/woocommerce/app/products/products-list-table.js b/client/extensions/woocommerce/app/products/products-list-table.js index 8324050a97703..28e66cd420265 100644 --- a/client/extensions/woocommerce/app/products/products-list-table.js +++ b/client/extensions/woocommerce/app/products/products-list-table.js @@ -19,7 +19,10 @@ import TableItem from 'woocommerce/components/table/table-item'; const ProductsListTable = ( { translate, products, site, isRequesting } ) => { const headings = ( - + { [ translate( 'Product' ), translate( 'Inventory' ), @@ -39,12 +42,12 @@ const ProductsListTable = ( { translate, products, site, isRequesting } ) => { className={ classNames( { 'is-requesting': isRequesting } ) } horizontalScroll > - { products && + { !! products.length && products.map( ( product, i ) => ( ) ) } - { ! products &&
    } + { ! products.length &&
    }
    ); }; diff --git a/client/extensions/woocommerce/app/products/products-list.js b/client/extensions/woocommerce/app/products/products-list.js index d1cc5ddee7a44..2f4a5329ee540 100644 --- a/client/extensions/woocommerce/app/products/products-list.js +++ b/client/extensions/woocommerce/app/products/products-list.js @@ -16,11 +16,14 @@ import Button from 'components/button'; import EmptyContent from 'components/empty-content'; import { getLink } from 'woocommerce/lib/nav-utils'; import { getSelectedSiteWithFallback } from 'woocommerce/state/sites/selectors'; -import { getTotalProducts, areProductsLoaded } from 'woocommerce/state/sites/products/selectors'; import { - getProductListCurrentPage, - getProductListProducts, - getProductListRequestedPage, + getTotalProducts, + areProductsLoaded, + getProducts, +} from 'woocommerce/state/sites/products/selectors'; +import { + getProductsCurrentPage, + getProductsRequestedPage, } from 'woocommerce/state/ui/products/selectors'; import ProductsListPagination from './products-list-pagination'; import ProductsListTable from './products-list-table'; @@ -83,16 +86,15 @@ ProductsList.propTypes = { function mapStateToProps( state ) { const site = getSelectedSiteWithFallback( state ); - const currentPage = site && getProductListCurrentPage( state, site.ID ); + const currentPage = site && getProductsCurrentPage( state, site.ID ); + const currentQuery = { page: currentPage }; const currentPageLoaded = - site && currentPage && areProductsLoaded( state, { page: currentPage, per_page: 10 }, site.ID ); - const requestedPage = site && getProductListRequestedPage( state, site.ID ); + site && currentPage && areProductsLoaded( state, currentQuery, site.ID ); + const requestedPage = site && getProductsRequestedPage( state, site.ID ); const requestedPageLoaded = - site && - requestedPage && - areProductsLoaded( state, { page: requestedPage, per_page: 10 }, site.ID ); - const products = site && getProductListProducts( state, site.ID ); - const totalProducts = site && getTotalProducts( state, site.ID ); + site && requestedPage && areProductsLoaded( state, { page: requestedPage }, site.ID ); + const products = site && getProducts( state, currentQuery, site.ID ); + const totalProducts = site && getTotalProducts( state, currentQuery, site.ID ); return { site, diff --git a/client/extensions/woocommerce/components/product-search/index.js b/client/extensions/woocommerce/components/product-search/index.js index 65a199f9b9e63..6bb1e94c8f06e 100644 --- a/client/extensions/woocommerce/components/product-search/index.js +++ b/client/extensions/woocommerce/components/product-search/index.js @@ -12,10 +12,7 @@ import { trim } from 'lodash'; /** * Internal dependencies */ -import { - fetchProductSearchResults, - clearProductSearch, -} from 'woocommerce/state/sites/products/actions'; +import { fetchProducts } from 'woocommerce/state/sites/products/actions'; import ProductSearchResults from './results'; import SearchCard from 'components/search-card'; @@ -33,19 +30,16 @@ class ProductSearch extends Component { if ( trim( query ) === '' ) { this.setState( { query: '' } ); - this.props.clearProductSearch( siteId ); return; } this.setState( { query } ); - this.props.fetchProductSearchResults( siteId, 1, query ); + this.props.fetchProducts( siteId, { search: query } ); }; handleSelect = product => { - const { siteId } = this.props; // Clear the search field this.setState( { query: '' } ); - this.props.clearProductSearch( siteId ); this.refs.searchCard.clear(); // Pass products back to parent component @@ -70,12 +64,6 @@ class ProductSearch extends Component { } } -export default connect( null, dispatch => - bindActionCreators( - { - fetchProductSearchResults, - clearProductSearch, - }, - dispatch - ) -)( localize( ProductSearch ) ); +export default connect( null, dispatch => bindActionCreators( { fetchProducts }, dispatch ) )( + localize( ProductSearch ) +); diff --git a/client/extensions/woocommerce/components/product-search/results.js b/client/extensions/woocommerce/components/product-search/results.js index 321b27fbdf20e..ed1ea36d39f3a 100644 --- a/client/extensions/woocommerce/components/product-search/results.js +++ b/client/extensions/woocommerce/components/product-search/results.js @@ -14,10 +14,10 @@ import Gridicon from 'gridicons'; */ import CompactCard from 'components/card/compact'; import { - areProductSearchResultsLoaded, - areProductSearchResultsLoading, + areProductsLoaded, + areProductsLoading, + getProducts, } from 'woocommerce/state/sites/products/selectors'; -import { getProductSearchResults } from 'woocommerce/state/ui/products/selectors'; import ProductItem from './item'; class ProductSearchResults extends Component { @@ -92,14 +92,12 @@ class ProductSearchResults extends Component { export default connect( ( state, props ) => { const query = { - page: 1, - per_page: 10, search: props.search, }; return { - isLoaded: areProductSearchResultsLoaded( state, query ), - isLoading: areProductSearchResultsLoading( state, query ), - products: getProductSearchResults( state ) || [], + isLoaded: areProductsLoaded( state, query ), + isLoading: areProductsLoading( state, query ), + products: getProducts( state, query ) || [], }; } )( localize( ProductSearchResults ) ); diff --git a/client/extensions/woocommerce/state/sites/products/README.md b/client/extensions/woocommerce/state/sites/products/README.md index c5ddddcb11b7c..f35408c655b7f 100644 --- a/client/extensions/woocommerce/state/sites/products/README.md +++ b/client/extensions/woocommerce/state/sites/products/README.md @@ -9,14 +9,9 @@ This module is used to manage products for a site. Create a product on the remote site via API. May also call action creator callbacks: successAction on successful product creation, or failureAction on an error. -## Actions - ### `fetchProducts( siteId: number, params )` -Pull products from the remote site. Does not run if a specific server page is already loading. -Params passed here go through to the API endpoint for things like `page` or `offset` -Default `per_page` is 10 if none is specified. - +Pull products from the remote site. Does not run if a specific server page is already loading. Params passed here go through to the API endpoint for things like `page`, `offset`, or `search`. Defaults: `page: 1`, `per_page: 10` ## Reducer @@ -27,10 +22,23 @@ Products are collected in `products`, `isLoading` indicates which pages are bein ```js { "products": { - // Keyed by page number - "isLoading": { - 1: false, - 2: true + "queries": { + // Keyed by request parameters + '{}': { + "id": [ 31, 32, 33, … ] + "isLoading": false, + "totalPages": 2, + "totalProducts": 18, + }, + '{"page":2}': { + "isLoading": true, + }, + '{"search":"test"}': { + "id": [ 32, 43 … ] + "isLoading": false, + "totalPages": 2, + "totalProducts": 18, + } }, "products": { [ { @@ -41,9 +49,7 @@ Products are collected in `products`, `isLoading` indicates which pages are bein "date_created": "2013-06-07T10:49:51", … }, - ] }, - // The total number of pages for this site's products. - "totalPages": 6 + ] } } } ``` @@ -58,6 +64,18 @@ Whether the product list on a given page has been successfully loaded from the s Whether the product list on a given page is currently being retrieved from the server. Optional `siteId`, will default to currently selected site. -### `getTotalProductsPages( state, siteId: number )` +### `getProducts( state, params, siteId: number )` + +Get the products that belong to a given request query. Optional `siteId`, will default to currently selected site. + +### `getProduct( state, productId: number, siteId: number )` + +Get a single product matching a given product ID. Optional `siteId`, will default to currently selected site. + +### `getTotalProductsPages( state, params, siteId: number )` + +Gets the total number of pages of products available for a request query. Optional `siteId`, will default to currently selected site. + +### `getTotalProducts( state, params, siteId: number )` -Gets the total number of pages of products available on a site. Optional `siteId`, will default to currently selected site. +Gets the total number of products available for a request query. Optional `siteId`, will default to currently selected site. diff --git a/client/extensions/woocommerce/state/sites/products/actions.js b/client/extensions/woocommerce/state/sites/products/actions.js index bc4a5d4922c7a..266def7e9362d 100644 --- a/client/extensions/woocommerce/state/sites/products/actions.js +++ b/client/extensions/woocommerce/state/sites/products/actions.js @@ -3,17 +3,13 @@ * * @format */ - import qs from 'querystring'; - +import { omitBy } from 'lodash'; /** * Internal dependencies */ -import { - areProductsLoading, - areProductSearchResultsLoading, - getProductSearchQuery, -} from './selectors'; +import { areProductsLoading } from './selectors'; +import { DEFAULT_QUERY, getNormalizedProductsQuery } from './utils'; import { getSelectedSiteId } from 'state/ui/selectors'; import request from '../request'; import { setError } from '../status/wc-api/actions'; @@ -21,10 +17,6 @@ import { WOOCOMMERCE_PRODUCTS_REQUEST, WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, WOOCOMMERCE_PRODUCTS_REQUEST_FAILURE, - WOOCOMMERCE_PRODUCTS_SEARCH_CLEAR, - WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST, - WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_SUCCESS, - WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_FAILURE, WOOCOMMERCE_PRODUCT_CREATE, WOOCOMMERCE_PRODUCT_DELETE, WOOCOMMERCE_PRODUCT_DELETE_SUCCESS, @@ -164,22 +156,22 @@ export const fetchProducts = ( siteId, params ) => ( dispatch, getState ) => { siteId = getSelectedSiteId( state ); } - // Default per_page to 10. - params.per_page = params.per_page || 10; - - if ( areProductsLoading( state, params, siteId ) ) { + const query = { ...DEFAULT_QUERY, ...params }; + if ( areProductsLoading( state, query, siteId ) ) { return; } + const normalizedQuery = getNormalizedProductsQuery( query ); const fetchAction = { type: WOOCOMMERCE_PRODUCTS_REQUEST, siteId, - params, + params: normalizedQuery, }; dispatch( fetchAction ); + const queryString = qs.stringify( omitBy( query, val => '' === val ) ); return request( siteId ) - .getWithHeaders( `products?${ qs.stringify( params ) }` ) + .getWithHeaders( `products?${ queryString }` ) .then( response => { const { headers, data } = response; const totalPages = headers[ 'X-WP-TotalPages' ]; @@ -187,7 +179,7 @@ export const fetchProducts = ( siteId, params ) => ( dispatch, getState ) => { dispatch( { type: WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, siteId, - params, + params: normalizedQuery, totalPages, totalProducts, products: data, @@ -198,69 +190,8 @@ export const fetchProducts = ( siteId, params ) => ( dispatch, getState ) => { dispatch( { type: WOOCOMMERCE_PRODUCTS_REQUEST_FAILURE, siteId, - params, - error, - } ); - } ); -}; - -export const fetchProductSearchResults = ( siteId, page, query ) => ( dispatch, getState ) => { - const state = getState(); - if ( ! siteId ) { - siteId = getSelectedSiteId( state ); - } - - const params = { - page, - per_page: 10, - search: query, - }; - - if ( ! query ) { - if ( areProductSearchResultsLoading( state, params, siteId ) ) { - return; - } - query = getProductSearchQuery( state, siteId ); - params.search = query; - } - - const fetchAction = { - type: WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST, - siteId, - query, - params, - }; - dispatch( fetchAction ); - - return request( siteId ) - .getWithHeaders( `products?${ qs.stringify( params ) }` ) - .then( response => { - const { headers, data } = response; - const totalProducts = headers[ 'X-WP-Total' ]; - dispatch( { - type: WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_SUCCESS, - siteId, - query, - params, - totalProducts, - products: data, - } ); - } ) - .catch( error => { - dispatch( setError( siteId, fetchAction, error ) ); - dispatch( { - type: WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_FAILURE, - siteId, - query, - params, + params: normalizedQuery, error, } ); } ); }; - -export function clearProductSearch( siteId ) { - return { - type: WOOCOMMERCE_PRODUCTS_SEARCH_CLEAR, - siteId, - }; -} diff --git a/client/extensions/woocommerce/state/sites/products/reducer.js b/client/extensions/woocommerce/state/sites/products/reducer.js index 55c768b1d420c..8d08d1728852a 100644 --- a/client/extensions/woocommerce/state/sites/products/reducer.js +++ b/client/extensions/woocommerce/state/sites/products/reducer.js @@ -4,22 +4,19 @@ * @format */ -import { reject } from 'lodash'; +import { difference, forEach, get, reject } from 'lodash'; /** * Internal dependencies */ import { createReducer } from 'state/utils'; +import { getSerializedProductsQuery } from './utils'; import { WOOCOMMERCE_PRODUCT_DELETE_SUCCESS, WOOCOMMERCE_PRODUCTS_REQUEST, WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, WOOCOMMERCE_PRODUCTS_REQUEST_FAILURE, WOOCOMMERCE_PRODUCT_UPDATED, - WOOCOMMERCE_PRODUCTS_SEARCH_CLEAR, - WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST, - WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_SUCCESS, - WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_FAILURE, } from 'woocommerce/state/action-types'; export default createReducer( @@ -30,22 +27,16 @@ export default createReducer( [ WOOCOMMERCE_PRODUCTS_REQUEST ]: productsRequest, [ WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS ]: productsRequestSuccess, [ WOOCOMMERCE_PRODUCTS_REQUEST_FAILURE ]: productsRequestFailure, - [ WOOCOMMERCE_PRODUCTS_SEARCH_CLEAR ]: productsSearchClear, - [ WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST ]: productsSearchRequest, - [ WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_SUCCESS ]: productsSearchRequestSuccess, - [ WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_FAILURE ]: productsSearchRequestFailure, } ); -function productUpdated( state, action ) { - const { data } = action; - const products = state.products || []; - return { - ...state, - products: updateCachedProduct( products, data ), - }; -} - +/** + * Merge a product into the products list + * + * @param {Array} products A list of products + * @param {Object} product A single product to update or add to the products list + * @return {Array} Updated product list + */ function updateCachedProduct( products, product ) { let found = false; const newProducts = products.map( p => { @@ -63,91 +54,118 @@ function updateCachedProduct( products, product ) { return newProducts; } -export function productsRequestSuccess( state, action ) { - const prevState = state || {}; - const isLoading = setLoading( prevState, action.params, false ); - let products = ( prevState.products && [ ...prevState.products ] ) || []; - action.products.forEach( function( product ) { - products = updateCachedProduct( products, product ); - } ); - - return { - ...prevState, - products, - isLoading, - totalPages: action.totalPages, - totalProducts: action.totalProducts, - }; +/** + * Set the loading status for a param set in state + * + * @param {Object} state The current state + * @param {Object} params Params of the query to update + * @param {Boolean} newStatus The new value to save + * @return {Object} Updated isLoading state + */ +function setLoading( state, params, newStatus ) { + const queries = ( state.queries && { ...state.queries } ) || {}; + const key = getSerializedProductsQuery( params ); + queries[ key ] = { ...( queries[ key ] || {} ), isLoading: newStatus }; + return queries; } -export function productsDeleteSuccess( state, action ) { - const prevState = state || {}; - const prevProducts = prevState.products || []; - const newProducts = reject( prevProducts, { id: action.data.id } ); +/** + * Update a single product in the state + * + * @param {Object} state Current state + * @param {Object} action Action payload + * @return {Object} Updated state + */ +export function productUpdated( state, action ) { + const { data } = action; + const products = state.products || []; return { - ...prevState, - products: newProducts, + ...state, + products: updateCachedProduct( products, data ), }; } -export function productsRequest( state, action ) { - const prevState = state || {}; - const isLoading = setLoading( prevState, action.params, true ); - return { ...prevState, isLoading }; -} - -export function productsRequestFailure( state, action ) { - const prevState = state || {}; - const isLoading = setLoading( prevState, action.params, false ); - return { ...prevState, isLoading }; -} - -export function productsSearchRequest( state, action ) { - const prevState = state || {}; - const prevSearch = prevState.search || {}; - const isLoading = setLoading( prevSearch, action.params, true ); - return { ...prevState, search: { ...prevSearch, isLoading, query: action.query } }; -} - -export function productsSearchRequestFailure( state, action ) { - const prevState = state || {}; - const prevSearch = prevState.search || {}; - const isLoading = setLoading( prevSearch, action.params, false ); - return { ...prevState, search: { ...prevSearch, isLoading, query: action.query } }; -} - -export function productsSearchRequestSuccess( state, action ) { - const prevState = state || {}; - const prevSearch = prevState.search || {}; - const isLoading = setLoading( prevSearch, action.params, false ); - - let products = ( prevState.products && [ ...prevState.products ] ) || []; +/** + * Update the product state after products load + * + * @param {Object} state Current state + * @param {Object} action Action payload + * @return {Object} Updated state + */ +export function productsRequestSuccess( state = {}, action ) { + let products = get( state, 'products', [] ); action.products.forEach( function( product ) { products = updateCachedProduct( products, product ); } ); - return { - ...prevState, - products, - search: { - ...prevSearch, + const ids = action.products.map( p => p.id ); + const isLoading = false; + const totalPages = get( action, 'totalPages', 0 ); + const totalProducts = get( action, 'totalProducts', 0 ); + + const query = getSerializedProductsQuery( action.params ); + const prevQueries = get( state, 'queries', {} ); + const queries = { + ...prevQueries, + [ query ]: { + ids, isLoading, - query: action.query, - totalProducts: action.totalProducts, + totalPages, + totalProducts, }, }; + + return { + ...state, + products, + queries, + }; } -export function productsSearchClear( state ) { - const prevState = state || {}; +/** + * Delete a product from the state + * + * @param {Object} state Current state + * @param {Object} action Action payload + * @return {Object} Updated state + */ +export function productsDeleteSuccess( state = {}, action ) { + const products = get( state, 'products', [] ); + const id = action.data.id; + const newProducts = reject( products, { id } ); + const newQueries = {}; + forEach( get( state, 'queries', {} ), ( item, key ) => { + const ids = difference( item.ids, [ id ] ); + newQueries[ key ] = { ...item, ids }; + } ); + return { - ...prevState, - search: {}, + ...state, + queries: newQueries, + products: newProducts, }; } -function setLoading( state, params, newStatus ) { - const isLoading = ( state.isLoading && { ...state.isLoading } ) || {}; - isLoading[ JSON.stringify( params ) ] = newStatus; - return isLoading; +/** + * Store that a product request has been started + * + * @param {Object} state Current state + * @param {Object} action Action payload + * @return {Object} Updated state + */ +export function productsRequest( state = {}, action ) { + const queries = setLoading( state, action.params, true ); + return { ...state, queries }; +} + +/** + * Store that the product request has failed + * + * @param {Object} state Current state + * @param {Object} action Action payload + * @return {Object} Updated state + */ +export function productsRequestFailure( state = {}, action ) { + const queries = setLoading( state, action.params, false ); + return { ...state, queries }; } diff --git a/client/extensions/woocommerce/state/sites/products/selectors.js b/client/extensions/woocommerce/state/sites/products/selectors.js index 3c5bfde6d22d7..5056eb2834450 100644 --- a/client/extensions/woocommerce/state/sites/products/selectors.js +++ b/client/extensions/woocommerce/state/sites/products/selectors.js @@ -10,6 +10,7 @@ import { get, find } from 'lodash'; * Internal dependencies */ import { getSelectedSiteId } from 'state/ui/selectors'; +import { getSerializedProductsQuery } from './utils'; export const getProduct = ( state, productId, siteId = getSelectedSiteId( state ) ) => { const allProducts = get( state, [ @@ -29,23 +30,15 @@ export const getProduct = ( state, productId, siteId = getSelectedSiteId( state * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used * @return {boolean} Whether the products list for a requested page has been successfully loaded from the server */ -export const areProductsLoaded = ( - state, - params = { page: 1, per_page: 10 }, - siteId = getSelectedSiteId( state ) -) => { - const key = JSON.stringify( params ); - const isLoadingKey = get( state, [ - 'extensions', - 'woocommerce', - 'sites', - siteId, - 'products', - 'isLoading', - key, - ] ); +export const areProductsLoaded = ( state, params = {}, siteId = getSelectedSiteId( state ) ) => { + const key = getSerializedProductsQuery( params ); + const isLoading = get( + state, + [ 'extensions', 'woocommerce', 'sites', siteId, 'products', 'queries', key, 'isLoading' ], + null + ); // Strict check because it could also be undefined. - return false === isLoadingKey; + return false === isLoading; }; /** @@ -54,125 +47,72 @@ export const areProductsLoaded = ( * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used * @return {boolean} Whether the products list for a request page is currently being retrieved from the server */ -export const areProductsLoading = ( - state, - params = { page: 1, per_page: 10 }, - siteId = getSelectedSiteId( state ) -) => { - const key = JSON.stringify( params ); - const isLoadingKey = get( state, [ - 'extensions', - 'woocommerce', - 'sites', - siteId, - 'products', - 'isLoading', - key, - ] ); - // Strict check because it could also be undefined. - return true === isLoadingKey; -}; - -/** - * @param {Object} state Whole Redux state tree - * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used - * @return {Number} Total number of pages of products available on a site, or 0 if not loaded yet. - */ -export const getTotalProductsPages = ( state, siteId = getSelectedSiteId( state ) ) => { - return get( +export const areProductsLoading = ( state, params = {}, siteId = getSelectedSiteId( state ) ) => { + const key = getSerializedProductsQuery( params ); + const isLoading = get( state, - [ 'extensions', 'woocommerce', 'sites', siteId, 'products', 'totalPages' ], - 0 + [ 'extensions', 'woocommerce', 'sites', siteId, 'products', 'queries', key, 'isLoading' ], + null ); + // Strict check because it could also be undefined. + return true === isLoading; }; /** * @param {Object} state Whole Redux state tree + * @param {Object} [params] Query used to fetch products. Can contain page, search, etc. If not provided, + * defaults to first page, all products * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used - * @return {Number} Total number of products available on a site, or 0 if not loaded yet. + * @return {array|false} List of products, or false if there was an error */ -export const getTotalProducts = ( state, siteId = getSelectedSiteId( state ) ) => { - return get( +export const getProducts = ( state, params = {}, siteId = getSelectedSiteId( state ) ) => { + if ( ! areProductsLoaded( state, params, siteId ) ) { + return []; + } + const key = getSerializedProductsQuery( params ); + const products = get( state, `extensions.woocommerce.sites[${ siteId }].products.products`, [] ); + const productIdsOnPage = get( state, - [ 'extensions', 'woocommerce', 'sites', siteId, 'products', 'totalProducts' ], - 0 + `extensions.woocommerce.sites[${ siteId }].products.queries[${ key }].ids`, + [] ); -}; -/** - * @param {Object} state Whole Redux state tree - * @param {Number} [params] Params given to API request. Defaults to { page: 1, per_page: 10 } - * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used - * @return {boolean} Whether the products search results have been successfully loaded from the server - */ -export const areProductSearchResultsLoaded = ( - state, - params = { page: 1, per_page: 10 }, - siteId = getSelectedSiteId( state ) -) => { - const key = JSON.stringify( params ); - const isLoadingKey = get( state, [ - 'extensions', - 'woocommerce', - 'sites', - siteId, - 'products', - 'search', - 'isLoading', - key, - ] ); - // Strict check because it could also be undefined. - return false === isLoadingKey; + if ( productIdsOnPage.length ) { + return productIdsOnPage.map( id => find( products, { id } ) ); + } + return false; }; /** * @param {Object} state Whole Redux state tree * @param {Number} [params] Params given to API request. Defaults to { page: 1, per_page: 10 } * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used - * @return {boolean} Whether the product search results are currently being retrieved from the server + * @return {Number} Total number of pages of products available on a site, or 0 if not loaded yet. */ -export const areProductSearchResultsLoading = ( +export const getTotalProductsPages = ( state, - params = { page: 1, per_page: 10 }, + params = {}, siteId = getSelectedSiteId( state ) ) => { - const key = JSON.stringify( params ); - const isLoadingKey = get( state, [ - 'extensions', - 'woocommerce', - 'sites', - siteId, - 'products', - 'search', - 'isLoading', - key, - ] ); - // Strict check because it could also be undefined. - return true === isLoadingKey; -}; - -/** - * @param {Object} state Whole Redux state tree - * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used - * @return {Number} Total number of products available for a search, or 0 if not loaded yet. - */ -export const getTotalProductSearchResults = ( state, siteId = getSelectedSiteId( state ) ) => { + const key = getSerializedProductsQuery( params ); return get( state, - [ 'extensions', 'woocommerce', 'sites', siteId, 'products', 'search', 'totalProducts' ], + [ 'extensions', 'woocommerce', 'sites', siteId, 'products', 'queries', key, 'totalPages' ], 0 ); }; /** * @param {Object} state Whole Redux state tree + * @param {Number} [params] Params given to API request. Defaults to { page: 1, per_page: 10 } * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used - * @return {string|null} Query for a search or null if no search is active. + * @return {Number} Total number of products available on a site, or 0 if not loaded yet. */ -export const getProductSearchQuery = ( state, siteId = getSelectedSiteId( state ) ) => { +export const getTotalProducts = ( state, params = {}, siteId = getSelectedSiteId( state ) ) => { + const key = getSerializedProductsQuery( params ); return get( state, - [ 'extensions', 'woocommerce', 'sites', siteId, 'products', 'search', 'query' ], - null + [ 'extensions', 'woocommerce', 'sites', siteId, 'products', 'queries', key, 'totalProducts' ], + 0 ); }; diff --git a/client/extensions/woocommerce/state/sites/products/test/actions.js b/client/extensions/woocommerce/state/sites/products/test/actions.js index c3503325ef346..009bb63f9cf3f 100644 --- a/client/extensions/woocommerce/state/sites/products/test/actions.js +++ b/client/extensions/woocommerce/state/sites/products/test/actions.js @@ -9,12 +9,7 @@ import { spy } from 'sinon'; /** * Internal dependencies */ -import { - fetchProducts, - fetchProductSearchResults, - clearProductSearch, - deleteProduct, -} from '../actions'; +import { fetchProducts, deleteProduct } from '../actions'; import product from './fixtures/product'; import products from './fixtures/products'; import useNock from 'test/helpers/use-nock'; @@ -25,10 +20,6 @@ import { WOOCOMMERCE_PRODUCTS_REQUEST, WOOCOMMERCE_PRODUCTS_REQUEST_FAILURE, WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, - WOOCOMMERCE_PRODUCTS_SEARCH_CLEAR, - WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST, - WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_SUCCESS, - WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_FAILURE, } from 'woocommerce/state/action-types'; describe( 'actions', () => { @@ -52,6 +43,42 @@ describe( 'actions', () => { path: '/wc/v3/products&page=invalid&per_page=10&_envelope&_method=get', json: true, } ) + .reply( 200, { + data: { + message: 'Invalid parameter(s): page', + error: 'rest_invalid_param', + status: 400, + }, + } ) + .get( '/rest/v1.1/jetpack-blogs/123/rest-api/' ) + .query( { + path: '/wc/v3/products&page=1&per_page=10&search=testing&_envelope&_method=get', + json: true, + } ) + .reply( 200, { + data: { + body: products, + headers: { 'X-WP-TotalPages': 3, 'X-WP-Total': 28 }, + status: 200, + }, + } ) + .get( '/rest/v1.1/jetpack-blogs/123/rest-api/' ) + .query( { + path: '/wc/v3/products&page=2&per_page=10&search=testing&_envelope&_method=get', + json: true, + } ) + .reply( 200, { + data: { + body: [ product ], + headers: { 'X-WP-TotalPages': 3, 'X-WP-Total': 28 }, + status: 200, + }, + } ) + .get( '/rest/v1.1/jetpack-blogs/234/rest-api/' ) + .query( { + path: '/wc/v3/products&page=invalid&per_page=10&search=testing&_envelope&_method=get', + json: true, + } ) .reply( 200, { data: { message: 'Invalid parameter(s): page', @@ -61,14 +88,14 @@ describe( 'actions', () => { } ); } ); - test( 'should dispatch an action', () => { + test( 'should dispatch an action for the default product list', () => { const getState = () => ( {} ); const dispatch = spy(); fetchProducts( siteId, { page: 1 } )( dispatch, getState ); expect( dispatch ).to.have.been.calledWith( { type: WOOCOMMERCE_PRODUCTS_REQUEST, siteId, - params: { page: 1, per_page: 10 }, + params: {}, } ); } ); @@ -81,7 +108,7 @@ describe( 'actions', () => { expect( dispatch ).to.have.been.calledWith( { type: WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, siteId, - params: { page: 1, per_page: 10 }, + params: {}, totalPages: 3, totalProducts: 30, products, @@ -110,7 +137,7 @@ describe( 'actions', () => { [ siteId ]: { products: { isLoading: { - [ JSON.stringify( { page: 1, per_page: 10 } ) ]: true, + '{}': true, }, }, }, @@ -122,104 +149,49 @@ describe( 'actions', () => { fetchProducts( siteId, { page: 1 } )( dispatch, getState ); expect( dispatch ).to.not.have.beenCalled; } ); - } ); - describe( '#fetchProductSearchResults()', () => { - const siteId = '123'; - - useNock( nock => { - nock( 'https://public-api.wordpress.com:443' ) - .persist() - .get( '/rest/v1.1/jetpack-blogs/123/rest-api/' ) - .query( { - path: '/wc/v3/products&page=1&per_page=10&search=testing&_envelope&_method=get', - json: true, - } ) - .reply( 200, { - data: { - body: products, - headers: { 'X-WP-Total': 28 }, - status: 200, - }, - } ) - .get( '/rest/v1.1/jetpack-blogs/123/rest-api/' ) - .query( { - path: '/wc/v3/products&page=2&per_page=10&search=testing&_envelope&_method=get', - json: true, - } ) - .reply( 200, { - data: { - body: [ product ], - headers: { 'X-WP-Total': 28 }, - status: 200, - }, - } ) - .get( '/rest/v1.1/jetpack-blogs/234/rest-api/' ) - .query( { - path: '/wc/v3/products&page=invalid&per_page=10&search=testing&_envelope&_method=get', - json: true, - } ) - .reply( 200, { - data: { - message: 'Invalid parameter(s): page', - error: 'rest_invalid_param', - status: 400, - }, - } ); - } ); - test( 'should dispatch an action', () => { + test( 'should dispatch a success action with search results when request completes', () => { const getState = () => ( {} ); const dispatch = spy(); - fetchProductSearchResults( siteId, 1, 'testing' )( dispatch, getState ); - expect( dispatch ).to.have.been.calledWith( { - type: WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST, - siteId, - params: { page: 1, per_page: 10, search: 'testing' }, - query: 'testing', - } ); - } ); - - test( 'should dispatch a success action with results when request completes', () => { - const getState = () => ( {} ); - const dispatch = spy(); - const response = fetchProductSearchResults( siteId, 1, 'testing' )( dispatch, getState ); + const response = fetchProducts( siteId, { search: 'testing' } )( dispatch, getState ); return response.then( () => { expect( dispatch ).to.have.been.calledWith( { - type: WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_SUCCESS, + type: WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, siteId, - params: { page: 1, per_page: 10, search: 'testing' }, + params: { search: 'testing' }, + totalPages: 3, totalProducts: 28, products, - query: 'testing', } ); } ); } ); - test( 'should dispatch a failure action with the error when a the request fails', () => { + test( 'should dispatch a failure action with the error when the search request fails', () => { const getState = () => ( {} ); const dispatch = spy(); - const response = fetchProductSearchResults( 234, 'invalid', 'testing' )( dispatch, getState ); + const response = fetchProducts( 234, { page: 'invalid', search: 'testing' } )( + dispatch, + getState + ); return response.then( () => { expect( dispatch ).to.have.been.calledWithMatch( { - type: WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_FAILURE, + type: WOOCOMMERCE_PRODUCTS_REQUEST_FAILURE, siteId: 234, } ); } ); } ); - test( 'should not dispatch if results are already loading for this page', () => { + test( 'should not dispatch if a search query is already loading for this term', () => { const getState = () => ( { extensions: { woocommerce: { sites: { [ siteId ]: { products: { - search: { - isLoading: { - 1: true, - }, + isLoading: { + '{"search":"testing"}': true, }, }, }, @@ -228,55 +200,14 @@ describe( 'actions', () => { }, } ); const dispatch = spy(); - fetchProductSearchResults( siteId, 1, 'testing' )( dispatch, getState ); + fetchProducts( siteId, { search: 'testing' } )( dispatch, getState ); expect( dispatch ).to.not.have.beenCalled; } ); - test( 'should get query from state if no new query is passed', () => { - const getState = () => ( { - extensions: { - woocommerce: { - sites: { - [ siteId ]: { - products: { - search: { - isLoading: { - [ JSON.stringify( { page: 1, per_page: 10 } ) ]: false, - }, - query: 'testing', - totalProducts: 28, - }, - }, - }, - }, - }, - }, - } ); - const dispatch = spy(); - const response = fetchProductSearchResults( siteId, 2 )( dispatch, getState ); - return response.then( () => { - expect( dispatch ).to.have.been.calledWith( { - type: WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_SUCCESS, - siteId, - params: { page: 2, per_page: 10, search: 'testing' }, - totalProducts: 28, - products: [ product ], - query: 'testing', - } ); - } ); - } ); - } ); - describe( '#clearProductSearch()', () => { - const siteId = '123'; - test( 'should dispatch an action', () => { - const dispatch = spy(); - dispatch( clearProductSearch( siteId ) ); - expect( dispatch ).to.have.been.calledWith( { - type: WOOCOMMERCE_PRODUCTS_SEARCH_CLEAR, - siteId, - } ); - } ); + // @todo Need to revisit this use case + // test( 'should get query from state if no new query is passed', () => {} ); } ); + describe( '#deleteProduct()', () => { const siteId = '123'; diff --git a/client/extensions/woocommerce/state/sites/products/test/fixtures/product.js b/client/extensions/woocommerce/state/sites/products/test/fixtures/product.js index 9a34c63f94a25..54ffcebaed291 100644 --- a/client/extensions/woocommerce/state/sites/products/test/fixtures/product.js +++ b/client/extensions/woocommerce/state/sites/products/test/fixtures/product.js @@ -1,8 +1,5 @@ -/** - * /* eslint-disable - * - * @format - */ +/** @format */ +/* eslint-disable */ export default { id: 31, diff --git a/client/extensions/woocommerce/state/sites/products/test/fixtures/products.js b/client/extensions/woocommerce/state/sites/products/test/fixtures/products.js index 4eb4fcbf019c7..55de202757f44 100644 --- a/client/extensions/woocommerce/state/sites/products/test/fixtures/products.js +++ b/client/extensions/woocommerce/state/sites/products/test/fixtures/products.js @@ -1,8 +1,5 @@ -/** - * /* eslint-disable - * - * @format - */ +/** @format */ +/* eslint-disable */ export default [ { diff --git a/client/extensions/woocommerce/state/sites/products/test/reducer.js b/client/extensions/woocommerce/state/sites/products/test/reducer.js index 03a1949e94348..e6da9aef6dd69 100644 --- a/client/extensions/woocommerce/state/sites/products/test/reducer.js +++ b/client/extensions/woocommerce/state/sites/products/test/reducer.js @@ -4,6 +4,7 @@ * External dependencies */ import { expect } from 'chai'; +import deepFreeze from 'deep-freeze'; /** * Internal dependencies @@ -13,10 +14,6 @@ import { productsRequest, productsRequestSuccess, productsRequestFailure, - productsSearchRequest, - productsSearchRequestFailure, - productsSearchRequestSuccess, - productsSearchClear, } from '../reducer'; import product from './fixtures/product'; import products from './fixtures/products'; @@ -25,15 +22,11 @@ import { WOOCOMMERCE_PRODUCTS_REQUEST, WOOCOMMERCE_PRODUCTS_REQUEST_FAILURE, WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, - WOOCOMMERCE_PRODUCTS_SEARCH_CLEAR, - WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST, - WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_SUCCESS, - WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_FAILURE, } from 'woocommerce/state/action-types'; describe( 'reducer', () => { describe( 'productsRequest', () => { - test( 'should store the currently loading page', () => { + test( 'should indicate loading the default query', () => { const params = { page: 1, per_page: 10 }; const action = { type: WOOCOMMERCE_PRODUCTS_REQUEST, @@ -41,237 +34,246 @@ describe( 'reducer', () => { params, }; const newState = productsRequest( undefined, action ); - expect( newState.isLoading ).to.exist; - expect( newState.isLoading[ JSON.stringify( params ) ] ).to.be.true; + expect( newState.queries ).to.exist; + expect( newState.queries[ '{}' ].isLoading ).to.be.true; + } ); + test( 'should indicate loading a search query', () => { + const action = { + type: WOOCOMMERCE_PRODUCTS_REQUEST, + siteId: 123, + params: { page: 1, per_page: 10, search: 'testing' }, + }; + const newState = productsRequest( undefined, action ); + expect( newState.queries ).to.exist; + expect( newState.queries[ '{"search":"testing"}' ].isLoading ).to.be.true; } ); } ); describe( 'productsRequestSuccess', () => { test( 'should should show that request is no longer loading', () => { - const params = { page: 1, per_page: 10 }; const action = { type: WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, siteId: 123, - params, + params: { page: 1, per_page: 10 }, totalPages: 3, products, }; - const newState = productsRequestSuccess( - { isLoading: { [ JSON.stringify( params ) ]: true } }, - action - ); - expect( newState.isLoading ).to.exist; - expect( newState.isLoading[ JSON.stringify( params ) ] ).to.be.false; + const newState = productsRequestSuccess( { isLoading: { '{}': true } }, action ); + expect( newState.queries ).to.exist; + expect( newState.queries[ '{}' ].isLoading ).to.be.false; + } ); + test( 'should should show that a search request is no longer loading', () => { + const action = { + type: WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, + siteId: 123, + products, + params: { page: 1, per_page: 10, search: 'testing' }, + totalProducts: 28, + }; + const originalState = { isLoading: { '{"search":"testing"}': true } }; + const newState = productsRequestSuccess( originalState, action ); + expect( newState.queries ).to.exist; + expect( newState.queries[ '{"search":"testing"}' ].isLoading ).to.be.false; } ); + test( 'should store the products in state', () => { const action = { type: WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, siteId: 123, params: { page: 1, per_page: 10 }, totalPages: 3, + totalProducts: 30, products, }; const newState = productsRequestSuccess( undefined, action ); expect( newState.products ).to.eql( products ); + expect( newState.queries[ '{}' ].ids ).to.eql( [ 15, 389 ] ); } ); test( 'should add new products onto the existing list', () => { - const params = { page: 2, per_page: 10 }; const additionalProducts = [ product ]; const action = { type: WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, siteId: 123, - page: 2, + params: { page: 2, per_page: 10 }, totalPages: 3, + totalProducts: 30, products: additionalProducts, }; const originalState = { products, - isLoading: { [ JSON.stringify( params ) ]: false }, - totalPages: 3, + isLoading: { '{}': false }, }; const newState = productsRequestSuccess( originalState, action ); expect( newState.products ).to.eql( [ ...products, ...additionalProducts ] ); + expect( newState.queries[ '{"page":2}' ].ids ).to.eql( [ 31 ] ); } ); - test( 'should store the total number of pages', () => { + test( 'should store the search result products in state', () => { const action = { type: WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, siteId: 123, - params: { page: 1, per_page: 10 }, + params: { page: 1, per_page: 10, search: 'testing' }, totalPages: 3, + totalProducts: 28, products, }; const newState = productsRequestSuccess( undefined, action ); - expect( newState.totalPages ).to.eql( 3 ); + expect( newState.products ).to.eql( products ); + expect( newState.queries[ '{"search":"testing"}' ].ids ).to.eql( [ 15, 389 ] ); } ); - test( 'should store the total number of products', () => { + test( 'should add new search result products onto the existing list', () => { + const additionalProducts = [ product ]; const action = { type: WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, siteId: 123, - params: { page: 1, per_page: 10 }, + params: { page: 2, per_page: 10, search: 'testing' }, totalPages: 3, - totalProducts: 30, + totalProducts: 28, + products: additionalProducts, + }; + const originalState = { + isLoading: { [ '{"search":"testing"}' ]: false }, products, }; - const newState = productsRequestSuccess( undefined, action ); - expect( newState.totalProducts ).to.eql( 30 ); + const newState = productsRequestSuccess( originalState, action ); + expect( newState.products ).to.eql( [ ...products, ...additionalProducts ] ); + expect( newState.queries[ '{"page":2,"search":"testing"}' ].ids ).to.eql( [ 31 ] ); } ); - } ); - describe( 'productsRequestFailure', () => { - test( 'should should show that request has loaded on failure', () => { - const params = { page: 1, per_page: 10 }; + test( 'should store the search result products in state alongside other product list queries', () => { const action = { - type: WOOCOMMERCE_PRODUCTS_REQUEST_FAILURE, + type: WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, siteId: 123, - params, - error: {}, + params: { page: 1, per_page: 10, search: 'testing' }, + totalPages: 3, + totalProducts: 28, + products: [ product ], }; - - const newState = productsRequestFailure( - { isLoading: { [ JSON.stringify( params ) ]: true } }, - action - ); - expect( newState.isLoading[ JSON.stringify( params ) ] ).to.be.false; - } ); - } ); - describe( 'productsSearchRequest', () => { - test( 'should store the currently loading page', () => { - const params = { page: 1, per_page: 10 }; - const action = { - type: WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST, - siteId: 123, - params, - query: 'test', + const originalState = { + products, + queries: { + '{}': { + ids: [ 15, 389 ], + }, + }, }; - const newState = productsSearchRequest( undefined, action ); - expect( newState.search.isLoading ).to.eql( { [ JSON.stringify( params ) ]: true } ); + const newState = productsRequestSuccess( originalState, action ); + expect( newState.products ).to.eql( [ ...products, product ] ); + expect( newState.queries[ '{}' ].ids ).to.eql( [ 15, 389 ] ); + expect( newState.queries[ '{"search":"testing"}' ].ids ).to.eql( [ 31 ] ); } ); - test( 'should store the query', () => { + + test( 'should store the total number of pages', () => { const action = { - type: WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST, + type: WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, siteId: 123, params: { page: 1, per_page: 10 }, - query: 'testing', - }; - const newState = productsSearchRequest( undefined, action ); - expect( newState.search.query ).to.eql( 'testing' ); - } ); - } ); - describe( 'productsSearchRequestSuccess', () => { - test( 'should should show that request is no longer loading', () => { - const params = { page: 1, per_page: 10 }; - const action = { - type: WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_SUCCESS, - siteId: 123, + totalPages: 3, + totalProducts: 30, products, - params, - totalProducts: 28, - query: 'testing', }; - const newState = productsSearchRequestSuccess( - { - search: { isLoading: { [ JSON.stringify( params ) ]: true } }, - }, - action - ); - expect( newState.search.isLoading[ JSON.stringify( params ) ] ).to.be.false; + const newState = productsRequestSuccess( undefined, action ); + expect( newState.queries[ '{}' ].totalPages ).to.eql( 3 ); } ); - test( 'should store the products in state', () => { - const params = { page: 1, per_page: 10 }; + + test( 'should store the total number of products', () => { const action = { - type: WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_SUCCESS, + type: WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, siteId: 123, + params: { page: 1, per_page: 10 }, + totalPages: 3, + totalProducts: 30, products, - params, - totalProducts: 28, - query: 'testing', }; - const newState = productsSearchRequestSuccess( undefined, action ); - expect( newState.products ).to.eql( products ); + const newState = productsRequestSuccess( undefined, action ); + expect( newState.queries[ '{}' ].totalProducts ).to.eql( 30 ); } ); - test( 'should add new products onto the existing list', () => { - const params = { page: 2, per_page: 10 }; - const additionalProducts = [ product ]; + test( 'should store the total number of products on a search result', () => { const action = { - type: WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_SUCCESS, + type: WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, siteId: 123, - params, + params: { page: 1, per_page: 10, search: 'testing' }, totalProducts: 28, - query: 'testing', - products: additionalProducts, - }; - const originalState = { products, - search: { - isLoading: { [ JSON.stringify( { page: 1, per_page: 10 } ) ]: false }, - totalProducts: 28, - }, }; - const newState = productsSearchRequestSuccess( originalState, action ); - expect( newState.products ).to.eql( [ ...products, ...additionalProducts ] ); + const newState = productsRequestSuccess( undefined, action ); + expect( newState.queries[ '{"search":"testing"}' ].totalProducts ).to.eql( 28 ); } ); - test( 'should store the total number of products', () => { + } ); + + describe( 'productsRequestFailure', () => { + test( 'should should show that request has finished on failure', () => { const params = { page: 1, per_page: 10 }; const action = { - type: WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_SUCCESS, + type: WOOCOMMERCE_PRODUCTS_REQUEST_FAILURE, siteId: 123, params, - totalProducts: 28, - query: 'testing', - products, + error: {}, }; - const newState = productsSearchRequestSuccess( undefined, action ); - expect( newState.search.totalProducts ).to.eql( 28 ); + + const newState = productsRequestFailure( { isLoading: { [ '{}' ]: true } }, action ); + expect( newState.queries[ '{}' ].isLoading ).to.be.false; } ); - } ); - describe( 'productsSearchRequestFailure', () => { - test( 'should should show that request has loaded on failure', () => { - const params = { page: 1, per_page: 10 }; + test( 'should should show that search result request has finished on failure', () => { const action = { - type: WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_FAILURE, + type: WOOCOMMERCE_PRODUCTS_REQUEST_FAILURE, siteId: 123, - params, - query: 'testing', + params: { page: 1, per_page: 10, search: 'testing' }, error: {}, }; + const originalState = { isLoading: { '{"search":"testing"}': true } }; + const newState = productsRequestFailure( originalState, action ); + expect( newState.queries[ '{"search":"testing"}' ].isLoading ).to.be.false; + } ); + } ); - const newState = productsSearchRequestFailure( - { - search: { isLoading: { [ JSON.stringify( params ) ]: true } }, + describe( 'productsDeleteSuccess', () => { + const originalState = deepFreeze( { + queries: { + '{}': { + isLoading: false, + ids: [ 15, 31, 389 ], + totalPages: 1, + totalProducts: 3, }, - action - ); - expect( newState.search.isLoading[ JSON.stringify( params ) ] ).to.be.false; + '{"search":"example"}': { + isLoading: false, + ids: [ 15, 389 ], + totalPages: 1, + totalProducts: 2, + }, + }, + products: [ ...products, product ], } ); - } ); - describe( 'productsSearchClear', () => { - test( 'should reset search state', () => { + + test( 'should remove the product from the products list', () => { const action = { - type: WOOCOMMERCE_PRODUCTS_SEARCH_CLEAR, + type: WOOCOMMERCE_PRODUCTS_DELETE_SUCCESS, siteId: 123, + data: product, }; - const newState = productsSearchClear( - { - search: { isLoading: { [ JSON.stringify( { page: 1, per_page: 10 } ) ]: true } }, - }, - action - ); - expect( newState.search ).to.eql( {} ); + const newState = productsDeleteSuccess( originalState, action ); + expect( newState.products ).to.eql( products ); } ); - } ); - describe( 'productsDeleteSuccess', () => { - test( 'should remove the product from the products list', () => { + + test( 'should remove the product from any query results', () => { const action = { type: WOOCOMMERCE_PRODUCTS_DELETE_SUCCESS, siteId: 123, data: product, }; - const additionalProducts = [ product ]; - const newState = productsDeleteSuccess( - { products: [ ...products, ...additionalProducts ] }, - action - ); - expect( newState.products ).to.eql( products ); + const newState = productsDeleteSuccess( originalState, action ); + expect( newState.queries[ '{}' ].ids ).to.eql( [ 15, 389 ] ); + } ); + + test( 'should not remove any IDs from unaffected query results', () => { + const action = { + type: WOOCOMMERCE_PRODUCTS_DELETE_SUCCESS, + siteId: 123, + data: product, + }; + + const newState = productsDeleteSuccess( originalState, action ); + expect( newState.queries[ '{"search":"example"}' ].ids ).to.eql( [ 15, 389 ] ); } ); } ); } ); diff --git a/client/extensions/woocommerce/state/sites/products/test/selectors.js b/client/extensions/woocommerce/state/sites/products/test/selectors.js index 9277e953490bb..5f7b51bcee92c 100644 --- a/client/extensions/woocommerce/state/sites/products/test/selectors.js +++ b/client/extensions/woocommerce/state/sites/products/test/selectors.js @@ -10,14 +10,11 @@ import { expect } from 'chai'; */ import { getProduct, + getProducts, areProductsLoaded, areProductsLoading, getTotalProductsPages, getTotalProducts, - areProductSearchResultsLoaded, - areProductSearchResultsLoading, - getTotalProductSearchResults, - getProductSearchQuery, } from '../selectors'; import products from './fixtures/products'; @@ -32,14 +29,13 @@ const loadingState = { sites: { 123: { products: { - isLoading: { - [ JSON.stringify( { page: 1, per_page: 10 } ) ]: true, - }, - search: { - isLoading: { - [ JSON.stringify( { page: 1, per_page: 10 } ) ]: true, + queries: { + '{}': { + isLoading: true, + }, + '{"search":"testing"}': { + isLoading: true, }, - query: 'testing', }, products: {}, }, @@ -54,33 +50,34 @@ const loadedState = { sites: { 123: { products: { - search: { - isLoading: { - [ JSON.stringify( { page: 1, per_page: 10 } ) ]: false, - }, - totalProducts: 28, - query: 'testing', - }, isLoading: { - [ JSON.stringify( { page: 1, per_page: 10 } ) ]: false, + '{}': false, + '{"search":"testing"}': false, + }, + queries: { + '{}': { + ids: [ 15, 389 ], + isLoading: false, + totalPages: 3, + totalProducts: 30, + }, + '{"search":"testing"}': { + ids: [ 15 ], + isLoading: false, + totalPages: 2, + totalProducts: 16, + }, }, products, - totalPages: 3, - totalProducts: 30, }, }, 401: { products: { - search: { - isLoading: { - [ JSON.stringify( { page: 1, per_page: 10 } ) ]: true, - }, + queries: { + '{}': { isLoading: true }, + '{"search":"testing"}': { isLoading: true }, }, - isLoading: { - [ JSON.stringify( { page: 1, per_page: 10 } ) ]: true, - }, - products: {}, - totalPages: 1, + products: [], }, }, }, @@ -124,6 +121,14 @@ describe( 'selectors', () => { expect( areProductsLoaded( loadedState, params, 456 ) ).to.be.false; } ); + test( 'should be false when a search request is currently being fetched.', () => { + expect( areProductsLoaded( loadingState, { search: 'testing' }, 123 ) ).to.be.false; + } ); + + test( 'should be true when a search request is loaded.', () => { + expect( areProductsLoaded( loadedState, { search: 'testing' }, 123 ) ).to.be.true; + } ); + test( 'should get the siteId from the UI tree if not provided.', () => { expect( areProductsLoaded( loadedStateWithUi, params ) ).to.be.true; } ); @@ -148,143 +153,98 @@ describe( 'selectors', () => { expect( areProductsLoading( loadedState, params, 456 ) ).to.be.false; } ); - test( 'should get the siteId from the UI tree if not provided.', () => { - expect( areProductsLoading( loadedStateWithUi, params ) ).to.be.false; + test( 'should be true when a search request is currently being fetched.', () => { + expect( areProductsLoading( loadingState, { search: 'testing' }, 123 ) ).to.be.true; } ); - } ); - describe( '#getTotalProductsPages', () => { - test( 'should be 0 (default) when woocommerce state is not available.', () => { - expect( getTotalProductsPages( preInitializedState, 123 ) ).to.eql( 0 ); - } ); - - test( 'should be 0 (default) when products are loading.', () => { - expect( getTotalProductsPages( loadingState, 123 ) ).to.eql( 0 ); - } ); - - test( 'should be 3, the set page total, if the products are loaded.', () => { - expect( getTotalProductsPages( loadedState, 123 ) ).to.eql( 3 ); - } ); - - test( 'should be 0 (default) when products are loaded only for a different site.', () => { - expect( getTotalProductsPages( loadedState, 456 ) ).to.eql( 0 ); + test( 'should be false when a search request is loaded.', () => { + expect( areProductsLoading( loadedState, { search: 'testing' }, 123 ) ).to.be.false; } ); test( 'should get the siteId from the UI tree if not provided.', () => { - expect( getTotalProductsPages( loadedStateWithUi ) ).to.eql( 3 ); + expect( areProductsLoading( loadedStateWithUi, params ) ).to.be.false; } ); } ); - describe( '#getTotalProducts', () => { - test( 'should be 0 (default) when woocommerce state is not available.', () => { - expect( getTotalProducts( preInitializedState, 123 ) ).to.eql( 0 ); - } ); - - test( 'should be 0 (default) when products are loading.', () => { - expect( getTotalProducts( loadingState, 123 ) ).to.eql( 0 ); - } ); - - test( 'should be 30, the set products total, if the products are loaded.', () => { - expect( getTotalProducts( loadedState, 123 ) ).to.eql( 30 ); - } ); + describe( '#getProducts', () => { + const params = { page: 1, per_page: 10 }; - test( 'should be 0 (default) when products are loaded only for a different site.', () => { - expect( getTotalProducts( loadedState, 456 ) ).to.eql( 0 ); + test( 'should be an empty array when woocommerce state is not available.', () => { + expect( getProducts( preInitializedState, params, 123 ) ).to.be.empty; } ); - test( 'should get the siteId from the UI tree if not provided.', () => { - expect( getTotalProducts( loadedStateWithUi ) ).to.eql( 30 ); + test( 'should be an empty array when product page is loading.', () => { + expect( getProducts( loadingState, params, 123 ) ).to.be.empty; } ); - } ); - describe( '#areProductSearchResultsLoaded', () => { - const params = { page: 1, per_page: 10 }; - test( 'should be false when woocommerce state is not available.', () => { - expect( areProductSearchResultsLoaded( preInitializedState, params, 123 ) ).to.be.false; + test( 'should be the list of products if the current page is loaded.', () => { + expect( getProducts( loadedState, params, 123 ) ).to.eql( products ); } ); - test( 'should be false when products are currently being fetched.', () => { - expect( areProductSearchResultsLoaded( loadingState, params, 123 ) ).to.be.false; + test( 'should be an empty array when a search request is loading.', () => { + expect( getProducts( loadingState, { search: 'testing' }, 123 ) ).to.be.empty; } ); - test( 'should be true when products are loaded.', () => { - expect( areProductSearchResultsLoaded( loadedState, params, 123 ) ).to.be.true; + test( 'should be the list of products if the search result is loaded.', () => { + const selectedProducts = getProducts( loadedState, { search: 'testing' }, 123 ); + expect( selectedProducts.length ).to.eql( 1 ); + expect( selectedProducts ).to.eql( [ products[ 0 ] ] ); } ); - test( 'should be false when products are loaded only for a different site.', () => { - expect( areProductSearchResultsLoaded( loadedState, params, 456 ) ).to.be.false; + test( 'should be an empty array when products are loaded only for a different site.', () => { + expect( getProducts( loadedState, params, 456 ) ).to.be.empty; } ); test( 'should get the siteId from the UI tree if not provided.', () => { - expect( areProductSearchResultsLoaded( loadedStateWithUi, params ) ).to.be.true; + expect( getProducts( loadedStateWithUi, params ) ).to.eql( products ); } ); } ); - describe( '#areProductSearchResultsLoading', () => { + describe( '#getTotalProductsPages', () => { const params = { page: 1, per_page: 10 }; - test( 'should be false when woocommerce state is not available.', () => { - expect( areProductSearchResultsLoading( preInitializedState, params, 123 ) ).to.be.false; - } ); - - test( 'should be true when products are currently being fetched.', () => { - expect( areProductSearchResultsLoading( loadingState, params, 123 ) ).to.be.true; - } ); - - test( 'should be false when products are loaded.', () => { - expect( areProductSearchResultsLoading( loadedState, params, 123 ) ).to.be.false; - } ); - - test( 'should be false when products are loaded only for a different site.', () => { - expect( areProductSearchResultsLoading( loadedState, params, 456 ) ).to.be.false; - } ); - - test( 'should get the siteId from the UI tree if not provided.', () => { - expect( areProductSearchResultsLoading( loadedStateWithUi, params ) ).to.be.false; - } ); - } ); - - describe( '#getTotalProductSearchResults', () => { test( 'should be 0 (default) when woocommerce state is not available.', () => { - expect( getTotalProductSearchResults( preInitializedState, 123 ) ).to.eql( 0 ); + expect( getTotalProductsPages( preInitializedState, params, 123 ) ).to.eql( 0 ); } ); test( 'should be 0 (default) when products are loading.', () => { - expect( getTotalProductSearchResults( loadingState, 123 ) ).to.eql( 0 ); + expect( getTotalProductsPages( loadingState, params, 123 ) ).to.eql( 0 ); } ); - test( 'should be 28, the set total, if the products are loaded.', () => { - expect( getTotalProductSearchResults( loadedState, 123 ) ).to.eql( 28 ); + test( 'should be 3, the set page total, if the products are loaded.', () => { + expect( getTotalProductsPages( loadedState, params, 123 ) ).to.eql( 3 ); } ); test( 'should be 0 (default) when products are loaded only for a different site.', () => { - expect( getTotalProductSearchResults( loadedState, 456 ) ).to.eql( 0 ); + expect( getTotalProductsPages( loadedState, params, 456 ) ).to.eql( 0 ); } ); test( 'should get the siteId from the UI tree if not provided.', () => { - expect( getTotalProductSearchResults( loadedStateWithUi ) ).to.eql( 28 ); + expect( getTotalProductsPages( loadedStateWithUi, params ) ).to.eql( 3 ); } ); } ); - describe( '#getProductSearchQuery', () => { - test( 'should be null (default) when woocommerce state is not available.', () => { - expect( getProductSearchQuery( preInitializedState, 123 ) ).to.be.null; + describe( '#getTotalProducts', () => { + const params = { page: 1, per_page: 10 }; + + test( 'should be 0 (default) when woocommerce state is not available.', () => { + expect( getTotalProducts( preInitializedState, params, 123 ) ).to.eql( 0 ); } ); - test( 'should be testing, the set query, when products are loading.', () => { - expect( getProductSearchQuery( loadingState, 123 ) ).to.eql( 'testing' ); + test( 'should be 0 (default) when products are loading.', () => { + expect( getTotalProducts( loadingState, params, 123 ) ).to.eql( 0 ); } ); - test( 'should be testing, the set query, if the products are loaded.', () => { - expect( getProductSearchQuery( loadedState, 123 ) ).to.eql( 'testing' ); + test( 'should be 30, the set products total, if the products are loaded.', () => { + expect( getTotalProducts( loadedState, params, 123 ) ).to.eql( 30 ); } ); - test( 'should be null (default) when products are loaded only for a different site.', () => { - expect( getProductSearchQuery( loadedState, 456 ) ).to.be.null; + test( 'should be 0 (default) when products are loaded only for a different site.', () => { + expect( getTotalProducts( loadedState, params, 456 ) ).to.eql( 0 ); } ); test( 'should get the siteId from the UI tree if not provided.', () => { - expect( getProductSearchQuery( loadedStateWithUi ) ).to.eql( 'testing' ); + expect( getTotalProducts( loadedStateWithUi, params ) ).to.eql( 30 ); } ); } ); } ); diff --git a/client/extensions/woocommerce/state/sites/products/utils.js b/client/extensions/woocommerce/state/sites/products/utils.js new file mode 100644 index 0000000000000..1a3d094d69ded --- /dev/null +++ b/client/extensions/woocommerce/state/sites/products/utils.js @@ -0,0 +1,35 @@ +/** @format */ +/** + * External dependencies + */ +import { omitBy } from 'lodash'; + +export const DEFAULT_QUERY = { + page: 1, + per_page: 10, + search: '', +}; + +/** + * Returns a normalized products query, excluding any values which match the + * default product query. + * + * @param {Object} query Products query + * @return {Object} Normalized products query + */ +export function getNormalizedProductsQuery( query ) { + return omitBy( query, ( value, key ) => DEFAULT_QUERY[ key ] === value ); +} + +/** + * Returns a serialized products query + * + * @param {Object} query Products query + * @return {String} Serialized products query + */ +export function getSerializedProductsQuery( query = {} ) { + const normalizedQuery = getNormalizedProductsQuery( query ); + const serializedQuery = JSON.stringify( normalizedQuery ); + + return serializedQuery; +} diff --git a/client/extensions/woocommerce/state/ui/products/README.md b/client/extensions/woocommerce/state/ui/products/README.md new file mode 100644 index 0000000000000..c8da3258b3671 --- /dev/null +++ b/client/extensions/woocommerce/state/ui/products/README.md @@ -0,0 +1,56 @@ +Products +======== + +This module is used to manage UI state related to products on a site. + +## Reducer + +The state holds both product edits (see [edits-reducer.js](./edits-reducer.js)), and information related to the product list display - current and request query parameters. Both of these are keyed by siteId. For example: + +```js +{ + "products": { + [ siteId ]: { + edits: {…} + list: { + currentPage: 1, + currentSearch: 'example', + requestedPage: null, + requestedSearch: null, + } + } + } +} +``` + +## Selectors + +For all, `siteId` is optional, and will default to currently selected site if not set. + +### `getCurrentlyEditingProduct( state, [siteId] )` + +Gets the product being currently edited in the UI. + +### `getProductsCurrentPage( state, [siteId] )` + +Gets the current product page being viewed. + +### `getProductsCurrentSearch( state, [siteId] )` + +Gets the current product search term being viewed (if exists). + +### `getProductsRequestedPage( state, [siteId] )` + +Gets the requested/loading product page being viewed. + +### `getProductsRequestedSearch( state, [siteId] )` + +Gets the requested/loading product search term being viewed (if exists). + +### `getProductEdits( state, productId, [siteId] )` + +Gets the accumulated edits for a product, if any. `productId` can be a numeric ID for an existing product, or a placeholder ID for a to-be-created product. + +### `getProductWithLocalEdits( state, productId, [siteId] )` + +Gets a product with local edits overlaid on top of fetched data. `productId` can be a numeric ID for an existing product, or a placeholder ID for a to-be-created product. diff --git a/client/extensions/woocommerce/state/ui/products/list-reducer.js b/client/extensions/woocommerce/state/ui/products/list-reducer.js index d7d7330bb5204..17d3b73ca96b5 100644 --- a/client/extensions/woocommerce/state/ui/products/list-reducer.js +++ b/client/extensions/woocommerce/state/ui/products/list-reducer.js @@ -5,53 +5,43 @@ */ import { createReducer } from 'state/utils'; +import { get } from 'lodash'; /** * Internal dependencies */ +import { DEFAULT_QUERY } from 'woocommerce/state/sites/products/utils'; import { - WOOCOMMERCE_PRODUCT_DELETE_SUCCESS, WOOCOMMERCE_PRODUCTS_REQUEST, WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, } from 'woocommerce/state/action-types'; export default createReducer( null, { - [ WOOCOMMERCE_PRODUCT_DELETE_SUCCESS ]: productsDeleteSuccess, [ WOOCOMMERCE_PRODUCTS_REQUEST ]: productsRequest, [ WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS ]: productsRequestSuccess, } ); -export function productsRequestSuccess( state, action ) { - const prevState = state || {}; - const { params, products } = action; - const page = params.page || null; - const productIds = products.map( p => { - return p.id; - } ); +export function productsRequestSuccess( state = {}, action ) { + // If not set in the action, default to the defaults + const page = get( action, 'params.page', DEFAULT_QUERY.page ); + const search = get( action, 'params.search', DEFAULT_QUERY.search ); + return { - ...prevState, + ...state, currentPage: page, - productIds, + currentSearch: search, requestedPage: null, + requestedSearch: null, }; } -export function productsRequest( state, action ) { - const prevState = state || {}; - const { params } = action; - const page = params.page || null; - return { - ...prevState, - requestedPage: page, - }; -} +export function productsRequest( state = {}, action ) { + const page = get( action, 'params.page', null ); + const search = get( action, 'params.search', null ); -export function productsDeleteSuccess( state, action ) { - const prevState = state || {}; - const prevProductIds = prevState.productIds || []; - const newProductIds = prevProductIds.filter( id => id !== action.data.id ); return { - ...prevState, - productIds: newProductIds, + ...state, + requestedPage: page, + requestedSearch: search, }; } diff --git a/client/extensions/woocommerce/state/ui/products/reducer.js b/client/extensions/woocommerce/state/ui/products/reducer.js index afd79a5120ca9..394fbc86f2b20 100644 --- a/client/extensions/woocommerce/state/ui/products/reducer.js +++ b/client/extensions/woocommerce/state/ui/products/reducer.js @@ -1,13 +1,10 @@ +/** @format */ /** * Internal dependencies - * - * @format */ - import { combineReducers, keyedReducer } from 'state/utils'; import edits from './edits-reducer'; import list from './list-reducer'; -import search from './search-reducer'; import variations from './variations/reducer'; export default keyedReducer( @@ -15,7 +12,6 @@ export default keyedReducer( combineReducers( { list, edits, - search, variations, } ) ); diff --git a/client/extensions/woocommerce/state/ui/products/search-reducer.js b/client/extensions/woocommerce/state/ui/products/search-reducer.js deleted file mode 100644 index 525cd28692ba2..0000000000000 --- a/client/extensions/woocommerce/state/ui/products/search-reducer.js +++ /dev/null @@ -1,52 +0,0 @@ -/** - * External dependencies - * - * @format - */ - -import { createReducer } from 'state/utils'; - -/** - * Internal dependencies - */ -import { - WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST, - WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_SUCCESS, - WOOCOMMERCE_PRODUCTS_SEARCH_CLEAR, -} from 'woocommerce/state/action-types'; - -export default createReducer( null, { - [ WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST ]: productsSearchRequest, - [ WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_SUCCESS ]: productsSearchRequestSuccess, - [ WOOCOMMERCE_PRODUCTS_SEARCH_CLEAR ]: productsSearchClear, -} ); - -export function productsSearchRequestSuccess( state, action ) { - const prevState = state || {}; - const { page, products } = action; - const productIds = - ( products && - products.map( p => { - return p.id; - } ) ) || - []; - return { - ...prevState, - currentPage: page, - productIds, - requestedPage: null, - }; -} - -export function productsSearchRequest( state, action ) { - const prevState = state || {}; - const { page } = action; - return { - ...prevState, - requestedPage: page, - }; -} - -export function productsSearchClear() { - return {}; -} diff --git a/client/extensions/woocommerce/state/ui/products/selectors.js b/client/extensions/woocommerce/state/ui/products/selectors.js index 093d9388e2969..fe9572871f9c8 100644 --- a/client/extensions/woocommerce/state/ui/products/selectors.js +++ b/client/extensions/woocommerce/state/ui/products/selectors.js @@ -33,7 +33,7 @@ export function getProductEdits( state, productId, siteId = getSelectedSiteId( s } /** - * Gets a product with local edits overlayed on top of fetched data. + * Gets a product with local edits overlaid on top of fetched data. * * @param {Object} state Global state tree * @param {any} productId The id of the product (or { placeholder: # } ) @@ -83,7 +83,7 @@ export function getCurrentlyEditingProduct( state, siteId = getSelectedSiteId( s * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used * @return {Number} Current product list page (defaul: 1) */ -export function getProductListCurrentPage( state, siteId = getSelectedSiteId( state ) ) { +export function getProductsCurrentPage( state, siteId = getSelectedSiteId( state ) ) { return get( state, [ 'extensions', 'woocommerce', 'ui', 'products', siteId, 'list', 'currentPage' ], @@ -92,27 +92,18 @@ export function getProductListCurrentPage( state, siteId = getSelectedSiteId( st } /** - * Gets an array of products for the current page being viewed. + * Gets the current products list search term being viewed (if exists). * * @param {Object} state Global state tree * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used - * @return {array|false} Array of products or false if products are not available. + * @return {String} Current product list search term (defaul: '') */ -export function getProductListProducts( state, siteId = getSelectedSiteId( state ) ) { - const products = get( - state, - [ 'extensions', 'woocommerce', 'sites', siteId, 'products', 'products' ], - {} - ); - const productIds = get( +export function getProductsCurrentSearch( state, siteId = getSelectedSiteId( state ) ) { + return get( state, - [ 'extensions', 'woocommerce', 'ui', 'products', siteId, 'list', 'productIds' ], - [] + [ 'extensions', 'woocommerce', 'ui', 'products', siteId, 'list', 'currentSearch' ], + '' ); - if ( productIds.length ) { - return productIds.map( id => find( products, p => isEqual( id, p.id ) ) ); - } - return false; } /** @@ -122,7 +113,7 @@ export function getProductListProducts( state, siteId = getSelectedSiteId( state * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used * @return {number|null} Requested product list page */ -export function getProductListRequestedPage( state, siteId = getSelectedSiteId( state ) ) { +export function getProductsRequestedPage( state, siteId = getSelectedSiteId( state ) ) { return get( state, [ 'extensions', 'woocommerce', 'ui', 'products', siteId, 'list', 'requestedPage' ], @@ -131,55 +122,16 @@ export function getProductListRequestedPage( state, siteId = getSelectedSiteId( } /** - * Gets the current product search page being viewed. - * - * @param {Object} state Global state tree - * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used - * @return {Number} Current product search page (default: 1) - */ -export function getProductSearchCurrentPage( state, siteId = getSelectedSiteId( state ) ) { - return get( - state, - [ 'extensions', 'woocommerce', 'ui', 'products', siteId, 'search', 'currentPage' ], - 1 - ); -} - -/** - * Gets an array of products for the current search page being viewed. - * - * @param {Object} state Global state tree - * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used - * @return {array|false} Array of products or false if products are not available. - */ -export function getProductSearchResults( state, siteId = getSelectedSiteId( state ) ) { - const products = get( - state, - [ 'extensions', 'woocommerce', 'sites', siteId, 'products', 'products' ], - {} - ); - const productIds = get( - state, - [ 'extensions', 'woocommerce', 'ui', 'products', siteId, 'search', 'productIds' ], - [] - ); - if ( productIds.length ) { - return productIds.map( id => find( products, p => isEqual( id, p.id ) ) ); - } - return false; -} - -/** - * Gets the requested page for products search. + * Gets the requested/loading search term for the products list. * * @param {Object} state Global state tree * @param {Number} [siteId] Site ID to check. If not provided, the Site ID selected in the UI will be used - * @return {number|null} Requested product search page + * @return {String|null} Requested product list term */ -export function getProductSearchRequestedPage( state, siteId = getSelectedSiteId( state ) ) { +export function getProductsRequestedSearch( state, siteId = getSelectedSiteId( state ) ) { return get( state, - [ 'extensions', 'woocommerce', 'ui', 'products', siteId, 'search', 'requestedPage' ], + [ 'extensions', 'woocommerce', 'ui', 'products', siteId, 'list', 'requestedSearch' ], null ); } diff --git a/client/extensions/woocommerce/state/ui/products/test/list-reducer.js b/client/extensions/woocommerce/state/ui/products/test/list-reducer.js index b628d267d638c..9a28af69be492 100644 --- a/client/extensions/woocommerce/state/ui/products/test/list-reducer.js +++ b/client/extensions/woocommerce/state/ui/products/test/list-reducer.js @@ -8,16 +8,12 @@ import { expect } from 'chai'; /** * Internal dependencies */ -import { productsDeleteSuccess, productsRequest, productsRequestSuccess } from '../list-reducer'; +import { productsRequest, productsRequestSuccess } from '../list-reducer'; import { - WOOCOMMERCE_PRODUCTS_DELETE_SUCCESS, WOOCOMMERCE_PRODUCTS_REQUEST, WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, } from 'woocommerce/state/action-types'; -import product from 'woocommerce/state/sites/products/test/fixtures/product'; -import products from 'woocommerce/state/sites/products/test/fixtures/products'; - describe( 'reducer', () => { describe( 'productsRequest', () => { test( 'should store the requested page', () => { @@ -28,44 +24,72 @@ describe( 'reducer', () => { }; const newState = productsRequest( undefined, action ); expect( newState.requestedPage ).to.eql( 3 ); + expect( newState.requestedSearch ).to.be.null; + } ); + test( 'should store the requested search', () => { + const action = { + type: WOOCOMMERCE_PRODUCTS_REQUEST, + siteId: 123, + params: { search: 'example' }, + }; + const newState = productsRequest( undefined, action ); + expect( newState.requestedPage ).to.be.null; + expect( newState.requestedSearch ).to.eql( 'example' ); + } ); + test( 'should update the requested query', () => { + const action = { + type: WOOCOMMERCE_PRODUCTS_REQUEST, + siteId: 123, + params: { page: 2 }, + }; + const originalState = { requestedPage: 1, requestedSearch: null }; + const newState = productsRequest( originalState, action ); + expect( newState.requestedPage ).to.eql( 2 ); } ); } ); + describe( 'productsRequestSuccess', () => { test( 'should store the current page', () => { const action = { type: WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, siteId: 123, params: { page: 2 }, - totalPages: 3, - totalProducts: 30, - products, }; const newState = productsRequestSuccess( undefined, action ); expect( newState.currentPage ).to.eql( 2 ); + expect( newState.currentSearch ).to.eql( '' ); + expect( newState.requestedPage ).to.be.null; + expect( newState.requestedSearch ).to.be.null; } ); - test( 'should store product ids for the current page', () => { + test( 'should store the current search', () => { const action = { type: WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, siteId: 123, - params: { page: 2 }, - totalPages: 3, - totalProducts: 30, - products, + params: { search: 'example' }, }; const newState = productsRequestSuccess( undefined, action ); - expect( newState.productIds ).to.eql( [ 15, 389 ] ); + expect( newState.currentPage ).to.eql( 1 ); + expect( newState.currentSearch ).to.eql( 'example' ); + expect( newState.requestedPage ).to.be.null; + expect( newState.requestedSearch ).to.be.null; } ); - } ); - describe( 'productsDeleteSuccess', () => { - test( 'should remove the product from the products list', () => { + test( 'should update the current query', () => { const action = { - type: WOOCOMMERCE_PRODUCTS_DELETE_SUCCESS, + type: WOOCOMMERCE_PRODUCTS_REQUEST_SUCCESS, siteId: 123, - data: product, + params: { page: 2 }, }; - - const newState = productsDeleteSuccess( { productIds: [ 31, 15, 389 ] }, action ); - expect( newState.productIds ).to.eql( [ 15, 389 ] ); + const originalState = { + requestedPage: 2, + requestedSearch: null, + currentPage: 1, + currentSearch: '', + }; + const newState = productsRequestSuccess( originalState, action ); + expect( newState.currentPage ).to.eql( 2 ); + expect( newState.currentSearch ).to.eql( '' ); + expect( newState.requestedPage ).to.be.null; + expect( newState.requestedSearch ).to.be.null; } ); } ); } ); diff --git a/client/extensions/woocommerce/state/ui/products/test/search-reducer.js b/client/extensions/woocommerce/state/ui/products/test/search-reducer.js deleted file mode 100644 index f19284fae0219..0000000000000 --- a/client/extensions/woocommerce/state/ui/products/test/search-reducer.js +++ /dev/null @@ -1,73 +0,0 @@ -/** @format */ - -/** - * External dependencies - */ -import { expect } from 'chai'; - -/** - * Internal dependencies - */ -import { - productsSearchRequest, - productsSearchRequestSuccess, - productsSearchClear, -} from '../search-reducer'; -import { - WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST, - WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_SUCCESS, - WOOCOMMERCE_PRODUCTS_SEARCH_CLEAR, -} from 'woocommerce/state/action-types'; - -import products from 'woocommerce/state/sites/products/test/fixtures/products'; - -describe( 'reducer', () => { - describe( 'productsRequest', () => { - test( 'should store the requested page', () => { - const action = { - type: WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST, - siteId: 123, - page: 3, - query: 'testing', - }; - const newState = productsSearchRequest( undefined, action ); - expect( newState.requestedPage ).to.eql( 3 ); - } ); - } ); - describe( 'productsRequestSuccess', () => { - test( 'should store the current page', () => { - const action = { - type: WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_SUCCESS, - siteId: 123, - page: 2, - totalProducts: 28, - query: 'testing', - products, - }; - const newState = productsSearchRequestSuccess( undefined, action ); - expect( newState.currentPage ).to.eql( 2 ); - } ); - test( 'should store product ids for the current page', () => { - const action = { - type: WOOCOMMERCE_PRODUCTS_SEARCH_REQUEST_SUCCESS, - siteId: 123, - page: 2, - totalProducts: 28, - query: 'testing', - products, - }; - const newState = productsSearchRequestSuccess( undefined, action ); - expect( newState.productIds ).to.eql( [ 15, 389 ] ); - } ); - } ); - describe( 'productsSearchClear', () => { - test( 'should reset the search state', () => { - const action = { - type: WOOCOMMERCE_PRODUCTS_SEARCH_CLEAR, - siteId: 123, - }; - const newState = productsSearchClear( undefined, action ); - expect( newState ).to.eql( {} ); - } ); - } ); -} ); diff --git a/client/extensions/woocommerce/state/ui/products/test/selectors.js b/client/extensions/woocommerce/state/ui/products/test/selectors.js index cfac3decd0782..423f869cc75a8 100644 --- a/client/extensions/woocommerce/state/ui/products/test/selectors.js +++ b/client/extensions/woocommerce/state/ui/products/test/selectors.js @@ -13,12 +13,10 @@ import { getProductEdits, getProductWithLocalEdits, getCurrentlyEditingProduct, - getProductListCurrentPage, - getProductListProducts, - getProductListRequestedPage, - getProductSearchCurrentPage, - getProductSearchResults, - getProductSearchRequestedPage, + getProductsCurrentPage, + getProductsCurrentSearch, + getProductsRequestedPage, + getProductsRequestedSearch, } from '../selectors'; import products from 'woocommerce/state/sites/products/test/fixtures/products'; @@ -38,8 +36,9 @@ const loadedListState = { 123: { list: { currentPage: 2, + currentSearch: 'example', requestedPage: 3, - productIds: [ 15, 389 ], + requestedSearch: 'test', }, }, 401: { @@ -65,41 +64,6 @@ const loadedListState = { const loadedListStateWithUi = { ...loadedListState, ui: { selectedSiteId: 123 } }; -const loadedSearchState = { - extensions: { - woocommerce: { - ui: { - products: { - 123: { - search: { - currentPage: 2, - requestedPage: 3, - productIds: [ 15, 389 ], - }, - }, - 401: { - search: {}, - }, - }, - }, - sites: { - 123: { - products: { - products, - }, - }, - 401: { - products: { - products: {}, - }, - }, - }, - }, - }, -}; - -const loadedSearchStateWithUi = { ...loadedSearchState, ui: { selectedSiteId: 123 } }; - describe( 'selectors', () => { let state; @@ -119,7 +83,6 @@ describe( 'selectors', () => { products: { 123: { list: {}, - search: {}, }, }, }, @@ -196,130 +159,92 @@ describe( 'selectors', () => { expect( getCurrentlyEditingProduct( state ) ).to.eql( newProduct ); } ); } ); - describe( '#getProductListCurrentPage', () => { + + describe( '#getProductsCurrentPage', () => { test( 'should be 1 (default) when woocommerce state is not available.', () => { - expect( getProductListCurrentPage( preInitializedListState, 123 ) ).to.eql( 1 ); + expect( getProductsCurrentPage( preInitializedListState, 123 ) ).to.eql( 1 ); } ); test( 'should be 1 (default) when products are loading.', () => { - expect( getProductListCurrentPage( state, 123 ) ).to.eql( 1 ); + expect( getProductsCurrentPage( state, 123 ) ).to.eql( 1 ); } ); test( 'should be 2, the set page, if the products are loaded.', () => { - expect( getProductListCurrentPage( loadedListState, 123 ) ).to.eql( 2 ); + expect( getProductsCurrentPage( loadedListState, 123 ) ).to.eql( 2 ); } ); test( 'should be 1 (default) when products are loaded only for a different site.', () => { - expect( getProductListCurrentPage( loadedListState, 456 ) ).to.eql( 1 ); + expect( getProductsCurrentPage( loadedListState, 456 ) ).to.eql( 1 ); } ); test( 'should get the siteId from the UI tree if not provided.', () => { - expect( getProductListCurrentPage( loadedListStateWithUi ) ).to.eql( 2 ); + expect( getProductsCurrentPage( loadedListStateWithUi ) ).to.eql( 2 ); } ); } ); - describe( '#getProductListRequestedPage', () => { - test( 'should be null (default) when woocommerce state is not available.', () => { - expect( getProductListRequestedPage( preInitializedListState, 123 ) ).to.be.null; - } ); - - test( 'should be null (default) when products are loading.', () => { - expect( getProductListRequestedPage( state, 123 ) ).to.be.null; - } ); - - test( 'should be 3, the set requested page, if the products are loaded.', () => { - expect( getProductListRequestedPage( loadedListState, 123 ) ).to.eql( 3 ); - } ); - - test( 'should be null (default) when products are loaded only for a different site.', () => { - expect( getProductListRequestedPage( loadedListState, 456 ) ).to.be.null; - } ); - - test( 'should get the siteId from the UI tree if not provided.', () => { - expect( getProductListRequestedPage( loadedListStateWithUi ) ).to.eql( 3 ); - } ); - } ); - describe( '#getProductListProducts', () => { - test( 'should be false when woocommerce state is not available.', () => { - expect( getProductListProducts( preInitializedListState, 123 ) ).to.be.false; - } ); - test( 'should be false when products are loading.', () => { - expect( getProductListProducts( state, 123 ) ).to.be.false; - } ); - - test( 'should be the list of products if they are loaded.', () => { - expect( getProductListProducts( loadedListState, 123 ) ).to.eql( products ); - } ); - - test( 'should be false when products are loaded only for a different site.', () => { - expect( getProductListProducts( loadedListState, 456 ) ).to.be.false; - } ); - - test( 'should get the siteId from the UI tree if not provided.', () => { - expect( getProductListProducts( loadedListStateWithUi ) ).to.eql( products ); - } ); - } ); - describe( '#getProductSearchCurrentPage', () => { + describe( '#getProductsCurrentSearch', () => { test( 'should be 1 (default) when woocommerce state is not available.', () => { - expect( getProductSearchCurrentPage( preInitializedListState, 123 ) ).to.eql( 1 ); + expect( getProductsCurrentSearch( preInitializedListState, 123 ) ).to.eql( '' ); } ); test( 'should be 1 (default) when products are loading.', () => { - expect( getProductSearchCurrentPage( state, 123 ) ).to.eql( 1 ); + expect( getProductsCurrentSearch( state, 123 ) ).to.eql( '' ); } ); test( 'should be 2, the set page, if the products are loaded.', () => { - expect( getProductSearchCurrentPage( loadedSearchState, 123 ) ).to.eql( 2 ); + expect( getProductsCurrentSearch( loadedListState, 123 ) ).to.eql( 'example' ); } ); test( 'should be 1 (default) when products are loaded only for a different site.', () => { - expect( getProductSearchCurrentPage( loadedSearchState, 456 ) ).to.eql( 1 ); + expect( getProductsCurrentSearch( loadedListState, 456 ) ).to.eql( '' ); } ); test( 'should get the siteId from the UI tree if not provided.', () => { - expect( getProductSearchCurrentPage( loadedSearchStateWithUi ) ).to.eql( 2 ); + expect( getProductsCurrentSearch( loadedListStateWithUi ) ).to.eql( 'example' ); } ); } ); - describe( '#getProductSearchRequestedPage', () => { + + describe( '#getProductsRequestedPage', () => { test( 'should be null (default) when woocommerce state is not available.', () => { - expect( getProductSearchRequestedPage( preInitializedListState, 123 ) ).to.be.null; + expect( getProductsRequestedPage( preInitializedListState, 123 ) ).to.be.null; } ); test( 'should be null (default) when products are loading.', () => { - expect( getProductSearchRequestedPage( state, 123 ) ).to.be.null; + expect( getProductsRequestedPage( state, 123 ) ).to.be.null; } ); test( 'should be 3, the set requested page, if the products are loaded.', () => { - expect( getProductSearchRequestedPage( loadedSearchState, 123 ) ).to.eql( 3 ); + expect( getProductsRequestedPage( loadedListState, 123 ) ).to.eql( 3 ); } ); test( 'should be null (default) when products are loaded only for a different site.', () => { - expect( getProductSearchRequestedPage( loadedSearchState, 456 ) ).to.be.null; + expect( getProductsRequestedPage( loadedListState, 456 ) ).to.be.null; } ); test( 'should get the siteId from the UI tree if not provided.', () => { - expect( getProductSearchRequestedPage( loadedSearchStateWithUi ) ).to.eql( 3 ); + expect( getProductsRequestedPage( loadedListStateWithUi ) ).to.eql( 3 ); } ); } ); - describe( '#getProductSearchResults', () => { - test( 'should be false when woocommerce state is not available.', () => { - expect( getProductSearchResults( preInitializedListState, 123 ) ).to.be.false; + + describe( '#getProductsRequestedSearch', () => { + test( 'should be null (default) when woocommerce state is not available.', () => { + expect( getProductsRequestedSearch( preInitializedListState, 123 ) ).to.be.null; } ); - test( 'should be false when products are loading.', () => { - expect( getProductSearchResults( state, 123 ) ).to.be.false; + test( 'should be null (default) when products are loading.', () => { + expect( getProductsRequestedSearch( state, 123 ) ).to.be.null; } ); - test( 'should be the list of products if they are loaded.', () => { - expect( getProductSearchResults( loadedSearchState, 123 ) ).to.eql( products ); + test( 'should be 3, the set requested page, if the products are loaded.', () => { + expect( getProductsRequestedSearch( loadedListState, 123 ) ).to.eql( 'test' ); } ); - test( 'should be false when products are loaded only for a different site.', () => { - expect( getProductSearchResults( loadedSearchState, 456 ) ).to.be.false; + test( 'should be null (default) when products are loaded only for a different site.', () => { + expect( getProductsRequestedSearch( loadedListState, 456 ) ).to.be.null; } ); test( 'should get the siteId from the UI tree if not provided.', () => { - expect( getProductSearchResults( loadedSearchStateWithUi ) ).to.eql( products ); + expect( getProductsRequestedSearch( loadedListStateWithUi ) ).to.eql( 'test' ); } ); } ); } ); From 9292c0701440bc701851ef95d37fc97ec15fc844 Mon Sep 17 00:00:00 2001 From: Kelly Dwan Date: Fri, 27 Oct 2017 10:21:43 -0400 Subject: [PATCH 192/192] Add CSS files in `/public/sections` to the watched hot-reload files list --- server/bundler/css-hot-reload.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/server/bundler/css-hot-reload.js b/server/bundler/css-hot-reload.js index 2dc80132b1820..07d2e968b2311 100644 --- a/server/bundler/css-hot-reload.js +++ b/server/bundler/css-hot-reload.js @@ -117,7 +117,13 @@ function setup( io ) { fs.readdirSync( PUBLIC_DIR ).forEach( function( file ) { if ( '.css' === file.slice( -4 ) ) { - var fullPath = path.join( PUBLIC_DIR, file ); + const fullPath = path.join( PUBLIC_DIR, file ); + publicCssFiles[ fullPath ] = md5File.sync( fullPath ); + } + } ); + fs.readdirSync( path.join( PUBLIC_DIR, 'sections' ) ).forEach( function( file ) { + if ( '.css' === file.slice( -4 ) ) { + const fullPath = path.join( PUBLIC_DIR, 'sections', file ); publicCssFiles[ fullPath ] = md5File.sync( fullPath ); } } );