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 ) );
+ } );
+} );