Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

Add useEffect to avoid multiple CES prompts on the Dashboard Page #1543

Open
wants to merge 12 commits into
base: develop
Choose a base branch
from
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* External dependencies
*/
import { useEffect, useState } from '@wordpress/element';
import CustomerFeedbackModal from '@woocommerce/customer-effort-score/build/customer-feedback-modal';
import { noop } from 'lodash';

/**
* Internal dependencies
*/
import useCESNotice from '.~/hooks/useCESNotice';

/**
* Renders an unmountable CES notice to gather a customer effort score.
*
* ## Motivation
*
* The CustomerEffortScore component included in the package @woocommerce/customer-effort-score does not remove the notice
* when the component has been unmounted, throwing an error when the "Give Feedback" button has been clicked. This new
* component removes the notice if the CES component does not exist.
*
* @param {Object} props Component props.
* @param {string} props.label The label displayed in the modal.
* @param {JSX.Element} props.icon Icon (React component) to be shown on the notice.
* @param {Function} [props.recordScoreCallback] Function to call when the score should be recorded.
* @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.
*/
const CustomerEffortScoreUnmountableNotice = ( {
label,
icon,
recordScoreCallback = noop,
onNoticeShownCallback = noop,
onNoticeDismissedCallback = noop,
onModalShownCallback = noop,
} ) => {
const [ shouldCreateNotice, setShouldCreateNotice ] = useState( true );
const [ visible, setVisible ] = useState( false );
const onClickFeedBack = () => {
setVisible( true );
onModalShownCallback();
};

const createCESNotice = useCESNotice(
label,
icon,
onClickFeedBack,
onNoticeDismissedCallback
);

useEffect( () => {
if ( ! shouldCreateNotice ) {
return;
}

createCESNotice();
setShouldCreateNotice( false );
onNoticeShownCallback();
}, [ shouldCreateNotice, createCESNotice, onNoticeShownCallback ] );

if ( shouldCreateNotice || ! visible ) {
return null;
}

return (
<CustomerFeedbackModal
label={ label }
recordScoreCallback={ recordScoreCallback }
/>
);
};

export default CustomerEffortScoreUnmountableNotice;
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* External dependencies
*/
import '@testing-library/jest-dom';
import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';

/**
* Internal dependencies
*/
import useCESNotice from '.~/hooks/useCESNotice';
import CustomerEffortScoreUnmountableNotice from '.~/components/customer-effort-score-prompt/customer-effort-unmountable-notice';

const mockCreateCESNoticeShowModal = jest
.fn()
.mockImplementation( ( onClickCallBack ) => onClickCallBack() );

const mockCreateCESNotice = jest.fn();

jest.mock( '.~/hooks/useCESNotice', () => {
return jest.fn();
} );

