From 718bac69813b3d853ab6cb586d4a7a6e1a8d0103 Mon Sep 17 00:00:00 2001 From: Moon Date: Thu, 17 Feb 2022 11:15:11 -0800 Subject: [PATCH] Prompt a modal to save any unsaved changes in OBW (https://github.com/woocommerce/woocommerce-admin/pull/8278) * Add a modal to show when unsaved changes are detected * Add functions to track value changes from the steps * Warn unsaved changes for the store details * Add styles for the unsaved modal * Warn unsaved changes for the Industry * Warn unsaved changes for the Product Types * Warn unsaved changes for the Business Details * Add changelog * Add testing instructions. * Sort array values before comparison * Use only the array values to compare the diff * Catch rejected promise on continue * Set initial value to an empty object * Fix failing tests --- .../woocommerce-admin/TESTING-INSTRUCTIONS.md | 13 +++ ...-prompt-save-modal-when-switching-stepper2 | 4 + .../client/profile-wizard/header.js | 85 +++++++++++++++++-- .../client/profile-wizard/index.js | 43 +++++++++- .../flows/selective-bundle/index.js | 12 ++- .../client/profile-wizard/steps/industry.js | 39 ++++++--- .../steps/product-types/index.js | 36 ++++++-- .../steps/store-details/index.js | 59 +++++++++---- .../client/profile-wizard/style.scss | 33 +++++++ .../profile-wizard/unsaved-changes-modal.js | 40 +++++++++ .../complete-onboarding-wizard.ts | 2 + 11 files changed, 326 insertions(+), 40 deletions(-) create mode 100644 plugins/woocommerce-admin/changelogs/update-7890-prompt-save-modal-when-switching-stepper2 create mode 100644 plugins/woocommerce-admin/client/profile-wizard/unsaved-changes-modal.js diff --git a/plugins/woocommerce-admin/TESTING-INSTRUCTIONS.md b/plugins/woocommerce-admin/TESTING-INSTRUCTIONS.md index c99d41fc851..e68e5c9a48a 100644 --- a/plugins/woocommerce-admin/TESTING-INSTRUCTIONS.md +++ b/plugins/woocommerce-admin/TESTING-INSTRUCTIONS.md @@ -1,5 +1,18 @@ # Testing instructions +## Unreleased + +### Prompt a modal to save any unsaved changes in OBW + +1. Start with a fresh install. +2. Navigate to WooCommerce -> Home to start the OBW. +3. Complete a few steps. +4. Click any of the previous steps and make some changes. +5. Click the next/previous step. You should be prompted by the modal to save your changes. Click the save button. +6. Go back to the step and confirm the changes. +7. Repeat the step, but click the disregard button for this time. +8. Confirm the changes are not saved for this time. + ## 3.2.0 ### Fix category report query returns invalid net sales diff --git a/plugins/woocommerce-admin/changelogs/update-7890-prompt-save-modal-when-switching-stepper2 b/plugins/woocommerce-admin/changelogs/update-7890-prompt-save-modal-when-switching-stepper2 new file mode 100644 index 00000000000..69504428f70 --- /dev/null +++ b/plugins/woocommerce-admin/changelogs/update-7890-prompt-save-modal-when-switching-stepper2 @@ -0,0 +1,4 @@ +Significance: minor +Type: Enhancement + +Prompts a modal to save any unsaved changes when the users try to move to a different step #8278 diff --git a/plugins/woocommerce-admin/client/profile-wizard/header.js b/plugins/woocommerce-admin/client/profile-wizard/header.js index 78c2bffa165..282d9b7c71a 100644 --- a/plugins/woocommerce-admin/client/profile-wizard/header.js +++ b/plugins/woocommerce-admin/client/profile-wizard/header.js @@ -2,11 +2,68 @@ * External dependencies */ import { Component } from '@wordpress/element'; -import { filter } from 'lodash'; +import { filter, isEqual } from 'lodash'; import { Stepper } from '@woocommerce/components'; import { updateQueryString } from '@woocommerce/navigation'; +/** + * Internal dependencies + */ +import UnsavedChangesModal from './unsaved-changes-modal'; + export default class ProfileWizardHeader extends Component { + constructor( props ) { + super( props ); + this.state = { + showUnsavedChangesModal: false, + }; + this.lastClickedStepKey = null; + } + + shouldWarnForUnsavedChanges( step ) { + if ( typeof this.props.stepValueChanges[ step ] !== 'undefined' ) { + const initialValues = this.props.stepValueChanges[ step ] + .initialValues; + const currentValues = this.props.stepValueChanges[ step ] + .currentValues; + + if ( + Array.isArray( initialValues ) && + Array.isArray( currentValues ) + ) { + initialValues.sort(); + currentValues.sort(); + } + + return ! isEqual( initialValues, currentValues ); + } + return false; + } + + findCurrentStep() { + return this.props.steps.find( + ( s ) => s.key === this.props.currentStep + ); + } + + moveToLastClickedStep() { + if ( this.lastClickedStepKey ) { + updateQueryString( { step: this.lastClickedStepKey } ); + this.lastClickedStepKey = null; + } + } + + saveCurrentStepChanges() { + const currentStep = this.findCurrentStep(); + if ( ! currentStep ) { + return null; + } + const stepValueChanges = this.props.stepValueChanges[ currentStep.key ]; + if ( typeof stepValueChanges.onSave === 'function' ) { + stepValueChanges.onSave(); + } + } + renderStepper() { const { currentStep, steps } = this.props; const visibleSteps = filter( steps, ( step ) => !! step.label ); @@ -22,7 +79,14 @@ export default class ProfileWizardHeader extends Component { } if ( ! previousStep || previousStep.isComplete ) { - step.onClick = ( key ) => updateQueryString( { step: key } ); + step.onClick = ( key ) => { + if ( this.shouldWarnForUnsavedChanges( currentStep ) ) { + this.setState( { showUnsavedChangesModal: true } ); + this.lastClickedStepKey = key; + } else { + updateQueryString( { step: key } ); + } + }; } return step; } ); @@ -31,9 +95,7 @@ export default class ProfileWizardHeader extends Component { } render() { - const currentStep = this.props.steps.find( - ( s ) => s.key === this.props.currentStep - ); + const currentStep = this.findCurrentStep(); if ( ! currentStep || ! currentStep.label ) { return null; @@ -41,6 +103,19 @@ export default class ProfileWizardHeader extends Component { return (
+ { this.state.showUnsavedChangesModal && ( + { + this.setState( { showUnsavedChangesModal: false } ); + this.moveToLastClickedStep(); + } } + onSave={ () => { + this.saveCurrentStepChanges(); + this.setState( { showUnsavedChangesModal: false } ); + this.moveToLastClickedStep(); + } } + /> + ) } { this.renderStepper() }
); diff --git a/plugins/woocommerce-admin/client/profile-wizard/index.js b/plugins/woocommerce-admin/client/profile-wizard/index.js index adb8e799336..e1d1e095ac6 100644 --- a/plugins/woocommerce-admin/client/profile-wizard/index.js +++ b/plugins/woocommerce-admin/client/profile-wizard/index.js @@ -42,6 +42,11 @@ class ProfileWizard extends Component { super( props ); this.cachedActivePlugins = props.activePlugins; this.goToNextStep = this.goToNextStep.bind( this ); + this.trackStepValueChanges = this.trackStepValueChanges.bind( this ); + this.updateCurrentStepValues = this.updateCurrentStepValues.bind( + this + ); + this.stepValueChanges = {}; } componentDidUpdate( prevProps ) { @@ -93,6 +98,36 @@ class ProfileWizard extends Component { document.body.classList.remove( 'is-wp-toolbar-disabled' ); } + /** + * Set the initial and current values of a step to track the state of the step. + * This is used to determine if the step has been changes or not. + * + * @param {string} step key of the step + * @param {*} initialValues the initial values of the step + * @param {*} currentValues the current values of the step + * @param {Function} onSave a function to call when the step is saved + */ + trackStepValueChanges( step, initialValues, currentValues, onSave ) { + this.stepValueChanges[ step ] = { + initialValues, + currentValues, + onSave, + }; + } + + /** + * Update currentValues of the given step. + * + * @param {string} step key of the step + * @param {*} currentValues the current values of the step + */ + updateCurrentStepValues( step, currentValues ) { + if ( ! this.stepValueChanges[ step ] ) { + return; + } + this.stepValueChanges[ step ].currentValues = currentValues; + } + getSteps() { const { profileItems } = this.props; const steps = []; @@ -256,6 +291,8 @@ class ProfileWizard extends Component { skipProfiler: () => { this.skipProfiler(); }, + trackStepValueChanges: this.trackStepValueChanges, + updateCurrentStepValues: this.updateCurrentStepValues, } ); const steps = this.getSteps().map( ( _step ) => pick( _step, [ 'key', 'label', 'isComplete' ] ) @@ -264,7 +301,11 @@ class ProfileWizard extends Component { return ( <> - +
{ container }
); diff --git a/plugins/woocommerce-admin/client/profile-wizard/steps/business-details/flows/selective-bundle/index.js b/plugins/woocommerce-admin/client/profile-wizard/steps/business-details/flows/selective-bundle/index.js index 65369b1d04c..329068b492e 100644 --- a/plugins/woocommerce-admin/client/profile-wizard/steps/business-details/flows/selective-bundle/index.js +++ b/plugins/woocommerce-admin/client/profile-wizard/steps/business-details/flows/selective-bundle/index.js @@ -143,7 +143,7 @@ export const isSellingOtherPlatformInPerson = ( selectedOption ) => [ 'other', 'brick-mortar-other' ].includes( selectedOption ); class BusinessDetails extends Component { - constructor() { + constructor( props ) { super(); this.state = { @@ -155,6 +155,12 @@ class BusinessDetails extends Component { this.onContinue = this.onContinue.bind( this ); this.validate = this.validate.bind( this ); + props.trackStepValueChanges( + props.step.key, + { ...( this.state.savedValues || props.initialValues ) }, + this.savedValues || props.initialValues, + this.persistProfileItems.bind( this ) + ); } async onContinue( @@ -436,6 +442,10 @@ class BusinessDetails extends Component { } } onChange={ ( _, values, isValid ) => { this.setState( { savedValues: values, isValid } ); + this.props.updateCurrentStepValues( + this.props.step.key, + values + ); } } validate={ this.validate } > diff --git a/plugins/woocommerce-admin/client/profile-wizard/steps/industry.js b/plugins/woocommerce-admin/client/profile-wizard/steps/industry.js index 8e7d3c94688..099d804ab25 100644 --- a/plugins/woocommerce-admin/client/profile-wizard/steps/industry.js +++ b/plugins/woocommerce-admin/client/profile-wizard/steps/industry.js @@ -66,6 +66,24 @@ class Industry extends Component { this.onContinue = this.onContinue.bind( this ); this.onIndustryChange = this.onIndustryChange.bind( this ); this.onDetailChange = this.onDetailChange.bind( this ); + const selectedSlugs = this.getSelectedSlugs(); + props.trackStepValueChanges( + props.step.key, + selectedSlugs, + selectedSlugs, + this.onContinue + ); + } + + getSelectedSlugs() { + return this.state.selected.map( ( industry ) => industry.slug ); + } + + componentDidUpdate() { + this.props.updateCurrentStepValues( + this.props.step.key, + this.getSelectedSlugs() + ); } async onContinue() { @@ -74,12 +92,7 @@ class Industry extends Component { return; } - const { - createNotice, - goToNextStep, - isError, - updateProfileItems, - } = this.props; + const { createNotice, isError, updateProfileItems } = this.props; const selectedIndustriesList = this.state.selected.map( ( industry ) => industry.slug ); @@ -96,9 +109,7 @@ class Industry extends Component { } ); await updateProfileItems( { industry: this.state.selected } ); - if ( ! isError ) { - goToNextStep(); - } else { + if ( isError ) { createNotice( 'error', __( @@ -106,7 +117,11 @@ class Industry extends Component { 'woocommerce-admin' ) ); + + return Promise.reject(); } + + return true; } async validateField() { @@ -251,7 +266,11 @@ class Industry extends Component { + + + + + + ); +}; + +export default UnsavedChangesModal; diff --git a/plugins/woocommerce-admin/packages/admin-e2e-tests/src/specs/activate-and-setup/complete-onboarding-wizard.ts b/plugins/woocommerce-admin/packages/admin-e2e-tests/src/specs/activate-and-setup/complete-onboarding-wizard.ts index 5e9827252e9..2feb0c595e3 100644 --- a/plugins/woocommerce-admin/packages/admin-e2e-tests/src/specs/activate-and-setup/complete-onboarding-wizard.ts +++ b/plugins/woocommerce-admin/packages/admin-e2e-tests/src/specs/activate-and-setup/complete-onboarding-wizard.ts @@ -412,6 +412,7 @@ const testSubscriptionsInclusion = () => { } ); it( 'should display the task "Add Subscriptions to my store"', async () => { + await profileWizard.navigate(); await profileWizard.goToOBWStep( 'Store Details' ); await profileWizard.skipStoreSetup(); const homescreen = new WcHomescreen( page ); @@ -495,6 +496,7 @@ const testSubscriptionsInclusion = () => { } ); it( 'should not display the task "Add Subscriptions to my store"', async () => { + await profileWizard.navigate(); await profileWizard.goToOBWStep( 'Store Details' ); await profileWizard.skipStoreSetup(); const homescreen = new WcHomescreen( page );