Keep score displayed if user cancels out of CES Modal

This commit is contained in:
Lourens Schep 2022-04-21 16:40:48 -03:00
parent 45dfda2b65
commit b441a84d9d
13 changed files with 164 additions and 159 deletions

View File

@ -4,8 +4,7 @@
import { createElement, useState, useEffect } from '@wordpress/element';
import PropTypes from 'prop-types';
import { __ } from '@wordpress/i18n';
import { compose } from '@wordpress/compose';
import { withDispatch } from '@wordpress/data';
import { useDispatch } from '@wordpress/data';
/**
* Internal dependencies
@ -23,16 +22,14 @@ const noop = () => {};
* @param {Object} props Component props.
* @param {Function} props.recordScoreCallback Function to call when the score should be recorded.
* @param {string} props.label The label displayed in the modal.
* @param {Function} props.createNotice Create a notice (snackbar).
* @param {Function} props.onNoticeShownCallback Function to call when the notice is shown.
* @param {Function} props.onNoticeDismissedCallback Function to call when the notice is dismissed.
* @param {Function} props.onModalShownCallback Function to call when the modal is shown.
* @param {Object} props.icon Icon (React component) to be shown on the notice.
*/
function CustomerEffortScoreComponent( {
function CustomerEffortScore( {
recordScoreCallback,
label,
createNotice,
onNoticeShownCallback = noop,
onNoticeDismissedCallback = noop,
onModalShownCallback = noop,
@ -40,6 +37,7 @@ function CustomerEffortScoreComponent( {
} ) {
const [ shouldCreateNotice, setShouldCreateNotice ] = useState( true );
const [ visible, setVisible ] = useState( false );
const { createNotice } = useDispatch( 'core/notices2' );
useEffect( () => {
if ( ! shouldCreateNotice ) {
@ -82,7 +80,7 @@ function CustomerEffortScoreComponent( {
);
}
CustomerEffortScoreComponent.propTypes = {
CustomerEffortScore.propTypes = {
/**
* The function to call to record the score.
*/
@ -91,10 +89,6 @@ CustomerEffortScoreComponent.propTypes = {
* The label displayed in the modal.
*/
label: PropTypes.string.isRequired,
/**
* Create a notice (snackbar).
*/
createNotice: PropTypes.func.isRequired,
/**
* The function to call when the notice is shown.
*/
@ -113,14 +107,4 @@ CustomerEffortScoreComponent.propTypes = {
icon: PropTypes.element,
};
const CustomerEffortScore = compose(
withDispatch( ( dispatch ) => {
const { createNotice } = dispatch( 'core/notices2' );
return {
createNotice,
};
} )
)( CustomerEffortScoreComponent );
export { CustomerEffortScore };

View File

@ -26,15 +26,19 @@ import { __ } from '@wordpress/i18n';
* @param {Object} props Component props.
* @param {Function} props.recordScoreCallback Function to call when the results are sent.
* @param {string} props.label Question to ask the customer.
* @param {string} props.defaultScore Default score.
* @param {Function} props.onCloseModal Callback for when user closes modal by clicking cancel.
*/
function CustomerFeedbackModal( {
recordScoreCallback,
label,
defaultScore = NaN,
onCloseModal,
}: {
recordScoreCallback: ( score: number, comments: string ) => void;
label: string;
defaultScore?: number;
onCloseModal?: () => void;
} ): JSX.Element | null {
const options = [
{
@ -64,7 +68,12 @@ function CustomerFeedbackModal( {
const [ showNoScoreMessage, setShowNoScoreMessage ] = useState( false );
const [ isOpen, setOpen ] = useState( true );
const closeModal = () => setOpen( false );
const closeModal = () => {
setOpen( false );
if ( onCloseModal ) {
onCloseModal();
}
};
const onRadioControlChange = ( value: string ) => {
const valueAsInt = parseInt( value, 10 );
@ -113,7 +122,7 @@ function CustomerFeedbackModal( {
{ ( score === 1 || score === 2 ) && (
<div className="woocommerce-customer-effort-score__comments">
<TextareaControl
label={ __( 'Comments (Optional)', 'woocommerce' ) }
label={ __( 'Comments (optional)', 'woocommerce' ) }
help={ __(
'Your feedback will go to the WooCommerce development team',
'woocommerce'
@ -154,7 +163,8 @@ function CustomerFeedbackModal( {
CustomerFeedbackModal.propTypes = {
recordScoreCallback: PropTypes.func.isRequired,
label: PropTypes.string.isRequired,
score: PropTypes.number,
defaultScore: PropTypes.number,
onCloseModal: PropTypes.func,
};
export { CustomerFeedbackModal };

View File

@ -7,7 +7,7 @@ import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import CustomerFeedbackModal from '../index';
import { CustomerFeedbackModal } from '../index';
const mockRecordScoreCallback = jest.fn();
@ -59,7 +59,7 @@ describe( 'CustomerFeedbackModal', () => {
await screen.findByRole( 'dialog' );
expect(
screen.queryByLabelText( 'Comments (Optional)' )
screen.queryByLabelText( 'Comments (optional)' )
).not.toBeInTheDocument();
} );
@ -80,7 +80,7 @@ describe( 'CustomerFeedbackModal', () => {
fireEvent.click( screen.getByLabelText( labelText ) );
// Wait for comments field to show.
await screen.findByLabelText( 'Comments (Optional)' );
await screen.findByLabelText( 'Comments (optional)' );
// Select neutral score.
fireEvent.click( screen.getByLabelText( 'Neutral' ) );
@ -88,7 +88,7 @@ describe( 'CustomerFeedbackModal', () => {
// Wait for comments field to hide.
await waitFor( () => {
expect(
screen.queryByLabelText( 'Comments (Optional)' )
screen.queryByLabelText( 'Comments (optional)' )
).not.toBeInTheDocument();
} );
}

View File

@ -15,6 +15,7 @@ import { __ } from '@wordpress/i18n';
type CustomerFeedbackSimpleProps = {
recordScoreCallback: ( score: number ) => void;
label: string;
feedbackScore?: number;
showFeedback?: boolean;
};
@ -32,12 +33,14 @@ type CustomerFeedbackSimpleProps = {
* @param {Object} props Component props.
* @param {Function} props.recordScoreCallback Function to call when the results are sent.
* @param {string} props.label Question to ask the customer.
* @param {string} props.showFeedback To show feedback message.
* @param {number} props.feedbackScore Feedback score.
* @param {boolean} props.showFeedback Show feedback.
*/
const CustomerFeedbackSimple: React.FC< CustomerFeedbackSimpleProps > = ( {
recordScoreCallback,
label,
showFeedback = false,
feedbackScore = NaN,
showFeedback,
} ) => {
const options = [
{
@ -67,7 +70,13 @@ const CustomerFeedbackSimple: React.FC< CustomerFeedbackSimpleProps > = ( {
},
];
const [ score, setScore ] = useState( NaN );
const [ score, setScore ] = useState( feedbackScore || NaN );
useEffect( () => {
if ( feedbackScore !== score ) {
setScore( feedbackScore );
}
}, [ feedbackScore ] );
useEffect( () => {
if ( ! isNaN( score ) ) {
@ -77,7 +86,7 @@ const CustomerFeedbackSimple: React.FC< CustomerFeedbackSimpleProps > = ( {
return (
<div className="customer-feedback-simple__container">
{ isNaN( score ) && ! showFeedback ? (
{ ! showFeedback ? (
<Fragment>
<Text
variant="subtitle.small"
@ -124,7 +133,6 @@ const CustomerFeedbackSimple: React.FC< CustomerFeedbackSimpleProps > = ( {
CustomerFeedbackSimple.propTypes = {
recordScoreCallback: PropTypes.func.isRequired,
label: PropTypes.string.isRequired,
showFeedback: PropTypes.bool,
};
export { CustomerFeedbackSimple };

View File

@ -1,96 +1,27 @@
/**
* External dependencies
*/
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import { render, screen, fireEvent } from '@testing-library/react';
import { createElement } from '@wordpress/element';
/**
* Internal dependencies
*/
import CustomerFeedbackModal from '../index';
import { CustomerFeedbackSimple } from '../index';
const mockRecordScoreCallback = jest.fn();
describe( 'CustomerFeedbackModal', () => {
it( 'should close modal when cancel button pressed', async () => {
describe( 'CustomerFeedbackSimple', () => {
it( 'should trigger recordScoreCallback when item is selected', () => {
render(
<CustomerFeedbackModal
<CustomerFeedbackSimple
recordScoreCallback={ mockRecordScoreCallback }
label="Testing"
/>
);
// Wait for the modal to render.
await screen.findByRole( 'dialog' );
// Select the option.
fireEvent.click( screen.getAllByText( '🙂' )[ 0 ] );
// Press cancel button.
fireEvent.click( screen.getByRole( 'button', { name: /cancel/i } ) );
expect( screen.queryByRole( 'dialog' ) ).not.toBeInTheDocument();
expect( mockRecordScoreCallback ).toHaveBeenCalledWith( 4 );
} );
it( 'should halt with an error when submitting without a score', async () => {
render(
<CustomerFeedbackModal
recordScoreCallback={ mockRecordScoreCallback }
label="Testing"
/>
);
await screen.findByRole( 'dialog' ); // Wait for the modal to render.
fireEvent.click( screen.getByRole( 'button', { name: /send/i } ) ); // Press send button.
// Wait for error message.
await screen.findByRole( 'alert' );
expect( screen.getByRole( 'dialog' ) ).toBeInTheDocument();
} );
it( 'should disable the comments field initially', async () => {
render(
<CustomerFeedbackModal
recordScoreCallback={ mockRecordScoreCallback }
label="Testing"
/>
);
// Wait for the modal to render.
await screen.findByRole( 'dialog' );
expect(
screen.queryByLabelText( 'Comments (Optional)' )
).not.toBeInTheDocument();
} );
it.each( [ 'Very difficult', 'Somewhat difficult' ] )(
'should toggle the comments field when %s is selected',
async ( labelText ) => {
render(
<CustomerFeedbackModal
recordScoreCallback={ mockRecordScoreCallback }
label="Testing"
/>
);
// Wait for the modal to render.
await screen.findByRole( 'dialog' );
// Select the option.
fireEvent.click( screen.getByLabelText( labelText ) );
// Wait for comments field to show.
await screen.findByLabelText( 'Comments (Optional)' );
// Select neutral score.
fireEvent.click( screen.getByLabelText( 'Neutral' ) );
// Wait for comments field to hide.
await waitFor( () => {
expect(
screen.queryByLabelText( 'Comments (Optional)' )
).not.toBeInTheDocument();
} );
}
);
} );

View File

@ -3,6 +3,7 @@
*/
import { render, screen } from '@testing-library/react';
import { createElement } from '@wordpress/element';
import { useDispatch } from '@wordpress/data';
/**
* Internal dependencies
@ -11,14 +12,28 @@ import { CustomerEffortScore } from '../customer-effort-score';
const noop = () => {};
jest.mock( '@wordpress/data', () => {
const originalModule = jest.requireActual( '@wordpress/data' );
return {
__esModule: true,
...originalModule,
useDispatch: jest.fn().mockReturnValue( {
createNotice: jest.fn(),
} ),
};
} );
describe( 'CustomerEffortScore', () => {
it( 'should call createNotice with appropriate parameters', async () => {
const mockCreateNotice = jest.fn();
useDispatch.mockReturnValue( {
createNotice: mockCreateNotice,
} );
const icon = <span>icon</span>;
render(
<CustomerEffortScore
createNotice={ mockCreateNotice }
recordScoreCallback={ noop }
label={ 'label' }
onNoticeDismissedCallback={ noop }
@ -41,6 +56,9 @@ describe( 'CustomerEffortScore', () => {
it( 'should not call createNotice on rerender', async () => {
const mockCreateNotice = jest.fn();
useDispatch.mockReturnValue( {
createNotice: mockCreateNotice,
} );
const { rerender } = render(
<CustomerEffortScore
@ -53,7 +71,6 @@ describe( 'CustomerEffortScore', () => {
// Simulate rerender by changing label prop.
rerender(
<CustomerEffortScore
createNotice={ mockCreateNotice }
recordScoreCallback={ noop }
label={ 'label2' }
/>
@ -65,7 +82,6 @@ describe( 'CustomerEffortScore', () => {
it( 'should not show dialog if no action is taken', async () => {
render(
<CustomerEffortScore
createNotice={ noop }
recordScoreCallback={ noop }
label={ 'label' }
/>
@ -91,10 +107,12 @@ describe( 'CustomerEffortScore', () => {
// Modal shown callback should also be called.
expect( mockOnModalShownCallback ).toHaveBeenCalled();
};
useDispatch.mockReturnValue( {
createNotice,
} );
render(
<CustomerEffortScore
createNotice={ createNotice }
recordScoreCallback={ noop }
label={ 'label' }
onModalShownCallback={ mockOnModalShownCallback }

View File

@ -34,6 +34,8 @@ const TYPES = {
ACTION_TASK_REQUEST: 'ACTION_TASK_REQUEST',
ACTION_TASK_SUCCESS: 'ACTION_TASK_SUCCESS',
VISITED_TASK: 'VISITED_TASK',
KEEP_COMPLETED_TASKS_REQUEST: 'KEEP_COMPLETED_TASKS_REQUEST',
KEEP_COMPLETED_TASKS_SUCCESS: 'KEEP_COMPLETED_TASKS_SUCCESS',
};
export default TYPES;

View File

@ -2,6 +2,7 @@
* External dependencies
*/
import { apiFetch } from '@wordpress/data-controls';
import { controls } from '@wordpress/data';
/**
* Internal dependencies
@ -9,6 +10,7 @@ import { apiFetch } from '@wordpress/data-controls';
import TYPES from './action-types';
import { WC_ADMIN_NAMESPACE } from '../constants';
import { DeprecatedTasks } from './deprecated-tasks';
import { STORE_NAME as OPTIONS_STORE_NAME } from '../options/constants';
export function getFreeExtensionsError( error ) {
return {
@ -203,6 +205,14 @@ export function optimisticallyCompleteTaskRequest( taskId ) {
};
}
export function keepCompletedTaskListSuccess( taskListId, keepCompletedList ) {
return {
type: TYPES.KEEP_COMPLETED_TASKS_SUCCESS,
taskListId,
keepCompletedTaskList: keepCompletedList,
};
}
export function visitedTask( taskId ) {
return {
type: TYPES.VISITED_TASK,
@ -260,6 +270,20 @@ export function getProductTypesError( error ) {
};
}
export function* keepCompletedTaskList( taskListId ) {
const updateOptionsParams = {
woocommerce_task_list_keep_completed: 'yes',
};
const response = yield controls.dispatch(
OPTIONS_STORE_NAME,
'updateOptions',
updateOptionsParams
);
if ( response && response.success ) {
yield keepCompletedTaskListSuccess( taskListId, 'yes' );
}
}
export function* updateProfileItems( items ) {
yield setIsRequesting( 'updateProfileItems', true );
yield setError( 'updateProfileItems', null );

View File

@ -72,6 +72,7 @@ const onboarding = (
taskListId,
taskList,
taskLists,
keepCompletedTaskList,
}
) => {
switch ( type ) {
@ -372,6 +373,17 @@ const onboarding = (
[ taskListId ]: taskList,
},
};
case TYPES.KEEP_COMPLETED_TASKS_SUCCESS:
return {
...state,
taskLists: {
...state.taskLists,
[ taskListId ]: {
...state.taskLists[ taskListId ],
keepCompletedTaskList,
},
},
};
case TYPES.OPTIMISTICALLY_COMPLETE_TASK_REQUEST:
return {
...state,

View File

@ -1,14 +1,14 @@
.wooocommerce-task-card__header .wooocommerce-task-card__header-subtitle {
color: $gray-700;
margin-bottom: $gap-large;
color: $gray-700;
margin-bottom: $gap-large;
}
.customer-feedback-simple__container {
height: 64px;
height: 64px;
}
.woocommerce-task-card__header-menu {
position: absolute;
right: $gap-large;
top: $gap;
}
position: absolute;
right: 0;
top: 0;
}

View File

@ -52,8 +52,8 @@ export const TaskListCompletedHeaderWithCES: React.FC< TaskListCompletedHeaderPr
} ) => {
const { updateOptions } = useDispatch( OPTIONS_STORE_NAME );
const [ showCesModal, setShowCesModal ] = useState( false );
const [ submittedScore, setSubmittedScore ] = useState( false );
const [ score, setScore ] = useState( NaN );
const [ showFeedback, setShowFeedback ] = useState( false );
const { storeAgeInWeeks, cesShownForActions } = useSelect( ( select ) => {
const { getOption } = select( OPTIONS_STORE_NAME );
@ -69,19 +69,26 @@ export const TaskListCompletedHeaderWithCES: React.FC< TaskListCompletedHeaderPr
return {};
} );
const submitScore = ( recordedScore: number, comments?: string ) => {
recordEvent( 'ces_feedback', {
action: CES_ACTION,
score: recordedScore,
comments: comments || '',
store_age: storeAgeInWeeks,
} );
updateOptions( {
[ SHOWN_FOR_ACTIONS_OPTION_NAME ]: [
CES_ACTION,
...cesShownForActions,
],
} );
setSubmittedScore( true );
};
const recordScore = ( recordedScore: number ) => {
if ( recordedScore > 2 ) {
recordEvent( 'ces_feedback', {
action: CES_ACTION,
score: recordedScore,
store_age: storeAgeInWeeks,
} );
updateOptions( {
[ SHOWN_FOR_ACTIONS_OPTION_NAME ]: [
CES_ACTION,
...cesShownForActions,
],
} );
setScore( recordedScore );
submitScore( recordedScore );
} else {
setScore( recordedScore );
setShowCesModal( true );
@ -94,19 +101,7 @@ export const TaskListCompletedHeaderWithCES: React.FC< TaskListCompletedHeaderPr
const recordModalScore = ( recordedScore: number, comments: string ) => {
setShowCesModal( false );
recordEvent( 'ces_feedback', {
action: 'store_setup',
score: recordedScore,
comments: comments || '',
store_age: storeAgeInWeeks,
} );
updateOptions( {
[ SHOWN_FOR_ACTIONS_OPTION_NAME ]: [
CES_ACTION,
...cesShownForActions,
],
} );
setShowFeedback( true );
submitScore( recordedScore, comments );
};
return (
@ -178,8 +173,9 @@ export const TaskListCompletedHeaderWithCES: React.FC< TaskListCompletedHeaderPr
'How was your experience?',
'woocommerce'
) }
showFeedback={ submittedScore }
recordScoreCallback={ recordScore }
showFeedback={ showFeedback }
feedbackScore={ score }
/>
) }
</Card>
@ -189,6 +185,10 @@ export const TaskListCompletedHeaderWithCES: React.FC< TaskListCompletedHeaderPr
label={ __( 'How was your experience?', 'woocommerce' ) }
defaultScore={ score }
recordScoreCallback={ recordModalScore }
onCloseModal={ () => {
setScore( NaN );
setShowCesModal( false );
} }
/>
) : null }
</>

View File

@ -24,6 +24,7 @@ import { TaskListProps } from '~/tasks/task-list';
import { ProgressHeader } from '~/task-lists/progress-header';
import { SectionPanelTitle } from './section-panel-title';
import { TaskListItem } from './task-list-item';
import { TaskListCompletedHeaderWithCES } from './completed-header-with-ces';
type PanelBodyProps = Omit< PanelBody.Props, 'title' | 'onToggle' > & {
title: string | React.ReactNode | undefined;
@ -40,6 +41,8 @@ export const SectionedTaskList: React.FC< TaskListProps > = ( {
isComplete,
sections,
displayProgressHeader,
cesHeader = true,
showCESFeedback = false,
} ) => {
const { updateOptions } = useDispatch( OPTIONS_STORE_NAME );
const { profileItems } = useSelect( ( select ) => {
@ -115,14 +118,22 @@ export const SectionedTaskList: React.FC< TaskListProps > = ( {
return <div className="woocommerce-task-dashboard__container"></div>;
}
if ( isComplete && ! keepCompletedTaskList ) {
if ( isComplete && keepCompletedTaskList !== 'yes' ) {
return (
<>
<TaskListCompleted
hideTasks={ hideTasks }
keepTasks={ keepTasks }
twoColumns={ false }
/>
{ cesHeader ? (
<TaskListCompletedHeaderWithCES
hideTasks={ hideTasks }
keepTasks={ keepTasks }
showCES={ showCESFeedback }
/>
) : (
<TaskListCompleted
hideTasks={ hideTasks }
keepTasks={ keepTasks }
twoColumns={ false }
/>
) }
</>
);
}

View File

@ -68,7 +68,11 @@ export const TaskList: React.FC< TaskListProps > = ( {
profileItems: getProfileItems(),
};
} );
const { hideTaskList, visitedTask } = useDispatch( ONBOARDING_STORE_NAME );
const {
hideTaskList,
visitedTask,
keepCompletedTaskList: keepCompletedTasks,
} = useDispatch( ONBOARDING_STORE_NAME );
const userPreferences = useUserPreferences();
const [ headerData, setHeaderData ] = useState< {
task?: TaskType;
@ -115,13 +119,14 @@ export const TaskList: React.FC< TaskListProps > = ( {
};
const keepTasks = () => {
const updateOptionsParams = {
woocommerce_task_list_keep_completed: 'yes',
};
keepCompletedTasks( id );
// const updateOptionsParams = {
// woocommerce_task_list_keep_completed: 'yes',
// };
updateOptions( {
...updateOptionsParams,
} );
// updateOptions( {
// ...updateOptionsParams,
// } );
};
const renderMenu = () => {