describe( 'Customer Effort Notice component', () => {
let defaultProps;

beforeEach( () => {
defaultProps = {
label: 'label test',
icon: <span>my icon</span>,
recordScoreCallback: jest.fn(),
onNoticeDismissedCallback: jest.fn(),
onNoticeShownCallback: jest.fn(),
onModalShownCallback: jest.fn(),
};
} );

test( 'Rendering the CES Notice without the CES modal', async () => {
useCESNotice.mockImplementation( () => mockCreateCESNotice );

const { queryByText } = render(
<CustomerEffortScoreUnmountableNotice { ...defaultProps } />
);

//Creates the notice
expect( mockCreateCESNotice ).toHaveBeenCalledTimes( 1 );
expect( defaultProps.onNoticeShownCallback ).toHaveBeenCalledTimes( 1 );
expect( defaultProps.onModalShownCallback ).toHaveBeenCalledTimes( 0 );

expect( useCESNotice ).toHaveBeenLastCalledWith(
defaultProps.label,
defaultProps.icon,
expect.any( Function ),
defaultProps.onNoticeDismissedCallback
);

//The modal should not be displayed
expect(
queryByText( 'Please share your feedback' )
).not.toBeInTheDocument();

expect( defaultProps.recordScoreCallback ).toHaveBeenCalledTimes( 0 );
} );

test( 'Rendering the CES Notice with the CES modal', async () => {
useCESNotice.mockImplementation(
( label, icon, onClickCallBack ) => () =>
mockCreateCESNoticeShowModal( onClickCallBack )
);

const { getByText } = render(
<CustomerEffortScoreUnmountableNotice { ...defaultProps } />
);

//Creates the notice
expect( mockCreateCESNotice ).toHaveBeenCalledTimes( 1 );
expect( defaultProps.onModalShownCallback ).toHaveBeenCalledTimes( 1 );
expect( useCESNotice ).toHaveBeenLastCalledWith(
defaultProps.label,
defaultProps.icon,
expect.any( Function ),
defaultProps.onNoticeDismissedCallback
);

//The modal should be displayed
expect( getByText( 'Please share your feedback' ) ).toBeInTheDocument();

//record score
const score = getByText( 'Very easy' );
const send = getByText( 'Send' );

expect( score ).toBeInTheDocument();
expect( send ).toBeInTheDocument();

userEvent.click( score );
userEvent.click( send );
expect( defaultProps.recordScoreCallback ).toHaveBeenCalledTimes( 1 );
} );
} );
4 changes: 2 additions & 2 deletions js/src/components/customer-effort-score-prompt/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import CustomerEffortScore from '@woocommerce/customer-effort-score';
import { recordEvent } from '@woocommerce/tracks';

/**
* Internal dependencies
*/
import { LOCAL_STORAGE_KEYS } from '.~/constants';
import localStorage from '.~/utils/localStorage';
import CustomerEffortScoreUnmountableNotice from './customer-effort-unmountable-notice';

/**
* CES prompt snackbar open
Expand Down Expand Up @@ -81,7 +81,7 @@ const CustomerEffortScorePrompt = ( { eventContext, label } ) => {
};

return (
<CustomerEffortScore
<CustomerEffortScoreUnmountableNotice
label={ label }
recordScoreCallback={ recordScore }
onNoticeShownCallback={ onNoticeShown }
Expand Down
41 changes: 41 additions & 0 deletions js/src/hooks/useCESNotice.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* External dependencies
*/
import { __ } from '@wordpress/i18n';
import { noop } from 'lodash';

/**
* Internal dependencies
*/
import useUnmountableNotice from '.~/hooks/useUnmountableNotice';

/**
* Hook to create a CES notice
*
* @param {string} label CES prompt label.
* @param {JSX.Element} icon Icon (React component) to be shown on the notice.
* @param {Function} [onClickCallBack] Function to call when Give feedback is clicked.
* @param {Function} [onNoticeDismissedCallback] Function to call when the notice is dismissed.
*
* @return {Function} a function that will create the CES notice.
*/
const useCESNotice = (
label,
icon,
onClickCallBack = noop,
onNoticeDismissedCallback = noop
) => {
return useUnmountableNotice( 'success', label, {
actions: [
{
label: __( 'Give feedback', 'google-listings-and-ads' ),
onClick: onClickCallBack,
},
],
icon,
explicitDismiss: true,
onDismiss: onNoticeDismissedCallback,
} );
};

export default useCESNotice;
52 changes: 52 additions & 0 deletions js/src/hooks/useCESNotice.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* External dependencies
*/
import { renderHook } from '@testing-library/react-hooks';

/**
* Internal dependencies
*/
import useCESNotice from './useCESNotice';
import useUnmountableNotice from './useUnmountableNotice';

const mockOnClickCallBack = jest.fn();
const mockOnNoticeDismissedCallback = jest.fn();
const mockUnmountableNotice = jest.fn();

jest.mock( '.~/hooks/useUnmountableNotice', () => {
return jest.fn();
} );

const Icon = <span>My Icon</span>;

