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
This commit is contained in:
Moon 2022-02-17 11:15:11 -08:00 committed by GitHub
parent c16a17a17b
commit 718bac6981
11 changed files with 326 additions and 40 deletions

View File

@ -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

View File

@ -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

View File

@ -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 (
<div className="woocommerce-profile-wizard__header">
{ this.state.showUnsavedChangesModal && (
<UnsavedChangesModal
onClose={ () => {
this.setState( { showUnsavedChangesModal: false } );
this.moveToLastClickedStep();
} }
onSave={ () => {
this.saveCurrentStepChanges();
this.setState( { showUnsavedChangesModal: false } );
this.moveToLastClickedStep();
} }
/>
) }
{ this.renderStepper() }
</div>
);

View File

@ -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 (
<>
<ProfileWizardHeader currentStep={ stepKey } steps={ steps } />
<ProfileWizardHeader
currentStep={ stepKey }
steps={ steps }
stepValueChanges={ this.stepValueChanges }
/>
<div className={ classNames }>{ container }</div>
</>
);

View File

@ -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 }
>

View File

@ -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 {
<CardFooter isBorderless justify="center">
<Button
isPrimary
onClick={ this.onContinue }
onClick={ () => {
this.onContinue().then(
this.props.goToNextStep
);
} }
isBusy={ isProfileItemsRequesting }
disabled={
! selected.length || isProfileItemsRequesting

View File

@ -61,16 +61,33 @@ export class ProductTypes extends Component {
}
}
componentDidUpdate( prevProps ) {
componentDidUpdate( prevProps, prevState ) {
const { profileItems, productTypes } = this.props;
if ( this.state.selected !== prevState.selected ) {
this.props.updateCurrentStepValues(
this.props.step.key,
this.state.selected
);
}
if ( prevProps.productTypes !== productTypes ) {
const defaultProductTypes = Object.keys( productTypes ).filter(
( key ) => !! productTypes[ key ].default
);
this.setState( {
selected: profileItems.product_types || defaultProductTypes,
} );
this.setState(
{
selected: profileItems.product_types || defaultProductTypes,
},
() => {
this.props.trackStepValueChanges(
this.props.step.key,
[ ...this.state.selected ],
this.state.selected,
this.onContinue
);
}
);
}
}
@ -85,7 +102,7 @@ export class ProductTypes extends Component {
return ! error;
}
onContinue() {
onContinue( onSuccess ) {
const { selected } = this.state;
const { installedPlugins = [] } = this.props;
@ -96,7 +113,6 @@ export class ProductTypes extends Component {
const {
countryCode,
createNotice,
goToNextStep,
installAndActivatePlugins,
updateProfileItems,
} = this.props;
@ -144,7 +160,9 @@ export class ProductTypes extends Component {
'storeprofiler_store_product_type_continue',
eventProps
);
goToNextStep();
if ( typeof onSuccess === 'function' ) {
onSuccess();
}
} )
.catch( () =>
createNotice(
@ -260,7 +278,9 @@ export class ProductTypes extends Component {
<CardFooter isBorderless justify="center">
<Button
isPrimary
onClick={ this.onContinue }
onClick={ () => {
this.onContinue( this.props.goToNextStep );
} }
isBusy={
isProfileItemsRequesting ||
isInstallingActivating

View File

@ -71,6 +71,27 @@ export class StoreDetails extends Component {
this.onContinue = this.onContinue.bind( this );
this.onSubmit = this.onSubmit.bind( this );
this.validateStoreDetails = this.validateStoreDetails.bind( this );
this.onFormValueChange = this.onFormValueChange.bind( this );
this.changedFormValues = {};
}
componentDidUpdate() {
if (
this.props.isLoading === false &&
Object.keys( this.changedFormValues ).length === 0
) {
// Make a copy of the initialValues.
// The values in this object gets updated on onFormValueChange.
this.changedFormValues = { ...this.props.initialValues };
this.props.trackStepValueChanges(
this.props.step.key,
this.props.initialValues,
this.changedFormValues,
() => {
this.onContinue( this.changedFormValues );
}
);
}
}
deriveCurrencySettings( countryState ) {
@ -98,10 +119,14 @@ export class StoreDetails extends Component {
} );
}
onFormValueChange( changedFormValue ) {
this.changedFormValues[ changedFormValue.name ] =
changedFormValue.value;
}
async onContinue( values ) {
const {
createNotice,
goToNextStep,
updateProfileItems,
updateAndPersistSettingsForGroup,
profileItems,
@ -184,20 +209,19 @@ export class StoreDetails extends Component {
! Boolean( errorsRef.current.settings ) &&
! errorMessages.length
) {
goToNextStep();
} else {
createNotice(
'error',
__(
'There was a problem saving your store details',
'woocommerce-admin'
)
);
errorMessages.forEach( ( message ) =>
createNotice( 'error', message )
);
return true;
}
createNotice(
'error',
__(
'There was a problem saving your store details',
'woocommerce-admin'
)
);
errorMessages.forEach( ( message ) =>
createNotice( 'error', message )
);
}
validateStoreDetails( values ) {
@ -302,6 +326,7 @@ export class StoreDetails extends Component {
initialValues={ initialValues }
onSubmit={ this.onSubmit }
validate={ this.validateStoreDetails }
onChange={ this.onFormValueChange }
>
{ ( {
getInputProps,
@ -317,7 +342,11 @@ export class StoreDetails extends Component {
if ( skipping ) {
skipProfiler();
} else {
this.onContinue( values );
this.onContinue(
values
).then( () =>
this.props.goToNextStep()
);
}
} }
onClose={ () =>

View File

@ -402,3 +402,36 @@
margin-left: auto;
}
}
.woocommerce-obw-unsaved-changes {
width: 565px;
max-width: 100%;
.components-modal__header {
border-bottom: 1px solid #ddd;
}
.woocommerce-usage-modal__message {
box-sizing: border-box;
border-bottom: 1px solid #ddd;
padding: 0 32px;
background: #fff;
align-items: center;
height: 60px;
z-index: 10;
position: relative;
position: sticky;
top: 0;
margin: 0 -32px 24px;
}
.woocommerce-usage-modal__actions {
display: flex;
justify-content: flex-end;
margin-top: $gap;
button {
margin-left: $gap;
}
}
}

View File

@ -0,0 +1,40 @@
/**
* External dependencies
*/
import { Button, Modal } from '@wordpress/components';
import { __ } from '@wordpress/i18n';
const UnsavedChangesModal = ( { onClose, onSave } ) => {
const title = __( 'Save changes?', 'woocommerce-admin' );
const message = __(
"You're about to go to a different step. Do you want to save the changes you've made here so far?",
'woocommerce-admin'
);
const discardText = __( 'Discard', 'woocommerce-admin' );
const saveText = __( 'Save', 'woocommerce-admin' );
return (
<>
<Modal
title={ title }
className="woocommerce-obw-unsaved-changes"
onRequestClose={ onClose }
>
<div className="woocommerce-obw-unsaved-changes-modal__wrapper">
<div className="woocommerce-usage-modal__message">
{ message }
</div>
<div className="woocommerce-usage-modal__actions">
<Button onClick={ () => onClose() }>
{ discardText }
</Button>
<Button isPrimary onClick={ onSave }>
{ saveText }
</Button>
</div>
</div>
</Modal>
</>
);
};
export default UnsavedChangesModal;

View File

@ -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 );