diff --git a/js/src/components/customer-effort-score-prompt/customer-effort-unmountable-notice.js b/js/src/components/customer-effort-score-prompt/customer-effort-unmountable-notice.js new file mode 100644 index 0000000000..2b645fc0d3 --- /dev/null +++ b/js/src/components/customer-effort-score-prompt/customer-effort-unmountable-notice.js @@ -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 ( + + ); +}; + +export default CustomerEffortScoreUnmountableNotice; diff --git a/js/src/components/customer-effort-score-prompt/customer-effort-unmountable-notice.test.js b/js/src/components/customer-effort-score-prompt/customer-effort-unmountable-notice.test.js new file mode 100644 index 0000000000..e99137e7cc --- /dev/null +++ b/js/src/components/customer-effort-score-prompt/customer-effort-unmountable-notice.test.js @@ -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: my icon, + 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( + + ); + + //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( + + ); + + //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 ); + } ); +} ); diff --git a/js/src/components/customer-effort-score-prompt/index.js b/js/src/components/customer-effort-score-prompt/index.js index d76845eece..9de6ac2d48 100644 --- a/js/src/components/customer-effort-score-prompt/index.js +++ b/js/src/components/customer-effort-score-prompt/index.js @@ -2,7 +2,6 @@ * External dependencies */ import { __ } from '@wordpress/i18n'; -import CustomerEffortScore from '@woocommerce/customer-effort-score'; import { recordEvent } from '@woocommerce/tracks'; /** @@ -10,6 +9,7 @@ import { recordEvent } from '@woocommerce/tracks'; */ import { LOCAL_STORAGE_KEYS } from '.~/constants'; import localStorage from '.~/utils/localStorage'; +import CustomerEffortScoreUnmountableNotice from './customer-effort-unmountable-notice'; /** * CES prompt snackbar open @@ -81,7 +81,7 @@ const CustomerEffortScorePrompt = ( { eventContext, label } ) => { }; return ( - { + return useUnmountableNotice( 'success', label, { + actions: [ + { + label: __( 'Give feedback', 'google-listings-and-ads' ), + onClick: onClickCallBack, + }, + ], + icon, + explicitDismiss: true, + onDismiss: onNoticeDismissedCallback, + } ); +}; + +export default useCESNotice; diff --git a/js/src/hooks/useCESNotice.test.js b/js/src/hooks/useCESNotice.test.js new file mode 100644 index 0000000000..f9d62d91a8 --- /dev/null +++ b/js/src/hooks/useCESNotice.test.js @@ -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 = My Icon; + +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 ); + } ); +} ); diff --git a/js/src/hooks/useUnmountableNotice.js b/js/src/hooks/useUnmountableNotice.js new file mode 100644 index 0000000000..01515f24bf --- /dev/null +++ b/js/src/hooks/useUnmountableNotice.js @@ -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; diff --git a/js/src/hooks/useUnmountableNotice.test.js b/js/src/hooks/useUnmountableNotice.test.js new file mode 100644 index 0000000000..611e05c1a5 --- /dev/null +++ b/js/src/hooks/useUnmountableNotice.test.js @@ -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 ) ); + } ); +} );