describe( 'useCESNotice', () => {
test( 'Should return function with the CES values', () => {
useUnmountableNotice.mockImplementation(
( type, label, options ) => () =>
mockUnmountableNotice( type, label, options )
);

const { result } = renderHook( () =>
useCESNotice(
'my label',
Icon,
mockOnClickCallBack,
mockOnNoticeDismissedCallback
)
);

result.current();

expect( mockUnmountableNotice ).toHaveBeenCalledTimes( 1 );

const type = mockUnmountableNotice.mock.calls[ 0 ][ 0 ];
const label = mockUnmountableNotice.mock.calls[ 0 ][ 1 ];
const options = mockUnmountableNotice.mock.calls[ 0 ][ 2 ];

expect( type ).toBe( 'success' );
expect( label ).toBe( 'my label' );
expect( options.icon ).toBe( Icon );
expect( options.onDismiss ).toBe( mockOnNoticeDismissedCallback );
expect( options.actions[ 0 ].onClick ).toBe( mockOnClickCallBack );
} );
} );
28 changes: 28 additions & 0 deletions js/src/hooks/useUnmountableNotice.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* External dependencies
*/
import { dispatch } from '@wordpress/data';
import { useEffect, useRef } from '@wordpress/element';
import { uniqueId } from 'lodash';

/**
* Hook to create a wp notice that will be removed when the parent component is unmounted
*
* @param {import('@wordpress/notices').Status} type Notice status.
* @param {string} content Notice content.
* @param {import('@wordpress/notices').Options} [options] Notice options.
*
* @return {Function} a function that will create the notice.
*/
const useUnmountableNotice = ( type, content, options = {} ) => {
const { createNotice, removeNotice } = dispatch( 'core/notices2' );
const idRef = useRef( options.id || uniqueId() );

//remove notice when the component is unmounted
useEffect( () => () => removeNotice( idRef.current ), [ removeNotice ] );

return () =>
createNotice( type, content, { ...options, id: idRef.current } );
};

export default useUnmountableNotice;
74 changes: 74 additions & 0 deletions js/src/hooks/useUnmountableNotice.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/**
* External dependencies
*/
import { renderHook } from '@testing-library/react-hooks';

/**
* Internal dependencies
*/
import useUnmountableNotice from './useUnmountableNotice';

const mockCreateNotice = jest.fn();
const mockRemoveNotice = jest.fn();

jest.mock( '@wordpress/data', () => {
return {
dispatch: jest.fn().mockImplementation( () => ( {
createNotice: mockCreateNotice,
removeNotice: mockRemoveNotice,
} ) ),
};
} );

describe( 'useUnmountableNotice', () => {
beforeEach( () => {
jest.clearAllMocks();
} );

test( 'Should return function with createNotice', () => {
const { result } = renderHook( () =>
useUnmountableNotice( 'success', 'test label' )
);

expect( mockRemoveNotice ).toHaveBeenCalledTimes( 0 );

expect( result.current ).toEqual( expect.any( Function ) );

result.current();

expect( mockCreateNotice ).toHaveBeenCalledTimes( 1 );
expect( mockCreateNotice ).toHaveBeenCalledWith(
'success',
'test label',
{ id: expect.any( String ) }
);
} );

test( 'Should remove notice when the hook is unmounted', () => {
const { result, unmount } = renderHook( () =>
useUnmountableNotice( 'success', 'test label' )
);

unmount();

expect( mockRemoveNotice ).toHaveBeenCalledTimes( 1 );
expect( mockCreateNotice ).toHaveBeenCalledTimes( 0 );
expect( result.current ).toEqual( expect.any( Function ) );
} );

test( 'Should remove with custom ID', () => {
const myID = 'my_custom_id';
const { result, unmount } = renderHook( () =>
useUnmountableNotice( 'success', 'test label', {
id: myID,
} )
);

unmount();

expect( mockRemoveNotice ).toHaveBeenCalledTimes( 1 );
expect( mockRemoveNotice ).toHaveBeenCalledWith( myID );
expect( mockCreateNotice ).toHaveBeenCalledTimes( 0 );
expect( result.current ).toEqual( expect.any( Function ) );
} );
} );