Commit 434650beaba for woocommerce
commit 434650beaba1a2c594c12a1262b9b139551f8df2
Author: Vlad Olaru <vlad.olaru@automattic.com>
Date: Wed Sep 2 11:17:18 2026 +0300
Fix CES submit label normalization (#68222)
* fix: Normalize CES submit labels
CES action creators, reducers, persisted queues, and components used three different submit-label spellings. The add-survey path consequently dropped custom success labels when its emitted field did not match the reducer contract.
Normalize historical aliases at store boundaries, keep canonical state for first-party consumers, preserve selector-visible legacy queue keys, and migrate the live PHP inline caller. This retains compatibility with existing dispatch callers and mixed-version persisted data.
Refs #39052
* chore: Add CES submit label changelogs
Record the submit-label normalization for both the public customer-effort-score package and the WooCommerce plugin caller so each affected package receives the correct patch release note.
Refs #39052
diff --git a/packages/js/customer-effort-score/changelog/fix-39052-normalize-submit-label b/packages/js/customer-effort-score/changelog/fix-39052-normalize-submit-label
new file mode 100644
index 00000000000..30d4ef52833
--- /dev/null
+++ b/packages/js/customer-effort-score/changelog/fix-39052-normalize-submit-label
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Normalize customer effort score submit labels while preserving legacy action and queue aliases.
diff --git a/packages/js/customer-effort-score/src/components/customer-effort-score-tracks-container/index.js b/packages/js/customer-effort-score/src/components/customer-effort-score-tracks-container/index.js
index b50076e350e..bf6bc22e00b 100644
--- a/packages/js/customer-effort-score/src/components/customer-effort-score-tracks-container/index.js
+++ b/packages/js/customer-effort-score/src/components/customer-effort-score-tracks-container/index.js
@@ -4,7 +4,6 @@
import { useEffect } from 'react';
import { compose } from '@wordpress/compose';
import { withDispatch, withSelect } from '@wordpress/data';
-import { createElement, Fragment } from '@wordpress/element';
import { optionsStore } from '@woocommerce/data';
/**
@@ -61,7 +60,7 @@ function _CustomerEffortScoreTracksContainer( {
secondQuestion={ item.secondQuestion }
icon={ item.icon }
title={ item.title }
- onSubmitLabel={ item.onsubmit_label }
+ onSubmitLabel={ item.onSubmitLabel }
trackProps={ item.props || {} }
/>
) ) }
diff --git a/packages/js/customer-effort-score/src/components/customer-effort-score-tracks-container/test/index.js b/packages/js/customer-effort-score/src/components/customer-effort-score-tracks-container/test/index.js
new file mode 100644
index 00000000000..f21fe2fce8d
--- /dev/null
+++ b/packages/js/customer-effort-score/src/components/customer-effort-score-tracks-container/test/index.js
@@ -0,0 +1,76 @@
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+import { createElement } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import { CustomerEffortScoreTracksContainer } from '..';
+
+jest.mock( '@wordpress/compose', () => ( {
+ compose: () => ( Component ) => Component,
+} ) );
+
+jest.mock( '@wordpress/data', () => ( {
+ withDispatch: jest.fn(),
+ withSelect: jest.fn(),
+} ) );
+
+jest.mock( '@woocommerce/data', () => ( {
+ optionsStore: 'wc/admin/options',
+} ) );
+
+jest.mock( '../../../store', () => ( {
+ QUEUE_OPTION_NAME: 'woocommerce_ces_tracks_queue',
+ STORE_KEY: 'wc/customer-effort-score',
+} ) );
+
+jest.mock( '../..', () => {
+ const { createElement: mockCreateElement } =
+ jest.requireActual( '@wordpress/element' );
+
+ return {
+ CustomerEffortScoreTracks: ( { onSubmitLabel } ) =>
+ mockCreateElement( 'span', null, onSubmitLabel ),
+ };
+} );
+
+describe( 'CustomerEffortScoreTracksContainer', () => {
+ const originalPagenow = window.pagenow;
+ const originalAdminpage = window.adminpage;
+
+ beforeEach( () => {
+ window.pagenow = 'product';
+ window.adminpage = 'post-php';
+ } );
+
+ afterEach( () => {
+ window.pagenow = originalPagenow;
+ window.adminpage = originalAdminpage;
+ } );
+
+ it( 'forwards the canonical label from a normalized queue item', () => {
+ const clearQueue = jest.fn();
+
+ render(
+ createElement( CustomerEffortScoreTracksContainer, {
+ queue: [
+ {
+ onSubmitLabel: 'Canonical success',
+ onsubmit_label: 'Legacy value',
+ pagenow: 'product',
+ adminpage: 'post-php',
+ },
+ ],
+ resolving: false,
+ clearQueue,
+ } )
+ );
+
+ expect( screen.getByText( 'Canonical success' ) ).toBeInTheDocument();
+ expect( screen.queryByText( 'Legacy value' ) ).not.toBeInTheDocument();
+ expect( clearQueue ).toHaveBeenCalledTimes( 1 );
+ } );
+} );
diff --git a/packages/js/customer-effort-score/src/store/actions.js b/packages/js/customer-effort-score/src/store/actions.js
index 0ab0f78dfa6..0da045a758a 100644
--- a/packages/js/customer-effort-score/src/store/actions.js
+++ b/packages/js/customer-effort-score/src/store/actions.js
@@ -7,6 +7,7 @@ import { __ } from '@wordpress/i18n';
* Internal dependencies
*/
import TYPES from './action-types';
+import { getOnSubmitLabel } from './get-on-submit-label';
/**
* Initialize the state
@@ -23,18 +24,20 @@ export function setCesSurveyQueue( queue ) {
/**
* Add a new CES track to the state.
*
- * @param {Object} args All arguments.
- * @param {string} args.action action name for the survey
- * @param {string} args.title title for the snackback
- * @param {string} args.description description for feedback modal.
- * @param {string} args.noticeLabel noticeLabel for notice.
- * @param {string} args.firstQuestion first question for modal survey
- * @param {string} args.secondQuestion second question for modal survey
- * @param {string} [args.icon] optional icon for notice.
- * @param {string} [args.pageNow] optional value of window.pagenow, default to window.pagenow
- * @param {string} [args.adminPage] optional value of window.adminpage, default to window.adminpage
- * @param {string} [args.onsubmitLabel] optional label for the snackback onsubmit, default to undefined
- * @param {Object} args.props object for optional props
+ * @param {Object} args All arguments.
+ * @param {string} args.action action name for the survey
+ * @param {string} args.title title for the snackback
+ * @param {string} args.description description for feedback modal.
+ * @param {string} args.noticeLabel noticeLabel for notice.
+ * @param {string} args.firstQuestion first question for modal survey
+ * @param {string} args.secondQuestion second question for modal survey
+ * @param {string} [args.icon] optional icon for notice.
+ * @param {string} [args.pageNow] optional value of window.pagenow, default to window.pagenow
+ * @param {string} [args.adminPage] optional value of window.adminpage, default to window.adminpage
+ * @param {string} [args.onSubmitLabel] optional label for the snackback onsubmit, default to undefined
+ * @param {string} [args.onsubmitLabel] deprecated lower-camel alias for onSubmitLabel
+ * @param {string} [args.onsubmit_label] deprecated snake-case alias for onSubmitLabel
+ * @param {Object} args.props object for optional props
*/
export function addCesSurvey( {
action,
@@ -46,7 +49,9 @@ export function addCesSurvey( {
icon,
pageNow = window.pagenow,
adminPage = window.adminpage,
- onsubmitLabel = undefined,
+ onSubmitLabel,
+ onsubmitLabel,
+ onsubmit_label,
props = {},
} ) {
return {
@@ -60,7 +65,11 @@ export function addCesSurvey( {
icon,
pageNow,
adminPage,
- onsubmit_label: onsubmitLabel,
+ onSubmitLabel: getOnSubmitLabel( {
+ onSubmitLabel,
+ onsubmitLabel,
+ onsubmit_label,
+ } ),
props,
};
}
@@ -81,7 +90,7 @@ export function showCesModal(
return {
type: TYPES.SHOW_CES_MODAL,
surveyProps,
- onsubmit_label: surveyProps.onsubmitLabel || '',
+ onSubmitLabel: getOnSubmitLabel( surveyProps ) ?? '',
props,
onSubmitNoticeProps,
tracksProps,
@@ -137,7 +146,6 @@ export function addCesSurveyForCustomerSearch() {
),
pageNow: 'woocommerce_page_wc-admin',
adminPage: 'woocommerce_page_wc-admin',
- onsubmit_label: undefined,
props: {
search_area: 'customer',
},
diff --git a/packages/js/customer-effort-score/src/store/get-on-submit-label.js b/packages/js/customer-effort-score/src/store/get-on-submit-label.js
new file mode 100644
index 00000000000..9dc263d0413
--- /dev/null
+++ b/packages/js/customer-effort-score/src/store/get-on-submit-label.js
@@ -0,0 +1,2 @@
+export const getOnSubmitLabel = ( value = {} ) =>
+ value.onSubmitLabel ?? value.onsubmitLabel ?? value.onsubmit_label;
diff --git a/packages/js/customer-effort-score/src/store/reducer.js b/packages/js/customer-effort-score/src/store/reducer.js
index 99f3570fd2b..301d974da2a 100644
--- a/packages/js/customer-effort-score/src/store/reducer.js
+++ b/packages/js/customer-effort-score/src/store/reducer.js
@@ -2,6 +2,7 @@
* Internal dependencies
*/
import TYPES from './action-types';
+import { getOnSubmitLabel } from './get-on-submit-label';
const DEFAULT_STATE = {
queue: [],
@@ -15,7 +16,13 @@ const reducer = ( state = DEFAULT_STATE, action ) => {
case TYPES.SET_CES_SURVEY_QUEUE:
return {
...state,
- queue: [ ...state.queue, ...action.queue ],
+ queue: [
+ ...state.queue,
+ ...action.queue.map( ( item ) => ( {
+ ...item,
+ onSubmitLabel: getOnSubmitLabel( item ),
+ } ) ),
+ ],
};
case TYPES.HIDE_CES_MODAL:
return {
@@ -29,7 +36,7 @@ const reducer = ( state = DEFAULT_STATE, action ) => {
description: action.surveyProps.description,
showDescription: action.surveyProps.showDescription,
title: action.surveyProps.title,
- onSubmitLabel: action.onsubmit_label,
+ onSubmitLabel: getOnSubmitLabel( action ),
firstQuestion: action.surveyProps.firstQuestion,
secondQuestion: action.surveyProps.secondQuestion,
onSubmitNoticeProps: action.onSubmitNoticeProps || {},
@@ -62,7 +69,7 @@ const reducer = ( state = DEFAULT_STATE, action ) => {
icon: action.icon,
pagenow: action.pageNow,
adminpage: action.adminPage,
- onSubmitLabel: action.onSubmitLabel,
+ onSubmitLabel: getOnSubmitLabel( action ),
props: action.props,
};
return {
diff --git a/packages/js/customer-effort-score/src/store/test/actions.js b/packages/js/customer-effort-score/src/store/test/actions.js
new file mode 100644
index 00000000000..d4788c92320
--- /dev/null
+++ b/packages/js/customer-effort-score/src/store/test/actions.js
@@ -0,0 +1,102 @@
+/**
+ * Internal dependencies
+ */
+import TYPES from '../action-types';
+import { addCesSurvey, showCesModal } from '../actions';
+
+const survey = {
+ action: 'save_product',
+ title: 'How easy was it to save this product?',
+ description: 'Tell us about your experience.',
+ noticeLabel: 'Product saved',
+ firstQuestion: 'Saving this product was easy.',
+ secondQuestion: 'The save flow met my needs.',
+ icon: 'product',
+ pageNow: 'product',
+ adminPage: 'post-php',
+ props: { productType: 'simple' },
+};
+
+const actionCreators = [
+ {
+ name: 'addCesSurvey',
+ type: TYPES.ADD_CES_SURVEY,
+ create: ( labels ) => addCesSurvey( { ...survey, ...labels } ),
+ defaultLabel: undefined,
+ },
+ {
+ name: 'showCesModal',
+ type: TYPES.SHOW_CES_MODAL,
+ create: ( labels ) => showCesModal( { ...survey, ...labels } ),
+ defaultLabel: '',
+ },
+];
+
+const precedenceCases = [
+ {
+ caseName: 'prefers the canonical value',
+ labels: {
+ onSubmitLabel: 'Canonical label',
+ onsubmitLabel: 'Lower-camel label',
+ onsubmit_label: 'Snake-case label',
+ },
+ expected: 'Canonical label',
+ },
+ {
+ caseName: 'keeps an empty canonical value',
+ labels: {
+ onSubmitLabel: '',
+ onsubmitLabel: 'Lower-camel label',
+ onsubmit_label: 'Snake-case label',
+ },
+ expected: '',
+ },
+ {
+ caseName: 'keeps an empty lower-camel value',
+ labels: {
+ onSubmitLabel: null,
+ onsubmitLabel: '',
+ onsubmit_label: 'Snake-case label',
+ },
+ expected: '',
+ },
+ {
+ caseName: 'falls through nullish values',
+ labels: {
+ onSubmitLabel: null,
+ onsubmitLabel: null,
+ onsubmit_label: 'Snake-case label',
+ },
+ expected: 'Snake-case label',
+ },
+];
+
+describe.each( actionCreators )(
+ '$name',
+ ( { create, defaultLabel, type } ) => {
+ it.each( [ 'onSubmitLabel', 'onsubmitLabel', 'onsubmit_label' ] )(
+ 'normalizes the %s input field to onSubmitLabel',
+ ( inputField ) => {
+ const action = create( { [ inputField ]: 'Share feedback' } );
+
+ expect( action ).toMatchObject( {
+ type,
+ onSubmitLabel: 'Share feedback',
+ } );
+ expect( action ).not.toHaveProperty( 'onsubmitLabel' );
+ expect( action ).not.toHaveProperty( 'onsubmit_label' );
+ }
+ );
+
+ it.each( precedenceCases )( '$caseName', ( { labels, expected } ) => {
+ expect( create( labels ).onSubmitLabel ).toBe( expected );
+ } );
+
+ it( 'uses the expected value when no label field is present', () => {
+ expect( create( {} ) ).toHaveProperty(
+ 'onSubmitLabel',
+ defaultLabel
+ );
+ } );
+ }
+);
diff --git a/packages/js/customer-effort-score/src/store/test/reducer.js b/packages/js/customer-effort-score/src/store/test/reducer.js
new file mode 100644
index 00000000000..25c8f305043
--- /dev/null
+++ b/packages/js/customer-effort-score/src/store/test/reducer.js
@@ -0,0 +1,203 @@
+/**
+ * Internal dependencies
+ */
+import TYPES from '../action-types';
+import reducer from '../reducer';
+
+const surveyAction = {
+ action: 'save_product',
+ title: 'How easy was it to save this product?',
+ description: 'Tell us about your experience.',
+ noticeLabel: 'Product saved',
+ firstQuestion: 'Saving this product was easy.',
+ secondQuestion: 'The save flow met my needs.',
+ icon: 'product',
+ pageNow: 'product',
+ adminPage: 'post-php',
+ props: { productType: 'simple' },
+};
+
+const reducerCases = [
+ {
+ name: 'ADD_CES_SURVEY',
+ createAction: ( labels ) => ( {
+ type: TYPES.ADD_CES_SURVEY,
+ ...surveyAction,
+ ...labels,
+ } ),
+ getLabel: ( state ) => state.queue[ 0 ].onSubmitLabel,
+ },
+ {
+ name: 'SHOW_CES_MODAL',
+ createAction: ( labels ) => ( {
+ type: TYPES.SHOW_CES_MODAL,
+ surveyProps: surveyAction,
+ props: surveyAction.props,
+ onSubmitNoticeProps: { type: 'success' },
+ tracksProps: { source: 'product-editor' },
+ ...labels,
+ } ),
+ getLabel: ( state ) => state.cesModalData.onSubmitLabel,
+ },
+];
+
+const precedenceCases = [
+ {
+ caseName: 'prefers the canonical action field',
+ labels: {
+ onSubmitLabel: 'Canonical label',
+ onsubmitLabel: 'Lower-camel label',
+ onsubmit_label: 'Snake-case label',
+ },
+ expected: 'Canonical label',
+ },
+ {
+ caseName: 'keeps an empty canonical action field',
+ labels: {
+ onSubmitLabel: '',
+ onsubmitLabel: 'Lower-camel label',
+ onsubmit_label: 'Snake-case label',
+ },
+ expected: '',
+ },
+ {
+ caseName: 'falls through a null canonical action field',
+ labels: {
+ onSubmitLabel: null,
+ onsubmitLabel: 'Lower-camel label',
+ onsubmit_label: 'Snake-case label',
+ },
+ expected: 'Lower-camel label',
+ },
+ {
+ caseName: 'keeps an empty lower-camel action field',
+ labels: {
+ onSubmitLabel: null,
+ onsubmitLabel: '',
+ onsubmit_label: 'Snake-case label',
+ },
+ expected: '',
+ },
+];
+
+describe( 'customer effort score reducer', () => {
+ describe.each( reducerCases )( '$name', ( { createAction, getLabel } ) => {
+ it.each( [ 'onSubmitLabel', 'onsubmitLabel', 'onsubmit_label' ] )(
+ 'keeps a custom label from the %s action field',
+ ( inputField ) => {
+ const state = reducer(
+ undefined,
+ createAction( { [ inputField ]: 'Share feedback' } )
+ );
+
+ expect( getLabel( state ) ).toBe( 'Share feedback' );
+ }
+ );
+
+ it.each( precedenceCases )( '$caseName', ( { labels, expected } ) => {
+ const state = reducer( undefined, createAction( labels ) );
+
+ expect( getLabel( state ) ).toBe( expected );
+ } );
+ } );
+
+ it( 'leaves the queue unchanged for a duplicate survey', () => {
+ const initialState = {
+ queue: [
+ { action: 'save_product', onSubmitLabel: 'Save label' },
+ {
+ action: 'publish_product',
+ onSubmitLabel: 'Publish label',
+ },
+ ],
+ cesModalData: undefined,
+ showCESModal: false,
+ showProductMVPFeedbackModal: false,
+ };
+
+ const state = reducer( initialState, {
+ type: TYPES.ADD_CES_SURVEY,
+ ...surveyAction,
+ onSubmitLabel: 'Replacement label',
+ } );
+
+ expect( state ).toBe( initialState );
+ } );
+
+ it( 'normalizes persisted queue labels without changing legacy data or order', () => {
+ const existingItem = Object.freeze( {
+ id: 'existing',
+ action: 'existing_survey',
+ onSubmitLabel: 'Existing label',
+ } );
+ const queue = Object.freeze( [
+ Object.freeze( {
+ id: 'canonical',
+ action: 'canonical_survey',
+ onSubmitLabel: 'Canonical label',
+ onsubmitLabel: 'Lower-camel label',
+ onsubmit_label: 'Snake-case label',
+ unrelated: Object.freeze( { source: 'canonical' } ),
+ } ),
+ Object.freeze( {
+ id: 'lower-camel',
+ action: 'lower_camel_survey',
+ onsubmitLabel: 'Lower-camel label',
+ onsubmit_label: 'Snake-case label',
+ } ),
+ Object.freeze( {
+ id: 'snake-case',
+ action: 'snake_case_survey',
+ onsubmit_label: 'Snake-case label',
+ } ),
+ Object.freeze( {
+ id: 'no-label',
+ action: 'default_label_survey',
+ } ),
+ ] );
+ const initialState = Object.freeze( {
+ queue: Object.freeze( [ existingItem ] ),
+ cesModalData: undefined,
+ showCESModal: false,
+ showProductMVPFeedbackModal: false,
+ } );
+ const action = Object.freeze( {
+ type: TYPES.SET_CES_SURVEY_QUEUE,
+ queue,
+ } );
+
+ const state = reducer( initialState, action );
+
+ expect( state.queue ).toEqual( [
+ existingItem,
+ {
+ ...queue[ 0 ],
+ onSubmitLabel: 'Canonical label',
+ },
+ {
+ ...queue[ 1 ],
+ onSubmitLabel: 'Lower-camel label',
+ },
+ {
+ ...queue[ 2 ],
+ onSubmitLabel: 'Snake-case label',
+ },
+ {
+ ...queue[ 3 ],
+ onSubmitLabel: undefined,
+ },
+ ] );
+ expect( state.queue.map( ( item ) => item.id ) ).toEqual( [
+ 'existing',
+ 'canonical',
+ 'lower-camel',
+ 'snake-case',
+ 'no-label',
+ ] );
+ expect( state.queue[ 0 ] ).toBe( existingItem );
+ expect( state.queue[ 1 ] ).not.toBe( queue[ 0 ] );
+ expect( state.queue[ 2 ] ).not.toBe( queue[ 1 ] );
+ expect( state.queue[ 3 ] ).not.toBe( queue[ 2 ] );
+ expect( state.queue[ 4 ] ).not.toBe( queue[ 3 ] );
+ } );
+} );
diff --git a/plugins/woocommerce/changelog/fix-39052-normalize-ces-submit-label b/plugins/woocommerce/changelog/fix-39052-normalize-ces-submit-label
new file mode 100644
index 00000000000..8ecafd1fae0
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-39052-normalize-ces-submit-label
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Use the canonical customer effort score submit label property for inline survey dispatches.
diff --git a/plugins/woocommerce/src/Internal/Admin/CustomerEffortScoreTracks.php b/plugins/woocommerce/src/Internal/Admin/CustomerEffortScoreTracks.php
index 3593baf48ef..87b06737038 100644
--- a/plugins/woocommerce/src/Internal/Admin/CustomerEffortScoreTracks.php
+++ b/plugins/woocommerce/src/Internal/Admin/CustomerEffortScoreTracks.php
@@ -160,7 +160,7 @@ class CustomerEffortScoreTracks {
if ( $('.tags tbody > tr').length > initialCount ) {
// New tag detected.
clearInterval( interval );
- wp.data.dispatch('wc/customer-effort-score').addCesSurvey({ action: '%s', title: '%s', firstQuestion: '%s', secondQuestion: '%s', onsubmitLabel: '%s' });
+ wp.data.dispatch('wc/customer-effort-score').addCesSurvey({ action: '%s', title: '%s', firstQuestion: '%s', secondQuestion: '%s', onSubmitLabel: '%s' });
} else {
// Form is no longer loading, most likely failed.
if ( $( '#addtag .submit .spinner.is-active' ).length < 1 ) {