From 6cb816d2766ce093bea5fa6680688ca863cbdf32 Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Tue, 13 Jan 2026 13:05:28 +0530
Subject: [PATCH 001/123] Update GenAICard to latest designs.
---
js/src/components/paid-ads/gen-ai-card.js | 82 ++++++-------------
js/src/components/paid-ads/gen-ai-card.scss | 35 ++++++--
.../gen-ai-check-notice.svg | 3 +
.../pmax-assets-improvements/gen-ai.svg | 41 +++++++++-
4 files changed, 99 insertions(+), 62 deletions(-)
create mode 100644 js/src/images/pmax-assets-improvements/gen-ai-check-notice.svg
diff --git a/js/src/components/paid-ads/gen-ai-card.js b/js/src/components/paid-ads/gen-ai-card.js
index 7851c943dc..2cfd5a22d8 100644
--- a/js/src/components/paid-ads/gen-ai-card.js
+++ b/js/src/components/paid-ads/gen-ai-card.js
@@ -2,25 +2,17 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
-import { addQueryArgs } from '@wordpress/url';
-import { external as externalIcon } from '@wordpress/icons';
-import { createInterpolateElement } from '@wordpress/element';
-import { Icon, Flex, FlexBlock, CardBody } from '@wordpress/components';
+import { Flex, FlexBlock, CardBody, Notice } from '@wordpress/components';
/**
* Internal dependencies
*/
-import Badge from '~/components/badge';
import Section from '~/components/section';
-import AppButton from '~/components/app-button';
import useGoogleAdsAccount from '~/hooks/useGoogleAdsAccount';
-import AppDocumentationLink from '~/components/app-documentation-link';
import genAIImageURL from '~/images/pmax-assets-improvements/gen-ai.svg';
-import { GOOGLE_ADS_ACCOUNT_STATUS } from '~/constants';
+import genAICheckMark from '~/images/pmax-assets-improvements/gen-ai-check-notice.svg';
import './gen-ai-card.scss';
-const { CONNECTED, INCOMPLETE } = GOOGLE_ADS_ACCOUNT_STATUS;
-
/**
* GenAICard component displays a promotional card for Google AI-powered asset generation
* within Performance Max campaigns. It provides information about the feature, a link to
@@ -31,9 +23,6 @@ const { CONNECTED, INCOMPLETE } = GOOGLE_ADS_ACCOUNT_STATUS;
*/
const GenAICard = () => {
const { googleAdsAccount } = useGoogleAdsAccount();
- const hasAdsAccount = [ CONNECTED, INCOMPLETE ].includes(
- googleAdsAccount?.status
- );
const queryArgs = {};
if ( googleAdsAccount?.ocid ) {
@@ -42,11 +31,6 @@ const GenAICard = () => {
queryArgs.ecid = googleAdsAccount.id;
}
- const recommendationsURL = addQueryArgs(
- 'https://ads.google.com/aw/recommendations',
- queryArgs
- );
-
return (
@@ -58,63 +42,49 @@ const GenAICard = () => {
>
-
- { __(
- 'Now available',
- 'google-listings-and-ads'
- ) }
-
-
{ __(
- 'You can use Google AI to help build Performance Max assets with a few clicks.',
+ 'Review Your AI Suggestions',
'google-listings-and-ads'
) }
- { createInterpolateElement(
- __(
- 'Starting with your website, Google AI will understand what you’re advertising and can generate or suggest text, image, logo, and video assets for you.
Learn more',
- 'google-listings-and-ads'
- ),
- {
- link: (
-
- ),
- }
+ { __(
+ 'Google AI analyzed your campaign’s URL to automatically generate your ad assets. Please review the suggested text and images below to ensure they align with your brand.',
+ 'google-listings-and-ads'
) }
- }
- iconPosition="right"
- href={ recommendationsURL }
- disabled={ ! hasAdsAccount }
- target="_blank"
- isSecondary
- >
- { __(
- 'Generate assets with GenAI',
- 'google-listings-and-ads'
- ) }
-
+
+
+
+ { __(
+ 'Text assets were auto-populate with Google AI',
+ 'google-listings-and-ads'
+ ) }
+
+
-
+
diff --git a/js/src/components/paid-ads/gen-ai-card.scss b/js/src/components/paid-ads/gen-ai-card.scss
index fed8366217..0a5280f96f 100644
--- a/js/src/components/paid-ads/gen-ai-card.scss
+++ b/js/src/components/paid-ads/gen-ai-card.scss
@@ -1,4 +1,6 @@
.gla-gen-ai-card {
+ font-family: "SF Pro Text", $default-font;
+
:where(.gla-gen-ai-card__wrapper) {
flex-direction: column-reverse;
@@ -10,12 +12,35 @@
:where(.gla-section-card-title) {
font-size: 16px;
margin-bottom: 8px;
+
+ div {
+ font-family: "SF Pro Display", $default-font;
+ }
+ }
+
+ :where(.is-success) {
+ width: 100%;
+
+ :where(.components-notice__content) {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
}
- img {
- display: block;
- max-height: 100%;
- margin: 0 auto;
- max-width: 100%;
+ :where(.gla-gen-ai-card__image-block) {
+ flex: 0 0 92px;
+
+ @media (min-width: $break-small) {
+ margin-top: 0;
+ margin-left: 24px;
+ }
+
+ img {
+ display: block;
+ max-height: 100%;
+ margin: 0 auto;
+ max-width: 100%;
+ }
}
}
diff --git a/js/src/images/pmax-assets-improvements/gen-ai-check-notice.svg b/js/src/images/pmax-assets-improvements/gen-ai-check-notice.svg
new file mode 100644
index 0000000000..8f283f9378
--- /dev/null
+++ b/js/src/images/pmax-assets-improvements/gen-ai-check-notice.svg
@@ -0,0 +1,3 @@
+
+
+
diff --git a/js/src/images/pmax-assets-improvements/gen-ai.svg b/js/src/images/pmax-assets-improvements/gen-ai.svg
index 098022ed98..73ea45c64b 100644
--- a/js/src/images/pmax-assets-improvements/gen-ai.svg
+++ b/js/src/images/pmax-assets-improvements/gen-ai.svg
@@ -1 +1,40 @@
-
\ No newline at end of file
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From 59a2edd8427d48c968a9c39644be67a2ec5fc8e9 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Tue, 13 Jan 2026 12:39:45 +0400
Subject: [PATCH 002/123] Add Gen AI media and text asset generation
functionality
---
js/src/data/action-types.js | 2 +
js/src/data/actions.js | 104 ++++++++++++++++++++++++++++
js/src/data/reducer.js | 13 ++++
js/src/data/selectors.js | 44 ++++++++++++
js/src/data/test/reducer.test.js | 1 +
js/src/hooks/useGenAIMediaAssets.js | 29 ++++++++
js/src/hooks/useGenAITextAssets.js | 29 ++++++++
7 files changed, 222 insertions(+)
create mode 100644 js/src/hooks/useGenAIMediaAssets.js
create mode 100644 js/src/hooks/useGenAITextAssets.js
diff --git a/js/src/data/action-types.js b/js/src/data/action-types.js
index 851829cc29..910a18a154 100644
--- a/js/src/data/action-types.js
+++ b/js/src/data/action-types.js
@@ -57,6 +57,8 @@ const TYPES = {
RECEIVE_PRICE_BENCHMARK_SUGGESTIONS_PRODUCT_PRICE:
'RECEIVE_PRICE_BENCHMARK_SUGGESTIONS_PRODUCT_PRICE',
RECEIVE_ADS_RECOMMENDATIONS: 'RECEIVE_ADS_RECOMMENDATIONS',
+ RECEIVE_GEN_AI_MEDIA_ASSETS: 'RECEIVE_GEN_AI_MEDIA_ASSETS',
+ RECEIVE_GEN_AI_TEXT_ASSETS: 'RECEIVE_GEN_AI_TEXT_ASSETS',
};
export default TYPES;
diff --git a/js/src/data/actions.js b/js/src/data/actions.js
index 8364e6aa15..57a90d9af2 100644
--- a/js/src/data/actions.js
+++ b/js/src/data/actions.js
@@ -1283,3 +1283,107 @@ export function* receiveAdsRecommendations(
recommendationTypes,
};
}
+
+/**
+ * Fetches Gen AI media assets. If no asset type is provided, it will fetch all asset types.
+ *
+ * @param {string} url The final URL for which to generate media assets.
+ * @param {'marketing_image'|'square_marketing_image'|'portrait_marketing_image'|undefined} [assetType] - The type of media asset to retrieve.
+ * @return {Object} Action object to save generated media assets.
+ * @throws Will throw an error if the request failed.
+ */
+export function* fetchGenAIMediaAssets( url, assetType ) {
+ try {
+ const response = yield apiFetch( {
+ path: `${ API_NAMESPACE }/ads/assets/generate-images`,
+ method: REQUEST_ACTIONS.POST,
+ data: {
+ final_url: url,
+ type: assetType,
+ },
+ } );
+
+ const items = response.items || [];
+ const formattedData = items.reduce(
+ ( accumulator, { temporary_image_url, type } ) => {
+ if ( assetType && type !== assetType ) {
+ return accumulator;
+ }
+
+ if ( ! temporary_image_url ) {
+ return accumulator;
+ }
+
+ accumulator[ type ] = accumulator[ type ] || [];
+ accumulator[ type ].push( temporary_image_url );
+ return accumulator;
+ },
+ {}
+ );
+
+ return {
+ type: TYPES.RECEIVE_GEN_AI_MEDIA_ASSETS,
+ url,
+ data: formattedData,
+ };
+ } catch ( error ) {
+ handleApiError(
+ error,
+ __(
+ 'There was an error generating media assets.',
+ 'google-listings-and-ads'
+ )
+ );
+ throw error;
+ }
+}
+
+/**
+ * Fetches Gen AI text assets. If no asset type is provided, it will fetch all asset types.
+ *
+ * @param {string} url The final URL for which to generate text assets.
+ * @param {'headline'|'long_headline'|'description'|undefined} [assetType] - The type of text asset to retrieve.
+ * @return {Object} Action object to save generated text assets.
+ */
+export function* fetchGenAITextAssets( url, assetType ) {
+ try {
+ const response = yield apiFetch( {
+ path: `${ API_NAMESPACE }/ads/assets/generate-text`,
+ method: REQUEST_ACTIONS.POST,
+ data: {
+ final_url: url,
+ type: assetType,
+ },
+ } );
+
+ const items = response.items || [];
+ const formattedData = items.reduce( ( accumulator, { text, type } ) => {
+ if ( assetType && type !== assetType ) {
+ return accumulator;
+ }
+
+ if ( ! text ) {
+ return accumulator;
+ }
+
+ accumulator[ type ] = accumulator[ type ] || [];
+ accumulator[ type ].push( text );
+ return accumulator;
+ }, {} );
+
+ return {
+ type: TYPES.RECEIVE_GEN_AI_TEXT_ASSETS,
+ url,
+ data: formattedData,
+ };
+ } catch ( error ) {
+ handleApiError(
+ error,
+ __(
+ 'There was an error generating text assets.',
+ 'google-listings-and-ads'
+ )
+ );
+ throw error;
+ }
+}
diff --git a/js/src/data/reducer.js b/js/src/data/reducer.js
index 2402e397b6..5c3bbbdfec 100644
--- a/js/src/data/reducer.js
+++ b/js/src/data/reducer.js
@@ -83,6 +83,7 @@ const DEFAULT_STATE = {
},
summary: {},
},
+ gen_ai_assets: {},
};
/**
@@ -631,6 +632,18 @@ const reducer = ( state = DEFAULT_STATE, action ) => {
);
}
+ case TYPES.RECEIVE_GEN_AI_MEDIA_ASSETS: {
+ const { url, data } = action;
+
+ return setIn( state, [ 'gen_ai_assets', url, 'media' ], data );
+ }
+
+ case TYPES.RECEIVE_GEN_AI_TEXT_ASSETS: {
+ const { url, data } = action;
+
+ return setIn( state, [ 'gen_ai_assets', url, 'text' ], data );
+ }
+
// Page will be reloaded after all accounts have been disconnected, so no need to mutate state.
case TYPES.DISCONNECT_ACCOUNTS_ALL:
default:
diff --git a/js/src/data/selectors.js b/js/src/data/selectors.js
index a16b2f7740..64b6e7740b 100644
--- a/js/src/data/selectors.js
+++ b/js/src/data/selectors.js
@@ -496,3 +496,47 @@ export const getAdsRecommendations = ( state, types, campaign_id = null ) => {
const key = arrayToUnderscoreKey( keyToHash );
return state.ads.recommendations[ key ] || null;
};
+
+/**
+ * Retrieves the GenAI media assets from the state for a given URL and type.
+ *
+ * @param {Object} state - The Redux state object containing GenAI assets data.
+ * @param {string} url - The URL associated with the GenAI assets.
+ * @param {'marketing_image'|'square_marketing_image'|'portrait_marketing_image'|undefined} [assetType] - The type of media asset to retrieve.
+ * @return {Object|null} The media assets for the specified URL and type, or null if not found.
+ */
+export const getGenAIMediaAssets = ( state, url, assetType ) => {
+ const mediaAssets = state.gen_ai_assets?.[ url ]?.media;
+
+ if ( ! url || ! mediaAssets ) {
+ return null;
+ }
+
+ if ( assetType ) {
+ return mediaAssets[ assetType ] ?? null;
+ }
+
+ return mediaAssets;
+};
+
+/**
+ * Retrieves the GenAI text assets from the state for a given URL and type.
+ *
+ * @param {Object} state - The Redux state object containing GenAI assets data.
+ * @param {string} url - The URL associated with the GenAI assets.
+ * @param {'headline'|'long_headline'|'description'|undefined} [assetType] - The type of text asset to retrieve.
+ * @return {Object|null} The text assets for the specified URL and type, or null if not found.
+ */
+export const getGenAITextAssets = ( state, url, assetType ) => {
+ const textAssets = state.gen_ai_assets?.[ url ]?.text;
+
+ if ( ! url || ! textAssets ) {
+ return null;
+ }
+
+ if ( assetType ) {
+ return textAssets[ assetType ] ?? null;
+ }
+
+ return textAssets;
+};
diff --git a/js/src/data/test/reducer.test.js b/js/src/data/test/reducer.test.js
index 76ed2e24a6..81edea494b 100644
--- a/js/src/data/test/reducer.test.js
+++ b/js/src/data/test/reducer.test.js
@@ -85,6 +85,7 @@ describe( 'reducer', () => {
},
summary: {},
},
+ gen_ai_assets: {},
} );
prepareState = prepareImmutableState.bind( null, defaultState );
diff --git a/js/src/hooks/useGenAIMediaAssets.js b/js/src/hooks/useGenAIMediaAssets.js
new file mode 100644
index 0000000000..9a7294d7c6
--- /dev/null
+++ b/js/src/hooks/useGenAIMediaAssets.js
@@ -0,0 +1,29 @@
+/**
+ * External dependencies
+ */
+import { useSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { STORE_KEY } from '~/data/constants';
+
+const selectorName = 'getGenAIMediaAssets';
+
+const useGenAIMediaAssets = ( url, assetType ) => {
+ return useSelect(
+ ( select ) => {
+ const assets = select( STORE_KEY )[ selectorName ](
+ url,
+ assetType
+ );
+
+ return {
+ assets,
+ };
+ },
+ [ url, assetType ]
+ );
+};
+
+export default useGenAIMediaAssets;
diff --git a/js/src/hooks/useGenAITextAssets.js b/js/src/hooks/useGenAITextAssets.js
new file mode 100644
index 0000000000..dd5fcafc43
--- /dev/null
+++ b/js/src/hooks/useGenAITextAssets.js
@@ -0,0 +1,29 @@
+/**
+ * External dependencies
+ */
+import { useSelect } from '@wordpress/data';
+
+/**
+ * Internal dependencies
+ */
+import { STORE_KEY } from '~/data/constants';
+
+const selectorName = 'getGenAITextAssets';
+
+const useGenAITextAssets = ( url, assetType ) => {
+ return useSelect(
+ ( select ) => {
+ const assets = select( STORE_KEY )[ selectorName ](
+ url,
+ assetType
+ );
+
+ return {
+ assets,
+ };
+ },
+ [ url, assetType ]
+ );
+};
+
+export default useGenAITextAssets;
From a1bdd84d9f90327657fa58e2913fb50a2d1dba7b Mon Sep 17 00:00:00 2001
From: asvinb
Date: Tue, 13 Jan 2026 12:54:52 +0400
Subject: [PATCH 003/123] chore: Update package.json maxSize value
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 2d40b2594f..9e8489d7f1 100644
--- a/package.json
+++ b/package.json
@@ -148,7 +148,7 @@
},
{
"path": "./js/build/index.js",
- "maxSize": "18.7 kB"
+ "maxSize": "19 kB"
},
{
"path": "./js/build/commons.js",
From 0ef29b58b508ac337fde6b044a9f42aeace76db2 Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Tue, 13 Jan 2026 15:27:52 +0530
Subject: [PATCH 004/123] Fix: JS tests.
---
.../__snapshots__/gen-ai-card.test.js.snap | 102 ++++++++++++++++++
.../components/paid-ads/gen-ai-card.test.js | 72 +++----------
2 files changed, 117 insertions(+), 57 deletions(-)
create mode 100644 js/src/components/paid-ads/__snapshots__/gen-ai-card.test.js.snap
diff --git a/js/src/components/paid-ads/__snapshots__/gen-ai-card.test.js.snap b/js/src/components/paid-ads/__snapshots__/gen-ai-card.test.js.snap
new file mode 100644
index 0000000000..2b4c6b2d0d
--- /dev/null
+++ b/js/src/components/paid-ads/__snapshots__/gen-ai-card.test.js.snap
@@ -0,0 +1,102 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`GenAICard Generate assets with GenAI button Match the snapshot 1`] = `
+
+
+
+
+
+
+
+
+
+ Review Your AI Suggestions
+
+
+ Google AI analyzed your campaign’s URL to automatically generate your ad assets. Please review the suggested text and images below to ensure they align with your brand.
+
+
+
+
+ Notice
+
+
+
+
+ Text assets were auto-populate with Google AI
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+`;
diff --git a/js/src/components/paid-ads/gen-ai-card.test.js b/js/src/components/paid-ads/gen-ai-card.test.js
index d9945dde24..ab0321040b 100644
--- a/js/src/components/paid-ads/gen-ai-card.test.js
+++ b/js/src/components/paid-ads/gen-ai-card.test.js
@@ -20,73 +20,31 @@ describe( 'GenAICard', () => {
jest.clearAllMocks();
} );
- const getGenAIButton = () =>
- screen.queryByRole( 'button', {
- name: /Generate Assets with GenAI/i,
- } ) ||
- screen.queryByRole( 'link', { name: /Generate Assets with GenAI/i } );
-
describe( 'Generate assets with GenAI button', () => {
- it( 'disables the button if googleAdsAccount is missing', () => {
+ it( 'Card should have title "Review Your AI Suggestions"', () => {
useGoogleAdsAccount.mockReturnValue( {} );
render( );
- expect( getGenAIButton() ).toBeDisabled();
- } );
- it( 'disables the button if googleAdsAccount.status is not "connected"', () => {
- useGoogleAdsAccount.mockReturnValue( {
- googleAdsAccount: { id: '123', ocid: '456', status: 'pending' },
- } );
- render( );
- expect( getGenAIButton() ).toBeDisabled();
+ expect(
+ screen.getByText( 'Review Your AI Suggestions' )
+ ).toBeInTheDocument();
} );
- it( 'enables the button if googleAdsAccount.status is "connected"', () => {
- useGoogleAdsAccount.mockReturnValue( {
- googleAdsAccount: {
- id: '123',
- ocid: '456',
- status: 'connected',
- },
- } );
+ it( 'Card should have the expected description', () => {
+ useGoogleAdsAccount.mockReturnValue( {} );
render( );
- expect( getGenAIButton() ).not.toBeDisabled();
- } );
- it( 'generates the correct recommendations URL when both ecid and ocid are available', () => {
- useGoogleAdsAccount.mockReturnValue( {
- googleAdsAccount: {
- id: '123',
- ocid: '456',
- status: 'connected',
- },
- } );
- render( );
- const button = getGenAIButton();
- expect( button ).toHaveAttribute(
- 'href',
- expect.stringContaining( 'ocid=456' )
- );
- expect( button ).not.toHaveAttribute(
- 'href',
- expect.stringContaining( 'ecid=' )
- );
+ expect(
+ screen.getByText(
+ 'Google AI analyzed your campaign’s URL to automatically generate your ad assets. Please review the suggested text and images below to ensure they align with your brand.'
+ )
+ ).toBeInTheDocument();
} );
- it( 'generates the correct recommendations URL with only ecid', () => {
- useGoogleAdsAccount.mockReturnValue( {
- googleAdsAccount: { id: '123', status: 'connected' },
- } );
- render( );
- const button = getGenAIButton();
- expect( button ).toHaveAttribute(
- 'href',
- expect.stringContaining( 'ecid=123' )
- );
- expect( button ).not.toHaveAttribute(
- 'href',
- expect.stringContaining( 'ocid=' )
- );
+ it( 'Match the snapshot', () => {
+ useGoogleAdsAccount.mockReturnValue( {} );
+ const { asFragment } = render( );
+ expect( asFragment() ).toMatchSnapshot();
} );
} );
} );
From c431c1aba6a19a0d5c145ce55a0bd2d7feaf03bf Mon Sep 17 00:00:00 2001
From: asvinb
Date: Tue, 13 Jan 2026 16:01:26 +0400
Subject: [PATCH 005/123] Add svgr/webpack loader for inline SVG files.
---
package-lock.json | 1 +
package.json | 1 +
webpack.config.js | 44 +++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 45 insertions(+), 1 deletion(-)
diff --git a/package-lock.json b/package-lock.json
index 3c4b73b17e..64ae1e304e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -45,6 +45,7 @@
"@hapi/h2o2": "^10.0.4",
"@hapi/hapi": "^21.3.10",
"@playwright/test": "^1.56.1",
+ "@svgr/webpack": "^8.1.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^16.0.0",
diff --git a/package.json b/package.json
index 9e8489d7f1..488bb3e9b0 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,7 @@
"@hapi/h2o2": "^10.0.4",
"@hapi/hapi": "^21.3.10",
"@playwright/test": "^1.56.1",
+ "@svgr/webpack": "^8.1.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^16.0.0",
diff --git a/webpack.config.js b/webpack.config.js
index b500499b56..ab72b0a344 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -22,7 +22,49 @@ const webpackConfig = {
// Remove `@wordpress/` rules for SVGs.
...defaultConfig.module.rules.filter( exceptSVGAndPNGRule ),
{
- test: /\.(svg|png|jpe?g|gif)$/i,
+ test: /\.svg$/i,
+ oneOf: [
+ {
+ resourceQuery: /inline/,
+ issuer: /\.[jt]sx?$/,
+ use: [
+ {
+ loader: require.resolve( '@svgr/webpack' ),
+ options: {
+ svgoConfig: {
+ plugins: [
+ {
+ name: 'preset-default',
+ params: {
+ overrides: {
+ removeViewBox: false,
+ },
+ },
+ },
+ {
+ name: 'prefixIds',
+ params: {
+ prefix: true,
+ },
+ },
+ ],
+ },
+ exportType: 'default',
+ },
+ },
+ ],
+ },
+ // Default: emit SVG as a file
+ {
+ type: 'asset/resource',
+ generator: {
+ filename: 'images/[path][contenthash].[name][ext]',
+ },
+ },
+ ],
+ },
+ {
+ test: /\.(png|jpe?g|gif)$/i,
type: 'asset/resource',
generator: {
filename: 'images/[path][contenthash].[name][ext]',
From a6e46f25cde715f0ee1400371be467a2199d112a Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Tue, 13 Jan 2026 17:46:19 +0530
Subject: [PATCH 006/123] Add new GenAIImagesNotice component.
---
.../asset-group-images-section.js | 2 ++
.../paid-ads/gen-ai-images-notice.js | 34 +++++++++++++++++++
.../paid-ads/gen-ai-images-notice.scss | 12 +++++++
.../gen-ai-images-notice.svg | 5 +++
4 files changed, 53 insertions(+)
create mode 100644 js/src/components/paid-ads/gen-ai-images-notice.js
create mode 100644 js/src/components/paid-ads/gen-ai-images-notice.scss
create mode 100644 js/src/images/pmax-assets-improvements/gen-ai-images-notice.svg
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-images-section.js b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-images-section.js
index e3917a8f33..76a6afcf3b 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-images-section.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-images-section.js
@@ -11,6 +11,7 @@ import ImagesSelector from './images-selector';
import AssetField from './asset-field';
import Section from '~/components/section';
import AppDocumentationLink from '~/components/app-documentation-link';
+import GenAIImagesNotice from '../../gen-ai-images-notice';
import { ASSET_IMAGE_SPECS } from '../../assetSpecs';
/**
@@ -74,6 +75,7 @@ const AssetGroupImagesSection = ( {
}
>
+
{ ASSET_IMAGE_SPECS.map( ( spec ) => {
const initialImageUrls = initialValues[ spec.key ];
diff --git a/js/src/components/paid-ads/gen-ai-images-notice.js b/js/src/components/paid-ads/gen-ai-images-notice.js
new file mode 100644
index 0000000000..fddb8b2bbe
--- /dev/null
+++ b/js/src/components/paid-ads/gen-ai-images-notice.js
@@ -0,0 +1,34 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Notice } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import GenAIImagesNoticeGraphic from '~/images/pmax-assets-improvements/gen-ai-images-notice.svg';
+import './gen-ai-images-notice.scss';
+
+const GenAIImagesNotice = () => {
+ return (
+
+
+ { __(
+ "We've used your final URL to auto-populate images…",
+ 'google-listings-and-ads'
+ ) }
+
+ );
+};
+
+export default GenAIImagesNotice;
diff --git a/js/src/components/paid-ads/gen-ai-images-notice.scss b/js/src/components/paid-ads/gen-ai-images-notice.scss
new file mode 100644
index 0000000000..da12e86de3
--- /dev/null
+++ b/js/src/components/paid-ads/gen-ai-images-notice.scss
@@ -0,0 +1,12 @@
+.gla-gen-ai-images-notice {
+ background-color: #f0f6fc;
+ border-left: none;
+ border: 1px solid #c5d9ed;
+ font-family: "SF Pro Text", $default-font;
+
+ :where(.components-notice__content) {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ }
+}
diff --git a/js/src/images/pmax-assets-improvements/gen-ai-images-notice.svg b/js/src/images/pmax-assets-improvements/gen-ai-images-notice.svg
new file mode 100644
index 0000000000..4470ea76b2
--- /dev/null
+++ b/js/src/images/pmax-assets-improvements/gen-ai-images-notice.svg
@@ -0,0 +1,5 @@
+
+
+
+
+
From de71d050d12c1f8f363d54c156f26bddcab20fc0 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Tue, 13 Jan 2026 17:27:30 +0400
Subject: [PATCH 007/123] Rename AddAssetItemButton to AssetItemActionButton
---
.../add-asset-item-button.js | 22 ------------
.../asset-group-text-section.js | 6 ++++
.../asset-item-action-button.js | 36 +++++++++++++++++++
...ton.scss => asset-item-action-button.scss} | 2 +-
.../asset-group-editor/images-selector.js | 4 +--
.../asset-group-editor/texts-editor.js | 25 +++++++++++--
.../youtube-video-selector/index.js | 4 +--
js/src/components/paid-ads/assetSpecs.js | 24 +++++++++++++
js/src/images/ai-icon.svg | 1 +
9 files changed, 95 insertions(+), 29 deletions(-)
delete mode 100644 js/src/components/paid-ads/asset-group/asset-group-editor/add-asset-item-button.js
create mode 100644 js/src/components/paid-ads/asset-group/asset-group-editor/asset-item-action-button.js
rename js/src/components/paid-ads/asset-group/asset-group-editor/{add-asset-item-button.scss => asset-item-action-button.scss} (58%)
create mode 100644 js/src/images/ai-icon.svg
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/add-asset-item-button.js b/js/src/components/paid-ads/asset-group/asset-group-editor/add-asset-item-button.js
deleted file mode 100644
index eae84ccdd4..0000000000
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/add-asset-item-button.js
+++ /dev/null
@@ -1,22 +0,0 @@
-/**
- * External dependencies
- */
-import GridiconPlusSmall from 'gridicons/dist/plus-small';
-
-/**
- * Internal dependencies
- */
-import AppButton from '~/components/app-button';
-import './add-asset-item-button.scss';
-
-export default function AddAssetItemButton( props ) {
- return (
- }
- iconSize={ 16 }
- { ...props }
- />
- );
-}
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-text-section.js b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-text-section.js
index d72a138f21..5e3a18203a 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-text-section.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-text-section.js
@@ -129,6 +129,12 @@ const AssetGroupTextSection = ( {
maxCharacterCounts={ spec.maxCharacterCounts }
placeholder={ spec.capitalizedName }
addButtonText={ spec.addButtonText }
+ generateButtonPluralText={
+ spec.generateButtonPluralText
+ }
+ generateButtonSingularText={
+ spec.generateButtonSingularText
+ }
onChange={ ( texts ) => {
if ( spec.requiredSingleValue ) {
textProps.onChange( texts[ 0 ] );
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/asset-item-action-button.js b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-item-action-button.js
new file mode 100644
index 0000000000..f1bf38cf79
--- /dev/null
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-item-action-button.js
@@ -0,0 +1,36 @@
+/**
+ * External dependencies
+ */
+import GridiconPlusSmall from 'gridicons/dist/plus-small';
+
+/**
+ * Internal dependencies
+ */
+import AppButton from '~/components/app-button';
+import AIIcon from '~/images/ai-icon.svg?inline';
+import './asset-item-action-button.scss';
+
+export const ACTION_TYPES = {
+ ADD: 'add',
+ GENERATE: 'generate',
+};
+
+const ACTION_ICONS = {
+ [ ACTION_TYPES.ADD ]: ,
+ [ ACTION_TYPES.GENERATE ]: ,
+};
+
+export default function AssetItemActionButton( {
+ action = ACTION_TYPES.ADD,
+ ...props
+} ) {
+ return (
+
+ );
+}
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/add-asset-item-button.scss b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-item-action-button.scss
similarity index 58%
rename from js/src/components/paid-ads/asset-group/asset-group-editor/add-asset-item-button.scss
rename to js/src/components/paid-ads/asset-group/asset-group-editor/asset-item-action-button.scss
index 5c1e09cd17..bb73355127 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/add-asset-item-button.scss
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-item-action-button.scss
@@ -1,4 +1,4 @@
-.gla-add-asset-item-button {
+.gla-asset-item-action-button {
&.has-icon {
padding: $grid-unit-05;
}
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js b/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js
index 92a0b2d69f..949e2314ae 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js
@@ -10,7 +10,7 @@ import { useState, useEffect, useRef } from '@wordpress/element';
*/
import useCroppedImageSelector from '~/hooks/useCroppedImageSelector';
import AppTooltip from '~/components/app-tooltip';
-import AddAssetItemButton from './add-asset-item-button';
+import AssetItemActionButton from './asset-item-action-button';
import MediaSelector from './media-selector';
/**
@@ -104,7 +104,7 @@ export default function ImagesSelector( {
const disabled =
maxNumberOfImages !== -1 && images.length >= maxNumberOfImages;
const button = (
- ) => void} [props.onChange] Callback function to be called when the texts are changed.
@@ -43,6 +47,8 @@ export default function TextsEditor( {
maxNumberOfTexts = 0,
maxCharacterCounts,
addButtonText,
+ generateButtonPluralText,
+ generateButtonSingularText,
placeholder,
children,
onChange = noop,
@@ -90,6 +96,14 @@ export default function TextsEditor( {
};
const normalizedMaxCharacterCounts = [ maxCharacterCounts ].flat();
+ const emptyFieldsCount = texts.filter( ( value ) => value === '' ).length;
+ let generateButtonText;
+
+ if ( emptyFieldsCount === 1 && generateButtonSingularText ) {
+ generateButtonText = generateButtonSingularText;
+ } else if ( emptyFieldsCount > 1 && generateButtonPluralText ) {
+ generateButtonText = generateButtonPluralText;
+ }
return (
@@ -133,7 +147,7 @@ export default function TextsEditor( {
} ) }
{ children }
- 0 &&
minNumberOfTexts === maxNumberOfTexts
@@ -145,6 +159,13 @@ export default function TextsEditor( {
text={ addButtonText }
onClick={ handleAddClick }
/>
+
+ { emptyFieldsCount > 0 && (
+
+ ) }
);
}
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/youtube-video-selector/index.js b/js/src/components/paid-ads/asset-group/asset-group-editor/youtube-video-selector/index.js
index 2a54fe7281..4bd8b0f996 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/youtube-video-selector/index.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/youtube-video-selector/index.js
@@ -8,7 +8,7 @@ import { useState } from '@wordpress/element';
* Internal dependencies
*/
import AppTooltip from '~/components/app-tooltip';
-import AddAssetItemButton from '../add-asset-item-button';
+import AssetItemActionButton from '../asset-item-action-button';
import MediaSelector from '../media-selector';
import YouTubeVideoInputControl from './youtube-video-input-control';
@@ -37,7 +37,7 @@ export default function YoutubeVideoSelector( {
const renderAddButton = () => {
const disabled = videoIds.length >= maxNumberOfVideos;
const button = (
-
>
),
+ generateButtonPluralText: __(
+ 'Generate long headlines',
+ 'google-listings-and-ads'
+ ),
+ generateButtonSingularText: __(
+ 'Generate long headline',
+ 'google-listings-and-ads'
+ ),
},
{
@@ -333,6 +349,14 @@ const ASSET_TEXT_SPECS = [
>
),
+ generateButtonPluralText: __(
+ 'Generate descriptions',
+ 'google-listings-and-ads'
+ ),
+ generateButtonSingularText: __(
+ 'Generate description',
+ 'google-listings-and-ads'
+ ),
},
];
diff --git a/js/src/images/ai-icon.svg b/js/src/images/ai-icon.svg
new file mode 100644
index 0000000000..23b02113d1
--- /dev/null
+++ b/js/src/images/ai-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
From fd244c8384a2e3b769195642b867bd5838e0a6f3 Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Tue, 13 Jan 2026 21:08:06 +0530
Subject: [PATCH 008/123] Upgrade ads library to V21
---
bin/GoogleAdsCleanupServices.php | 2 +-
composer.json | 2 +-
src/API/Google/Ads.php | 12 +-
src/API/Google/AdsAsset.php | 22 ++--
src/API/Google/AdsAssetGroup.php | 24 ++--
src/API/Google/AdsAssetGroupAsset.php | 10 +-
src/API/Google/AdsCampaign.php | 22 ++--
src/API/Google/AdsCampaignBudget.php | 10 +-
src/API/Google/AdsCampaignCriterion.php | 12 +-
src/API/Google/AdsCampaignLabel.php | 14 +--
src/API/Google/AdsConversionAction.php | 26 ++--
src/API/Google/AdsReport.php | 4 +-
src/API/Google/AssetFieldType.php | 2 +-
src/API/Google/BillingSetupStatus.php | 2 +-
src/API/Google/BudgetMetrics.php | 16 +--
src/API/Google/BudgetRecommendations.php | 14 +--
src/API/Google/CallToActionType.php | 2 +-
src/API/Google/CampaignStatus.php | 2 +-
src/API/Google/CampaignType.php | 2 +-
src/API/Google/MerchantMetrics.php | 2 +-
src/API/Google/Query/AdsQuery.php | 6 +-
src/API/Google/Query/AdsReportQuery.php | 2 +-
src/Ads/AdsRecommendationsService.php | 4 +-
src/Google/Ads/ServiceClientFactoryTrait.php | 38 +++---
.../GoogleServiceProvider.php | 2 +-
.../HelperTrait/GoogleAdsClientTrait.php | 112 +++++++++---------
.../API/Google/AdsAssetGroupAssetTest.php | 4 +-
tests/Unit/API/Google/AdsAssetGroupTest.php | 6 +-
tests/Unit/API/Google/AdsAssetTest.php | 2 +-
.../API/Google/AdsCampaignCriterionTest.php | 2 +-
.../API/Google/AdsConversionActionTest.php | 2 +-
tests/Unit/API/Google/AdsTest.php | 8 +-
tests/Unit/API/Google/MerchantMetricsTest.php | 6 +-
33 files changed, 198 insertions(+), 198 deletions(-)
diff --git a/bin/GoogleAdsCleanupServices.php b/bin/GoogleAdsCleanupServices.php
index 83d17a3497..864764bb87 100644
--- a/bin/GoogleAdsCleanupServices.php
+++ b/bin/GoogleAdsCleanupServices.php
@@ -26,7 +26,7 @@ class GoogleAdsCleanupServices {
*
* @var string
*/
- protected $version = 'V20';
+ protected $version = 'V21';
/**
* @var Event Composer event.
diff --git a/composer.json b/composer.json
index 1e1c3daf22..4c22d2f0a9 100644
--- a/composer.json
+++ b/composer.json
@@ -60,7 +60,7 @@
"Google\\Task\\Composer::cleanup",
"Automattic\\WooCommerce\\GoogleListingsAndAds\\Util\\SymfonyPolyfillCleanup::remove",
"Automattic\\WooCommerce\\GoogleListingsAndAds\\Util\\GoogleAdsCleanupServices::remove",
- "composer run-script remove-google-ads-api-version-support -- 18 19 21",
+ "composer run-script remove-google-ads-api-version-support -- 18 19 20",
"php ./bin/prefix-vendor-namespace.php",
"bash ./bin/cleanup-vendor-files.sh",
"composer dump-autoload"
diff --git a/src/API/Google/Ads.php b/src/API/Google/Ads.php
index 97bda86023..51b7fa049d 100644
--- a/src/API/Google/Ads.php
+++ b/src/API/Google/Ads.php
@@ -13,12 +13,12 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Exception;
-use Google\Ads\GoogleAds\Util\V20\ResourceNames;
-use Google\Ads\GoogleAds\V20\Enums\AccessRoleEnum\AccessRole;
-use Google\Ads\GoogleAds\V20\Enums\ProductLinkInvitationStatusEnum\ProductLinkInvitationStatus;
-use Google\Ads\GoogleAds\V20\Resources\ProductLinkInvitation;
-use Google\Ads\GoogleAds\V20\Services\ListAccessibleCustomersRequest;
-use Google\Ads\GoogleAds\V20\Services\UpdateProductLinkInvitationRequest;
+use Google\Ads\GoogleAds\Util\V21\ResourceNames;
+use Google\Ads\GoogleAds\V21\Enums\AccessRoleEnum\AccessRole;
+use Google\Ads\GoogleAds\V21\Enums\ProductLinkInvitationStatusEnum\ProductLinkInvitationStatus;
+use Google\Ads\GoogleAds\V21\Resources\ProductLinkInvitation;
+use Google\Ads\GoogleAds\V21\Services\ListAccessibleCustomersRequest;
+use Google\Ads\GoogleAds\V21\Services\UpdateProductLinkInvitationRequest;
use Google\ApiCore\ApiException;
use Google\ApiCore\ValidationException;
diff --git a/src/API/Google/AdsAsset.php b/src/API/Google/AdsAsset.php
index 3308454626..0be762dd02 100644
--- a/src/API/Google/AdsAsset.php
+++ b/src/API/Google/AdsAsset.php
@@ -6,17 +6,17 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
-use Google\Ads\GoogleAds\V20\Services\GoogleAdsRow;
-use Google\Ads\GoogleAds\V20\Enums\AssetTypeEnum\AssetType;
-use Google\Ads\GoogleAds\V20\Resources\Asset;
-use Google\Ads\GoogleAds\V20\Services\AssetOperation;
-use Google\Ads\GoogleAds\V20\Services\MutateGoogleAdsRequest;
-use Google\Ads\GoogleAds\V20\Services\MutateOperation;
-use Google\Ads\GoogleAds\Util\V20\ResourceNames;
-use Google\Ads\GoogleAds\V20\Common\TextAsset;
-use Google\Ads\GoogleAds\V20\Common\ImageAsset;
-use Google\Ads\GoogleAds\V20\Common\CallToActionAsset;
-use Google\Ads\GoogleAds\V20\Common\YoutubeVideoAsset;
+use Google\Ads\GoogleAds\V21\Services\GoogleAdsRow;
+use Google\Ads\GoogleAds\V21\Enums\AssetTypeEnum\AssetType;
+use Google\Ads\GoogleAds\V21\Resources\Asset;
+use Google\Ads\GoogleAds\V21\Services\AssetOperation;
+use Google\Ads\GoogleAds\V21\Services\MutateGoogleAdsRequest;
+use Google\Ads\GoogleAds\V21\Services\MutateOperation;
+use Google\Ads\GoogleAds\Util\V21\ResourceNames;
+use Google\Ads\GoogleAds\V21\Common\TextAsset;
+use Google\Ads\GoogleAds\V21\Common\ImageAsset;
+use Google\Ads\GoogleAds\V21\Common\CallToActionAsset;
+use Google\Ads\GoogleAds\V21\Common\YoutubeVideoAsset;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Google\ApiCore\ApiException;
use Exception;
diff --git a/src/API/Google/AdsAssetGroup.php b/src/API/Google/AdsAssetGroup.php
index 1c43b255cc..8f813d3a58 100644
--- a/src/API/Google/AdsAssetGroup.php
+++ b/src/API/Google/AdsAssetGroup.php
@@ -7,18 +7,18 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
-use Google\Ads\GoogleAds\Util\V20\ResourceNames;
-use Google\Ads\GoogleAds\V20\Enums\ListingGroupFilterListingSourceEnum\ListingGroupFilterListingSource;
-use Google\Ads\GoogleAds\V20\Enums\AssetGroupStatusEnum\AssetGroupStatus;
-use Google\Ads\GoogleAds\V20\Enums\ListingGroupFilterTypeEnum\ListingGroupFilterType;
-use Google\Ads\GoogleAds\V20\Resources\AssetGroup;
-use Google\Ads\GoogleAds\V20\Resources\AssetGroupListingGroupFilter;
-use Google\Ads\GoogleAds\V20\Services\AssetGroupListingGroupFilterOperation;
-use Google\Ads\GoogleAds\V20\Services\AssetGroupOperation;
-use Google\Ads\GoogleAds\V20\Services\GoogleAdsRow;
-use Google\Ads\GoogleAds\V20\Services\MutateGoogleAdsRequest;
-use Google\Ads\GoogleAds\V20\Services\MutateOperation;
-use Google\Ads\GoogleAds\V20\Services\Client\AssetGroupServiceClient;
+use Google\Ads\GoogleAds\Util\V21\ResourceNames;
+use Google\Ads\GoogleAds\V21\Enums\ListingGroupFilterListingSourceEnum\ListingGroupFilterListingSource;
+use Google\Ads\GoogleAds\V21\Enums\AssetGroupStatusEnum\AssetGroupStatus;
+use Google\Ads\GoogleAds\V21\Enums\ListingGroupFilterTypeEnum\ListingGroupFilterType;
+use Google\Ads\GoogleAds\V21\Resources\AssetGroup;
+use Google\Ads\GoogleAds\V21\Resources\AssetGroupListingGroupFilter;
+use Google\Ads\GoogleAds\V21\Services\AssetGroupListingGroupFilterOperation;
+use Google\Ads\GoogleAds\V21\Services\AssetGroupOperation;
+use Google\Ads\GoogleAds\V21\Services\GoogleAdsRow;
+use Google\Ads\GoogleAds\V21\Services\MutateGoogleAdsRequest;
+use Google\Ads\GoogleAds\V21\Services\MutateOperation;
+use Google\Ads\GoogleAds\V21\Services\Client\AssetGroupServiceClient;
use Google\ApiCore\ApiException;
use Google\ApiCore\ValidationException;
use Google\Protobuf\FieldMask;
diff --git a/src/API/Google/AdsAssetGroupAsset.php b/src/API/Google/AdsAssetGroupAsset.php
index 99addcd37d..751634bbf7 100644
--- a/src/API/Google/AdsAssetGroupAsset.php
+++ b/src/API/Google/AdsAssetGroupAsset.php
@@ -7,13 +7,13 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
-use Google\Ads\GoogleAds\V20\Services\GoogleAdsRow;
-use Google\Ads\GoogleAds\V20\Resources\AssetGroupAsset;
+use Google\Ads\GoogleAds\V21\Services\GoogleAdsRow;
+use Google\Ads\GoogleAds\V21\Resources\AssetGroupAsset;
use Google\ApiCore\ApiException;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
-use Google\Ads\GoogleAds\V20\Services\MutateOperation;
-use Google\Ads\GoogleAds\V20\Services\AssetGroupAssetOperation;
-use Google\Ads\GoogleAds\Util\V20\ResourceNames;
+use Google\Ads\GoogleAds\V21\Services\MutateOperation;
+use Google\Ads\GoogleAds\V21\Services\AssetGroupAssetOperation;
+use Google\Ads\GoogleAds\Util\V21\ResourceNames;
diff --git a/src/API/Google/AdsCampaign.php b/src/API/Google/AdsCampaign.php
index faa38acc29..30fc93dcf5 100644
--- a/src/API/Google/AdsCampaign.php
+++ b/src/API/Google/AdsCampaign.php
@@ -17,17 +17,17 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Google\Ads\GoogleAds\Util\FieldMasks;
-use Google\Ads\GoogleAds\Util\V20\ResourceNames;
-use Google\Ads\GoogleAds\V20\Common\MaximizeConversionValue;
-use Google\Ads\GoogleAds\V20\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType;
-use Google\Ads\GoogleAds\V20\Resources\Campaign;
-use Google\Ads\GoogleAds\V20\Enums\EuPoliticalAdvertisingStatusEnum\EuPoliticalAdvertisingStatus;
-use Google\Ads\GoogleAds\V20\Resources\Campaign\ShoppingSetting;
-use Google\Ads\GoogleAds\V20\Services\Client\CampaignServiceClient;
-use Google\Ads\GoogleAds\V20\Services\CampaignOperation;
-use Google\Ads\GoogleAds\V20\Services\GoogleAdsRow;
-use Google\Ads\GoogleAds\V20\Services\MutateGoogleAdsRequest;
-use Google\Ads\GoogleAds\V20\Services\MutateOperation;
+use Google\Ads\GoogleAds\Util\V21\ResourceNames;
+use Google\Ads\GoogleAds\V21\Common\MaximizeConversionValue;
+use Google\Ads\GoogleAds\V21\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType;
+use Google\Ads\GoogleAds\V21\Resources\Campaign;
+use Google\Ads\GoogleAds\V21\Enums\EuPoliticalAdvertisingStatusEnum\EuPoliticalAdvertisingStatus;
+use Google\Ads\GoogleAds\V21\Resources\Campaign\ShoppingSetting;
+use Google\Ads\GoogleAds\V21\Services\Client\CampaignServiceClient;
+use Google\Ads\GoogleAds\V21\Services\CampaignOperation;
+use Google\Ads\GoogleAds\V21\Services\GoogleAdsRow;
+use Google\Ads\GoogleAds\V21\Services\MutateGoogleAdsRequest;
+use Google\Ads\GoogleAds\V21\Services\MutateOperation;
use Google\ApiCore\ApiException;
use Google\ApiCore\ValidationException;
use Exception;
diff --git a/src/API/Google/AdsCampaignBudget.php b/src/API/Google/AdsCampaignBudget.php
index 602a564b7b..5f1afe0e23 100644
--- a/src/API/Google/AdsCampaignBudget.php
+++ b/src/API/Google/AdsCampaignBudget.php
@@ -9,11 +9,11 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Google\Ads\GoogleAds\Util\FieldMasks;
-use Google\Ads\GoogleAds\Util\V20\ResourceNames;
-use Google\Ads\GoogleAds\V20\Resources\CampaignBudget;
-use Google\Ads\GoogleAds\V20\Services\CampaignBudgetOperation;
-use Google\Ads\GoogleAds\V20\Services\Client\CampaignBudgetServiceClient;
-use Google\Ads\GoogleAds\V20\Services\MutateOperation;
+use Google\Ads\GoogleAds\Util\V21\ResourceNames;
+use Google\Ads\GoogleAds\V21\Resources\CampaignBudget;
+use Google\Ads\GoogleAds\V21\Services\CampaignBudgetOperation;
+use Google\Ads\GoogleAds\V21\Services\Client\CampaignBudgetServiceClient;
+use Google\Ads\GoogleAds\V21\Services\MutateOperation;
use Google\ApiCore\ValidationException;
use Exception;
diff --git a/src/API/Google/AdsCampaignCriterion.php b/src/API/Google/AdsCampaignCriterion.php
index d3e77e3eac..9ab50584b8 100644
--- a/src/API/Google/AdsCampaignCriterion.php
+++ b/src/API/Google/AdsCampaignCriterion.php
@@ -3,12 +3,12 @@
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
-use Google\Ads\GoogleAds\Util\V20\ResourceNames;
-use Google\Ads\GoogleAds\V20\Common\LocationInfo;
-use Google\Ads\GoogleAds\V20\Enums\CampaignCriterionStatusEnum\CampaignCriterionStatus;
-use Google\Ads\GoogleAds\V20\Resources\CampaignCriterion;
-use Google\Ads\GoogleAds\V20\Services\CampaignCriterionOperation;
-use Google\Ads\GoogleAds\V20\Services\MutateOperation;
+use Google\Ads\GoogleAds\Util\V21\ResourceNames;
+use Google\Ads\GoogleAds\V21\Common\LocationInfo;
+use Google\Ads\GoogleAds\V21\Enums\CampaignCriterionStatusEnum\CampaignCriterionStatus;
+use Google\Ads\GoogleAds\V21\Resources\CampaignCriterion;
+use Google\Ads\GoogleAds\V21\Services\CampaignCriterionOperation;
+use Google\Ads\GoogleAds\V21\Services\MutateOperation;
/**
* Class AdsCampaignCriterion
diff --git a/src/API/Google/AdsCampaignLabel.php b/src/API/Google/AdsCampaignLabel.php
index cc50a0dc6b..528fc0d0c3 100644
--- a/src/API/Google/AdsCampaignLabel.php
+++ b/src/API/Google/AdsCampaignLabel.php
@@ -7,13 +7,13 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
-use Google\Ads\GoogleAds\Util\V20\ResourceNames;
-use Google\Ads\GoogleAds\V20\Resources\Label;
-use Google\Ads\GoogleAds\V20\Resources\CampaignLabel;
-use Google\Ads\GoogleAds\V20\Services\LabelOperation;
-use Google\Ads\GoogleAds\V20\Services\CampaignLabelOperation;
-use Google\Ads\GoogleAds\V20\Services\MutateOperation;
-use Google\Ads\GoogleAds\V20\Services\MutateGoogleAdsRequest;
+use Google\Ads\GoogleAds\Util\V21\ResourceNames;
+use Google\Ads\GoogleAds\V21\Resources\Label;
+use Google\Ads\GoogleAds\V21\Resources\CampaignLabel;
+use Google\Ads\GoogleAds\V21\Services\LabelOperation;
+use Google\Ads\GoogleAds\V21\Services\CampaignLabelOperation;
+use Google\Ads\GoogleAds\V21\Services\MutateOperation;
+use Google\Ads\GoogleAds\V21\Services\MutateGoogleAdsRequest;
/**
* Class AdsCampaignLabel
diff --git a/src/API/Google/AdsConversionAction.php b/src/API/Google/AdsConversionAction.php
index 42dbe04e5b..21e182d60e 100644
--- a/src/API/Google/AdsConversionAction.php
+++ b/src/API/Google/AdsConversionAction.php
@@ -8,19 +8,19 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Exception;
-use Google\Ads\GoogleAds\V20\Common\TagSnippet;
-use Google\Ads\GoogleAds\V20\Enums\ConversionActionCategoryEnum\ConversionActionCategory;
-use Google\Ads\GoogleAds\V20\Enums\ConversionActionStatusEnum\ConversionActionStatus;
-use Google\Ads\GoogleAds\V20\Enums\ConversionActionTypeEnum\ConversionActionType;
-use Google\Ads\GoogleAds\V20\Enums\TrackingCodePageFormatEnum\TrackingCodePageFormat;
-use Google\Ads\GoogleAds\V20\Enums\TrackingCodeTypeEnum\TrackingCodeType;
-use Google\Ads\GoogleAds\V20\Resources\ConversionAction;
-use Google\Ads\GoogleAds\V20\Resources\ConversionAction\ValueSettings;
-use Google\Ads\GoogleAds\V20\Services\ConversionActionOperation;
-use Google\Ads\GoogleAds\V20\Services\Client\ConversionActionServiceClient;
-use Google\Ads\GoogleAds\V20\Services\GoogleAdsRow;
-use Google\Ads\GoogleAds\V20\Services\MutateConversionActionResult;
-use Google\Ads\GoogleAds\V20\Services\MutateConversionActionsRequest;
+use Google\Ads\GoogleAds\V21\Common\TagSnippet;
+use Google\Ads\GoogleAds\V21\Enums\ConversionActionCategoryEnum\ConversionActionCategory;
+use Google\Ads\GoogleAds\V21\Enums\ConversionActionStatusEnum\ConversionActionStatus;
+use Google\Ads\GoogleAds\V21\Enums\ConversionActionTypeEnum\ConversionActionType;
+use Google\Ads\GoogleAds\V21\Enums\TrackingCodePageFormatEnum\TrackingCodePageFormat;
+use Google\Ads\GoogleAds\V21\Enums\TrackingCodeTypeEnum\TrackingCodeType;
+use Google\Ads\GoogleAds\V21\Resources\ConversionAction;
+use Google\Ads\GoogleAds\V21\Resources\ConversionAction\ValueSettings;
+use Google\Ads\GoogleAds\V21\Services\ConversionActionOperation;
+use Google\Ads\GoogleAds\V21\Services\Client\ConversionActionServiceClient;
+use Google\Ads\GoogleAds\V21\Services\GoogleAdsRow;
+use Google\Ads\GoogleAds\V21\Services\MutateConversionActionResult;
+use Google\Ads\GoogleAds\V21\Services\MutateConversionActionsRequest;
use Google\ApiCore\ApiException;
/**
diff --git a/src/API/Google/AdsReport.php b/src/API/Google/AdsReport.php
index 5c257c97ae..1f05af7161 100644
--- a/src/API/Google/AdsReport.php
+++ b/src/API/Google/AdsReport.php
@@ -15,8 +15,8 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use DateTime;
-use Google\Ads\GoogleAds\V20\Common\Segments;
-use Google\Ads\GoogleAds\V20\Services\GoogleAdsRow;
+use Google\Ads\GoogleAds\V21\Common\Segments;
+use Google\Ads\GoogleAds\V21\Services\GoogleAdsRow;
use Google\ApiCore\ApiException;
/**
diff --git a/src/API/Google/AssetFieldType.php b/src/API/Google/AssetFieldType.php
index ed152c9aee..dbb782f5d4 100644
--- a/src/API/Google/AssetFieldType.php
+++ b/src/API/Google/AssetFieldType.php
@@ -3,7 +3,7 @@
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
-use Google\Ads\GoogleAds\V20\Enums\AssetFieldTypeEnum\AssetFieldType as AdsAssetFieldType;
+use Google\Ads\GoogleAds\V21\Enums\AssetFieldTypeEnum\AssetFieldType as AdsAssetFieldType;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping;
use UnexpectedValueException;
diff --git a/src/API/Google/BillingSetupStatus.php b/src/API/Google/BillingSetupStatus.php
index 9bcfdbd32d..bb092e9a3a 100644
--- a/src/API/Google/BillingSetupStatus.php
+++ b/src/API/Google/BillingSetupStatus.php
@@ -3,7 +3,7 @@
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
-use Google\Ads\GoogleAds\V20\Enums\BillingSetupStatusEnum\BillingSetupStatus as AdsBillingSetupStatus;
+use Google\Ads\GoogleAds\V21\Enums\BillingSetupStatusEnum\BillingSetupStatus as AdsBillingSetupStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping;
/**
diff --git a/src/API/Google/BudgetMetrics.php b/src/API/Google/BudgetMetrics.php
index cde49b9b91..084a71efbb 100644
--- a/src/API/Google/BudgetMetrics.php
+++ b/src/API/Google/BudgetMetrics.php
@@ -12,14 +12,14 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
-use Google\Ads\GoogleAds\V20\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType;
-use Google\Ads\GoogleAds\V20\Enums\BiddingStrategyTypeEnum\BiddingStrategyType;
-use Google\Ads\GoogleAds\V20\Enums\RecommendationTypeEnum\RecommendationType;
-use Google\Ads\GoogleAds\V20\Resources\Recommendation\CampaignBudgetRecommendation;
-use Google\Ads\GoogleAds\V20\Services\GenerateRecommendationsRequest;
-use Google\Ads\GoogleAds\V20\Services\GenerateRecommendationsRequest\AssetGroupInfo;
-use Google\Ads\GoogleAds\V20\Services\GenerateRecommendationsRequest\BiddingInfo;
-use Google\Ads\GoogleAds\V20\Services\GenerateRecommendationsRequest\BudgetInfo;
+use Google\Ads\GoogleAds\V21\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType;
+use Google\Ads\GoogleAds\V21\Enums\BiddingStrategyTypeEnum\BiddingStrategyType;
+use Google\Ads\GoogleAds\V21\Enums\RecommendationTypeEnum\RecommendationType;
+use Google\Ads\GoogleAds\V21\Resources\Recommendation\CampaignBudgetRecommendation;
+use Google\Ads\GoogleAds\V21\Services\GenerateRecommendationsRequest;
+use Google\Ads\GoogleAds\V21\Services\GenerateRecommendationsRequest\AssetGroupInfo;
+use Google\Ads\GoogleAds\V21\Services\GenerateRecommendationsRequest\BiddingInfo;
+use Google\Ads\GoogleAds\V21\Services\GenerateRecommendationsRequest\BudgetInfo;
use Google\ApiCore\ApiException;
/**
diff --git a/src/API/Google/BudgetRecommendations.php b/src/API/Google/BudgetRecommendations.php
index c2181c5c21..7611d955fd 100644
--- a/src/API/Google/BudgetRecommendations.php
+++ b/src/API/Google/BudgetRecommendations.php
@@ -12,13 +12,13 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
-use Google\Ads\GoogleAds\V20\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType;
-use Google\Ads\GoogleAds\V20\Enums\BiddingStrategyTypeEnum\BiddingStrategyType;
-use Google\Ads\GoogleAds\V20\Enums\RecommendationTypeEnum\RecommendationType;
-use Google\Ads\GoogleAds\V20\Resources\Recommendation\CampaignBudgetRecommendation;
-use Google\Ads\GoogleAds\V20\Services\GenerateRecommendationsRequest;
-use Google\Ads\GoogleAds\V20\Services\GenerateRecommendationsRequest\AssetGroupInfo;
-use Google\Ads\GoogleAds\V20\Services\GenerateRecommendationsRequest\BiddingInfo;
+use Google\Ads\GoogleAds\V21\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType;
+use Google\Ads\GoogleAds\V21\Enums\BiddingStrategyTypeEnum\BiddingStrategyType;
+use Google\Ads\GoogleAds\V21\Enums\RecommendationTypeEnum\RecommendationType;
+use Google\Ads\GoogleAds\V21\Resources\Recommendation\CampaignBudgetRecommendation;
+use Google\Ads\GoogleAds\V21\Services\GenerateRecommendationsRequest;
+use Google\Ads\GoogleAds\V21\Services\GenerateRecommendationsRequest\AssetGroupInfo;
+use Google\Ads\GoogleAds\V21\Services\GenerateRecommendationsRequest\BiddingInfo;
use Google\ApiCore\ApiException;
/**
diff --git a/src/API/Google/CallToActionType.php b/src/API/Google/CallToActionType.php
index faab4e5403..966b9f36f8 100644
--- a/src/API/Google/CallToActionType.php
+++ b/src/API/Google/CallToActionType.php
@@ -3,7 +3,7 @@
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
-use Google\Ads\GoogleAds\V20\Enums\CallToActionTypeEnum\CallToActionType as AdsCallToActionType;
+use Google\Ads\GoogleAds\V21\Enums\CallToActionTypeEnum\CallToActionType as AdsCallToActionType;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping;
diff --git a/src/API/Google/CampaignStatus.php b/src/API/Google/CampaignStatus.php
index 3a59b8595f..2928951e0a 100644
--- a/src/API/Google/CampaignStatus.php
+++ b/src/API/Google/CampaignStatus.php
@@ -3,7 +3,7 @@
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
-use Google\Ads\GoogleAds\V20\Enums\CampaignStatusEnum\CampaignStatus as AdsCampaignStatus;
+use Google\Ads\GoogleAds\V21\Enums\CampaignStatusEnum\CampaignStatus as AdsCampaignStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping;
/**
diff --git a/src/API/Google/CampaignType.php b/src/API/Google/CampaignType.php
index 447178591a..03f28f179d 100644
--- a/src/API/Google/CampaignType.php
+++ b/src/API/Google/CampaignType.php
@@ -3,7 +3,7 @@
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
-use Google\Ads\GoogleAds\V20\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType as AdsCampaignType;
+use Google\Ads\GoogleAds\V21\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType as AdsCampaignType;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping;
/**
diff --git a/src/API/Google/MerchantMetrics.php b/src/API/Google/MerchantMetrics.php
index 9a73125659..d879561bd3 100644
--- a/src/API/Google/MerchantMetrics.php
+++ b/src/API/Google/MerchantMetrics.php
@@ -15,7 +15,7 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\SearchResponse;
use DateTime;
use Exception;
-use Google\Ads\GoogleAds\V20\Services\GoogleAdsRow;
+use Google\Ads\GoogleAds\V21\Services\GoogleAdsRow;
use Google\ApiCore\PagedListResponse;
/**
diff --git a/src/API/Google/Query/AdsQuery.php b/src/API/Google/Query/AdsQuery.php
index 20850e2f2e..9c9e4c2e2f 100644
--- a/src/API/Google/Query/AdsQuery.php
+++ b/src/API/Google/Query/AdsQuery.php
@@ -5,9 +5,9 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidProperty;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
-use Google\Ads\GoogleAds\V20\Services\GoogleAdsRow;
-use Google\Ads\GoogleAds\V20\Services\SearchGoogleAdsRequest;
-use Google\Ads\GoogleAds\V20\Services\SearchSettings;
+use Google\Ads\GoogleAds\V21\Services\GoogleAdsRow;
+use Google\Ads\GoogleAds\V21\Services\SearchGoogleAdsRequest;
+use Google\Ads\GoogleAds\V21\Services\SearchSettings;
use Google\ApiCore\ApiException;
defined( 'ABSPATH' ) || exit;
diff --git a/src/API/Google/Query/AdsReportQuery.php b/src/API/Google/Query/AdsReportQuery.php
index 121fde8b9b..dab79aba86 100644
--- a/src/API/Google/Query/AdsReportQuery.php
+++ b/src/API/Google/Query/AdsReportQuery.php
@@ -3,7 +3,7 @@
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
-use Google\Ads\GoogleAds\V20\Resources\ShoppingPerformanceView;
+use Google\Ads\GoogleAds\V21\Resources\ShoppingPerformanceView;
defined( 'ABSPATH' ) || exit;
diff --git a/src/Ads/AdsRecommendationsService.php b/src/Ads/AdsRecommendationsService.php
index f182924653..0338244b52 100644
--- a/src/Ads/AdsRecommendationsService.php
+++ b/src/Ads/AdsRecommendationsService.php
@@ -15,8 +15,8 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Exception as GoogleException;
-use Google\Ads\GoogleAds\V20\Resources\Recommendation;
-use Google\Ads\GoogleAds\V20\Enums\RecommendationTypeEnum\RecommendationType;
+use Google\Ads\GoogleAds\V21\Resources\Recommendation;
+use Google\Ads\GoogleAds\V21\Enums\RecommendationTypeEnum\RecommendationType;
use Exception;
defined( 'ABSPATH' ) || exit;
diff --git a/src/Google/Ads/ServiceClientFactoryTrait.php b/src/Google/Ads/ServiceClientFactoryTrait.php
index 2b9e867bfa..d697b41ada 100644
--- a/src/Google/Ads/ServiceClientFactoryTrait.php
+++ b/src/Google/Ads/ServiceClientFactoryTrait.php
@@ -13,25 +13,25 @@
use Google\Ads\GoogleAds\Constants;
use Google\Ads\GoogleAds\Lib\ConfigurationTrait;
-use Google\Ads\GoogleAds\V20\Services\Client\AccountLinkServiceClient;
-use Google\Ads\GoogleAds\V20\Services\Client\AdGroupAdLabelServiceClient;
-use Google\Ads\GoogleAds\V20\Services\Client\AdGroupAdServiceClient;
-use Google\Ads\GoogleAds\V20\Services\Client\AdGroupCriterionServiceClient;
-use Google\Ads\GoogleAds\V20\Services\Client\AdGroupServiceClient;
-use Google\Ads\GoogleAds\V20\Services\Client\AdServiceClient;
-use Google\Ads\GoogleAds\V20\Services\Client\AssetGroupListingGroupFilterServiceClient;
-use Google\Ads\GoogleAds\V20\Services\Client\AssetGroupServiceClient;
-use Google\Ads\GoogleAds\V20\Services\Client\BillingSetupServiceClient;
-use Google\Ads\GoogleAds\V20\Services\Client\CampaignBudgetServiceClient;
-use Google\Ads\GoogleAds\V20\Services\Client\CampaignCriterionServiceClient;
-use Google\Ads\GoogleAds\V20\Services\Client\CampaignServiceClient;
-use Google\Ads\GoogleAds\V20\Services\Client\ConversionActionServiceClient;
-use Google\Ads\GoogleAds\V20\Services\Client\CustomerServiceClient;
-use Google\Ads\GoogleAds\V20\Services\Client\CustomerUserAccessServiceClient;
-use Google\Ads\GoogleAds\V20\Services\Client\GeoTargetConstantServiceClient;
-use Google\Ads\GoogleAds\V20\Services\Client\GoogleAdsServiceClient;
-use Google\Ads\GoogleAds\V20\Services\Client\ProductLinkInvitationServiceClient;
-use Google\Ads\GoogleAds\V20\Services\Client\RecommendationServiceClient;
+use Google\Ads\GoogleAds\V21\Services\Client\AccountLinkServiceClient;
+use Google\Ads\GoogleAds\V21\Services\Client\AdGroupAdLabelServiceClient;
+use Google\Ads\GoogleAds\V21\Services\Client\AdGroupAdServiceClient;
+use Google\Ads\GoogleAds\V21\Services\Client\AdGroupCriterionServiceClient;
+use Google\Ads\GoogleAds\V21\Services\Client\AdGroupServiceClient;
+use Google\Ads\GoogleAds\V21\Services\Client\AdServiceClient;
+use Google\Ads\GoogleAds\V21\Services\Client\AssetGroupListingGroupFilterServiceClient;
+use Google\Ads\GoogleAds\V21\Services\Client\AssetGroupServiceClient;
+use Google\Ads\GoogleAds\V21\Services\Client\BillingSetupServiceClient;
+use Google\Ads\GoogleAds\V21\Services\Client\CampaignBudgetServiceClient;
+use Google\Ads\GoogleAds\V21\Services\Client\CampaignCriterionServiceClient;
+use Google\Ads\GoogleAds\V21\Services\Client\CampaignServiceClient;
+use Google\Ads\GoogleAds\V21\Services\Client\ConversionActionServiceClient;
+use Google\Ads\GoogleAds\V21\Services\Client\CustomerServiceClient;
+use Google\Ads\GoogleAds\V21\Services\Client\CustomerUserAccessServiceClient;
+use Google\Ads\GoogleAds\V21\Services\Client\GeoTargetConstantServiceClient;
+use Google\Ads\GoogleAds\V21\Services\Client\GoogleAdsServiceClient;
+use Google\Ads\GoogleAds\V21\Services\Client\ProductLinkInvitationServiceClient;
+use Google\Ads\GoogleAds\V21\Services\Client\RecommendationServiceClient;
/**
* Contains service client factory methods.
diff --git a/src/Internal/DependencyManagement/GoogleServiceProvider.php b/src/Internal/DependencyManagement/GoogleServiceProvider.php
index 0ddf8f7321..f2715c8d9d 100644
--- a/src/Internal/DependencyManagement/GoogleServiceProvider.php
+++ b/src/Internal/DependencyManagement/GoogleServiceProvider.php
@@ -49,7 +49,7 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\League\Container\Definition\Definition;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Http\Message\RequestInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Http\Message\ResponseInterface;
-use Google\Ads\GoogleAds\Util\V20\GoogleAdsFailures;
+use Google\Ads\GoogleAds\Util\V21\GoogleAdsFailures;
use Jetpack_Options;
defined( 'ABSPATH' ) || exit;
diff --git a/tests/Tools/HelperTrait/GoogleAdsClientTrait.php b/tests/Tools/HelperTrait/GoogleAdsClientTrait.php
index 2b9dc2a68c..02534244cc 100644
--- a/tests/Tools/HelperTrait/GoogleAdsClientTrait.php
+++ b/tests/Tools/HelperTrait/GoogleAdsClientTrait.php
@@ -10,62 +10,62 @@
use Automattic\WooCommerce\GoogleListingsAndAds\API\MicroTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Exception;
-use Google\Ads\GoogleAds\Util\V20\ResourceNames;
-use Google\Ads\GoogleAds\V20\Common\LocationInfo;
-use Google\Ads\GoogleAds\V20\Common\Metrics;
-use Google\Ads\GoogleAds\V20\Common\Segments;
-use Google\Ads\GoogleAds\V20\Common\TagSnippet;
-use Google\Ads\GoogleAds\V20\Common\ImageAsset;
-use Google\Ads\GoogleAds\V20\Common\TextAsset;
-use Google\Ads\GoogleAds\V20\Common\CallToActionAsset;
-use Google\Ads\GoogleAds\V20\Common\ImageDimension;
-use Google\Ads\GoogleAds\V20\Common\YoutubeVideoAsset;
-use Google\Ads\GoogleAds\V20\Enums\AccessRoleEnum\AccessRole;
-use Google\Ads\GoogleAds\V20\Enums\CampaignStatusEnum\CampaignStatus as AdsCampaignStatus;
-use Google\Ads\GoogleAds\V20\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType as AdsCampaignType;
-use Google\Ads\GoogleAds\V20\Enums\AssetTypeEnum\AssetType;
-use Google\Ads\GoogleAds\V20\Enums\TrackingCodePageFormatEnum\TrackingCodePageFormat;
-use Google\Ads\GoogleAds\V20\Enums\TrackingCodeTypeEnum\TrackingCodeType;
-use Google\Ads\GoogleAds\V20\Resources\BillingSetup;
-use Google\Ads\GoogleAds\V20\Resources\Campaign;
-use Google\Ads\GoogleAds\V20\Resources\Label;
-use Google\Ads\GoogleAds\V20\Resources\Asset;
-use Google\Ads\GoogleAds\V20\Resources\AssetGroup;
-use Google\Ads\GoogleAds\V20\Resources\AssetGroupAsset;
-use Google\Ads\GoogleAds\V20\Services\AssetGroupAssetOperation;
-use Google\Ads\GoogleAds\V20\Resources\CampaignBudget;
-use Google\Ads\GoogleAds\V20\Resources\CampaignCriterion;
-use Google\Ads\GoogleAds\V20\Resources\Campaign\ShoppingSetting;
-use Google\Ads\GoogleAds\V20\Resources\ConversionAction;
-use Google\Ads\GoogleAds\V20\Resources\Customer;
-use Google\Ads\GoogleAds\V20\Resources\CustomerUserAccess;
-use Google\Ads\GoogleAds\V20\Resources\GeoTargetConstant;
-use Google\Ads\GoogleAds\V20\Resources\Recommendation;
-use Google\Ads\GoogleAds\V20\Resources\Recommendation\CampaignBudgetRecommendation;
-use Google\Ads\GoogleAds\V20\Resources\Recommendation\CampaignBudgetRecommendation\CampaignBudgetRecommendationOption;
-use Google\Ads\GoogleAds\V20\Resources\Recommendation\RecommendationImpact;
-use Google\Ads\GoogleAds\V20\Resources\Recommendation\RecommendationMetrics;
-use Google\Ads\GoogleAds\V20\Resources\ShoppingPerformanceView;
-use Google\Ads\GoogleAds\V20\Services\Client\ConversionActionServiceClient;
-use Google\Ads\GoogleAds\V20\Services\Client\CustomerServiceClient;
-use Google\Ads\GoogleAds\V20\Services\Client\GoogleAdsServiceClient;
-use Google\Ads\GoogleAds\V20\Services\Client\ProductLinkInvitationServiceClient;
-use Google\Ads\GoogleAds\V20\Services\Client\RecommendationServiceClient;
-use Google\Ads\GoogleAds\V20\Services\GenerateRecommendationsResponse;
-use Google\Ads\GoogleAds\V20\Services\GoogleAdsRow;
-use Google\Ads\GoogleAds\V20\Services\ListAccessibleCustomersResponse;
-use Google\Ads\GoogleAds\V20\Services\MutateCampaignResult;
-use Google\Ads\GoogleAds\V20\Services\MutateLabelResult;
-use Google\Ads\GoogleAds\V20\Services\MutateConversionActionResult;
-use Google\Ads\GoogleAds\V20\Services\MutateConversionActionsRequest;
-use Google\Ads\GoogleAds\V20\Services\MutateConversionActionsResponse;
-use Google\Ads\GoogleAds\V20\Services\MutateGoogleAdsRequest;
-use Google\Ads\GoogleAds\V20\Services\MutateGoogleAdsResponse;
-use Google\Ads\GoogleAds\V20\Services\MutateOperationResponse;
-use Google\Ads\GoogleAds\V20\Services\MutateOperation;
-use Google\Ads\GoogleAds\V20\Services\MutateAssetGroupResult;
-use Google\Ads\GoogleAds\V20\Services\MutateAssetResult;
-use Google\Ads\GoogleAds\V20\Services\SearchGoogleAdsResponse;
+use Google\Ads\GoogleAds\Util\V21\ResourceNames;
+use Google\Ads\GoogleAds\V21\Common\LocationInfo;
+use Google\Ads\GoogleAds\V21\Common\Metrics;
+use Google\Ads\GoogleAds\V21\Common\Segments;
+use Google\Ads\GoogleAds\V21\Common\TagSnippet;
+use Google\Ads\GoogleAds\V21\Common\ImageAsset;
+use Google\Ads\GoogleAds\V21\Common\TextAsset;
+use Google\Ads\GoogleAds\V21\Common\CallToActionAsset;
+use Google\Ads\GoogleAds\V21\Common\ImageDimension;
+use Google\Ads\GoogleAds\V21\Common\YoutubeVideoAsset;
+use Google\Ads\GoogleAds\V21\Enums\AccessRoleEnum\AccessRole;
+use Google\Ads\GoogleAds\V21\Enums\CampaignStatusEnum\CampaignStatus as AdsCampaignStatus;
+use Google\Ads\GoogleAds\V21\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType as AdsCampaignType;
+use Google\Ads\GoogleAds\V21\Enums\AssetTypeEnum\AssetType;
+use Google\Ads\GoogleAds\V21\Enums\TrackingCodePageFormatEnum\TrackingCodePageFormat;
+use Google\Ads\GoogleAds\V21\Enums\TrackingCodeTypeEnum\TrackingCodeType;
+use Google\Ads\GoogleAds\V21\Resources\BillingSetup;
+use Google\Ads\GoogleAds\V21\Resources\Campaign;
+use Google\Ads\GoogleAds\V21\Resources\Label;
+use Google\Ads\GoogleAds\V21\Resources\Asset;
+use Google\Ads\GoogleAds\V21\Resources\AssetGroup;
+use Google\Ads\GoogleAds\V21\Resources\AssetGroupAsset;
+use Google\Ads\GoogleAds\V21\Services\AssetGroupAssetOperation;
+use Google\Ads\GoogleAds\V21\Resources\CampaignBudget;
+use Google\Ads\GoogleAds\V21\Resources\CampaignCriterion;
+use Google\Ads\GoogleAds\V21\Resources\Campaign\ShoppingSetting;
+use Google\Ads\GoogleAds\V21\Resources\ConversionAction;
+use Google\Ads\GoogleAds\V21\Resources\Customer;
+use Google\Ads\GoogleAds\V21\Resources\CustomerUserAccess;
+use Google\Ads\GoogleAds\V21\Resources\GeoTargetConstant;
+use Google\Ads\GoogleAds\V21\Resources\Recommendation;
+use Google\Ads\GoogleAds\V21\Resources\Recommendation\CampaignBudgetRecommendation;
+use Google\Ads\GoogleAds\V21\Resources\Recommendation\CampaignBudgetRecommendation\CampaignBudgetRecommendationOption;
+use Google\Ads\GoogleAds\V21\Resources\Recommendation\RecommendationImpact;
+use Google\Ads\GoogleAds\V21\Resources\Recommendation\RecommendationMetrics;
+use Google\Ads\GoogleAds\V21\Resources\ShoppingPerformanceView;
+use Google\Ads\GoogleAds\V21\Services\Client\ConversionActionServiceClient;
+use Google\Ads\GoogleAds\V21\Services\Client\CustomerServiceClient;
+use Google\Ads\GoogleAds\V21\Services\Client\GoogleAdsServiceClient;
+use Google\Ads\GoogleAds\V21\Services\Client\ProductLinkInvitationServiceClient;
+use Google\Ads\GoogleAds\V21\Services\Client\RecommendationServiceClient;
+use Google\Ads\GoogleAds\V21\Services\GenerateRecommendationsResponse;
+use Google\Ads\GoogleAds\V21\Services\GoogleAdsRow;
+use Google\Ads\GoogleAds\V21\Services\ListAccessibleCustomersResponse;
+use Google\Ads\GoogleAds\V21\Services\MutateCampaignResult;
+use Google\Ads\GoogleAds\V21\Services\MutateLabelResult;
+use Google\Ads\GoogleAds\V21\Services\MutateConversionActionResult;
+use Google\Ads\GoogleAds\V21\Services\MutateConversionActionsRequest;
+use Google\Ads\GoogleAds\V21\Services\MutateConversionActionsResponse;
+use Google\Ads\GoogleAds\V21\Services\MutateGoogleAdsRequest;
+use Google\Ads\GoogleAds\V21\Services\MutateGoogleAdsResponse;
+use Google\Ads\GoogleAds\V21\Services\MutateOperationResponse;
+use Google\Ads\GoogleAds\V21\Services\MutateOperation;
+use Google\Ads\GoogleAds\V21\Services\MutateAssetGroupResult;
+use Google\Ads\GoogleAds\V21\Services\MutateAssetResult;
+use Google\Ads\GoogleAds\V21\Services\SearchGoogleAdsResponse;
use Google\ApiCore\ApiException;
use Google\ApiCore\Page;
use Google\ApiCore\PagedListResponse;
diff --git a/tests/Unit/API/Google/AdsAssetGroupAssetTest.php b/tests/Unit/API/Google/AdsAssetGroupAssetTest.php
index 380264ac28..69247a8622 100644
--- a/tests/Unit/API/Google/AdsAssetGroupAssetTest.php
+++ b/tests/Unit/API/Google/AdsAssetGroupAssetTest.php
@@ -10,8 +10,8 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Tools\HelperTrait\GoogleAdsClientTrait;
use PHPUnit\Framework\MockObject\MockObject;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AssetFieldType;
-use Google\Ads\GoogleAds\V20\Enums\AssetTypeEnum\AssetType;
-use Google\Ads\GoogleAds\Util\V20\ResourceNames;
+use Google\Ads\GoogleAds\V21\Enums\AssetTypeEnum\AssetType;
+use Google\Ads\GoogleAds\Util\V21\ResourceNames;
defined( 'ABSPATH' ) || exit;
diff --git a/tests/Unit/API/Google/AdsAssetGroupTest.php b/tests/Unit/API/Google/AdsAssetGroupTest.php
index 2ff00136fc..bb4e192ea5 100644
--- a/tests/Unit/API/Google/AdsAssetGroupTest.php
+++ b/tests/Unit/API/Google/AdsAssetGroupTest.php
@@ -8,9 +8,9 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Framework\UnitTest;
use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Tools\HelperTrait\GoogleAdsClientTrait;
-use Google\Ads\GoogleAds\V20\Enums\AssetGroupStatusEnum\AssetGroupStatus;
-use Google\Ads\GoogleAds\V20\Enums\ListingGroupFilterListingSourceEnum\ListingGroupFilterListingSource;
-use Google\Ads\GoogleAds\V20\Enums\ListingGroupFilterTypeEnum\ListingGroupFilterType;
+use Google\Ads\GoogleAds\V21\Enums\AssetGroupStatusEnum\AssetGroupStatus;
+use Google\Ads\GoogleAds\V21\Enums\ListingGroupFilterListingSourceEnum\ListingGroupFilterListingSource;
+use Google\Ads\GoogleAds\V21\Enums\ListingGroupFilterTypeEnum\ListingGroupFilterType;
use PHPUnit\Framework\MockObject\MockObject;
use Google\ApiCore\ApiException;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
diff --git a/tests/Unit/API/Google/AdsAssetTest.php b/tests/Unit/API/Google/AdsAssetTest.php
index b897156944..ad9a9fa0e7 100644
--- a/tests/Unit/API/Google/AdsAssetTest.php
+++ b/tests/Unit/API/Google/AdsAssetTest.php
@@ -10,7 +10,7 @@
use PHPUnit\Framework\MockObject\MockObject;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AssetFieldType;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\CallToActionType;
-use Google\Ads\GoogleAds\Util\V20\ResourceNames;
+use Google\Ads\GoogleAds\Util\V21\ResourceNames;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Exception;
use WP_Error;
diff --git a/tests/Unit/API/Google/AdsCampaignCriterionTest.php b/tests/Unit/API/Google/AdsCampaignCriterionTest.php
index cecdde30e1..8eba9904cd 100644
--- a/tests/Unit/API/Google/AdsCampaignCriterionTest.php
+++ b/tests/Unit/API/Google/AdsCampaignCriterionTest.php
@@ -6,7 +6,7 @@
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsCampaignCriterion;
use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Framework\UnitTest;
use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Tools\HelperTrait\GoogleAdsClientTrait;
-use Google\Ads\GoogleAds\V20\Enums\CampaignCriterionStatusEnum\CampaignCriterionStatus;
+use Google\Ads\GoogleAds\V21\Enums\CampaignCriterionStatusEnum\CampaignCriterionStatus;
defined( 'ABSPATH' ) || exit;
diff --git a/tests/Unit/API/Google/AdsConversionActionTest.php b/tests/Unit/API/Google/AdsConversionActionTest.php
index d692ee2823..ba4c6ec022 100644
--- a/tests/Unit/API/Google/AdsConversionActionTest.php
+++ b/tests/Unit/API/Google/AdsConversionActionTest.php
@@ -8,7 +8,7 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Framework\UnitTest;
use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Tools\HelperTrait\GoogleAdsClientTrait;
use Exception;
-use Google\Ads\GoogleAds\V20\Enums\ConversionActionStatusEnum\ConversionActionStatus;
+use Google\Ads\GoogleAds\V21\Enums\ConversionActionStatusEnum\ConversionActionStatus;
use Google\ApiCore\ApiException;
use PHPUnit\Framework\MockObject\MockObject;
diff --git a/tests/Unit/API/Google/AdsTest.php b/tests/Unit/API/Google/AdsTest.php
index 24b3bc64f3..ce9ad5be5c 100644
--- a/tests/Unit/API/Google/AdsTest.php
+++ b/tests/Unit/API/Google/AdsTest.php
@@ -10,10 +10,10 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Framework\UnitTest;
use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Tools\HelperTrait\GoogleAdsClientTrait;
use Exception;
-use Google\Ads\GoogleAds\V20\Enums\BillingSetupStatusEnum\BillingSetupStatus as AdsBillingSetupStatus;
-use Google\Ads\GoogleAds\V20\Enums\ProductLinkInvitationStatusEnum\ProductLinkInvitationStatus;
-use Google\Ads\GoogleAds\V20\Resources\MerchantCenterLinkInvitationIdentifier;
-use Google\Ads\GoogleAds\V20\Resources\ProductLinkInvitation;
+use Google\Ads\GoogleAds\V21\Enums\BillingSetupStatusEnum\BillingSetupStatus as AdsBillingSetupStatus;
+use Google\Ads\GoogleAds\V21\Enums\ProductLinkInvitationStatusEnum\ProductLinkInvitationStatus;
+use Google\Ads\GoogleAds\V21\Resources\MerchantCenterLinkInvitationIdentifier;
+use Google\Ads\GoogleAds\V21\Resources\ProductLinkInvitation;
use Google\ApiCore\ApiException;
use PHPUnit\Framework\MockObject\MockObject;
diff --git a/tests/Unit/API/Google/MerchantMetricsTest.php b/tests/Unit/API/Google/MerchantMetricsTest.php
index 841134a23f..8713929ac5 100644
--- a/tests/Unit/API/Google/MerchantMetricsTest.php
+++ b/tests/Unit/API/Google/MerchantMetricsTest.php
@@ -16,9 +16,9 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\SearchResponse;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Resource\Reports;
use DateTime;
-use Google\Ads\GoogleAds\V20\Common\Metrics as AdMetrics;
-use Google\Ads\GoogleAds\V20\Services\GoogleAdsRow;
-use Google\Ads\GoogleAds\V20\Services\Client\GoogleAdsServiceClient;
+use Google\Ads\GoogleAds\V21\Common\Metrics as AdMetrics;
+use Google\Ads\GoogleAds\V21\Services\GoogleAdsRow;
+use Google\Ads\GoogleAds\V21\Services\Client\GoogleAdsServiceClient;
use Google\ApiCore\Page;
use Google\ApiCore\PagedListResponse;
use PHPUnit\Framework\MockObject\MockObject;
From 159641e766c114ddcaa2d5f74261cee3417c485f Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Tue, 13 Jan 2026 21:14:06 +0530
Subject: [PATCH 009/123] Update bundle size.
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 2d40b2594f..53b307b1fa 100644
--- a/package.json
+++ b/package.json
@@ -168,7 +168,7 @@
},
{
"path": "./google-listings-and-ads.zip",
- "maxSize": "8.22 mB",
+ "maxSize": "8.26 mB",
"compression": "none"
}
],
From 281ca3dddb0f352e80c950886ed507552ed2bf2c Mon Sep 17 00:00:00 2001
From: asvinb
Date: Tue, 13 Jan 2026 20:08:49 +0400
Subject: [PATCH 010/123] feat(paid-ads): Add AI-generated text assets and
improve UI
---
.../asset-group-text-section.js | 2 +
.../asset-item-action-button.js | 4 +-
.../asset-item-action-button.scss | 5 +
.../asset-group-editor/texts-editor.js | 103 ++++++++++++++++++
4 files changed, 113 insertions(+), 1 deletion(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-text-section.js b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-text-section.js
index 5e3a18203a..7e8bf864e7 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-text-section.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-text-section.js
@@ -129,6 +129,8 @@ const AssetGroupTextSection = ( {
maxCharacterCounts={ spec.maxCharacterCounts }
placeholder={ spec.capitalizedName }
addButtonText={ spec.addButtonText }
+ finalUrl={ finalUrl }
+ assetKey={ spec.key }
generateButtonPluralText={
spec.generateButtonPluralText
}
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/asset-item-action-button.js b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-item-action-button.js
index f1bf38cf79..50ccff0594 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/asset-item-action-button.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-item-action-button.js
@@ -22,14 +22,16 @@ const ACTION_ICONS = {
export default function AssetItemActionButton( {
action = ACTION_TYPES.ADD,
+ loading,
...props
} ) {
return (
);
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/asset-item-action-button.scss b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-item-action-button.scss
index bb73355127..e0ec55f797 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/asset-item-action-button.scss
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-item-action-button.scss
@@ -2,4 +2,9 @@
&.has-icon {
padding: $grid-unit-05;
}
+
+ .woocommerce-spinner {
+ width: $spinner-size;
+ height: $spinner-size;
+ }
}
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
index 1eac6ec600..2d1f7deabc 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
@@ -9,6 +9,8 @@ import GridiconCrossSmall from 'gridicons/dist/cross-small';
/**
* Internal dependencies
*/
+import { useAppDispatch } from '~/data';
+import useDispatchCoreNotices from '~/hooks/useDispatchCoreNotices';
import AppButton from '~/components/app-button';
import AppInputControl from '~/components/app-input-control';
import AssetItemActionButton, {
@@ -26,10 +28,67 @@ function normalizeNumberOfTexts( texts, minNumberOfTexts, maxNumberOfTexts ) {
return texts.concat( supplement ).slice( ...sliceArgs );
}
+/**
+ * Result returned by fillEmptyAssetSlotsWithUniqueValues.
+ *
+ * @typedef {Object} FillEmptyAssetSlotsResult
+ * @property {string[]} assets Updated asset list.
+ * @property {number} updatedCount Number of empty ("") slots that were filled.
+ */
+
+/**
+ * Fill empty asset slots (represented by empty strings "") with unique
+ * generated values.
+ *
+ * Existing non-empty values are preserved.
+ * Empty slots that cannot be filled remain as "".
+ *
+ * @param {string[]} currentAssets Current asset values, where "" represents an empty slot.
+ * @param {string[]} generatedAssets Newly generated candidate asset values.
+ *
+ * @return {FillEmptyAssetSlotsResult} Result containing updated assets and count of filled slots.
+ */
+export function fillEmptyAssetSlotsWithUniqueValues(
+ currentAssets,
+ generatedAssets
+) {
+ const existingAssetValues = new Set( currentAssets.filter( Boolean ) );
+
+ let generatedIndex = 0;
+ let updatedCount = 0;
+
+ const assets = currentAssets.map( ( assetValue ) => {
+ if ( assetValue !== '' ) {
+ return assetValue;
+ }
+
+ while (
+ generatedIndex < generatedAssets.length &&
+ existingAssetValues.has( generatedAssets[ generatedIndex ] )
+ ) {
+ generatedIndex++;
+ }
+
+ if ( generatedIndex < generatedAssets.length ) {
+ const nextGeneratedValue = generatedAssets[ generatedIndex ];
+ existingAssetValues.add( nextGeneratedValue );
+ generatedIndex++;
+ updatedCount++;
+ return nextGeneratedValue;
+ }
+
+ return '';
+ } );
+
+ return { assets, updatedCount };
+}
+
/**
* Renders a list of text inputs for managing the single type of asset texts.
*
* @param {Object} props React props.
+ * @param {string} props.assetKey Key of the text asset.
+ * @param {string} props.finalUrl The final URL for the ad.
* @param {string[]} [props.initialTexts=[]] Initial texts.
* @param {number} [props.minNumberOfTexts=0] Minimum number of texts.
* @param {number} [props.maxNumberOfTexts=0] Maximum number of texts.
@@ -42,6 +101,8 @@ function normalizeNumberOfTexts( texts, minNumberOfTexts, maxNumberOfTexts ) {
* @param {(texts: Array) => void} [props.onChange] Callback function to be called when the texts are changed.
*/
export default function TextsEditor( {
+ assetKey,
+ finalUrl,
initialTexts = [],
minNumberOfTexts = 0,
maxNumberOfTexts = 0,
@@ -54,7 +115,10 @@ export default function TextsEditor( {
onChange = noop,
} ) {
const updateTextsRef = useRef();
+ const { createNotice } = useDispatchCoreNotices();
+ const { fetchGenAITextAssets } = useAppDispatch();
const [ texts, setTexts ] = useState( initialTexts );
+ const [ isGeneratingAssets, setIsGeneratingAssets ] = useState( false );
const updateTexts = ( nextTexts ) => {
setTexts( nextTexts );
@@ -95,6 +159,43 @@ export default function TextsEditor( {
updateTexts( texts.concat( '' ) );
};
+ const handleGenerateClick = async () => {
+ setIsGeneratingAssets( true );
+
+ try {
+ const response = await fetchGenAITextAssets( finalUrl, assetKey );
+ const generatedTextAssets = response?.data?.[ assetKey ] ?? [];
+
+ const { assets: updatedTexts, updatedCount } =
+ fillEmptyAssetSlotsWithUniqueValues(
+ texts,
+ generatedTextAssets
+ );
+
+ if ( updatedCount > 0 ) {
+ updateTexts( updatedTexts );
+ } else {
+ createNotice(
+ 'info',
+ __(
+ 'No texts were generated. Please try again.',
+ 'google-listings-and-ads'
+ )
+ );
+ }
+ } catch ( error ) {
+ createNotice(
+ 'error',
+ __(
+ 'Something went wrong while generating texts. Please try again.',
+ 'google-listings-and-ads'
+ )
+ );
+ } finally {
+ setIsGeneratingAssets( false );
+ }
+ };
+
const normalizedMaxCharacterCounts = [ maxCharacterCounts ].flat();
const emptyFieldsCount = texts.filter( ( value ) => value === '' ).length;
let generateButtonText;
@@ -164,6 +265,8 @@ export default function TextsEditor( {
) }
From b415f3f26196769b9da97d71aace345a7366ad97 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Wed, 14 Jan 2026 16:37:52 +0400
Subject: [PATCH 011/123] Use existing Tip component and remove
GenAIImagesNotice component.
---
.../asset-group-images-section.js | 81 +++++++++++--------
.../paid-ads/gen-ai-images-notice.js | 34 --------
.../paid-ads/gen-ai-images-notice.scss | 12 ---
.../gen-ai-images-notice.svg | 5 --
4 files changed, 49 insertions(+), 83 deletions(-)
delete mode 100644 js/src/components/paid-ads/gen-ai-images-notice.js
delete mode 100644 js/src/components/paid-ads/gen-ai-images-notice.scss
delete mode 100644 js/src/images/pmax-assets-improvements/gen-ai-images-notice.svg
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-images-section.js b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-images-section.js
index 76a6afcf3b..6bd7c5701d 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-images-section.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-images-section.js
@@ -2,6 +2,7 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
+import { Flex, FlexItem, Tip } from '@wordpress/components';
/**
* Internal dependencies
@@ -11,7 +12,6 @@ import ImagesSelector from './images-selector';
import AssetField from './asset-field';
import Section from '~/components/section';
import AppDocumentationLink from '~/components/app-documentation-link';
-import GenAIImagesNotice from '../../gen-ai-images-notice';
import { ASSET_IMAGE_SPECS } from '../../assetSpecs';
/**
@@ -34,7 +34,8 @@ const AssetGroupImagesSection = ( {
getNumOfIssues,
renderErrors,
} ) => {
- const { values, getInputProps } = useAdaptiveFormContext();
+ const { values, getInputProps, adapter } = useAdaptiveFormContext();
+ const showTip = adapter.hasImportedAssets;
return (
}
>
-
- { ASSET_IMAGE_SPECS.map( ( spec ) => {
- const initialImageUrls = initialValues[ spec.key ];
- const imageProps = getInputProps( spec.key );
-
- return (
-
-
+ { showTip && (
+
+
+ { __(
+ "We've used your final URL to auto-populate images…",
+ 'google-listings-and-ads'
) }
- imageConfig={ spec.imageConfig }
- onChange={ imageProps.onChange }
- >
- { renderErrors( spec.key ) }
-
-
- );
- } ) }
+
+
+ ) }
+
+
+ { ASSET_IMAGE_SPECS.map( ( spec ) => {
+ const initialImageUrls = initialValues[ spec.key ];
+ const imageProps = getInputProps( spec.key );
+
+ return (
+
+
+ { renderErrors( spec.key ) }
+
+
+ );
+ } ) }
+
+
);
diff --git a/js/src/components/paid-ads/gen-ai-images-notice.js b/js/src/components/paid-ads/gen-ai-images-notice.js
deleted file mode 100644
index fddb8b2bbe..0000000000
--- a/js/src/components/paid-ads/gen-ai-images-notice.js
+++ /dev/null
@@ -1,34 +0,0 @@
-/**
- * External dependencies
- */
-import { __ } from '@wordpress/i18n';
-import { Notice } from '@wordpress/components';
-
-/**
- * Internal dependencies
- */
-import GenAIImagesNoticeGraphic from '~/images/pmax-assets-improvements/gen-ai-images-notice.svg';
-import './gen-ai-images-notice.scss';
-
-const GenAIImagesNotice = () => {
- return (
-
-
- { __(
- "We've used your final URL to auto-populate images…",
- 'google-listings-and-ads'
- ) }
-
- );
-};
-
-export default GenAIImagesNotice;
diff --git a/js/src/components/paid-ads/gen-ai-images-notice.scss b/js/src/components/paid-ads/gen-ai-images-notice.scss
deleted file mode 100644
index da12e86de3..0000000000
--- a/js/src/components/paid-ads/gen-ai-images-notice.scss
+++ /dev/null
@@ -1,12 +0,0 @@
-.gla-gen-ai-images-notice {
- background-color: #f0f6fc;
- border-left: none;
- border: 1px solid #c5d9ed;
- font-family: "SF Pro Text", $default-font;
-
- :where(.components-notice__content) {
- display: flex;
- align-items: center;
- gap: 12px;
- }
-}
diff --git a/js/src/images/pmax-assets-improvements/gen-ai-images-notice.svg b/js/src/images/pmax-assets-improvements/gen-ai-images-notice.svg
deleted file mode 100644
index 4470ea76b2..0000000000
--- a/js/src/images/pmax-assets-improvements/gen-ai-images-notice.svg
+++ /dev/null
@@ -1,5 +0,0 @@
-
-
-
-
-
From 8a0cb5618f6101f50baad5a0b19d028d05a1361c Mon Sep 17 00:00:00 2001
From: asvinb
Date: Wed, 14 Jan 2026 18:11:05 +0400
Subject: [PATCH 012/123] feat(data): Add adaptGenAIAssets function and
refactor GenAI media/text asset fetching logic
---
js/src/data/actions.js | 41 ++++++++++-------------------------------
js/src/data/adapters.js | 30 ++++++++++++++++++++++++++++++
2 files changed, 40 insertions(+), 31 deletions(-)
diff --git a/js/src/data/actions.js b/js/src/data/actions.js
index 57a90d9af2..a38ae2e7bb 100644
--- a/js/src/data/actions.js
+++ b/js/src/data/actions.js
@@ -15,7 +15,7 @@ import {
EMPTY_ASSET_ENTITY_GROUP,
} from './constants';
import { handleApiError } from '~/utils/handleError';
-import { adaptAdsCampaign } from './adapters';
+import { adaptAdsCampaign, adaptGenAIAssets } from './adapters';
import { isWCIos, isWCAndroid } from '~/utils/isMobileApp';
import { convertKeysFromSnakeCaseToCamelCase } from './utils';
@@ -1303,22 +1303,10 @@ export function* fetchGenAIMediaAssets( url, assetType ) {
},
} );
- const items = response.items || [];
- const formattedData = items.reduce(
- ( accumulator, { temporary_image_url, type } ) => {
- if ( assetType && type !== assetType ) {
- return accumulator;
- }
-
- if ( ! temporary_image_url ) {
- return accumulator;
- }
-
- accumulator[ type ] = accumulator[ type ] || [];
- accumulator[ type ].push( temporary_image_url );
- return accumulator;
- },
- {}
+ const formattedData = adaptGenAIAssets(
+ response.items,
+ 'temporary_image_url',
+ assetType
);
return {
@@ -1356,20 +1344,11 @@ export function* fetchGenAITextAssets( url, assetType ) {
},
} );
- const items = response.items || [];
- const formattedData = items.reduce( ( accumulator, { text, type } ) => {
- if ( assetType && type !== assetType ) {
- return accumulator;
- }
-
- if ( ! text ) {
- return accumulator;
- }
-
- accumulator[ type ] = accumulator[ type ] || [];
- accumulator[ type ].push( text );
- return accumulator;
- }, {} );
+ const formattedData = adaptGenAIAssets(
+ response.items,
+ 'text',
+ assetType
+ );
return {
type: TYPES.RECEIVE_GEN_AI_TEXT_ASSETS,
diff --git a/js/src/data/adapters.js b/js/src/data/adapters.js
index e4d8d8097b..35a51d73a6 100644
--- a/js/src/data/adapters.js
+++ b/js/src/data/adapters.js
@@ -262,3 +262,33 @@ export function adaptRaiseAdsBudgetRecommendations( rawData ) {
return finalData;
}
+
+/**
+ * Formats raw API items into a grouped object by type.
+ * @param {Array} items The raw items array from API.
+ * @param {string} valueKey The key to extract (e.g., 'text' or 'temporary_image_url').
+ * @param {string} [filterType] Optional type to filter by.
+ * @return {Object} Groups of assets keyed by their type.
+ */
+export function adaptGenAIAssets( items = [], valueKey, filterType ) {
+ const data = {};
+
+ for ( const item of items ) {
+ const { type, [ valueKey ]: value } = item;
+
+ // Skip if:
+ // 1. We have a filter and it doesn't match
+ // 2. The value for the specified key is empty/null
+ if ( ( filterType && type !== filterType ) || ! value ) {
+ continue;
+ }
+
+ if ( ! data[ type ] ) {
+ data[ type ] = [];
+ }
+
+ data[ type ].push( value );
+ }
+
+ return data;
+}
From 40d6b1a2679804aa2b4a9ce9ee5ac427bd442909 Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Thu, 15 Jan 2026 15:13:39 +0530
Subject: [PATCH 013/123] Update google ads library.
---
composer.json | 2 +-
composer.lock | 127 +++++++++++++++++++++++++++++---------------------
2 files changed, 75 insertions(+), 54 deletions(-)
diff --git a/composer.json b/composer.json
index 4c22d2f0a9..a8a6b3e883 100644
--- a/composer.json
+++ b/composer.json
@@ -9,7 +9,7 @@
"ext-json": "*",
"google/apiclient": "^2.16",
"google/apiclient-services": "^0.350.0",
- "googleads/google-ads-php": "dev-legacy-v31.0.1",
+ "googleads/google-ads-php": "dev-legacy-v31.1.0",
"league/container": "^4.2",
"league/iso3166": "^4.1",
"phpseclib/bcmath_compat": "^2.0",
diff --git a/composer.lock b/composer.lock
index 6f979bc7be..bee4987b18 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "34bf83c9b42bffe4e88d69b8031e6cfc",
+ "content-hash": "29f7915131697943e319a56e9a05e9a0",
"packages": [
{
"name": "firebase/php-jwt",
@@ -349,20 +349,20 @@
},
{
"name": "google/longrunning",
- "version": "0.4.7",
+ "version": "0.6.0",
"source": {
"type": "git",
"url": "https://github.com/googleapis/php-longrunning.git",
- "reference": "624cabb874c10e5ddc9034c999f724894b70a3d3"
+ "reference": "226d3b5166eaa13754cc5e452b37872478e23375"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/googleapis/php-longrunning/zipball/624cabb874c10e5ddc9034c999f724894b70a3d3",
- "reference": "624cabb874c10e5ddc9034c999f724894b70a3d3",
+ "url": "https://api.github.com/repos/googleapis/php-longrunning/zipball/226d3b5166eaa13754cc5e452b37872478e23375",
+ "reference": "226d3b5166eaa13754cc5e452b37872478e23375",
"shasum": ""
},
"require-dev": {
- "google/gax": "^1.36.0",
+ "google/gax": "^1.38.0",
"phpunit/phpunit": "^9.0"
},
"type": "library",
@@ -387,9 +387,9 @@
],
"description": "Google LongRunning Client for PHP",
"support": {
- "source": "https://github.com/googleapis/php-longrunning/tree/v0.4.7"
+ "source": "https://github.com/googleapis/php-longrunning/tree/v0.6.0"
},
- "time": "2025-01-24T21:24:06+00:00"
+ "time": "2025-10-07T18:41:09+00:00"
},
{
"name": "google/protobuf",
@@ -437,19 +437,20 @@
},
{
"name": "googleads/google-ads-php",
- "version": "dev-legacy-v31.0.1",
+ "version": "dev-legacy-v31.1.0",
"source": {
"type": "git",
"url": "https://github.com/googleads/google-ads-php.git",
- "reference": "e217e6176fb5d72ff51f9c47e91b8e71bbf21dfb"
+ "reference": "73a755b69f8088a22da27fd0124e2a2d43ec7b82"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/googleads/google-ads-php/zipball/e217e6176fb5d72ff51f9c47e91b8e71bbf21dfb",
- "reference": "e217e6176fb5d72ff51f9c47e91b8e71bbf21dfb",
+ "url": "https://api.github.com/repos/googleads/google-ads-php/zipball/73a755b69f8088a22da27fd0124e2a2d43ec7b82",
+ "reference": "73a755b69f8088a22da27fd0124e2a2d43ec7b82",
"shasum": ""
},
"require": {
+ "google/auth": "^1.30 || ^2.0",
"google/gax": "^1.19.1",
"google/protobuf": "^3.21.5 || >=4.26 <=4.30.0",
"grpc/grpc": ">=1.36.0 <=1.57.0",
@@ -494,28 +495,28 @@
"homepage": "https://github.com/googleads/google-ads-php",
"support": {
"issues": "https://github.com/googleads/google-ads-php/issues",
- "source": "https://github.com/googleads/google-ads-php/tree/legacy-v31.0.1"
+ "source": "https://github.com/googleads/google-ads-php/tree/legacy-v31.1.0"
},
- "time": "2025-08-28T15:13:29+00:00"
+ "time": "2026-01-09T20:17:46+00:00"
},
{
"name": "guzzlehttp/guzzle",
- "version": "7.9.3",
+ "version": "7.10.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
- "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77"
+ "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/guzzle/zipball/7b2f29fe81dc4da0ca0ea7d42107a0845946ea77",
- "reference": "7b2f29fe81dc4da0ca0ea7d42107a0845946ea77",
+ "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
+ "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4",
"shasum": ""
},
"require": {
"ext-json": "*",
- "guzzlehttp/promises": "^1.5.3 || ^2.0.3",
- "guzzlehttp/psr7": "^2.7.0",
+ "guzzlehttp/promises": "^2.3",
+ "guzzlehttp/psr7": "^2.8",
"php": "^7.2.5 || ^8.0",
"psr/http-client": "^1.0",
"symfony/deprecation-contracts": "^2.2 || ^3.0"
@@ -606,7 +607,7 @@
],
"support": {
"issues": "https://github.com/guzzle/guzzle/issues",
- "source": "https://github.com/guzzle/guzzle/tree/7.9.3"
+ "source": "https://github.com/guzzle/guzzle/tree/7.10.0"
},
"funding": [
{
@@ -622,20 +623,20 @@
"type": "tidelift"
}
],
- "time": "2025-03-27T13:37:11+00:00"
+ "time": "2025-08-23T22:36:01+00:00"
},
{
"name": "guzzlehttp/promises",
- "version": "2.2.0",
+ "version": "2.3.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/promises.git",
- "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c"
+ "reference": "481557b130ef3790cf82b713667b43030dc9c957"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/promises/zipball/7c69f28996b0a6920945dd20b3857e499d9ca96c",
- "reference": "7c69f28996b0a6920945dd20b3857e499d9ca96c",
+ "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957",
+ "reference": "481557b130ef3790cf82b713667b43030dc9c957",
"shasum": ""
},
"require": {
@@ -643,7 +644,7 @@
},
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
- "phpunit/phpunit": "^8.5.39 || ^9.6.20"
+ "phpunit/phpunit": "^8.5.44 || ^9.6.25"
},
"type": "library",
"extra": {
@@ -689,7 +690,7 @@
],
"support": {
"issues": "https://github.com/guzzle/promises/issues",
- "source": "https://github.com/guzzle/promises/tree/2.2.0"
+ "source": "https://github.com/guzzle/promises/tree/2.3.0"
},
"funding": [
{
@@ -705,20 +706,20 @@
"type": "tidelift"
}
],
- "time": "2025-03-27T13:27:01+00:00"
+ "time": "2025-08-22T14:34:08+00:00"
},
{
"name": "guzzlehttp/psr7",
- "version": "2.7.1",
+ "version": "2.8.0",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
- "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16"
+ "reference": "21dc724a0583619cd1652f673303492272778051"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/psr7/zipball/c2270caaabe631b3b44c85f99e5a04bbb8060d16",
- "reference": "c2270caaabe631b3b44c85f99e5a04bbb8060d16",
+ "url": "https://api.github.com/repos/guzzle/psr7/zipball/21dc724a0583619cd1652f673303492272778051",
+ "reference": "21dc724a0583619cd1652f673303492272778051",
"shasum": ""
},
"require": {
@@ -734,7 +735,7 @@
"require-dev": {
"bamarni/composer-bin-plugin": "^1.8.2",
"http-interop/http-factory-tests": "0.9.0",
- "phpunit/phpunit": "^8.5.39 || ^9.6.20"
+ "phpunit/phpunit": "^8.5.44 || ^9.6.25"
},
"suggest": {
"laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
@@ -805,7 +806,7 @@
],
"support": {
"issues": "https://github.com/guzzle/psr7/issues",
- "source": "https://github.com/guzzle/psr7/tree/2.7.1"
+ "source": "https://github.com/guzzle/psr7/tree/2.8.0"
},
"funding": [
{
@@ -821,7 +822,7 @@
"type": "tidelift"
}
],
- "time": "2025-03-27T12:30:47+00:00"
+ "time": "2025-08-23T21:21:41+00:00"
},
{
"name": "league/container",
@@ -974,16 +975,16 @@
},
{
"name": "monolog/monolog",
- "version": "2.10.0",
+ "version": "2.11.0",
"source": {
"type": "git",
"url": "https://github.com/Seldaek/monolog.git",
- "reference": "5cf826f2991858b54d5c3809bee745560a1042a7"
+ "reference": "37308608e599f34a1a4845b16440047ec98a172a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Seldaek/monolog/zipball/5cf826f2991858b54d5c3809bee745560a1042a7",
- "reference": "5cf826f2991858b54d5c3809bee745560a1042a7",
+ "url": "https://api.github.com/repos/Seldaek/monolog/zipball/37308608e599f34a1a4845b16440047ec98a172a",
+ "reference": "37308608e599f34a1a4845b16440047ec98a172a",
"shasum": ""
},
"require": {
@@ -1001,7 +1002,7 @@
"graylog2/gelf-php": "^1.4.2 || ^2@dev",
"guzzlehttp/guzzle": "^7.4",
"guzzlehttp/psr7": "^2.2",
- "mongodb/mongodb": "^1.8",
+ "mongodb/mongodb": "^1.8 || ^2.0",
"php-amqplib/php-amqplib": "~2.4 || ^3",
"phpspec/prophecy": "^1.15",
"phpstan/phpstan": "^1.10",
@@ -1060,7 +1061,7 @@
],
"support": {
"issues": "https://github.com/Seldaek/monolog/issues",
- "source": "https://github.com/Seldaek/monolog/tree/2.10.0"
+ "source": "https://github.com/Seldaek/monolog/tree/2.11.0"
},
"funding": [
{
@@ -1072,7 +1073,7 @@
"type": "tidelift"
}
],
- "time": "2024-11-12T12:43:37+00:00"
+ "time": "2026-01-01T13:05:00+00:00"
},
{
"name": "paragonie/constant_time_encoding",
@@ -1783,7 +1784,7 @@
},
{
"name": "symfony/polyfill-ctype",
- "version": "v1.32.0",
+ "version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-ctype.git",
@@ -1842,7 +1843,7 @@
"portable"
],
"support": {
- "source": "https://github.com/symfony/polyfill-ctype/tree/v1.32.0"
+ "source": "https://github.com/symfony/polyfill-ctype/tree/v1.33.0"
},
"funding": [
{
@@ -1853,6 +1854,10 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
@@ -1862,7 +1867,7 @@
},
{
"name": "symfony/polyfill-intl-normalizer",
- "version": "v1.32.0",
+ "version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
@@ -1923,7 +1928,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.32.0"
+ "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.33.0"
},
"funding": [
{
@@ -1934,6 +1939,10 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
@@ -1943,7 +1952,7 @@
},
{
"name": "symfony/polyfill-mbstring",
- "version": "v1.32.0",
+ "version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-mbstring.git",
@@ -2004,7 +2013,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.32.0"
+ "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.33.0"
},
"funding": [
{
@@ -2015,6 +2024,10 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
@@ -2100,7 +2113,7 @@
},
{
"name": "symfony/polyfill-php80",
- "version": "v1.32.0",
+ "version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php80.git",
@@ -2160,7 +2173,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php80/tree/v1.32.0"
+ "source": "https://github.com/symfony/polyfill-php80/tree/v1.33.0"
},
"funding": [
{
@@ -2171,6 +2184,10 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
@@ -2180,7 +2197,7 @@
},
{
"name": "symfony/polyfill-php81",
- "version": "v1.32.0",
+ "version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php81.git",
@@ -2236,7 +2253,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php81/tree/v1.32.0"
+ "source": "https://github.com/symfony/polyfill-php81/tree/v1.33.0"
},
"funding": [
{
@@ -2247,6 +2264,10 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
From 458917f051ca4d8033715efa8d1dc425fd318f4c Mon Sep 17 00:00:00 2001
From: asvinb
Date: Thu, 15 Jan 2026 15:52:41 +0400
Subject: [PATCH 014/123] Add E2E tests
---
.../asset-group-editor/texts-editor.js | 1 -
js/src/data/actions.js | 4 +-
.../add-paid-campaigns.test.js | 955 ++++++++++++------
tests/e2e/utils/mock-requests.js | 86 ++
tests/e2e/utils/pages/create-campaign.js | 520 ++++++++++
5 files changed, 1270 insertions(+), 296 deletions(-)
create mode 100644 tests/e2e/utils/pages/create-campaign.js
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
index 2d1f7deabc..1c365dce5e 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
@@ -253,7 +253,6 @@ export default function TextsEditor( {
minNumberOfTexts > 0 &&
minNumberOfTexts === maxNumberOfTexts
}
- aria-label={ __( 'Add text', 'google-listings-and-ads' ) }
disabled={
maxNumberOfTexts > 0 && texts.length >= maxNumberOfTexts
}
diff --git a/js/src/data/actions.js b/js/src/data/actions.js
index 57a90d9af2..4ca5f2e6a9 100644
--- a/js/src/data/actions.js
+++ b/js/src/data/actions.js
@@ -1299,7 +1299,7 @@ export function* fetchGenAIMediaAssets( url, assetType ) {
method: REQUEST_ACTIONS.POST,
data: {
final_url: url,
- type: assetType,
+ types: assetType ? [ assetType ] : undefined,
},
} );
@@ -1352,7 +1352,7 @@ export function* fetchGenAITextAssets( url, assetType ) {
method: REQUEST_ACTIONS.POST,
data: {
final_url: url,
- type: assetType,
+ types: assetType ? [ assetType ] : undefined,
},
} );
diff --git a/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js b/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
index 9ca407bf4b..ad7c578133 100644
--- a/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
+++ b/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
@@ -6,15 +6,22 @@ import { expect, test } from '@playwright/test';
/**
* Internal dependencies
*/
-import { clearOnboardedMerchant, setOnboardedMerchant } from '../../utils/api';
+import {
+ clearOnboardedMerchant,
+ setOnboardedMerchant,
+ setCompletedAdsSetup,
+ clearCompletedAdsSetup,
+} from '../../utils/api';
import DashboardPage from '../../utils/pages/dashboard';
import SetupAdsAccountsPage from '../../utils/pages/ads-onboarding/setup-ads-accounts';
import SetupBudgetPage from '../../utils/pages/ads-onboarding/setup-budget';
+import CreateCampaignPage from '../../utils/pages/create-campaign';
import { LOAD_STATE } from '../../utils/constants';
import {
getFAQPanelTitle,
getFAQPanelRow,
checkFAQExpandable,
+ checkSnackBarMessage,
} from '../../utils/page';
const ADS_ACCOUNTS = [
@@ -37,6 +44,11 @@ test.describe.configure( { mode: 'serial' } );
*/
let dashboardPage = null;
+/**
+ * @type {import('../../utils/pages/create-campaign').default} createCampaignPage
+ */
+let createCampaignPage = null;
+
/**
* @type {import('../../utils/pages/ads-onboarding/setup-ads-accounts').default} setupAdsAccounts
*/
@@ -52,12 +64,13 @@ let setupBudgetPage = null;
*/
let page = null;
-test.describe( 'Set up Ads account', () => {
+test.describe( 'Add paid campaign', () => {
test.beforeAll( async ( { browser } ) => {
page = await browser.newPage();
dashboardPage = new DashboardPage( page );
setupAdsAccounts = new SetupAdsAccountsPage( page );
setupBudgetPage = new SetupBudgetPage( page );
+ createCampaignPage = new CreateCampaignPage( page );
await setOnboardedMerchant();
await setupAdsAccounts.mockAdsAccountsResponse( [] );
await setupBudgetPage.fulfillBillingStatusRequest( {
@@ -115,389 +128,745 @@ test.describe( 'Set up Ads account', () => {
await expect( dashboardPage.addPaidCampaignButton ).toBeEnabled();
} );
- test.describe( 'Set up your accounts page', async () => {
- test.beforeAll( async () => {
- await setupAdsAccounts.mockAdsAccountsResponse( [] );
- await dashboardPage.addPaidCampaignButton.click();
- await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED );
- } );
-
- test( 'Page header should be "Set up your accounts"', async () => {
- await expect(
- page.getByRole( 'heading', { name: 'Set up your accounts' } )
- ).toBeVisible();
- await expect(
- page.getByText(
- 'Connect your Google account and your Google Ads account to set up a Performance Max campaign.'
- )
- ).toBeVisible();
- } );
+ test.describe( 'With Ads account not connected', async () => {
+ test.describe( 'Set up your accounts page', async () => {
+ test.beforeAll( async () => {
+ await setupAdsAccounts.mockAdsAccountsResponse( [] );
+ await dashboardPage.addPaidCampaignButton.click();
+ await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED );
+ } );
- test( 'Google Account should show as connected', async () => {
- await expect(
- page.getByText(
- 'This Google account is connected to your store’s product feed.'
- )
- ).toBeVisible();
- } );
+ test( 'Page header should be "Set up your accounts"', async () => {
+ await expect(
+ page.getByRole( 'heading', {
+ name: 'Set up your accounts',
+ } )
+ ).toBeVisible();
+ await expect(
+ page.getByText(
+ 'Connect your Google account and your Google Ads account to set up a Performance Max campaign.'
+ )
+ ).toBeVisible();
+ } );
- test( 'Continue Button should be disabled', async () => {
- await expect( setupAdsAccounts.getContinueButton() ).toBeDisabled();
- } );
- } );
+ test( 'Google Account should show as connected', async () => {
+ await expect(
+ page.getByText(
+ 'This Google account is connected to your store’s product feed.'
+ )
+ ).toBeVisible();
+ } );
- test.describe( 'Add campaigns with no Ads account', async () => {
- test( 'Create an account should be visible', async () => {
- const createAccountButton = page.getByRole( 'button', {
- name: 'Create account',
+ test( 'Continue Button should be disabled', async () => {
+ await expect(
+ setupAdsAccounts.getContinueButton()
+ ).toBeDisabled();
} );
+ } );
- await expect( createAccountButton ).toBeVisible();
+ test.describe( 'Add campaigns with no Ads account', async () => {
+ test( 'Create an account should be visible', async () => {
+ const createAccountButton = page.getByRole( 'button', {
+ name: 'Create account',
+ } );
- await expect( setupAdsAccounts.getContinueButton() ).toBeDisabled();
+ await expect( createAccountButton ).toBeVisible();
- await expect(
- page.getByText(
- 'Required to set up conversion measurement and create campaigns.'
- )
- ).toBeVisible();
+ await expect(
+ setupAdsAccounts.getContinueButton()
+ ).toBeDisabled();
- await createAccountButton.click();
- } );
+ await expect(
+ page.getByText(
+ 'Required to set up conversion measurement and create campaigns.'
+ )
+ ).toBeVisible();
- test( 'Create account button should be disable if the ToS have not been accepted.', async () => {
- await expect(
- page.getByRole( 'heading', {
- name: 'Create Google Ads Account',
- } )
- ).toBeVisible();
-
- await expect(
- page.getByText(
- 'By creating a Google Ads account, you agree to the following terms and conditions:'
- )
- ).toBeVisible();
-
- await expect(
- setupAdsAccounts.getCreateAdsAccountButtonModal()
- ).toBeDisabled();
- } );
+ await createAccountButton.click();
+ } );
- test( 'Accept terms and conditions to enable the create account button', async () => {
- await setupAdsAccounts.getAcceptTermCreateAccount().check();
+ test( 'Create account button should be disable if the ToS have not been accepted.', async () => {
+ await expect(
+ page.getByRole( 'heading', {
+ name: 'Create Google Ads Account',
+ } )
+ ).toBeVisible();
- await expect(
- setupAdsAccounts.getCreateAdsAccountButtonModal()
- ).toBeEnabled();
- } );
+ await expect(
+ page.getByText(
+ 'By creating a Google Ads account, you agree to the following terms and conditions:'
+ )
+ ).toBeVisible();
- test( 'Create an Ads account', async () => {
- // Intercept Ads connection request.
- const connectAdsAccountRequest =
- setupAdsAccounts.registerConnectAdsAccountRequests();
+ await expect(
+ setupAdsAccounts.getCreateAdsAccountButtonModal()
+ ).toBeDisabled();
+ } );
- await setupAdsAccounts.mockAdsAccountsResponse( ADS_ACCOUNTS );
+ test( 'Accept terms and conditions to enable the create account button', async () => {
+ await setupAdsAccounts.getAcceptTermCreateAccount().check();
- // Mock request to fulfill Ads connection.
- await setupAdsAccounts.fulfillAdsConnection( {
- id: ADS_ACCOUNTS[ 0 ].id,
- currency: 'USD',
- symbol: '$',
- status: 'incomplete',
- step: 'account_access',
+ await expect(
+ setupAdsAccounts.getCreateAdsAccountButtonModal()
+ ).toBeEnabled();
} );
- await setupAdsAccounts.mockAdsStatusNotClaimed();
+ test( 'Create an Ads account', async () => {
+ // Intercept Ads connection request.
+ const connectAdsAccountRequest =
+ setupAdsAccounts.registerConnectAdsAccountRequests();
- await setupAdsAccounts.getCreateAdsAccountButtonModal().click();
+ await setupAdsAccounts.mockAdsAccountsResponse( ADS_ACCOUNTS );
- await connectAdsAccountRequest;
+ // Mock request to fulfill Ads connection.
+ await setupAdsAccounts.fulfillAdsConnection( {
+ id: ADS_ACCOUNTS[ 0 ].id,
+ currency: 'USD',
+ symbol: '$',
+ status: 'incomplete',
+ step: 'account_access',
+ } );
- const modal = setupAdsAccounts.getAcceptAccountModal();
- await expect( modal ).toBeVisible();
- } );
+ await setupAdsAccounts.mockAdsStatusNotClaimed();
- test( 'Show Unclaimed Ads account', async () => {
- await setupAdsAccounts.clickCloseAcceptAccountButtonFromModal();
+ await setupAdsAccounts.getCreateAdsAccountButtonModal().click();
- const claimButton = setupAdsAccounts.getAdsClaimAccountButton();
- const claimText = setupAdsAccounts.getAdsClaimAccountText();
+ await connectAdsAccountRequest;
- await expect( claimButton ).toBeVisible();
- await expect( claimText ).toBeVisible();
+ const modal = setupAdsAccounts.getAcceptAccountModal();
+ await expect( modal ).toBeVisible();
+ } );
- await expect( setupAdsAccounts.getContinueButton() ).toBeDisabled();
- } );
+ test( 'Show Unclaimed Ads account', async () => {
+ await setupAdsAccounts.clickCloseAcceptAccountButtonFromModal();
+
+ const claimButton = setupAdsAccounts.getAdsClaimAccountButton();
+ const claimText = setupAdsAccounts.getAdsClaimAccountText();
+
+ await expect( claimButton ).toBeVisible();
+ await expect( claimText ).toBeVisible();
- test( 'Show Claimed Ads account', async () => {
- // Intercept Ads connection request.
- await setupAdsAccounts.fulfillAdsConnection( {
- id: ADS_ACCOUNTS[ 0 ].id,
- currency: 'USD',
- symbol: '$',
- status: 'connected',
- step: '',
+ await expect(
+ setupAdsAccounts.getContinueButton()
+ ).toBeDisabled();
} );
- await setupAdsAccounts.mockAdsStatusClaimed();
+ test( 'Show Claimed Ads account', async () => {
+ // Intercept Ads connection request.
+ await setupAdsAccounts.fulfillAdsConnection( {
+ id: ADS_ACCOUNTS[ 0 ].id,
+ currency: 'USD',
+ symbol: '$',
+ status: 'connected',
+ step: '',
+ } );
- await page.dispatchEvent( 'body', 'blur' );
- await page.dispatchEvent( 'body', 'focus' );
+ await setupAdsAccounts.mockAdsStatusClaimed();
- await expect( setupAdsAccounts.getContinueButton() ).toBeEnabled();
+ await page.dispatchEvent( 'body', 'blur' );
+ await page.dispatchEvent( 'body', 'focus' );
- await expect(
- page.getByRole( 'link', {
- name: `Account ${ ADS_ACCOUNTS[ 0 ].id }`,
- } )
- ).toBeVisible();
+ await expect(
+ setupAdsAccounts.getContinueButton()
+ ).toBeEnabled();
- await expect( setupAdsAccounts.getContinueButton() ).toBeEnabled();
- } );
- } );
+ await expect(
+ page.getByRole( 'link', {
+ name: `Account ${ ADS_ACCOUNTS[ 0 ].id }`,
+ } )
+ ).toBeVisible();
- test.describe( 'Add campaigns with existing Ads accounts', () => {
- test.beforeAll( async () => {
- await setupAdsAccounts.mockAdsAccountsResponse( ADS_ACCOUNTS );
- //Disconnect the account from the previous test
- setupAdsAccounts.fulfillAdsConnection( {
- id: ADS_ACCOUNTS[ 1 ].id,
- currency: 'EUR',
- symbol: '\u20ac',
- status: 'disconnected',
+ await expect(
+ setupAdsAccounts.getContinueButton()
+ ).toBeEnabled();
} );
-
- await page.reload();
} );
- test( 'Select one existing account', async () => {
- const adsAccountSelected = `${ ADS_ACCOUNTS[ 1 ].id }`;
+ test.describe( 'Add campaigns with existing Ads accounts', () => {
+ test.beforeAll( async () => {
+ await setupAdsAccounts.mockAdsAccountsResponse( ADS_ACCOUNTS );
+ //Disconnect the account from the previous test
+ setupAdsAccounts.fulfillAdsConnection( {
+ id: ADS_ACCOUNTS[ 1 ].id,
+ currency: 'EUR',
+ symbol: '\u20ac',
+ status: 'disconnected',
+ } );
+
+ await page.reload();
+ } );
- await setupAdsAccounts.selectAnExistingAdsAccount(
- adsAccountSelected
- );
+ test( 'Select one existing account', async () => {
+ const adsAccountSelected = `${ ADS_ACCOUNTS[ 1 ].id }`;
- //Intercept Ads connection request
- const connectAdsAccountRequest =
- setupAdsAccounts.registerConnectAdsAccountRequests(
+ await setupAdsAccounts.selectAnExistingAdsAccount(
adsAccountSelected
);
- //Mock request to fulfill Ads connection
- setupAdsAccounts.fulfillAdsConnection( {
- id: ADS_ACCOUNTS[ 1 ].id,
- currency: 'EUR',
- symbol: '\u20ac',
- status: 'connected',
- } );
+ //Intercept Ads connection request
+ const connectAdsAccountRequest =
+ setupAdsAccounts.registerConnectAdsAccountRequests(
+ adsAccountSelected
+ );
- await setupAdsAccounts.clickConnectAds();
- await connectAdsAccountRequest;
+ //Mock request to fulfill Ads connection
+ setupAdsAccounts.fulfillAdsConnection( {
+ id: ADS_ACCOUNTS[ 1 ].id,
+ currency: 'EUR',
+ symbol: '\u20ac',
+ status: 'connected',
+ } );
- await expect( setupAdsAccounts.getContinueButton() ).toBeEnabled();
- } );
- } );
+ await setupAdsAccounts.clickConnectAds();
+ await connectAdsAccountRequest;
- test.describe( 'Create your campaign', () => {
- test( 'Continue to create your campaign', async () => {
- await setupAdsAccounts.clickContinue();
- await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED );
- await expect(
- page.getByRole( 'heading', {
- name: 'Create your campaign',
- } )
- ).toBeVisible();
-
- await expect(
- page.getByRole( 'heading', { name: 'Set your budget' } )
- ).toBeVisible();
-
- await expect(
- page.getByRole( 'link', {
- name: 'See what your ads will look like.',
- } )
- ).toBeVisible();
+ await expect(
+ setupAdsAccounts.getContinueButton()
+ ).toBeEnabled();
+ } );
} );
- test.describe( 'Preview product ad', () => {
- test( 'Preview product ad should be visible', async () => {
+ test.describe( 'Create your campaign', () => {
+ test( 'Continue to create your campaign', async () => {
+ await setupAdsAccounts.clickContinue();
+ await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED );
await expect(
- page.getByText( 'Preview product ad' )
+ page.getByRole( 'heading', {
+ name: 'Create your campaign',
+ } )
).toBeVisible();
+
await expect(
- page.getByText(
- "Each of your product variants will have its own ad. Previews shown here are examples and don't include all possible formats."
- )
+ page.getByRole( 'heading', { name: 'Set your budget' } )
).toBeVisible();
- } );
-
- test( 'Change image buttons should be enabled', async () => {
- const buttonsToChangeImage = page.locator(
- '.gla-campaign-preview-card__moving-button'
- );
- expect( buttonsToChangeImage ).toHaveCount( 2 );
-
- for ( const button of await buttonsToChangeImage.all() ) {
- await expect( button ).toBeEnabled();
- }
+ await expect(
+ page.getByRole( 'link', {
+ name: 'See what your ads will look like.',
+ } )
+ ).toBeVisible();
} );
- } );
- test.describe( 'FAQ panels', () => {
- test( 'should see five questions in FAQ', async () => {
- const faqTitles = getFAQPanelTitle( page );
- await expect( faqTitles ).toHaveCount( 5 );
+ test.describe( 'Preview product ad', () => {
+ test( 'Preview product ad should be visible', async () => {
+ await expect(
+ page.getByText( 'Preview product ad' )
+ ).toBeVisible();
+ await expect(
+ page.getByText(
+ "Each of your product variants will have its own ad. Previews shown here are examples and don't include all possible formats."
+ )
+ ).toBeVisible();
+ } );
+
+ test( 'Change image buttons should be enabled', async () => {
+ const buttonsToChangeImage = page.locator(
+ '.gla-campaign-preview-card__moving-button'
+ );
+
+ expect( buttonsToChangeImage ).toHaveCount( 2 );
+
+ for ( const button of await buttonsToChangeImage.all() ) {
+ await expect( button ).toBeEnabled();
+ }
+ } );
} );
- test( 'should not see FAQ rows when FAQ titles are not clicked', async () => {
- const faqRows = getFAQPanelRow( page );
- await expect( faqRows ).toHaveCount( 0 );
+ test.describe( 'FAQ panels', () => {
+ test( 'should see five questions in FAQ', async () => {
+ const faqTitles = getFAQPanelTitle( page );
+ await expect( faqTitles ).toHaveCount( 5 );
+ } );
+
+ test( 'should not see FAQ rows when FAQ titles are not clicked', async () => {
+ const faqRows = getFAQPanelRow( page );
+ await expect( faqRows ).toHaveCount( 0 );
+ } );
+
+ // eslint-disable-next-line jest/expect-expect
+ test( 'should see FAQ rows when all FAQ titles are clicked', async () => {
+ await checkFAQExpandable( page );
+ } );
} );
+ } );
- // eslint-disable-next-line jest/expect-expect
- test( 'should see FAQ rows when all FAQ titles are clicked', async () => {
- await checkFAQExpandable( page );
+ test.describe( 'Create Ads with billing data already setup', () => {
+ test.describe( 'Set the budget', async () => {
+ test( 'Continue button should be disabled if budget is 0', async () => {
+ await setupBudgetPage.fillBudget( '0' );
+
+ await expect(
+ setupBudgetPage.getCreateCampaignButton()
+ ).toBeDisabled();
+ } );
+
+ test( 'Continue button should be enabled when selecting an option from the recommendations, even if the entered value is invalid', async () => {
+ await setupBudgetPage.fillBudget( '0' );
+ await expect(
+ setupBudgetPage.getCreateCampaignButton()
+ ).toBeDisabled();
+
+ await page.getByLabel( 'low' ).click();
+ await expect(
+ setupBudgetPage.getCreateCampaignButton()
+ ).toBeEnabled();
+
+ await page.getByLabel( 'custom' ).click();
+ await expect(
+ setupBudgetPage.getCreateCampaignButton()
+ ).toBeDisabled();
+
+ await page.getByLabel( 'high' ).click();
+ await expect(
+ setupBudgetPage.getCreateCampaignButton()
+ ).toBeEnabled();
+
+ await page.getByLabel( 'custom' ).click();
+ await expect(
+ setupBudgetPage.getCreateCampaignButton()
+ ).toBeDisabled();
+
+ await page.getByLabel( 'recommended' ).click();
+ await expect(
+ setupBudgetPage.getCreateCampaignButton()
+ ).toBeEnabled();
+ } );
+
+ test( 'Continue button should be disabled if budget is less than 30% of the daily budget baseline', async () => {
+ await setupBudgetPage.fillBudget( '2' );
+
+ await expect(
+ setupBudgetPage.getCreateCampaignButton()
+ ).toBeDisabled();
+ } );
+
+ test( 'User is notified of the minimum value', async () => {
+ await setupBudgetPage.fillBudget( '3' );
+ await setupBudgetPage.getBudgetInput().blur();
+
+ await expect(
+ page.getByText(
+ 'Please make sure daily average cost is at least €4.00'
+ )
+ ).toBeVisible();
+ } );
+
+ test( 'Continue button should be enabled if budget is above the recommended value', async () => {
+ await setupBudgetPage.fillBudget( '5' );
+
+ await expect(
+ setupBudgetPage.getCreateCampaignButton()
+ ).toBeEnabled();
+ } );
+
+ test( 'Display the recommended budget if the budget is valid but lower than the lowest recommended value', async () => {
+ await setupBudgetPage.fillBudget( '6' );
+
+ await expect(
+ page.getByText(
+ `Your budget is lower than other advertisers' budgets, which may affect performance. For best results, we recommend at least €15.00 per day.`
+ )
+ ).toBeVisible();
+ } );
} );
- } );
- } );
- test.describe( 'Create Ads with billing data already setup', () => {
- test.describe( 'Set the budget', async () => {
- test( 'Continue button should be disabled if budget is 0', async () => {
- await setupBudgetPage.fillBudget( '0' );
+ test( 'It should show the campaign creation success message', async () => {
+ await setupBudgetPage.fillBudget( '6' );
+ await setupBudgetPage.getCreateCampaignButton().click();
+ const cancelButton = page.getByRole( 'button', {
+ name: 'Cancel',
+ } );
await expect(
- setupBudgetPage.getCreateCampaignButton()
- ).toBeDisabled();
- } );
+ page.getByText( 'This offer won’t last long!' )
+ ).toBeVisible();
+ await expect( cancelButton ).toBeEnabled();
- test( 'Continue button should be enabled when selecting an option from the recommendations, even if the entered value is invalid', async () => {
- await setupBudgetPage.fillBudget( '0' );
- await expect(
- setupBudgetPage.getCreateCampaignButton()
- ).toBeDisabled();
+ await cancelButton.click();
- await page.getByLabel( 'low' ).click();
- await expect(
- setupBudgetPage.getCreateCampaignButton()
- ).toBeEnabled();
+ await expect( cancelButton ).not.toBeVisible();
- await page.getByLabel( 'custom' ).click();
- await expect(
- setupBudgetPage.getCreateCampaignButton()
- ).toBeDisabled();
+ // Mock the campaign creation request.
+ const campaignCreation =
+ setupBudgetPage.mockCampaignCreationAndAdsSetupCompletion(
+ '6',
+ [ 'US' ]
+ );
- await page.getByLabel( 'high' ).click();
- await expect(
- setupBudgetPage.getCreateCampaignButton()
- ).toBeEnabled();
+ await setupBudgetPage.getCreateCampaignButton().click();
+
+ await campaignCreation;
+
+ //It should redirect to the dashboard page
+ await page.waitForURL(
+ '/wp-admin/admin.php?page=wc-admin&path=%2Fgoogle%2Fdashboard&guide=campaign-creation-success',
+ {
+ waitUntil: LOAD_STATE.DOM_CONTENT_LOADED,
+ }
+ );
- await page.getByLabel( 'custom' ).click();
await expect(
- setupBudgetPage.getCreateCampaignButton()
- ).toBeDisabled();
+ page.getByRole( 'heading', {
+ name: "You've set up a Performance Max Campaign!",
+ } )
+ ).toBeVisible();
- await page.getByLabel( 'recommended' ).click();
await expect(
- setupBudgetPage.getCreateCampaignButton()
+ page.getByRole( 'button', {
+ name: 'Create another campaign',
+ } )
).toBeEnabled();
- } );
-
- test( 'Continue button should be disabled if budget is less than 30% of the daily budget baseline', async () => {
- await setupBudgetPage.fillBudget( '2' );
await expect(
- setupBudgetPage.getCreateCampaignButton()
- ).toBeDisabled();
+ page.getByRole( 'button', {
+ name: 'Got It',
+ } )
+ ).toBeEnabled();
+
+ await page
+ .getByRole( 'button', {
+ name: 'Got It',
+ } )
+ .click();
} );
+ } );
+ } );
+
+ test.describe( 'With connected Ads account', async () => {
+ test.beforeAll( async () => {
+ await setCompletedAdsSetup();
+ await createCampaignPage.mockRequests();
+ await createCampaignPage.mockOptimizeCampaignRequests();
+ createCampaignPage.goto();
+ } );
- test( 'User is notified of the minimum value', async () => {
- await setupBudgetPage.fillBudget( '3' );
- await setupBudgetPage.getBudgetInput().blur();
+ test.afterAll( async () => {
+ await clearCompletedAdsSetup();
+ await page.close();
+ } );
+ test.describe( 'Create Campaign page', async () => {
+ test( 'Page header should be "Create your campaign"', async () => {
+ await expect(
+ page.getByRole( 'heading', {
+ name: 'Create your campaign',
+ } )
+ ).toBeVisible();
await expect(
page.getByText(
- 'Please make sure daily average cost is at least €4.00'
+ 'Performance Max campaigns are automatically optimized for you by Google.'
)
).toBeVisible();
} );
- test( 'Continue button should be enabled if budget is above the recommended value', async () => {
- await setupBudgetPage.fillBudget( '5' );
-
- await expect(
- setupBudgetPage.getCreateCampaignButton()
- ).toBeEnabled();
- } );
-
- test( 'Display the recommended budget if the budget is valid but lower than the lowest recommended value', async () => {
- await setupBudgetPage.fillBudget( '6' );
+ test( 'Clicking the "Continue" button takes you to the "Optimize your campaign" step', async () => {
+ const continueButton = createCampaignPage.getContinueButton();
+ continueButton.click();
await expect(
- page.getByText(
- `Your budget is lower than other advertisers' budgets, which may affect performance. For best results, we recommend at least €15.00 per day.`
- )
+ page.getByRole( 'heading', {
+ name: 'Optimize your campaign',
+ } )
).toBeVisible();
} );
} );
- test( 'It should show the campaign creation success message', async () => {
- await setupBudgetPage.fillBudget( '6' );
- await setupBudgetPage.getCreateCampaignButton().click();
+ test.describe( 'Optimize your campaign step', async () => {
+ test( 'Create Campaign button should be disabled if no URL selected', async () => {
+ const createCampaignButton =
+ createCampaignPage.getCreateCampaignButton();
+ await expect( createCampaignButton ).toBeDisabled();
+ } );
- const cancelButton = page.getByRole( 'button', { name: 'Cancel' } );
- await expect(
- page.getByText( 'This offer won’t last long!' )
- ).toBeVisible();
- await expect( cancelButton ).toBeEnabled();
+ test( 'Selecting final URL enables Create Campaign button', async () => {
+ await createCampaignPage.selectUrlOption();
- await cancelButton.click();
+ const createCampaignButton =
+ createCampaignPage.getCreateCampaignButton();
+ await expect( createCampaignButton ).toBeEnabled();
+ } );
- await expect( cancelButton ).not.toBeVisible();
+ test( 'Selecting the "Or, select a different Final URL" button disables the Create Campaign button', async () => {
+ const selectDifferentFinalUrlButton =
+ createCampaignPage.getSelectDifferentFinalUrlButton();
+ await selectDifferentFinalUrlButton.click();
- // Mock the campaign creation request.
- const campaignCreation =
- setupBudgetPage.mockCampaignCreationAndAdsSetupCompletion(
- '6',
- [ 'US' ]
- );
+ const createCampaignButton =
+ createCampaignPage.getCreateCampaignButton();
+ await expect( createCampaignButton ).toBeDisabled();
+ } );
- await setupBudgetPage.getCreateCampaignButton().click();
+ test( 'Selecting the Final URL again enables the Create Campaign button', async () => {
+ await createCampaignPage.selectUrlOption();
- await campaignCreation;
+ const createCampaignButton =
+ createCampaignPage.getCreateCampaignButton();
+ await expect( createCampaignButton ).toBeEnabled();
+ } );
- //It should redirect to the dashboard page
- await page.waitForURL(
- '/wp-admin/admin.php?page=wc-admin&path=%2Fgoogle%2Fdashboard&guide=campaign-creation-success',
- {
- waitUntil: LOAD_STATE.DOM_CONTENT_LOADED,
- }
- );
-
- await expect(
- page.getByRole( 'heading', {
- name: "You've set up a Performance Max Campaign!",
- } )
- ).toBeVisible();
-
- await expect(
- page.getByRole( 'button', {
- name: 'Create another campaign',
- } )
- ).toBeEnabled();
-
- await expect(
- page.getByRole( 'button', {
- name: 'Got It',
- } )
- ).toBeEnabled();
-
- await page
- .getByRole( 'button', {
- name: 'Got It',
- } )
- .click();
+ test.describe( 'Gen AI', () => {
+ test.describe( 'Text Assets', () => {
+ test.describe( 'Headlines', () => {
+ test.describe( 'Visibility', () => {
+ test( 'Generate headline button is hidden when all inputs are filled', async () => {
+ const generateHeadlineButton =
+ createCampaignPage.getGenerateHeadlineButton();
+ await expect(
+ generateHeadlineButton
+ ).not.toBeVisible();
+ } );
+
+ test( 'Generate headline button is visible when at least one input is empty', async () => {
+ const addHeadlineButton =
+ createCampaignPage.getAddHeadlineButton();
+ await addHeadlineButton.click();
+
+ const generateHeadlineButton =
+ createCampaignPage.getGenerateHeadlineButton();
+ await expect(
+ generateHeadlineButton
+ ).toBeVisible();
+
+ const generateHeadlinesButton =
+ createCampaignPage.getGenerateHeadlinesButton();
+ await expect(
+ generateHeadlinesButton
+ ).not.toBeVisible();
+
+ const headlineInputsValues =
+ await createCampaignPage.getHeadlineInputsValues();
+
+ await expect(
+ headlineInputsValues
+ ).toHaveLength( 4 );
+
+ const lastValue =
+ headlineInputsValues[
+ headlineInputsValues.length - 1
+ ];
+ expect( lastValue ).toBe( '' );
+ } );
+ } );
+
+ test.describe( 'Generate action', () => {
+ test.beforeEach( async () => {
+ createCampaignPage.mockGenerateTextAssetsSuccess();
+ } );
+
+ test( 'Clicking generate headline sends the correct POST request', async () => {
+ const generateRequest =
+ createCampaignPage.awaitForGenerateTextRequest(
+ 'https://woo.com/shop/',
+ [ 'headline' ]
+ );
+
+ const generateHeadlineButton =
+ createCampaignPage.getGenerateHeadlineButton();
+ await generateHeadlineButton.click();
+ await generateRequest;
+ } );
+ } );
+
+ test.describe( 'Success', () => {
+ test.beforeEach( async () => {
+ createCampaignPage.mockGenerateTextAssetsSuccess();
+ } );
+
+ test( 'Clicking generate headline fills empty headline inputs', async () => {
+ const headlineInputsValues =
+ await createCampaignPage.getHeadlineInputsValues();
+ const lastValue =
+ headlineInputsValues[
+ headlineInputsValues.length - 1
+ ];
+ expect( lastValue ).toBe(
+ 'Shop the Latest Deals'
+ );
+ } );
+ } );
+ } );
+
+ test.describe( 'Long Headlines', () => {
+ test.describe( 'Visibility', () => {
+ test( 'Generate long headline button is hidden when all inputs are filled', async () => {
+ const generateLongHeadlineButton =
+ createCampaignPage.getGenerateLongHeadlineButton();
+ await expect(
+ generateLongHeadlineButton
+ ).not.toBeVisible();
+ } );
+
+ test( 'Generate long headline button is visible when at least one input is empty', async () => {
+ const addLongHeadlineButton =
+ createCampaignPage.getAddLongHeadlineButton();
+ await addLongHeadlineButton.click();
+
+ const generateLongHeadlineButton =
+ createCampaignPage.getGenerateLongHeadlineButton();
+ await expect(
+ generateLongHeadlineButton
+ ).toBeVisible();
+
+ const generateLongHeadlinesButton =
+ createCampaignPage.getGenerateLongHeadlinesButton();
+ await expect(
+ generateLongHeadlinesButton
+ ).not.toBeVisible();
+
+ const longHeadlineInputsValues =
+ await createCampaignPage.getLongHeadlineInputsValues();
+
+ await expect(
+ longHeadlineInputsValues
+ ).toHaveLength( 2 );
+
+ const lastValue =
+ longHeadlineInputsValues[
+ longHeadlineInputsValues.length - 1
+ ];
+ expect( lastValue ).toBe( '' );
+ } );
+ } );
+
+ test.describe( 'Generate action', () => {
+ test.beforeEach( async () => {
+ createCampaignPage.mockGenerateTextAssetsSuccess();
+ } );
+
+ test( 'Clicking generate long headline sends the correct POST request', async () => {
+ const generateRequest =
+ createCampaignPage.awaitForGenerateTextRequest(
+ 'https://woo.com/shop/',
+ [ 'long_headline' ]
+ );
+
+ const generateLongHeadlineButton =
+ createCampaignPage.getGenerateLongHeadlineButton();
+ await generateLongHeadlineButton.click();
+ await generateRequest;
+ } );
+ } );
+
+ test.describe( 'Success', () => {
+ test.beforeEach( async () => {
+ createCampaignPage.mockGenerateTextAssetsSuccess();
+ } );
+
+ test( 'Clicking generate long headline fills empty long headline inputs', async () => {
+ const longHeadlineInputsValues =
+ await createCampaignPage.getLongHeadlineInputsValues();
+ const lastValue =
+ longHeadlineInputsValues[
+ longHeadlineInputsValues.length - 1
+ ];
+ expect( lastValue ).toBe(
+ 'Discover quality products at great prices'
+ );
+ } );
+ } );
+ } );
+
+ test.describe( 'Descriptions', () => {
+ test.describe( 'Visibility', () => {
+ test( 'Generate description button is hidden when all inputs are filled', async () => {
+ const generateDescriptionButton =
+ createCampaignPage.getGenerateDescriptionButton();
+ await expect(
+ generateDescriptionButton
+ ).not.toBeVisible();
+ } );
+
+ test( 'Generate description button is visible when at least one input is empty', async () => {
+ const addDescriptionButton =
+ createCampaignPage.getAddDescriptionButton();
+ await addDescriptionButton.click();
+
+ const generateDescriptionButton =
+ createCampaignPage.getGenerateDescriptionButton();
+ await expect(
+ generateDescriptionButton
+ ).toBeVisible();
+
+ const generateDescriptionsButton =
+ createCampaignPage.getGenerateDescriptionsButton();
+ await expect(
+ generateDescriptionsButton
+ ).not.toBeVisible();
+
+ const descriptionInputsValues =
+ await createCampaignPage.getDescriptionInputsValues();
+ await expect(
+ descriptionInputsValues
+ ).toHaveLength( 3 );
+
+ const lastValue =
+ descriptionInputsValues[
+ descriptionInputsValues.length - 1
+ ];
+ expect( lastValue ).toBe( '' );
+ } );
+ } );
+
+ test.describe( 'Generate action', () => {
+ test.beforeEach( async () => {
+ createCampaignPage.mockGenerateTextAssetsSuccess();
+ } );
+
+ test( 'Clicking generate description sends the correct POST request', async () => {
+ const generateRequest =
+ createCampaignPage.awaitForGenerateTextRequest(
+ 'https://woo.com/shop/',
+ [ 'description' ]
+ );
+
+ const generateDescriptionButton =
+ createCampaignPage.getGenerateDescriptionButton();
+ await generateDescriptionButton.click();
+ await generateRequest;
+ } );
+ } );
+
+ test.describe( 'Success', () => {
+ test.beforeEach( async () => {
+ createCampaignPage.mockGenerateTextAssetsSuccess();
+ } );
+
+ test( 'Clicking generate description fills empty description inputs', async () => {
+ const descriptionInputsValues =
+ await createCampaignPage.getDescriptionInputsValues();
+ const lastValue =
+ descriptionInputsValues[
+ descriptionInputsValues.length - 1
+ ];
+ expect( lastValue ).toBe(
+ 'Browse top picks and enjoy exclusive savings.'
+ );
+ } );
+ } );
+ } );
+
+ test.describe( 'Error', () => {
+ test.beforeEach( async () => {
+ createCampaignPage.mockEmptyGenerateTextAssets();
+ } );
+
+ test( 'Displays error message when there are no more generated text', async () => {
+ const addDescriptionButton =
+ createCampaignPage.getAddDescriptionButton();
+ await addDescriptionButton.click();
+
+ const generateDescriptionButton =
+ createCampaignPage.getGenerateDescriptionButton();
+ await generateDescriptionButton.click();
+
+ await checkSnackBarMessage(
+ page,
+ 'No texts were generated. Please try again.'
+ );
+ } );
+ } );
+ } );
+ } );
} );
} );
} );
diff --git a/tests/e2e/utils/mock-requests.js b/tests/e2e/utils/mock-requests.js
index ab83eaa7fa..732ff5ec54 100644
--- a/tests/e2e/utils/mock-requests.js
+++ b/tests/e2e/utils/mock-requests.js
@@ -1111,4 +1111,90 @@ export default class MockRequests {
[ 'GET' ]
);
}
+
+ /**
+ * Fulfills a mock request for the final URL suggestions endpoint for campaign assets.
+ *
+ * @param {Object} payload - The mock response payload to be returned.
+ * @return {Promise} A promise that resolves when the request is fulfilled.
+ */
+ async fulfillFinalUrlSuggestions( payload, status = 200 ) {
+ await this.fulfillRequest(
+ /\/wc\/gla\/assets\/final-url\/suggestions\b/,
+ payload,
+ status,
+ [ 'GET' ]
+ );
+ }
+
+ /**
+ * Mocks a request for final URL suggestions.
+ *
+ * @param {Object} payload - The mock response payload to be returned.
+ * @param {number} [status=200] - The HTTP status code to be returned. Defaults to 200.
+ * @return {Promise} A promise that resolves when the request is mocked.
+ */
+ async mockFinalUrlSuggestions( payload, status = 200 ) {
+ await this.fulfillFinalUrlSuggestions( payload, status );
+ }
+
+ /**
+ * Fulfills a mock request for the asset suggestions endpoint.
+ *
+ * @param {Object} payload - The mock response payload to be returned.
+ * @param {number} [status=200] - The HTTP status code to be returned.
+ * @return {Promise} A promise that resolves when the request is fulfilled.
+ */
+ async fulfillAssetSuggestions( payload, status = 200 ) {
+ await this.fulfillRequest(
+ /\/wc\/gla\/assets\/suggestions\b/,
+ payload,
+ status,
+ [ 'GET' ]
+ );
+ }
+
+ /**
+ * Mocks a request for asset suggestions.
+ *
+ * @param {Object} payload - The mock response payload to be returned.
+ */
+ async mockAssetSuggestions( payload, status = 200 ) {
+ await this.fulfillAssetSuggestions( payload, status );
+ }
+
+ /**
+ * Fulfills a mock request for the asset groups of a specific campaign.
+ *
+ * @param {string|number} campaignId - The ID of the campaign to get asset groups for.
+ * @param {Object} payload - The mock response payload to be returned.
+ * @param {number} [status=200] - The HTTP status code to be returned.
+ * @return {Promise} A promise that resolves when the request is fulfilled.
+ */
+ async fulfillAssetGroupsForCampaign( campaignId, payload, status = 200 ) {
+ await this.fulfillRequest(
+ new RegExp(
+ `\\/wc\\/gla\\/ads\\/campaigns\\/asset-groups\\?.*campaign_id=${ campaignId }\\b`
+ ),
+ payload,
+ status,
+ [ 'GET' ]
+ );
+ }
+
+ /**
+ * Fulfill generate text assets request.
+ *
+ * @param {Object} payload - The response payload to return.
+ * @param {number} status - The HTTP status in the response.
+ * @return {Promise}
+ */
+ async fulfillGenerateTextAssetsRequest( payload, status = 200 ) {
+ await this.fulfillRequest(
+ /\/wc\/gla\/ads\/assets\/generate-text\b/,
+ payload,
+ status,
+ [ 'POST' ]
+ );
+ }
}
diff --git a/tests/e2e/utils/pages/create-campaign.js b/tests/e2e/utils/pages/create-campaign.js
new file mode 100644
index 0000000000..e8770f5ee3
--- /dev/null
+++ b/tests/e2e/utils/pages/create-campaign.js
@@ -0,0 +1,520 @@
+/**
+ * Internal dependencies
+ */
+import { LOAD_STATE } from '../constants';
+import MockRequests from '../mock-requests';
+
+/**
+ * Dashboard page object class.
+ */
+export default class CreateCampaignPage extends MockRequests {
+ /**
+ * @param {import('@playwright/test').Page} page
+ */
+ constructor( page ) {
+ super( page );
+ this.page = page;
+ }
+
+ /**
+ * Close the current page.
+ *
+ * @return {Promise}
+ */
+ async closePage() {
+ await this.page.close();
+ }
+
+ /**
+ * Mock all requests related to external accounts such as Merchant Center, Google, etc.
+ *
+ * @return {Promise}
+ */
+ async mockRequests() {
+ // Mock Reports Programs
+ await this.fulfillJetPackConnection( {
+ active: 'yes',
+ owner: 'yes',
+ displayName: 'John',
+ email: 'john@email.com',
+ } );
+
+ await this.mockGoogleConnected();
+ await this.mockAdsAccountConnected();
+ }
+
+ /**
+ * Go to the Create Campaign page.
+ *
+ * @return {Promise}
+ */
+ async goto() {
+ await this.page.goto(
+ '/wp-admin/admin.php?page=wc-admin&path=%2Fgoogle%2Fdashboard&subpath=%2Fcampaigns%2Fcreate',
+ { waitUntil: LOAD_STATE.DOM_CONTENT_LOADED }
+ );
+ }
+
+ /**
+ * Get the Continue button.
+ *
+ * @return {import('@playwright/test').Locator} Continue button.
+ */
+ getContinueButton() {
+ return this.page.getByRole( 'button', {
+ name: 'Continue',
+ exact: true,
+ } );
+ }
+
+ /**
+ * Get create campaign button.
+ *
+ * @return {import('@playwright/test').Locator} Get create campaign button.
+ */
+ getCreateCampaignButton() {
+ // Intentionally not using getByRole here, as another button with the same accessible name exists in the Stepper header.
+ return this.page.locator(
+ 'button[data-action="submit-campaign-and-assets"]'
+ );
+ }
+
+ /**
+ * Click create campaign button.
+ *
+ * @return {Promise}
+ */
+ async clickCreateCampaignButton() {
+ const createCampaignButton = this.getCreateCampaignButton();
+ await createCampaignButton.click();
+ }
+
+ /**
+ * Get final URL select dropdown.
+ *
+ * @return {import('@playwright/test').Locator} Get final URL select dropdown.
+ */
+ getFinalUrlSelect() {
+ return this.page.getByRole( 'combobox' );
+ }
+
+ /**
+ * Get select button.
+ *
+ * @return {import('@playwright/test').Locator} Get select button.
+ */
+ getSelectButton() {
+ return this.page.getByRole( 'button', { name: 'Select' } );
+ }
+
+ /**
+ * Get select different final URL button.
+ *
+ * @return {import('@playwright/test').Locator} Get select different final URL button.
+ */
+ getSelectDifferentFinalUrlButton() {
+ return this.page.getByRole( 'button', {
+ name: 'Or, select a different Final URL',
+ } );
+ }
+
+ /**
+ * Get Add headline button.
+ *
+ * @return {import('@playwright/test').Locator} Get Add headline button.
+ */
+ getAddHeadlineButton() {
+ return this.page.getByRole( 'button', { name: 'Add headline' } );
+ }
+
+ /**
+ * Get Add long headline button.
+ *
+ * @return {import('@playwright/test').Locator} Get Add long headline button.
+ */
+ getAddLongHeadlineButton() {
+ return this.page.getByRole( 'button', { name: 'Add long headline' } );
+ }
+
+ /**
+ * Get Add description button.
+ *
+ * @return {import('@playwright/test').Locator} Get Add description button.
+ */
+ getAddDescriptionButton() {
+ return this.page.getByRole( 'button', { name: 'Add description' } );
+ }
+
+ /**
+ * Get generate headline button.
+ *
+ * @return {import('@playwright/test').Locator} Get generate headline button.
+ */
+ getGenerateHeadlineButton() {
+ return this.page.getByRole( 'button', {
+ name: 'Generate headline',
+ } );
+ }
+
+ /**
+ * Get generate headlines button.
+ *
+ * @return {import('@playwright/test').Locator} Get generate headlines button.
+ */
+ getGenerateHeadlinesButton() {
+ return this.page.getByRole( 'button', {
+ name: 'Generate headlines',
+ } );
+ }
+
+ /**
+ * Get generate long headline button.
+ *
+ * @return {import('@playwright/test').Locator} Get generate long headline button.
+ */
+ getGenerateLongHeadlineButton() {
+ return this.page.getByRole( 'button', {
+ name: 'Generate long headline',
+ } );
+ }
+
+ /**
+ * Get generate long headlines button.
+ *
+ * @return {import('@playwright/test').Locator} Get generate long headlines button.
+ */
+ getGenerateLongHeadlinesButton() {
+ return this.page.getByRole( 'button', {
+ name: 'Generate long headlines',
+ } );
+ }
+
+ /**
+ * Get generate description button.
+ *
+ * @return {import('@playwright/test').Locator} Get generate description button.
+ */
+ getGenerateDescriptionButton() {
+ return this.page.getByRole( 'button', {
+ name: 'Generate description',
+ } );
+ }
+
+ /**
+ * Get generate descriptions button.
+ *
+ * @return {import('@playwright/test').Locator} Get generate descriptions button.
+ */
+ getGenerateDescriptionsButton() {
+ return this.page.getByRole( 'button', {
+ name: 'Generate descriptions',
+ } );
+ }
+
+ /**
+ * Get headlines section.
+ *
+ * @return {import('@playwright/test').Locator} Get headlines section.
+ */
+ getHeadlinesSection() {
+ return this.page
+ .locator(
+ '.gla-asset-field:has(:where(.gla-asset-field__heading):has-text("Headlines"))'
+ )
+ .first();
+ }
+
+ /**
+ * Get headline inputs.
+ *
+ * @return {import('@playwright/test').Locator} Get headline inputs.
+ */
+ getHeadlineInputs() {
+ const headlinesSection = this.getHeadlinesSection();
+ const headlineInputs = headlinesSection.locator(
+ 'input[placeholder="Headline"]'
+ );
+
+ return headlineInputs;
+ }
+
+ /**
+ * Get headline inputs values.
+ *
+ * @return {Promise} Get headline inputs values.
+ */
+ async getHeadlineInputsValues() {
+ const headlineInputs = this.getHeadlineInputs();
+ const values = await headlineInputs.evaluateAll( ( inputs ) =>
+ inputs.map( ( input ) => input.value )
+ );
+
+ return values;
+ }
+
+ /**
+ * Get long headlines section.
+ *
+ * @return {import('@playwright/test').Locator} Get long headlines section.
+ */
+ getLongHeadlinesSection() {
+ return this.page
+ .locator(
+ '.gla-asset-field:has(:where(.gla-asset-field__heading):has-text("Long headlines"))'
+ )
+ .first();
+ }
+
+ /**
+ * Get headline inputs.
+ *
+ * @return {import('@playwright/test').Locator} Get headline inputs.
+ */
+ getLongHeadlineInputs() {
+ const longHeadlinesSection = this.getLongHeadlinesSection();
+ const longHeadlineInputs = longHeadlinesSection.locator(
+ 'input[placeholder="Long headline"]'
+ );
+
+ return longHeadlineInputs;
+ }
+
+ /**
+ * Get descriptions section.
+ *
+ * @return {import('@playwright/test').Locator} Get descriptions section.
+ */
+ getDescriptionsSection() {
+ return this.page
+ .locator(
+ '.gla-asset-field:has(:where(.gla-asset-field__heading):has-text("Descriptions"))'
+ )
+ .first();
+ }
+
+ /**
+ * Get description inputs.
+ *
+ * @return {import('@playwright/test').Locator} Get description inputs.
+ */
+ getDescriptionInputs() {
+ const descriptionsSection = this.getDescriptionsSection();
+ const descriptionInputs = descriptionsSection.locator(
+ 'input[placeholder="Description"]'
+ );
+
+ return descriptionInputs;
+ }
+
+ /**
+ * Get description inputs values.
+ *
+ * @return {Promise} Get description inputs values.
+ */
+ async getDescriptionInputsValues() {
+ const descriptionInputs = this.getDescriptionInputs();
+ const values = await descriptionInputs.evaluateAll( ( inputs ) =>
+ inputs.map( ( input ) => input.value )
+ );
+
+ return values;
+ }
+
+ /**
+ * Get long headline inputs values.
+ *
+ * @return {Promise} Get long headline inputs values.
+ */
+ async getLongHeadlineInputsValues() {
+ const longHeadlineInputs = this.getLongHeadlineInputs();
+ const values = await longHeadlineInputs.evaluateAll( ( inputs ) =>
+ inputs.map( ( input ) => input.value )
+ );
+
+ return values;
+ }
+
+ /**
+ * Select URL option.
+ *
+ * @return {Promise}
+ */
+ async selectUrlOption() {
+ const finalUrlSelect = this.getFinalUrlSelect();
+ await finalUrlSelect.focus();
+ const option = this.page.getByRole( 'option', {
+ name: 'Shop',
+ } );
+ await option.click();
+
+ const selectButton = this.getSelectButton();
+ await selectButton.click();
+ }
+
+ /**
+ * Mock optimize campaign requests.
+ *
+ * @return {Promise}
+ */
+ async mockOptimizeCampaignRequests() {
+ await this.mockFinalUrlSuggestions( [
+ {
+ id: 0,
+ type: 'homepage',
+ title: 'Homepage',
+ url: 'https://woo.com',
+ },
+ {
+ id: 7,
+ type: 'post',
+ title: 'Shop',
+ url: 'https://woo.com/shop/',
+ },
+ ] );
+
+ await this.mockAssetSuggestions( {
+ logo: [
+ 'https://tpc.googlesyndication.com/simgad/2643735098967285793',
+ ],
+ business_name: 'My Woo Store',
+ square_marketing_image: [
+ 'https://tpc.googlesyndication.com/simgad/2643735098967285793',
+ ],
+ marketing_image: [
+ 'https://tpc.googlesyndication.com/simgad/6792129722137622820',
+ ],
+ portrait_marketing_image: [],
+ call_to_action_selection: null,
+ final_url: 'https://woo.com/shop/',
+ display_url_path: [ 'shop', '' ],
+ headline: [ 'My Woo Store', 'Shop', 'Buy Now' ],
+ description: [ 'Best products available here.', 'Shop today!' ],
+ long_headline: [ 'My Woo Store: Shop' ],
+ } );
+
+ await this.fulfillAssetGroupsForCampaign( 1, [
+ {
+ id: 1,
+ final_url: '',
+ display_url_path: [ '', '' ],
+ assets: {},
+ },
+ ] );
+ }
+
+ /**
+ * Await for the generate text assets request.
+ *
+ * @param {string} finalUrl The final URL.
+ * @param {Array} types The requested asset types.
+ * @return {Promise} The request.
+ */
+ async awaitForGenerateTextRequest( finalUrl, types ) {
+ return this.page.waitForRequest( ( request ) => {
+ if (
+ ! request.url().includes( '/gla/ads/assets/generate-text' ) ||
+ request.method() !== 'POST'
+ ) {
+ return false;
+ }
+
+ const payload = request.postDataJSON();
+
+ return (
+ payload.final_url === finalUrl &&
+ Array.isArray( payload.types ) &&
+ types.every( ( type ) => payload.types.includes( type ) )
+ );
+ } );
+ }
+
+ /**
+ * Mock generate text assets success response.
+ *
+ * @return {Promise}
+ */
+ async mockGenerateTextAssetsSuccess() {
+ await this.fulfillGenerateTextAssetsRequest( {
+ final_url: 'https://woo.com/shop/',
+ items: [
+ // Headlines
+ {
+ text: 'Shop the Latest Deals',
+ type: 'headline',
+ },
+ {
+ text: 'Limited-Time Offers',
+ type: 'headline',
+ },
+ {
+ text: 'New Arrivals In Store',
+ type: 'headline',
+ },
+ {
+ text: 'Top Deals This Week',
+ type: 'headline',
+ },
+ {
+ text: 'Fast Shipping Available',
+ type: 'headline',
+ },
+
+ // Long headlines
+ {
+ text: 'Discover quality products at great prices',
+ type: 'long_headline',
+ },
+ {
+ text: 'Everything you need, delivered fast',
+ type: 'long_headline',
+ },
+ {
+ text: 'Upgrade your everyday shopping experience',
+ type: 'long_headline',
+ },
+ {
+ text: 'Find your next favorite product today',
+ type: 'long_headline',
+ },
+ {
+ text: 'Smart shopping starts right here',
+ type: 'long_headline',
+ },
+
+ // Descriptions
+ {
+ text: 'Browse top picks and enjoy exclusive savings.',
+ type: 'description',
+ },
+ {
+ text: 'Shop trusted products with fast delivery.',
+ type: 'description',
+ },
+ {
+ text: 'Great value items curated just for you.',
+ type: 'description',
+ },
+ {
+ text: 'Simple shopping with reliable service.',
+ type: 'description',
+ },
+ {
+ text: 'Quality products backed by great support.',
+ type: 'description',
+ },
+ ],
+ } );
+ }
+
+ /**
+ * Mock generate text assets empty response.
+ *
+ * @return {Promise}
+ */
+ async mockEmptyGenerateTextAssets() {
+ await this.fulfillGenerateTextAssetsRequest( {
+ final_url: 'https://woo.com/shop/',
+ items: [],
+ } );
+ }
+}
From cfae55d9cb166df90795f284521ba7ca6785de0b Mon Sep 17 00:00:00 2001
From: asvinb
Date: Thu, 15 Jan 2026 16:02:35 +0400
Subject: [PATCH 015/123] Add @svgr/webpack for SVG processing in webpack
config
---
package-lock.json | 1 +
package.json | 1 +
webpack.config.js | 44 +++++++++++++++++++++++++++++++++++++++++++-
3 files changed, 45 insertions(+), 1 deletion(-)
diff --git a/package-lock.json b/package-lock.json
index 3c4b73b17e..64ae1e304e 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -45,6 +45,7 @@
"@hapi/h2o2": "^10.0.4",
"@hapi/hapi": "^21.3.10",
"@playwright/test": "^1.56.1",
+ "@svgr/webpack": "^8.1.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^16.0.0",
diff --git a/package.json b/package.json
index 2d40b2594f..b725449f17 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,7 @@
"@hapi/h2o2": "^10.0.4",
"@hapi/hapi": "^21.3.10",
"@playwright/test": "^1.56.1",
+ "@svgr/webpack": "^8.1.0",
"@testing-library/dom": "^10.4.0",
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^16.0.0",
diff --git a/webpack.config.js b/webpack.config.js
index b500499b56..ab72b0a344 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -22,7 +22,49 @@ const webpackConfig = {
// Remove `@wordpress/` rules for SVGs.
...defaultConfig.module.rules.filter( exceptSVGAndPNGRule ),
{
- test: /\.(svg|png|jpe?g|gif)$/i,
+ test: /\.svg$/i,
+ oneOf: [
+ {
+ resourceQuery: /inline/,
+ issuer: /\.[jt]sx?$/,
+ use: [
+ {
+ loader: require.resolve( '@svgr/webpack' ),
+ options: {
+ svgoConfig: {
+ plugins: [
+ {
+ name: 'preset-default',
+ params: {
+ overrides: {
+ removeViewBox: false,
+ },
+ },
+ },
+ {
+ name: 'prefixIds',
+ params: {
+ prefix: true,
+ },
+ },
+ ],
+ },
+ exportType: 'default',
+ },
+ },
+ ],
+ },
+ // Default: emit SVG as a file
+ {
+ type: 'asset/resource',
+ generator: {
+ filename: 'images/[path][contenthash].[name][ext]',
+ },
+ },
+ ],
+ },
+ {
+ test: /\.(png|jpe?g|gif)$/i,
type: 'asset/resource',
generator: {
filename: 'images/[path][contenthash].[name][ext]',
From 6024bd33f1a9fb4e7326944d6de080cc428b2592 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Thu, 15 Jan 2026 16:06:33 +0400
Subject: [PATCH 016/123] chore(package.json): Update package.json max size
value
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 488bb3e9b0..0513341331 100644
--- a/package.json
+++ b/package.json
@@ -149,7 +149,7 @@
},
{
"path": "./js/build/index.js",
- "maxSize": "19 kB"
+ "maxSize": "19.1 kB"
},
{
"path": "./js/build/commons.js",
From 75ff3169d3a2c6be66b237a663cfdd3340f5fd03 Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Thu, 15 Jan 2026 17:38:36 +0530
Subject: [PATCH 017/123] Update imports for V22.
---
bin/GoogleAdsCleanupServices.php | 2 +-
composer.json | 2 +-
src/API/Google/Ads.php | 12 +-
src/API/Google/AdsAsset.php | 22 ++--
src/API/Google/AdsAssetGroup.php | 24 ++--
src/API/Google/AdsAssetGroupAsset.php | 10 +-
src/API/Google/AdsCampaign.php | 22 ++--
src/API/Google/AdsCampaignBudget.php | 10 +-
src/API/Google/AdsCampaignCriterion.php | 12 +-
src/API/Google/AdsCampaignLabel.php | 14 +--
src/API/Google/AdsConversionAction.php | 26 ++--
src/API/Google/AdsReport.php | 4 +-
src/API/Google/AssetFieldType.php | 2 +-
src/API/Google/BillingSetupStatus.php | 2 +-
src/API/Google/BudgetMetrics.php | 16 +--
src/API/Google/BudgetRecommendations.php | 14 +--
src/API/Google/CallToActionType.php | 2 +-
src/API/Google/CampaignStatus.php | 2 +-
src/API/Google/CampaignType.php | 2 +-
src/API/Google/MerchantMetrics.php | 2 +-
src/API/Google/Query/AdsQuery.php | 6 +-
src/API/Google/Query/AdsReportQuery.php | 2 +-
src/Ads/AdsRecommendationsService.php | 4 +-
src/Google/Ads/ServiceClientFactoryTrait.php | 38 +++---
.../GoogleServiceProvider.php | 2 +-
.../HelperTrait/GoogleAdsClientTrait.php | 112 +++++++++---------
.../API/Google/AdsAssetGroupAssetTest.php | 4 +-
tests/Unit/API/Google/AdsAssetGroupTest.php | 6 +-
tests/Unit/API/Google/AdsAssetTest.php | 2 +-
.../API/Google/AdsCampaignCriterionTest.php | 2 +-
.../API/Google/AdsConversionActionTest.php | 2 +-
tests/Unit/API/Google/AdsTest.php | 8 +-
tests/Unit/API/Google/MerchantMetricsTest.php | 6 +-
33 files changed, 198 insertions(+), 198 deletions(-)
diff --git a/bin/GoogleAdsCleanupServices.php b/bin/GoogleAdsCleanupServices.php
index 864764bb87..239ac5443a 100644
--- a/bin/GoogleAdsCleanupServices.php
+++ b/bin/GoogleAdsCleanupServices.php
@@ -26,7 +26,7 @@ class GoogleAdsCleanupServices {
*
* @var string
*/
- protected $version = 'V21';
+ protected $version = 'V22';
/**
* @var Event Composer event.
diff --git a/composer.json b/composer.json
index a8a6b3e883..d03f8f9d76 100644
--- a/composer.json
+++ b/composer.json
@@ -60,7 +60,7 @@
"Google\\Task\\Composer::cleanup",
"Automattic\\WooCommerce\\GoogleListingsAndAds\\Util\\SymfonyPolyfillCleanup::remove",
"Automattic\\WooCommerce\\GoogleListingsAndAds\\Util\\GoogleAdsCleanupServices::remove",
- "composer run-script remove-google-ads-api-version-support -- 18 19 20",
+ "composer run-script remove-google-ads-api-version-support -- 18 19 20 21",
"php ./bin/prefix-vendor-namespace.php",
"bash ./bin/cleanup-vendor-files.sh",
"composer dump-autoload"
diff --git a/src/API/Google/Ads.php b/src/API/Google/Ads.php
index 51b7fa049d..971ee743fb 100644
--- a/src/API/Google/Ads.php
+++ b/src/API/Google/Ads.php
@@ -13,12 +13,12 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Exception;
-use Google\Ads\GoogleAds\Util\V21\ResourceNames;
-use Google\Ads\GoogleAds\V21\Enums\AccessRoleEnum\AccessRole;
-use Google\Ads\GoogleAds\V21\Enums\ProductLinkInvitationStatusEnum\ProductLinkInvitationStatus;
-use Google\Ads\GoogleAds\V21\Resources\ProductLinkInvitation;
-use Google\Ads\GoogleAds\V21\Services\ListAccessibleCustomersRequest;
-use Google\Ads\GoogleAds\V21\Services\UpdateProductLinkInvitationRequest;
+use Google\Ads\GoogleAds\Util\V22\ResourceNames;
+use Google\Ads\GoogleAds\V22\Enums\AccessRoleEnum\AccessRole;
+use Google\Ads\GoogleAds\V22\Enums\ProductLinkInvitationStatusEnum\ProductLinkInvitationStatus;
+use Google\Ads\GoogleAds\V22\Resources\ProductLinkInvitation;
+use Google\Ads\GoogleAds\V22\Services\ListAccessibleCustomersRequest;
+use Google\Ads\GoogleAds\V22\Services\UpdateProductLinkInvitationRequest;
use Google\ApiCore\ApiException;
use Google\ApiCore\ValidationException;
diff --git a/src/API/Google/AdsAsset.php b/src/API/Google/AdsAsset.php
index 0be762dd02..fcf8dac4df 100644
--- a/src/API/Google/AdsAsset.php
+++ b/src/API/Google/AdsAsset.php
@@ -6,17 +6,17 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
-use Google\Ads\GoogleAds\V21\Services\GoogleAdsRow;
-use Google\Ads\GoogleAds\V21\Enums\AssetTypeEnum\AssetType;
-use Google\Ads\GoogleAds\V21\Resources\Asset;
-use Google\Ads\GoogleAds\V21\Services\AssetOperation;
-use Google\Ads\GoogleAds\V21\Services\MutateGoogleAdsRequest;
-use Google\Ads\GoogleAds\V21\Services\MutateOperation;
-use Google\Ads\GoogleAds\Util\V21\ResourceNames;
-use Google\Ads\GoogleAds\V21\Common\TextAsset;
-use Google\Ads\GoogleAds\V21\Common\ImageAsset;
-use Google\Ads\GoogleAds\V21\Common\CallToActionAsset;
-use Google\Ads\GoogleAds\V21\Common\YoutubeVideoAsset;
+use Google\Ads\GoogleAds\V22\Services\GoogleAdsRow;
+use Google\Ads\GoogleAds\V22\Enums\AssetTypeEnum\AssetType;
+use Google\Ads\GoogleAds\V22\Resources\Asset;
+use Google\Ads\GoogleAds\V22\Services\AssetOperation;
+use Google\Ads\GoogleAds\V22\Services\MutateGoogleAdsRequest;
+use Google\Ads\GoogleAds\V22\Services\MutateOperation;
+use Google\Ads\GoogleAds\Util\V22\ResourceNames;
+use Google\Ads\GoogleAds\V22\Common\TextAsset;
+use Google\Ads\GoogleAds\V22\Common\ImageAsset;
+use Google\Ads\GoogleAds\V22\Common\CallToActionAsset;
+use Google\Ads\GoogleAds\V22\Common\YoutubeVideoAsset;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Google\ApiCore\ApiException;
use Exception;
diff --git a/src/API/Google/AdsAssetGroup.php b/src/API/Google/AdsAssetGroup.php
index 8f813d3a58..c17f750ad9 100644
--- a/src/API/Google/AdsAssetGroup.php
+++ b/src/API/Google/AdsAssetGroup.php
@@ -7,18 +7,18 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
-use Google\Ads\GoogleAds\Util\V21\ResourceNames;
-use Google\Ads\GoogleAds\V21\Enums\ListingGroupFilterListingSourceEnum\ListingGroupFilterListingSource;
-use Google\Ads\GoogleAds\V21\Enums\AssetGroupStatusEnum\AssetGroupStatus;
-use Google\Ads\GoogleAds\V21\Enums\ListingGroupFilterTypeEnum\ListingGroupFilterType;
-use Google\Ads\GoogleAds\V21\Resources\AssetGroup;
-use Google\Ads\GoogleAds\V21\Resources\AssetGroupListingGroupFilter;
-use Google\Ads\GoogleAds\V21\Services\AssetGroupListingGroupFilterOperation;
-use Google\Ads\GoogleAds\V21\Services\AssetGroupOperation;
-use Google\Ads\GoogleAds\V21\Services\GoogleAdsRow;
-use Google\Ads\GoogleAds\V21\Services\MutateGoogleAdsRequest;
-use Google\Ads\GoogleAds\V21\Services\MutateOperation;
-use Google\Ads\GoogleAds\V21\Services\Client\AssetGroupServiceClient;
+use Google\Ads\GoogleAds\Util\V22\ResourceNames;
+use Google\Ads\GoogleAds\V22\Enums\ListingGroupFilterListingSourceEnum\ListingGroupFilterListingSource;
+use Google\Ads\GoogleAds\V22\Enums\AssetGroupStatusEnum\AssetGroupStatus;
+use Google\Ads\GoogleAds\V22\Enums\ListingGroupFilterTypeEnum\ListingGroupFilterType;
+use Google\Ads\GoogleAds\V22\Resources\AssetGroup;
+use Google\Ads\GoogleAds\V22\Resources\AssetGroupListingGroupFilter;
+use Google\Ads\GoogleAds\V22\Services\AssetGroupListingGroupFilterOperation;
+use Google\Ads\GoogleAds\V22\Services\AssetGroupOperation;
+use Google\Ads\GoogleAds\V22\Services\GoogleAdsRow;
+use Google\Ads\GoogleAds\V22\Services\MutateGoogleAdsRequest;
+use Google\Ads\GoogleAds\V22\Services\MutateOperation;
+use Google\Ads\GoogleAds\V22\Services\Client\AssetGroupServiceClient;
use Google\ApiCore\ApiException;
use Google\ApiCore\ValidationException;
use Google\Protobuf\FieldMask;
diff --git a/src/API/Google/AdsAssetGroupAsset.php b/src/API/Google/AdsAssetGroupAsset.php
index 751634bbf7..dd6be91c12 100644
--- a/src/API/Google/AdsAssetGroupAsset.php
+++ b/src/API/Google/AdsAssetGroupAsset.php
@@ -7,13 +7,13 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
-use Google\Ads\GoogleAds\V21\Services\GoogleAdsRow;
-use Google\Ads\GoogleAds\V21\Resources\AssetGroupAsset;
+use Google\Ads\GoogleAds\V22\Services\GoogleAdsRow;
+use Google\Ads\GoogleAds\V22\Resources\AssetGroupAsset;
use Google\ApiCore\ApiException;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
-use Google\Ads\GoogleAds\V21\Services\MutateOperation;
-use Google\Ads\GoogleAds\V21\Services\AssetGroupAssetOperation;
-use Google\Ads\GoogleAds\Util\V21\ResourceNames;
+use Google\Ads\GoogleAds\V22\Services\MutateOperation;
+use Google\Ads\GoogleAds\V22\Services\AssetGroupAssetOperation;
+use Google\Ads\GoogleAds\Util\V22\ResourceNames;
diff --git a/src/API/Google/AdsCampaign.php b/src/API/Google/AdsCampaign.php
index 30fc93dcf5..dcca194b79 100644
--- a/src/API/Google/AdsCampaign.php
+++ b/src/API/Google/AdsCampaign.php
@@ -17,17 +17,17 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WC;
use Google\Ads\GoogleAds\Util\FieldMasks;
-use Google\Ads\GoogleAds\Util\V21\ResourceNames;
-use Google\Ads\GoogleAds\V21\Common\MaximizeConversionValue;
-use Google\Ads\GoogleAds\V21\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType;
-use Google\Ads\GoogleAds\V21\Resources\Campaign;
-use Google\Ads\GoogleAds\V21\Enums\EuPoliticalAdvertisingStatusEnum\EuPoliticalAdvertisingStatus;
-use Google\Ads\GoogleAds\V21\Resources\Campaign\ShoppingSetting;
-use Google\Ads\GoogleAds\V21\Services\Client\CampaignServiceClient;
-use Google\Ads\GoogleAds\V21\Services\CampaignOperation;
-use Google\Ads\GoogleAds\V21\Services\GoogleAdsRow;
-use Google\Ads\GoogleAds\V21\Services\MutateGoogleAdsRequest;
-use Google\Ads\GoogleAds\V21\Services\MutateOperation;
+use Google\Ads\GoogleAds\Util\V22\ResourceNames;
+use Google\Ads\GoogleAds\V22\Common\MaximizeConversionValue;
+use Google\Ads\GoogleAds\V22\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType;
+use Google\Ads\GoogleAds\V22\Resources\Campaign;
+use Google\Ads\GoogleAds\V22\Enums\EuPoliticalAdvertisingStatusEnum\EuPoliticalAdvertisingStatus;
+use Google\Ads\GoogleAds\V22\Resources\Campaign\ShoppingSetting;
+use Google\Ads\GoogleAds\V22\Services\Client\CampaignServiceClient;
+use Google\Ads\GoogleAds\V22\Services\CampaignOperation;
+use Google\Ads\GoogleAds\V22\Services\GoogleAdsRow;
+use Google\Ads\GoogleAds\V22\Services\MutateGoogleAdsRequest;
+use Google\Ads\GoogleAds\V22\Services\MutateOperation;
use Google\ApiCore\ApiException;
use Google\ApiCore\ValidationException;
use Exception;
diff --git a/src/API/Google/AdsCampaignBudget.php b/src/API/Google/AdsCampaignBudget.php
index 5f1afe0e23..80c2d50564 100644
--- a/src/API/Google/AdsCampaignBudget.php
+++ b/src/API/Google/AdsCampaignBudget.php
@@ -9,11 +9,11 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Google\Ads\GoogleAds\Util\FieldMasks;
-use Google\Ads\GoogleAds\Util\V21\ResourceNames;
-use Google\Ads\GoogleAds\V21\Resources\CampaignBudget;
-use Google\Ads\GoogleAds\V21\Services\CampaignBudgetOperation;
-use Google\Ads\GoogleAds\V21\Services\Client\CampaignBudgetServiceClient;
-use Google\Ads\GoogleAds\V21\Services\MutateOperation;
+use Google\Ads\GoogleAds\Util\V22\ResourceNames;
+use Google\Ads\GoogleAds\V22\Resources\CampaignBudget;
+use Google\Ads\GoogleAds\V22\Services\CampaignBudgetOperation;
+use Google\Ads\GoogleAds\V22\Services\Client\CampaignBudgetServiceClient;
+use Google\Ads\GoogleAds\V22\Services\MutateOperation;
use Google\ApiCore\ValidationException;
use Exception;
diff --git a/src/API/Google/AdsCampaignCriterion.php b/src/API/Google/AdsCampaignCriterion.php
index 9ab50584b8..08dfe877a8 100644
--- a/src/API/Google/AdsCampaignCriterion.php
+++ b/src/API/Google/AdsCampaignCriterion.php
@@ -3,12 +3,12 @@
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
-use Google\Ads\GoogleAds\Util\V21\ResourceNames;
-use Google\Ads\GoogleAds\V21\Common\LocationInfo;
-use Google\Ads\GoogleAds\V21\Enums\CampaignCriterionStatusEnum\CampaignCriterionStatus;
-use Google\Ads\GoogleAds\V21\Resources\CampaignCriterion;
-use Google\Ads\GoogleAds\V21\Services\CampaignCriterionOperation;
-use Google\Ads\GoogleAds\V21\Services\MutateOperation;
+use Google\Ads\GoogleAds\Util\V22\ResourceNames;
+use Google\Ads\GoogleAds\V22\Common\LocationInfo;
+use Google\Ads\GoogleAds\V22\Enums\CampaignCriterionStatusEnum\CampaignCriterionStatus;
+use Google\Ads\GoogleAds\V22\Resources\CampaignCriterion;
+use Google\Ads\GoogleAds\V22\Services\CampaignCriterionOperation;
+use Google\Ads\GoogleAds\V22\Services\MutateOperation;
/**
* Class AdsCampaignCriterion
diff --git a/src/API/Google/AdsCampaignLabel.php b/src/API/Google/AdsCampaignLabel.php
index 528fc0d0c3..22259c8e71 100644
--- a/src/API/Google/AdsCampaignLabel.php
+++ b/src/API/Google/AdsCampaignLabel.php
@@ -7,13 +7,13 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
-use Google\Ads\GoogleAds\Util\V21\ResourceNames;
-use Google\Ads\GoogleAds\V21\Resources\Label;
-use Google\Ads\GoogleAds\V21\Resources\CampaignLabel;
-use Google\Ads\GoogleAds\V21\Services\LabelOperation;
-use Google\Ads\GoogleAds\V21\Services\CampaignLabelOperation;
-use Google\Ads\GoogleAds\V21\Services\MutateOperation;
-use Google\Ads\GoogleAds\V21\Services\MutateGoogleAdsRequest;
+use Google\Ads\GoogleAds\Util\V22\ResourceNames;
+use Google\Ads\GoogleAds\V22\Resources\Label;
+use Google\Ads\GoogleAds\V22\Resources\CampaignLabel;
+use Google\Ads\GoogleAds\V22\Services\LabelOperation;
+use Google\Ads\GoogleAds\V22\Services\CampaignLabelOperation;
+use Google\Ads\GoogleAds\V22\Services\MutateOperation;
+use Google\Ads\GoogleAds\V22\Services\MutateGoogleAdsRequest;
/**
* Class AdsCampaignLabel
diff --git a/src/API/Google/AdsConversionAction.php b/src/API/Google/AdsConversionAction.php
index 21e182d60e..4e9895773b 100644
--- a/src/API/Google/AdsConversionAction.php
+++ b/src/API/Google/AdsConversionAction.php
@@ -8,19 +8,19 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Exception;
-use Google\Ads\GoogleAds\V21\Common\TagSnippet;
-use Google\Ads\GoogleAds\V21\Enums\ConversionActionCategoryEnum\ConversionActionCategory;
-use Google\Ads\GoogleAds\V21\Enums\ConversionActionStatusEnum\ConversionActionStatus;
-use Google\Ads\GoogleAds\V21\Enums\ConversionActionTypeEnum\ConversionActionType;
-use Google\Ads\GoogleAds\V21\Enums\TrackingCodePageFormatEnum\TrackingCodePageFormat;
-use Google\Ads\GoogleAds\V21\Enums\TrackingCodeTypeEnum\TrackingCodeType;
-use Google\Ads\GoogleAds\V21\Resources\ConversionAction;
-use Google\Ads\GoogleAds\V21\Resources\ConversionAction\ValueSettings;
-use Google\Ads\GoogleAds\V21\Services\ConversionActionOperation;
-use Google\Ads\GoogleAds\V21\Services\Client\ConversionActionServiceClient;
-use Google\Ads\GoogleAds\V21\Services\GoogleAdsRow;
-use Google\Ads\GoogleAds\V21\Services\MutateConversionActionResult;
-use Google\Ads\GoogleAds\V21\Services\MutateConversionActionsRequest;
+use Google\Ads\GoogleAds\V22\Common\TagSnippet;
+use Google\Ads\GoogleAds\V22\Enums\ConversionActionCategoryEnum\ConversionActionCategory;
+use Google\Ads\GoogleAds\V22\Enums\ConversionActionStatusEnum\ConversionActionStatus;
+use Google\Ads\GoogleAds\V22\Enums\ConversionActionTypeEnum\ConversionActionType;
+use Google\Ads\GoogleAds\V22\Enums\TrackingCodePageFormatEnum\TrackingCodePageFormat;
+use Google\Ads\GoogleAds\V22\Enums\TrackingCodeTypeEnum\TrackingCodeType;
+use Google\Ads\GoogleAds\V22\Resources\ConversionAction;
+use Google\Ads\GoogleAds\V22\Resources\ConversionAction\ValueSettings;
+use Google\Ads\GoogleAds\V22\Services\ConversionActionOperation;
+use Google\Ads\GoogleAds\V22\Services\Client\ConversionActionServiceClient;
+use Google\Ads\GoogleAds\V22\Services\GoogleAdsRow;
+use Google\Ads\GoogleAds\V22\Services\MutateConversionActionResult;
+use Google\Ads\GoogleAds\V22\Services\MutateConversionActionsRequest;
use Google\ApiCore\ApiException;
/**
diff --git a/src/API/Google/AdsReport.php b/src/API/Google/AdsReport.php
index 1f05af7161..ced62e7c07 100644
--- a/src/API/Google/AdsReport.php
+++ b/src/API/Google/AdsReport.php
@@ -15,8 +15,8 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use DateTime;
-use Google\Ads\GoogleAds\V21\Common\Segments;
-use Google\Ads\GoogleAds\V21\Services\GoogleAdsRow;
+use Google\Ads\GoogleAds\V22\Common\Segments;
+use Google\Ads\GoogleAds\V22\Services\GoogleAdsRow;
use Google\ApiCore\ApiException;
/**
diff --git a/src/API/Google/AssetFieldType.php b/src/API/Google/AssetFieldType.php
index dbb782f5d4..160298f6ce 100644
--- a/src/API/Google/AssetFieldType.php
+++ b/src/API/Google/AssetFieldType.php
@@ -3,7 +3,7 @@
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
-use Google\Ads\GoogleAds\V21\Enums\AssetFieldTypeEnum\AssetFieldType as AdsAssetFieldType;
+use Google\Ads\GoogleAds\V22\Enums\AssetFieldTypeEnum\AssetFieldType as AdsAssetFieldType;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping;
use UnexpectedValueException;
diff --git a/src/API/Google/BillingSetupStatus.php b/src/API/Google/BillingSetupStatus.php
index bb092e9a3a..547e117aea 100644
--- a/src/API/Google/BillingSetupStatus.php
+++ b/src/API/Google/BillingSetupStatus.php
@@ -3,7 +3,7 @@
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
-use Google\Ads\GoogleAds\V21\Enums\BillingSetupStatusEnum\BillingSetupStatus as AdsBillingSetupStatus;
+use Google\Ads\GoogleAds\V22\Enums\BillingSetupStatusEnum\BillingSetupStatus as AdsBillingSetupStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping;
/**
diff --git a/src/API/Google/BudgetMetrics.php b/src/API/Google/BudgetMetrics.php
index 084a71efbb..640d4637e8 100644
--- a/src/API/Google/BudgetMetrics.php
+++ b/src/API/Google/BudgetMetrics.php
@@ -12,14 +12,14 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
-use Google\Ads\GoogleAds\V21\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType;
-use Google\Ads\GoogleAds\V21\Enums\BiddingStrategyTypeEnum\BiddingStrategyType;
-use Google\Ads\GoogleAds\V21\Enums\RecommendationTypeEnum\RecommendationType;
-use Google\Ads\GoogleAds\V21\Resources\Recommendation\CampaignBudgetRecommendation;
-use Google\Ads\GoogleAds\V21\Services\GenerateRecommendationsRequest;
-use Google\Ads\GoogleAds\V21\Services\GenerateRecommendationsRequest\AssetGroupInfo;
-use Google\Ads\GoogleAds\V21\Services\GenerateRecommendationsRequest\BiddingInfo;
-use Google\Ads\GoogleAds\V21\Services\GenerateRecommendationsRequest\BudgetInfo;
+use Google\Ads\GoogleAds\V22\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType;
+use Google\Ads\GoogleAds\V22\Enums\BiddingStrategyTypeEnum\BiddingStrategyType;
+use Google\Ads\GoogleAds\V22\Enums\RecommendationTypeEnum\RecommendationType;
+use Google\Ads\GoogleAds\V22\Resources\Recommendation\CampaignBudgetRecommendation;
+use Google\Ads\GoogleAds\V22\Services\GenerateRecommendationsRequest;
+use Google\Ads\GoogleAds\V22\Services\GenerateRecommendationsRequest\AssetGroupInfo;
+use Google\Ads\GoogleAds\V22\Services\GenerateRecommendationsRequest\BiddingInfo;
+use Google\Ads\GoogleAds\V22\Services\GenerateRecommendationsRequest\BudgetInfo;
use Google\ApiCore\ApiException;
/**
diff --git a/src/API/Google/BudgetRecommendations.php b/src/API/Google/BudgetRecommendations.php
index 7611d955fd..c489f7dd89 100644
--- a/src/API/Google/BudgetRecommendations.php
+++ b/src/API/Google/BudgetRecommendations.php
@@ -12,13 +12,13 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
-use Google\Ads\GoogleAds\V21\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType;
-use Google\Ads\GoogleAds\V21\Enums\BiddingStrategyTypeEnum\BiddingStrategyType;
-use Google\Ads\GoogleAds\V21\Enums\RecommendationTypeEnum\RecommendationType;
-use Google\Ads\GoogleAds\V21\Resources\Recommendation\CampaignBudgetRecommendation;
-use Google\Ads\GoogleAds\V21\Services\GenerateRecommendationsRequest;
-use Google\Ads\GoogleAds\V21\Services\GenerateRecommendationsRequest\AssetGroupInfo;
-use Google\Ads\GoogleAds\V21\Services\GenerateRecommendationsRequest\BiddingInfo;
+use Google\Ads\GoogleAds\V22\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType;
+use Google\Ads\GoogleAds\V22\Enums\BiddingStrategyTypeEnum\BiddingStrategyType;
+use Google\Ads\GoogleAds\V22\Enums\RecommendationTypeEnum\RecommendationType;
+use Google\Ads\GoogleAds\V22\Resources\Recommendation\CampaignBudgetRecommendation;
+use Google\Ads\GoogleAds\V22\Services\GenerateRecommendationsRequest;
+use Google\Ads\GoogleAds\V22\Services\GenerateRecommendationsRequest\AssetGroupInfo;
+use Google\Ads\GoogleAds\V22\Services\GenerateRecommendationsRequest\BiddingInfo;
use Google\ApiCore\ApiException;
/**
diff --git a/src/API/Google/CallToActionType.php b/src/API/Google/CallToActionType.php
index 966b9f36f8..bf85248317 100644
--- a/src/API/Google/CallToActionType.php
+++ b/src/API/Google/CallToActionType.php
@@ -3,7 +3,7 @@
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
-use Google\Ads\GoogleAds\V21\Enums\CallToActionTypeEnum\CallToActionType as AdsCallToActionType;
+use Google\Ads\GoogleAds\V22\Enums\CallToActionTypeEnum\CallToActionType as AdsCallToActionType;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping;
diff --git a/src/API/Google/CampaignStatus.php b/src/API/Google/CampaignStatus.php
index 2928951e0a..b0d6fb9550 100644
--- a/src/API/Google/CampaignStatus.php
+++ b/src/API/Google/CampaignStatus.php
@@ -3,7 +3,7 @@
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
-use Google\Ads\GoogleAds\V21\Enums\CampaignStatusEnum\CampaignStatus as AdsCampaignStatus;
+use Google\Ads\GoogleAds\V22\Enums\CampaignStatusEnum\CampaignStatus as AdsCampaignStatus;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping;
/**
diff --git a/src/API/Google/CampaignType.php b/src/API/Google/CampaignType.php
index 03f28f179d..ed2d7d1f9b 100644
--- a/src/API/Google/CampaignType.php
+++ b/src/API/Google/CampaignType.php
@@ -3,7 +3,7 @@
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google;
-use Google\Ads\GoogleAds\V21\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType as AdsCampaignType;
+use Google\Ads\GoogleAds\V22\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType as AdsCampaignType;
use Automattic\WooCommerce\GoogleListingsAndAds\Internal\StatusMapping;
/**
diff --git a/src/API/Google/MerchantMetrics.php b/src/API/Google/MerchantMetrics.php
index d879561bd3..cad3fe3177 100644
--- a/src/API/Google/MerchantMetrics.php
+++ b/src/API/Google/MerchantMetrics.php
@@ -15,7 +15,7 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\SearchResponse;
use DateTime;
use Exception;
-use Google\Ads\GoogleAds\V21\Services\GoogleAdsRow;
+use Google\Ads\GoogleAds\V22\Services\GoogleAdsRow;
use Google\ApiCore\PagedListResponse;
/**
diff --git a/src/API/Google/Query/AdsQuery.php b/src/API/Google/Query/AdsQuery.php
index 9c9e4c2e2f..8b3023369c 100644
--- a/src/API/Google/Query/AdsQuery.php
+++ b/src/API/Google/Query/AdsQuery.php
@@ -5,9 +5,9 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\InvalidProperty;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
-use Google\Ads\GoogleAds\V21\Services\GoogleAdsRow;
-use Google\Ads\GoogleAds\V21\Services\SearchGoogleAdsRequest;
-use Google\Ads\GoogleAds\V21\Services\SearchSettings;
+use Google\Ads\GoogleAds\V22\Services\GoogleAdsRow;
+use Google\Ads\GoogleAds\V22\Services\SearchGoogleAdsRequest;
+use Google\Ads\GoogleAds\V22\Services\SearchSettings;
use Google\ApiCore\ApiException;
defined( 'ABSPATH' ) || exit;
diff --git a/src/API/Google/Query/AdsReportQuery.php b/src/API/Google/Query/AdsReportQuery.php
index dab79aba86..30e8a1354c 100644
--- a/src/API/Google/Query/AdsReportQuery.php
+++ b/src/API/Google/Query/AdsReportQuery.php
@@ -3,7 +3,7 @@
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Query;
-use Google\Ads\GoogleAds\V21\Resources\ShoppingPerformanceView;
+use Google\Ads\GoogleAds\V22\Resources\ShoppingPerformanceView;
defined( 'ABSPATH' ) || exit;
diff --git a/src/Ads/AdsRecommendationsService.php b/src/Ads/AdsRecommendationsService.php
index 0338244b52..8d9716730a 100644
--- a/src/Ads/AdsRecommendationsService.php
+++ b/src/Ads/AdsRecommendationsService.php
@@ -15,8 +15,8 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\TransientsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Exception as GoogleException;
-use Google\Ads\GoogleAds\V21\Resources\Recommendation;
-use Google\Ads\GoogleAds\V21\Enums\RecommendationTypeEnum\RecommendationType;
+use Google\Ads\GoogleAds\V22\Resources\Recommendation;
+use Google\Ads\GoogleAds\V22\Enums\RecommendationTypeEnum\RecommendationType;
use Exception;
defined( 'ABSPATH' ) || exit;
diff --git a/src/Google/Ads/ServiceClientFactoryTrait.php b/src/Google/Ads/ServiceClientFactoryTrait.php
index d697b41ada..e1a575b2fb 100644
--- a/src/Google/Ads/ServiceClientFactoryTrait.php
+++ b/src/Google/Ads/ServiceClientFactoryTrait.php
@@ -13,25 +13,25 @@
use Google\Ads\GoogleAds\Constants;
use Google\Ads\GoogleAds\Lib\ConfigurationTrait;
-use Google\Ads\GoogleAds\V21\Services\Client\AccountLinkServiceClient;
-use Google\Ads\GoogleAds\V21\Services\Client\AdGroupAdLabelServiceClient;
-use Google\Ads\GoogleAds\V21\Services\Client\AdGroupAdServiceClient;
-use Google\Ads\GoogleAds\V21\Services\Client\AdGroupCriterionServiceClient;
-use Google\Ads\GoogleAds\V21\Services\Client\AdGroupServiceClient;
-use Google\Ads\GoogleAds\V21\Services\Client\AdServiceClient;
-use Google\Ads\GoogleAds\V21\Services\Client\AssetGroupListingGroupFilterServiceClient;
-use Google\Ads\GoogleAds\V21\Services\Client\AssetGroupServiceClient;
-use Google\Ads\GoogleAds\V21\Services\Client\BillingSetupServiceClient;
-use Google\Ads\GoogleAds\V21\Services\Client\CampaignBudgetServiceClient;
-use Google\Ads\GoogleAds\V21\Services\Client\CampaignCriterionServiceClient;
-use Google\Ads\GoogleAds\V21\Services\Client\CampaignServiceClient;
-use Google\Ads\GoogleAds\V21\Services\Client\ConversionActionServiceClient;
-use Google\Ads\GoogleAds\V21\Services\Client\CustomerServiceClient;
-use Google\Ads\GoogleAds\V21\Services\Client\CustomerUserAccessServiceClient;
-use Google\Ads\GoogleAds\V21\Services\Client\GeoTargetConstantServiceClient;
-use Google\Ads\GoogleAds\V21\Services\Client\GoogleAdsServiceClient;
-use Google\Ads\GoogleAds\V21\Services\Client\ProductLinkInvitationServiceClient;
-use Google\Ads\GoogleAds\V21\Services\Client\RecommendationServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\AccountLinkServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\AdGroupAdLabelServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\AdGroupAdServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\AdGroupCriterionServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\AdGroupServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\AdServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\AssetGroupListingGroupFilterServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\AssetGroupServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\BillingSetupServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\CampaignBudgetServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\CampaignCriterionServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\CampaignServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\ConversionActionServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\CustomerServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\CustomerUserAccessServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\GeoTargetConstantServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\GoogleAdsServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\ProductLinkInvitationServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\RecommendationServiceClient;
/**
* Contains service client factory methods.
diff --git a/src/Internal/DependencyManagement/GoogleServiceProvider.php b/src/Internal/DependencyManagement/GoogleServiceProvider.php
index f2715c8d9d..678abd2d72 100644
--- a/src/Internal/DependencyManagement/GoogleServiceProvider.php
+++ b/src/Internal/DependencyManagement/GoogleServiceProvider.php
@@ -49,7 +49,7 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\League\Container\Definition\Definition;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Http\Message\RequestInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Psr\Http\Message\ResponseInterface;
-use Google\Ads\GoogleAds\Util\V21\GoogleAdsFailures;
+use Google\Ads\GoogleAds\Util\V22\GoogleAdsFailures;
use Jetpack_Options;
defined( 'ABSPATH' ) || exit;
diff --git a/tests/Tools/HelperTrait/GoogleAdsClientTrait.php b/tests/Tools/HelperTrait/GoogleAdsClientTrait.php
index 02534244cc..32112ec846 100644
--- a/tests/Tools/HelperTrait/GoogleAdsClientTrait.php
+++ b/tests/Tools/HelperTrait/GoogleAdsClientTrait.php
@@ -10,62 +10,62 @@
use Automattic\WooCommerce\GoogleListingsAndAds\API\MicroTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Exception;
-use Google\Ads\GoogleAds\Util\V21\ResourceNames;
-use Google\Ads\GoogleAds\V21\Common\LocationInfo;
-use Google\Ads\GoogleAds\V21\Common\Metrics;
-use Google\Ads\GoogleAds\V21\Common\Segments;
-use Google\Ads\GoogleAds\V21\Common\TagSnippet;
-use Google\Ads\GoogleAds\V21\Common\ImageAsset;
-use Google\Ads\GoogleAds\V21\Common\TextAsset;
-use Google\Ads\GoogleAds\V21\Common\CallToActionAsset;
-use Google\Ads\GoogleAds\V21\Common\ImageDimension;
-use Google\Ads\GoogleAds\V21\Common\YoutubeVideoAsset;
-use Google\Ads\GoogleAds\V21\Enums\AccessRoleEnum\AccessRole;
-use Google\Ads\GoogleAds\V21\Enums\CampaignStatusEnum\CampaignStatus as AdsCampaignStatus;
-use Google\Ads\GoogleAds\V21\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType as AdsCampaignType;
-use Google\Ads\GoogleAds\V21\Enums\AssetTypeEnum\AssetType;
-use Google\Ads\GoogleAds\V21\Enums\TrackingCodePageFormatEnum\TrackingCodePageFormat;
-use Google\Ads\GoogleAds\V21\Enums\TrackingCodeTypeEnum\TrackingCodeType;
-use Google\Ads\GoogleAds\V21\Resources\BillingSetup;
-use Google\Ads\GoogleAds\V21\Resources\Campaign;
-use Google\Ads\GoogleAds\V21\Resources\Label;
-use Google\Ads\GoogleAds\V21\Resources\Asset;
-use Google\Ads\GoogleAds\V21\Resources\AssetGroup;
-use Google\Ads\GoogleAds\V21\Resources\AssetGroupAsset;
-use Google\Ads\GoogleAds\V21\Services\AssetGroupAssetOperation;
-use Google\Ads\GoogleAds\V21\Resources\CampaignBudget;
-use Google\Ads\GoogleAds\V21\Resources\CampaignCriterion;
-use Google\Ads\GoogleAds\V21\Resources\Campaign\ShoppingSetting;
-use Google\Ads\GoogleAds\V21\Resources\ConversionAction;
-use Google\Ads\GoogleAds\V21\Resources\Customer;
-use Google\Ads\GoogleAds\V21\Resources\CustomerUserAccess;
-use Google\Ads\GoogleAds\V21\Resources\GeoTargetConstant;
-use Google\Ads\GoogleAds\V21\Resources\Recommendation;
-use Google\Ads\GoogleAds\V21\Resources\Recommendation\CampaignBudgetRecommendation;
-use Google\Ads\GoogleAds\V21\Resources\Recommendation\CampaignBudgetRecommendation\CampaignBudgetRecommendationOption;
-use Google\Ads\GoogleAds\V21\Resources\Recommendation\RecommendationImpact;
-use Google\Ads\GoogleAds\V21\Resources\Recommendation\RecommendationMetrics;
-use Google\Ads\GoogleAds\V21\Resources\ShoppingPerformanceView;
-use Google\Ads\GoogleAds\V21\Services\Client\ConversionActionServiceClient;
-use Google\Ads\GoogleAds\V21\Services\Client\CustomerServiceClient;
-use Google\Ads\GoogleAds\V21\Services\Client\GoogleAdsServiceClient;
-use Google\Ads\GoogleAds\V21\Services\Client\ProductLinkInvitationServiceClient;
-use Google\Ads\GoogleAds\V21\Services\Client\RecommendationServiceClient;
-use Google\Ads\GoogleAds\V21\Services\GenerateRecommendationsResponse;
-use Google\Ads\GoogleAds\V21\Services\GoogleAdsRow;
-use Google\Ads\GoogleAds\V21\Services\ListAccessibleCustomersResponse;
-use Google\Ads\GoogleAds\V21\Services\MutateCampaignResult;
-use Google\Ads\GoogleAds\V21\Services\MutateLabelResult;
-use Google\Ads\GoogleAds\V21\Services\MutateConversionActionResult;
-use Google\Ads\GoogleAds\V21\Services\MutateConversionActionsRequest;
-use Google\Ads\GoogleAds\V21\Services\MutateConversionActionsResponse;
-use Google\Ads\GoogleAds\V21\Services\MutateGoogleAdsRequest;
-use Google\Ads\GoogleAds\V21\Services\MutateGoogleAdsResponse;
-use Google\Ads\GoogleAds\V21\Services\MutateOperationResponse;
-use Google\Ads\GoogleAds\V21\Services\MutateOperation;
-use Google\Ads\GoogleAds\V21\Services\MutateAssetGroupResult;
-use Google\Ads\GoogleAds\V21\Services\MutateAssetResult;
-use Google\Ads\GoogleAds\V21\Services\SearchGoogleAdsResponse;
+use Google\Ads\GoogleAds\Util\V22\ResourceNames;
+use Google\Ads\GoogleAds\V22\Common\LocationInfo;
+use Google\Ads\GoogleAds\V22\Common\Metrics;
+use Google\Ads\GoogleAds\V22\Common\Segments;
+use Google\Ads\GoogleAds\V22\Common\TagSnippet;
+use Google\Ads\GoogleAds\V22\Common\ImageAsset;
+use Google\Ads\GoogleAds\V22\Common\TextAsset;
+use Google\Ads\GoogleAds\V22\Common\CallToActionAsset;
+use Google\Ads\GoogleAds\V22\Common\ImageDimension;
+use Google\Ads\GoogleAds\V22\Common\YoutubeVideoAsset;
+use Google\Ads\GoogleAds\V22\Enums\AccessRoleEnum\AccessRole;
+use Google\Ads\GoogleAds\V22\Enums\CampaignStatusEnum\CampaignStatus as AdsCampaignStatus;
+use Google\Ads\GoogleAds\V22\Enums\AdvertisingChannelTypeEnum\AdvertisingChannelType as AdsCampaignType;
+use Google\Ads\GoogleAds\V22\Enums\AssetTypeEnum\AssetType;
+use Google\Ads\GoogleAds\V22\Enums\TrackingCodePageFormatEnum\TrackingCodePageFormat;
+use Google\Ads\GoogleAds\V22\Enums\TrackingCodeTypeEnum\TrackingCodeType;
+use Google\Ads\GoogleAds\V22\Resources\BillingSetup;
+use Google\Ads\GoogleAds\V22\Resources\Campaign;
+use Google\Ads\GoogleAds\V22\Resources\Label;
+use Google\Ads\GoogleAds\V22\Resources\Asset;
+use Google\Ads\GoogleAds\V22\Resources\AssetGroup;
+use Google\Ads\GoogleAds\V22\Resources\AssetGroupAsset;
+use Google\Ads\GoogleAds\V22\Services\AssetGroupAssetOperation;
+use Google\Ads\GoogleAds\V22\Resources\CampaignBudget;
+use Google\Ads\GoogleAds\V22\Resources\CampaignCriterion;
+use Google\Ads\GoogleAds\V22\Resources\Campaign\ShoppingSetting;
+use Google\Ads\GoogleAds\V22\Resources\ConversionAction;
+use Google\Ads\GoogleAds\V22\Resources\Customer;
+use Google\Ads\GoogleAds\V22\Resources\CustomerUserAccess;
+use Google\Ads\GoogleAds\V22\Resources\GeoTargetConstant;
+use Google\Ads\GoogleAds\V22\Resources\Recommendation;
+use Google\Ads\GoogleAds\V22\Resources\Recommendation\CampaignBudgetRecommendation;
+use Google\Ads\GoogleAds\V22\Resources\Recommendation\CampaignBudgetRecommendation\CampaignBudgetRecommendationOption;
+use Google\Ads\GoogleAds\V22\Resources\Recommendation\RecommendationImpact;
+use Google\Ads\GoogleAds\V22\Resources\Recommendation\RecommendationMetrics;
+use Google\Ads\GoogleAds\V22\Resources\ShoppingPerformanceView;
+use Google\Ads\GoogleAds\V22\Services\Client\ConversionActionServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\CustomerServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\GoogleAdsServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\ProductLinkInvitationServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\RecommendationServiceClient;
+use Google\Ads\GoogleAds\V22\Services\GenerateRecommendationsResponse;
+use Google\Ads\GoogleAds\V22\Services\GoogleAdsRow;
+use Google\Ads\GoogleAds\V22\Services\ListAccessibleCustomersResponse;
+use Google\Ads\GoogleAds\V22\Services\MutateCampaignResult;
+use Google\Ads\GoogleAds\V22\Services\MutateLabelResult;
+use Google\Ads\GoogleAds\V22\Services\MutateConversionActionResult;
+use Google\Ads\GoogleAds\V22\Services\MutateConversionActionsRequest;
+use Google\Ads\GoogleAds\V22\Services\MutateConversionActionsResponse;
+use Google\Ads\GoogleAds\V22\Services\MutateGoogleAdsRequest;
+use Google\Ads\GoogleAds\V22\Services\MutateGoogleAdsResponse;
+use Google\Ads\GoogleAds\V22\Services\MutateOperationResponse;
+use Google\Ads\GoogleAds\V22\Services\MutateOperation;
+use Google\Ads\GoogleAds\V22\Services\MutateAssetGroupResult;
+use Google\Ads\GoogleAds\V22\Services\MutateAssetResult;
+use Google\Ads\GoogleAds\V22\Services\SearchGoogleAdsResponse;
use Google\ApiCore\ApiException;
use Google\ApiCore\Page;
use Google\ApiCore\PagedListResponse;
diff --git a/tests/Unit/API/Google/AdsAssetGroupAssetTest.php b/tests/Unit/API/Google/AdsAssetGroupAssetTest.php
index 69247a8622..51c39f7b37 100644
--- a/tests/Unit/API/Google/AdsAssetGroupAssetTest.php
+++ b/tests/Unit/API/Google/AdsAssetGroupAssetTest.php
@@ -10,8 +10,8 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Tools\HelperTrait\GoogleAdsClientTrait;
use PHPUnit\Framework\MockObject\MockObject;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AssetFieldType;
-use Google\Ads\GoogleAds\V21\Enums\AssetTypeEnum\AssetType;
-use Google\Ads\GoogleAds\Util\V21\ResourceNames;
+use Google\Ads\GoogleAds\V22\Enums\AssetTypeEnum\AssetType;
+use Google\Ads\GoogleAds\Util\V22\ResourceNames;
defined( 'ABSPATH' ) || exit;
diff --git a/tests/Unit/API/Google/AdsAssetGroupTest.php b/tests/Unit/API/Google/AdsAssetGroupTest.php
index bb4e192ea5..7099054553 100644
--- a/tests/Unit/API/Google/AdsAssetGroupTest.php
+++ b/tests/Unit/API/Google/AdsAssetGroupTest.php
@@ -8,9 +8,9 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Framework\UnitTest;
use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Tools\HelperTrait\GoogleAdsClientTrait;
-use Google\Ads\GoogleAds\V21\Enums\AssetGroupStatusEnum\AssetGroupStatus;
-use Google\Ads\GoogleAds\V21\Enums\ListingGroupFilterListingSourceEnum\ListingGroupFilterListingSource;
-use Google\Ads\GoogleAds\V21\Enums\ListingGroupFilterTypeEnum\ListingGroupFilterType;
+use Google\Ads\GoogleAds\V22\Enums\AssetGroupStatusEnum\AssetGroupStatus;
+use Google\Ads\GoogleAds\V22\Enums\ListingGroupFilterListingSourceEnum\ListingGroupFilterListingSource;
+use Google\Ads\GoogleAds\V22\Enums\ListingGroupFilterTypeEnum\ListingGroupFilterType;
use PHPUnit\Framework\MockObject\MockObject;
use Google\ApiCore\ApiException;
use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
diff --git a/tests/Unit/API/Google/AdsAssetTest.php b/tests/Unit/API/Google/AdsAssetTest.php
index ad9a9fa0e7..66726b610f 100644
--- a/tests/Unit/API/Google/AdsAssetTest.php
+++ b/tests/Unit/API/Google/AdsAssetTest.php
@@ -10,7 +10,7 @@
use PHPUnit\Framework\MockObject\MockObject;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AssetFieldType;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\CallToActionType;
-use Google\Ads\GoogleAds\Util\V21\ResourceNames;
+use Google\Ads\GoogleAds\Util\V22\ResourceNames;
use Automattic\WooCommerce\GoogleListingsAndAds\Proxies\WP;
use Exception;
use WP_Error;
diff --git a/tests/Unit/API/Google/AdsCampaignCriterionTest.php b/tests/Unit/API/Google/AdsCampaignCriterionTest.php
index 8eba9904cd..17be13ccf4 100644
--- a/tests/Unit/API/Google/AdsCampaignCriterionTest.php
+++ b/tests/Unit/API/Google/AdsCampaignCriterionTest.php
@@ -6,7 +6,7 @@
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsCampaignCriterion;
use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Framework\UnitTest;
use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Tools\HelperTrait\GoogleAdsClientTrait;
-use Google\Ads\GoogleAds\V21\Enums\CampaignCriterionStatusEnum\CampaignCriterionStatus;
+use Google\Ads\GoogleAds\V22\Enums\CampaignCriterionStatusEnum\CampaignCriterionStatus;
defined( 'ABSPATH' ) || exit;
diff --git a/tests/Unit/API/Google/AdsConversionActionTest.php b/tests/Unit/API/Google/AdsConversionActionTest.php
index ba4c6ec022..5f6056b96b 100644
--- a/tests/Unit/API/Google/AdsConversionActionTest.php
+++ b/tests/Unit/API/Google/AdsConversionActionTest.php
@@ -8,7 +8,7 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Framework\UnitTest;
use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Tools\HelperTrait\GoogleAdsClientTrait;
use Exception;
-use Google\Ads\GoogleAds\V21\Enums\ConversionActionStatusEnum\ConversionActionStatus;
+use Google\Ads\GoogleAds\V22\Enums\ConversionActionStatusEnum\ConversionActionStatus;
use Google\ApiCore\ApiException;
use PHPUnit\Framework\MockObject\MockObject;
diff --git a/tests/Unit/API/Google/AdsTest.php b/tests/Unit/API/Google/AdsTest.php
index ce9ad5be5c..7dea294866 100644
--- a/tests/Unit/API/Google/AdsTest.php
+++ b/tests/Unit/API/Google/AdsTest.php
@@ -10,10 +10,10 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Framework\UnitTest;
use Automattic\WooCommerce\GoogleListingsAndAds\Tests\Tools\HelperTrait\GoogleAdsClientTrait;
use Exception;
-use Google\Ads\GoogleAds\V21\Enums\BillingSetupStatusEnum\BillingSetupStatus as AdsBillingSetupStatus;
-use Google\Ads\GoogleAds\V21\Enums\ProductLinkInvitationStatusEnum\ProductLinkInvitationStatus;
-use Google\Ads\GoogleAds\V21\Resources\MerchantCenterLinkInvitationIdentifier;
-use Google\Ads\GoogleAds\V21\Resources\ProductLinkInvitation;
+use Google\Ads\GoogleAds\V22\Enums\BillingSetupStatusEnum\BillingSetupStatus as AdsBillingSetupStatus;
+use Google\Ads\GoogleAds\V22\Enums\ProductLinkInvitationStatusEnum\ProductLinkInvitationStatus;
+use Google\Ads\GoogleAds\V22\Resources\MerchantCenterLinkInvitationIdentifier;
+use Google\Ads\GoogleAds\V22\Resources\ProductLinkInvitation;
use Google\ApiCore\ApiException;
use PHPUnit\Framework\MockObject\MockObject;
diff --git a/tests/Unit/API/Google/MerchantMetricsTest.php b/tests/Unit/API/Google/MerchantMetricsTest.php
index 8713929ac5..980d6720bf 100644
--- a/tests/Unit/API/Google/MerchantMetricsTest.php
+++ b/tests/Unit/API/Google/MerchantMetricsTest.php
@@ -16,9 +16,9 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\SearchResponse;
use Automattic\WooCommerce\GoogleListingsAndAds\Vendor\Google\Service\ShoppingContent\Resource\Reports;
use DateTime;
-use Google\Ads\GoogleAds\V21\Common\Metrics as AdMetrics;
-use Google\Ads\GoogleAds\V21\Services\GoogleAdsRow;
-use Google\Ads\GoogleAds\V21\Services\Client\GoogleAdsServiceClient;
+use Google\Ads\GoogleAds\V22\Common\Metrics as AdMetrics;
+use Google\Ads\GoogleAds\V22\Services\GoogleAdsRow;
+use Google\Ads\GoogleAds\V22\Services\Client\GoogleAdsServiceClient;
use Google\ApiCore\Page;
use Google\ApiCore\PagedListResponse;
use PHPUnit\Framework\MockObject\MockObject;
From 5a9ac176e6c678a74d3f8a105ee33054e7ba8cf5 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Thu, 15 Jan 2026 16:13:58 +0400
Subject: [PATCH 018/123] feat(jest): Update moduleNameMapper regex for SVG
handling
---
jest.config.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/jest.config.js b/jest.config.js
index e642a11e17..f03a9dd236 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -12,7 +12,7 @@ module.exports = {
],
moduleNameMapper: {
'\\.(png|jpg)$': '/tests/mocks/assets/imageMock.js',
- '\\.svg$': '/tests/mocks/assets/svgrMock.js',
+ '\\.svg(\\?inline)?$': '/tests/mocks/assets/svgrMock.js',
'\\.scss$': '/tests/mocks/assets/styleMock.js',
// Transform our `~/` alias.
'^~/(.*)$': '/js/src/$1',
From 41301247146a5094a19dfa093d08ccf380048a7a Mon Sep 17 00:00:00 2001
From: asvinb
Date: Thu, 15 Jan 2026 16:53:06 +0400
Subject: [PATCH 019/123] Update Jest config and mock SVG handling
---
jest.config.js | 3 ++-
.../asset-group/asset-group-editor/texts-editor.js | 1 +
tests/e2e/utils/pages/create-campaign.js | 12 +++++++++---
tests/mocks/assets/svgrMock.js | 14 +++++++++-----
4 files changed, 21 insertions(+), 9 deletions(-)
diff --git a/jest.config.js b/jest.config.js
index f03a9dd236..7a9808961d 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -12,7 +12,8 @@ module.exports = {
],
moduleNameMapper: {
'\\.(png|jpg)$': '/tests/mocks/assets/imageMock.js',
- '\\.svg(\\?inline)?$': '/tests/mocks/assets/svgrMock.js',
+ '\\.svg\\?inline$': '/tests/mocks/assets/svgrMock.js',
+ '\\.svg$': '/tests/mocks/assets/svgrMock.js',
'\\.scss$': '/tests/mocks/assets/styleMock.js',
// Transform our `~/` alias.
'^~/(.*)$': '/js/src/$1',
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
index 1c365dce5e..2d1f7deabc 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
@@ -253,6 +253,7 @@ export default function TextsEditor( {
minNumberOfTexts > 0 &&
minNumberOfTexts === maxNumberOfTexts
}
+ aria-label={ __( 'Add text', 'google-listings-and-ads' ) }
disabled={
maxNumberOfTexts > 0 && texts.length >= maxNumberOfTexts
}
diff --git a/tests/e2e/utils/pages/create-campaign.js b/tests/e2e/utils/pages/create-campaign.js
index e8770f5ee3..3abd5fc1ac 100644
--- a/tests/e2e/utils/pages/create-campaign.js
+++ b/tests/e2e/utils/pages/create-campaign.js
@@ -124,7 +124,9 @@ export default class CreateCampaignPage extends MockRequests {
* @return {import('@playwright/test').Locator} Get Add headline button.
*/
getAddHeadlineButton() {
- return this.page.getByRole( 'button', { name: 'Add headline' } );
+ return this.page
+ .getByRole( 'button', { name: 'Add text' } )
+ .filter( { hasText: 'Add headline' } );
}
/**
@@ -133,7 +135,9 @@ export default class CreateCampaignPage extends MockRequests {
* @return {import('@playwright/test').Locator} Get Add long headline button.
*/
getAddLongHeadlineButton() {
- return this.page.getByRole( 'button', { name: 'Add long headline' } );
+ return this.page
+ .getByRole( 'button', { name: 'Add text' } )
+ .filter( { hasText: 'Add long headline' } );
}
/**
@@ -142,7 +146,9 @@ export default class CreateCampaignPage extends MockRequests {
* @return {import('@playwright/test').Locator} Get Add description button.
*/
getAddDescriptionButton() {
- return this.page.getByRole( 'button', { name: 'Add description' } );
+ return this.page
+ .getByRole( 'button', { name: 'Add text' } )
+ .filter( { hasText: 'Add description' } );
}
/**
diff --git a/tests/mocks/assets/svgrMock.js b/tests/mocks/assets/svgrMock.js
index 99b5ffa206..146f6be6dd 100644
--- a/tests/mocks/assets/svgrMock.js
+++ b/tests/mocks/assets/svgrMock.js
@@ -1,10 +1,14 @@
/**
* External dependencies
*/
-import { forwardRef } from '@wordpress/element';
+import { createElement, forwardRef } from '@wordpress/element';
-export const ReactComponent = forwardRef( ( props, ref ) => (
-
-) );
+const SvgMock = forwardRef( ( props, ref ) =>
+ createElement( 'svg', { ref, ...props } )
+);
-export default 'SvgrURL';
+// Common SVGR compatibility: named export ReactComponent
+export const ReactComponent = SvgMock;
+
+// Default export should be the component for `?inline` usage
+export default SvgMock;
From 70d9a5c9cd9f958de4f9171698f4647ae1178ddf Mon Sep 17 00:00:00 2001
From: asvinb
Date: Thu, 15 Jan 2026 17:10:40 +0400
Subject: [PATCH 020/123] refactor(jest): Simplify svg handling in
moduleNameMapper
---
jest.config.js | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/jest.config.js b/jest.config.js
index 7a9808961d..f03a9dd236 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -12,8 +12,7 @@ module.exports = {
],
moduleNameMapper: {
'\\.(png|jpg)$': '/tests/mocks/assets/imageMock.js',
- '\\.svg\\?inline$': '/tests/mocks/assets/svgrMock.js',
- '\\.svg$': '/tests/mocks/assets/svgrMock.js',
+ '\\.svg(\\?inline)?$': '/tests/mocks/assets/svgrMock.js',
'\\.scss$': '/tests/mocks/assets/styleMock.js',
// Transform our `~/` alias.
'^~/(.*)$': '/js/src/$1',
From 7cc51119d8359564977d2c72f23a39a4eb272ea8 Mon Sep 17 00:00:00 2001
From: James Morrison
Date: Thu, 15 Jan 2026 13:12:19 +0000
Subject: [PATCH 021/123] Add AdsAssetGenerationService for AI asset
generation.
---
src/Ads/AdsAssetGenerationService.php | 237 ++++++++++++++++++
src/Google/Ads/ServiceClientFactoryTrait.php | 8 +
.../GoogleServiceProvider.php | 3 +
.../HelperTrait/GoogleAdsClientTrait.php | 82 ++++++
.../Ads/AdsAssetGenerationServiceTest.php | 198 +++++++++++++++
5 files changed, 528 insertions(+)
create mode 100644 src/Ads/AdsAssetGenerationService.php
create mode 100644 tests/Unit/Ads/AdsAssetGenerationServiceTest.php
diff --git a/src/Ads/AdsAssetGenerationService.php b/src/Ads/AdsAssetGenerationService.php
new file mode 100644
index 0000000000..352448795e
--- /dev/null
+++ b/src/Ads/AdsAssetGenerationService.php
@@ -0,0 +1,237 @@
+ AssetFieldType::HEADLINE,
+ 'LONG_HEADLINE' => AssetFieldType::LONG_HEADLINE,
+ 'DESCRIPTION' => AssetFieldType::DESCRIPTION,
+ 'MARKETING_IMAGE' => AssetFieldType::MARKETING_IMAGE,
+ 'SQUARE_MARKETING_IMAGE' => AssetFieldType::SQUARE_MARKETING_IMAGE,
+ 'PORTRAIT_MARKETING_IMAGE' => AssetFieldType::PORTRAIT_MARKETING_IMAGE,
+ ];
+
+ /**
+ * AdsAssetGenerationService constructor.
+ *
+ * @param GoogleAdsClient $client The Google Ads client.
+ */
+ public function __construct( GoogleAdsClient $client ) {
+ $this->client = $client;
+ }
+
+ /**
+ * Generate text assets using Google's AI.
+ *
+ * @param array $args {
+ * Optional. Arguments for generating text assets.
+ *
+ * @type string $final_url The final URL - defaults to the Site URL.
+ * @type array $asset_field_types Can be one or more of: HEADLINE, LONG_HEADLINE, DESCRIPTION.
+ * }
+ * @return array Array of generated text objects with 'text' and 'type' keys.
+ * @throws Exception If the text assets can't be generated.
+ */
+ public function generate_text( array $args = [] ): array {
+ $customer_id = $this->options->get_ads_id();
+ if ( empty( $customer_id ) ) {
+ throw new Exception( __( 'Ads account ID is required.', 'google-listings-and-ads' ) );
+ }
+
+ $final_url = $args['final_url'] ?? $this->get_site_url();
+
+ // Convert asset field types from uppercase strings to enum numbers.
+ $asset_field_types = $this->convert_text_types_to_enums( $args['asset_field_types'] ?? [] );
+
+ $request = new GenerateTextRequest(
+ [
+ 'customer_id' => $customer_id,
+ 'final_url' => $final_url,
+ 'advertising_channel_type' => AdvertisingChannelType::PERFORMANCE_MAX,
+ 'asset_field_types' => $asset_field_types,
+ ]
+ );
+
+ try {
+ $service_client = $this->client->getAssetGenerationServiceClient();
+ $response = $service_client->generateTextAssets( $request );
+
+ $results = [];
+ foreach ( $response->getTextAssets() as $text_asset ) {
+ $asset_field_type_number = $text_asset->getAssetFieldType();
+ $asset_field_type_label = AssetFieldType::label( $asset_field_type_number );
+ $results[] = [
+ 'text' => $text_asset->getText(),
+ 'type' => AssetFieldType::name( $asset_field_type_label ),
+ ];
+ }
+
+ return $results;
+ } catch ( ApiException $e ) {
+ do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
+ throw new Exception( __( 'Unable to generate text assets.', 'google-listings-and-ads' ) . ' ' . $e->getMessage(), $e->getCode() );
+ }
+ }
+
+ /**
+ * Generate image assets using Google's AI.
+ *
+ * @param array $args {
+ * Optional. Arguments for generating image assets.
+ *
+ * @type string $final_url The final URL - defaults to the Site URL.
+ * @type array $asset_field_types Can be one or more of: MARKETING_IMAGE, SQUARE_MARKETING_IMAGE, PORTRAIT_MARKETING_IMAGE.
+ * }
+ * @return array Array of generated image objects with 'temporary_image_url' and 'type' keys.
+ * @throws Exception If the image assets can't be generated.
+ */
+ public function generate_images( array $args = [] ): array {
+ $customer_id = $this->options->get_ads_id();
+ if ( empty( $customer_id ) ) {
+ throw new Exception( __( 'Ads account ID is required.', 'google-listings-and-ads' ) );
+ }
+
+ $final_url = $args['final_url'] ?? $this->get_site_url();
+
+ // Convert asset field types from uppercase strings to enum numbers (if provided).
+ $asset_field_types = [];
+ if ( ! empty( $args['asset_field_types'] ) ) {
+ $asset_field_types = $this->convert_image_types_to_enums( $args['asset_field_types'] );
+ }
+
+ $request_data = [
+ 'customer_id' => $customer_id,
+ 'generation_type' => 'final_url_generation',
+ 'advertising_channel_type' => AdvertisingChannelType::PERFORMANCE_MAX,
+ ];
+
+ // Add final_url_generation_input.
+ $request_data['final_url_generation_input'] = new FinalUrlImageGenerationInput(
+ [
+ 'final_url' => $final_url,
+ ]
+ );
+
+ // Add asset_field_types only if provided.
+ if ( ! empty( $asset_field_types ) ) {
+ $request_data['asset_field_types'] = $asset_field_types;
+ }
+
+ $request = new GenerateImagesRequest( $request_data );
+
+ try {
+ $service_client = $this->client->getAssetGenerationServiceClient();
+ $response = $service_client->generateImages( $request );
+
+ $results = [];
+ foreach ( $response->getImageAssets() as $image_asset ) {
+ $asset_field_type_number = $image_asset->getAssetFieldType();
+ $asset_field_type_label = AssetFieldType::label( $asset_field_type_number );
+ $results[] = [
+ 'temporary_image_url' => $image_asset->getTemporaryImageUrl(),
+ 'type' => AssetFieldType::name( $asset_field_type_label ),
+ ];
+ }
+
+ return $results;
+ } catch ( ApiException $e ) {
+ do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
+ throw new Exception( __( 'Unable to generate image assets.', 'google-listings-and-ads' ) . ' ' . $e->getMessage(), $e->getCode() );
+ }
+ }
+
+ /**
+ * Convert text asset field types from uppercase strings to enum numbers.
+ *
+ * @param array $types Array of uppercase type strings (HEADLINE, LONG_HEADLINE, DESCRIPTION).
+ * @return array Array of enum numbers. Defaults to all text types if empty.
+ */
+ protected function convert_text_types_to_enums( array $types ): array {
+ if ( empty( $types ) ) {
+ // Default to all text types.
+ return [
+ AssetFieldType::number( AssetFieldType::HEADLINE ),
+ AssetFieldType::number( AssetFieldType::LONG_HEADLINE ),
+ AssetFieldType::number( AssetFieldType::DESCRIPTION ),
+ ];
+ }
+
+ $enums = [];
+ foreach ( $types as $type ) {
+ if ( ! isset( self::TYPE_MAPPING[ $type ] ) ) {
+ continue;
+ }
+
+ $internal_type = self::TYPE_MAPPING[ $type ];
+ // Only include text types.
+ if ( in_array( $internal_type, [ AssetFieldType::HEADLINE, AssetFieldType::LONG_HEADLINE, AssetFieldType::DESCRIPTION ], true ) ) {
+ $enums[] = AssetFieldType::number( $internal_type );
+ }
+ }
+
+ return $enums;
+ }
+
+ /**
+ * Convert image asset field types from uppercase strings to enum numbers.
+ *
+ * @param array $types Array of uppercase type strings (MARKETING_IMAGE, SQUARE_MARKETING_IMAGE, PORTRAIT_MARKETING_IMAGE).
+ * @return array Array of enum numbers.
+ */
+ protected function convert_image_types_to_enums( array $types ): array {
+ $enums = [];
+ foreach ( $types as $type ) {
+ if ( ! isset( self::TYPE_MAPPING[ $type ] ) ) {
+ continue;
+ }
+
+ $internal_type = self::TYPE_MAPPING[ $type ];
+ // Only include image types.
+ if ( in_array( $internal_type, [ AssetFieldType::MARKETING_IMAGE, AssetFieldType::SQUARE_MARKETING_IMAGE, AssetFieldType::PORTRAIT_MARKETING_IMAGE ], true ) ) {
+ $enums[] = AssetFieldType::number( $internal_type );
+ }
+ }
+
+ return $enums;
+ }
+}
diff --git a/src/Google/Ads/ServiceClientFactoryTrait.php b/src/Google/Ads/ServiceClientFactoryTrait.php
index 2b9e867bfa..87aac4049d 100644
--- a/src/Google/Ads/ServiceClientFactoryTrait.php
+++ b/src/Google/Ads/ServiceClientFactoryTrait.php
@@ -32,6 +32,7 @@
use Google\Ads\GoogleAds\V20\Services\Client\GoogleAdsServiceClient;
use Google\Ads\GoogleAds\V20\Services\Client\ProductLinkInvitationServiceClient;
use Google\Ads\GoogleAds\V20\Services\Client\RecommendationServiceClient;
+use Google\Ads\GoogleAds\V22\Services\Client\AssetGenerationServiceClient;
/**
* Contains service client factory methods.
@@ -209,4 +210,11 @@ public function getProductLinkInvitationServiceClient(): ProductLinkInvitationSe
public function getRecommendationServiceClient(): RecommendationServiceClient {
return new RecommendationServiceClient( $this->getGoogleAdsClientOptions() );
}
+
+ /**
+ * @return AssetGenerationServiceClient
+ */
+ public function getAssetGenerationServiceClient(): AssetGenerationServiceClient {
+ return new AssetGenerationServiceClient( $this->getGoogleAdsClientOptions() );
+ }
}
diff --git a/src/Internal/DependencyManagement/GoogleServiceProvider.php b/src/Internal/DependencyManagement/GoogleServiceProvider.php
index 0ddf8f7321..ce90f3affe 100644
--- a/src/Internal/DependencyManagement/GoogleServiceProvider.php
+++ b/src/Internal/DependencyManagement/GoogleServiceProvider.php
@@ -4,6 +4,7 @@
namespace Automattic\WooCommerce\GoogleListingsAndAds\Internal\DependencyManagement;
use Automattic\Jetpack\Connection\Manager;
+use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AdsAssetGenerationService;
use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AdsRecommendationsService;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsAssetGroup;
@@ -87,6 +88,7 @@ class GoogleServiceProvider extends AbstractServiceProvider {
AdsConversionAction::class => true,
AdsReport::class => true,
AdsRecommendationsService::class => true,
+ AdsAssetGenerationService::class => true,
AdsAssetGroupAsset::class => true,
AdsAsset::class => true,
BudgetMetrics::class => true,
@@ -125,6 +127,7 @@ public function register(): void {
$this->share( AdsConversionAction::class, GoogleAdsClient::class );
$this->share( AdsReport::class, GoogleAdsClient::class );
$this->share( AdsRecommendationsService::class, GoogleAdsClient::class );
+ $this->share( AdsAssetGenerationService::class, GoogleAdsClient::class );
$this->share( BudgetMetrics::class, GoogleAdsClient::class );
$this->share( BudgetRecommendations::class, GoogleAdsClient::class );
diff --git a/tests/Tools/HelperTrait/GoogleAdsClientTrait.php b/tests/Tools/HelperTrait/GoogleAdsClientTrait.php
index 2b9dc2a68c..a462d03fd7 100644
--- a/tests/Tools/HelperTrait/GoogleAdsClientTrait.php
+++ b/tests/Tools/HelperTrait/GoogleAdsClientTrait.php
@@ -94,6 +94,9 @@ trait GoogleAdsClientTrait {
/** @var MockObject|GoogleAdsServiceClient $service_client */
protected $service_client;
+ /** @var MockObject|\Google\Ads\GoogleAds\V22\Services\Client\AssetGenerationServiceClient $asset_generation_service */
+ protected $asset_generation_service;
+
/** @var int $ads_id */
protected $ads_id;
@@ -115,6 +118,9 @@ protected function ads_client_setup() {
$this->recommendation_service = $this->createMock( RecommendationServiceClient::class );
$this->client->method( 'getRecommendationServiceClient' )->willReturn( $this->recommendation_service );
+
+ $this->asset_generation_service = $this->createMock( \Google\Ads\GoogleAds\V22\Services\Client\AssetGenerationServiceClient::class );
+ $this->client->method( 'getAssetGenerationServiceClient' )->willReturn( $this->asset_generation_service );
}
/**
@@ -1157,4 +1163,80 @@ protected function generate_location_ids_mock( array $locations ) {
$this->generate_ads_query_mock( array_values( $locations ) );
}
+
+ /**
+ * Generates a mocked response for text asset generation.
+ *
+ * @param array $text_assets Array of text assets with 'text' and 'type' keys (type in uppercase like 'HEADLINE').
+ */
+ protected function generate_text_assets_mock( array $text_assets ) {
+ $type_mapping = [
+ 'HEADLINE' => AssetFieldType::HEADLINE,
+ 'LONG_HEADLINE' => AssetFieldType::LONG_HEADLINE,
+ 'DESCRIPTION' => AssetFieldType::DESCRIPTION,
+ ];
+
+ $text_asset_objects = [];
+ foreach ( $text_assets as $asset ) {
+ $text_asset = $this->createMock( \Google\Ads\GoogleAds\V22\Services\TextAsset::class );
+ $text_asset->method( 'getText' )->willReturn( $asset['text'] );
+ // Convert uppercase type string to enum number.
+ $type_label = $type_mapping[ $asset['type'] ] ?? AssetFieldType::HEADLINE;
+ $type_number = AssetFieldType::number( $type_label );
+ $text_asset->method( 'getAssetFieldType' )->willReturn( $type_number );
+ $text_asset_objects[] = $text_asset;
+ }
+
+ $response = $this->createMock( \Google\Ads\GoogleAds\V22\Services\GenerateTextResponse::class );
+ $response->method( 'getTextAssets' )->willReturn( $text_asset_objects );
+
+ $this->asset_generation_service->method( 'generateTextAssets' )->willReturn( $response );
+ }
+
+ /**
+ * Generates a mocked exception when text assets are requested.
+ *
+ * @param ApiException $exception
+ */
+ protected function generate_text_assets_mock_exception( ApiException $exception ) {
+ $this->asset_generation_service->method( 'generateTextAssets' )->willThrowException( $exception );
+ }
+
+ /**
+ * Generates a mocked response for image asset generation.
+ *
+ * @param array $image_assets Array of image assets with 'temporary_image_url' and 'type' keys (type in uppercase like 'MARKETING_IMAGE').
+ */
+ protected function generate_image_assets_mock( array $image_assets ) {
+ $type_mapping = [
+ 'MARKETING_IMAGE' => AssetFieldType::MARKETING_IMAGE,
+ 'SQUARE_MARKETING_IMAGE' => AssetFieldType::SQUARE_MARKETING_IMAGE,
+ 'PORTRAIT_MARKETING_IMAGE' => AssetFieldType::PORTRAIT_MARKETING_IMAGE,
+ ];
+
+ $image_asset_objects = [];
+ foreach ( $image_assets as $asset ) {
+ $image_asset = $this->createMock( \Google\Ads\GoogleAds\V22\Services\ImageAsset::class );
+ $image_asset->method( 'getTemporaryImageUrl' )->willReturn( $asset['temporary_image_url'] );
+ // Convert uppercase type string to enum number.
+ $type_label = $type_mapping[ $asset['type'] ] ?? AssetFieldType::MARKETING_IMAGE;
+ $type_number = AssetFieldType::number( $type_label );
+ $image_asset->method( 'getAssetFieldType' )->willReturn( $type_number );
+ $image_asset_objects[] = $image_asset;
+ }
+
+ $response = $this->createMock( \Google\Ads\GoogleAds\V22\Services\GenerateImagesResponse::class );
+ $response->method( 'getImageAssets' )->willReturn( $image_asset_objects );
+
+ $this->asset_generation_service->method( 'generateImages' )->willReturn( $response );
+ }
+
+ /**
+ * Generates a mocked exception when image assets are requested.
+ *
+ * @param ApiException $exception
+ */
+ protected function generate_image_assets_mock_exception( ApiException $exception ) {
+ $this->asset_generation_service->method( 'generateImages' )->willThrowException( $exception );
+ }
}
diff --git a/tests/Unit/Ads/AdsAssetGenerationServiceTest.php b/tests/Unit/Ads/AdsAssetGenerationServiceTest.php
new file mode 100644
index 0000000000..f79759844a
--- /dev/null
+++ b/tests/Unit/Ads/AdsAssetGenerationServiceTest.php
@@ -0,0 +1,198 @@
+ads_client_setup();
+
+ $this->options = $this->createMock( OptionsInterface::class );
+ $this->service = new AdsAssetGenerationService( $this->client );
+ $this->service->set_options_object( $this->options );
+
+ $this->options->method( 'get_ads_id' )->willReturn( self::TEST_ADS_ID );
+ }
+
+ public function test_generate_text_with_defaults() {
+ $expected_text_assets = [
+ [
+ 'text' => 'Generated headline text example.',
+ 'type' => 'HEADLINE',
+ ],
+ [
+ 'text' => 'Generated long headline text example.',
+ 'type' => 'LONG_HEADLINE',
+ ],
+ [
+ 'text' => 'Generated description text example.',
+ 'type' => 'DESCRIPTION',
+ ],
+ ];
+
+ $this->generate_text_assets_mock( $expected_text_assets );
+
+ $result = $this->service->generate_text( [] );
+
+ $this->assertEquals( $expected_text_assets, $result );
+ }
+
+ public function test_generate_text_with_custom_final_url() {
+ $final_url = 'https://custom-url.com';
+ $expected_text_assets = [
+ [
+ 'text' => 'Custom headline',
+ 'type' => 'HEADLINE',
+ ],
+ ];
+
+ $this->generate_text_assets_mock( $expected_text_assets );
+
+ $result = $this->service->generate_text( [ 'final_url' => $final_url ] );
+
+ $this->assertEquals( $expected_text_assets, $result );
+ }
+
+ public function test_generate_text_with_specific_types() {
+ $expected_text_assets = [
+ [
+ 'text' => 'Headline only',
+ 'type' => 'HEADLINE',
+ ],
+ ];
+
+ $this->generate_text_assets_mock( $expected_text_assets );
+
+ $result = $this->service->generate_text( [ 'asset_field_types' => [ 'HEADLINE' ] ] );
+
+ $this->assertEquals( $expected_text_assets, $result );
+ }
+
+ public function test_generate_text_exception() {
+ $this->generate_text_assets_mock_exception(
+ new ApiException( 'API error', 7 )
+ );
+
+ $this->expectException( Exception::class );
+ $this->expectExceptionMessage( 'Unable to generate text assets' );
+
+ $this->service->generate_text( [] );
+ $this->assertEquals( 1, did_action( 'woocommerce_gla_ads_client_exception' ) );
+ }
+
+ public function test_generate_text_no_ads_id() {
+ $this->options->method( 'get_ads_id' )->willReturn( null );
+
+ $this->expectException( Exception::class );
+ $this->expectExceptionMessage( 'Ads account ID is required' );
+
+ $this->service->generate_text( [] );
+ }
+
+ public function test_generate_images_with_defaults() {
+ $expected_image_assets = [
+ [
+ 'temporary_image_url' => 'https://example.com/temporary_image_url-marketing.jpg',
+ 'type' => 'MARKETING_IMAGE',
+ ],
+ [
+ 'temporary_image_url' => 'https://example.com/temporary_image_url-square.jpg',
+ 'type' => 'SQUARE_MARKETING_IMAGE',
+ ],
+ [
+ 'temporary_image_url' => 'https://example.com/temporary_image_url-portrait.jpg',
+ 'type' => 'PORTRAIT_MARKETING_IMAGE',
+ ],
+ ];
+
+ $this->generate_image_assets_mock( $expected_image_assets );
+
+ $result = $this->service->generate_images( [] );
+
+ $this->assertEquals( $expected_image_assets, $result );
+ }
+
+ public function test_generate_images_with_custom_final_url() {
+ $final_url = 'https://custom-url.com';
+ $expected_image_assets = [
+ [
+ 'temporary_image_url' => 'https://example.com/custom-image.jpg',
+ 'type' => 'MARKETING_IMAGE',
+ ],
+ ];
+
+ $this->generate_image_assets_mock( $expected_image_assets );
+
+ $result = $this->service->generate_images( [ 'final_url' => $final_url ] );
+
+ $this->assertEquals( $expected_image_assets, $result );
+ }
+
+ public function test_generate_images_with_specific_types() {
+ $expected_image_assets = [
+ [
+ 'temporary_image_url' => 'https://example.com/marketing-image.jpg',
+ 'type' => 'MARKETING_IMAGE',
+ ],
+ ];
+
+ $this->generate_image_assets_mock( $expected_image_assets );
+
+ $result = $this->service->generate_images( [ 'asset_field_types' => [ 'MARKETING_IMAGE' ] ] );
+
+ $this->assertEquals( $expected_image_assets, $result );
+ }
+
+ public function test_generate_images_exception() {
+ $this->generate_image_assets_mock_exception(
+ new ApiException( 'API error', 7 )
+ );
+
+ $this->expectException( Exception::class );
+ $this->expectExceptionMessage( 'Unable to generate image assets' );
+
+ $this->service->generate_images( [] );
+ $this->assertEquals( 1, did_action( 'woocommerce_gla_ads_client_exception' ) );
+ }
+
+ public function test_generate_images_no_ads_id() {
+ $this->options->method( 'get_ads_id' )->willReturn( null );
+
+ $this->expectException( Exception::class );
+ $this->expectExceptionMessage( 'Ads account ID is required' );
+
+ $this->service->generate_images( [] );
+ }
+}
From 0cbc15e4bb75e275ffe3b0016cc591f8473004f6 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Thu, 15 Jan 2026 17:33:05 +0400
Subject: [PATCH 022/123] Update svgr mock component in tests
---
tests/mocks/assets/svgrMock.js | 14 +++++++++-----
1 file changed, 9 insertions(+), 5 deletions(-)
diff --git a/tests/mocks/assets/svgrMock.js b/tests/mocks/assets/svgrMock.js
index 99b5ffa206..146f6be6dd 100644
--- a/tests/mocks/assets/svgrMock.js
+++ b/tests/mocks/assets/svgrMock.js
@@ -1,10 +1,14 @@
/**
* External dependencies
*/
-import { forwardRef } from '@wordpress/element';
+import { createElement, forwardRef } from '@wordpress/element';
-export const ReactComponent = forwardRef( ( props, ref ) => (
-
-) );
+const SvgMock = forwardRef( ( props, ref ) =>
+ createElement( 'svg', { ref, ...props } )
+);
-export default 'SvgrURL';
+// Common SVGR compatibility: named export ReactComponent
+export const ReactComponent = SvgMock;
+
+// Default export should be the component for `?inline` usage
+export default SvgMock;
From 7392d7e56194ecd16e2ef5f8d14c66a4e18abdce Mon Sep 17 00:00:00 2001
From: James Morrison
Date: Thu, 15 Jan 2026 14:16:25 +0000
Subject: [PATCH 023/123] Add AI asset generation service and REST endpoints
---
.../Ads/AssetGenerationController.php | 249 +++++++++++++
.../RESTServiceProvider.php | 3 +
.../Ads/AssetGenerationControllerTest.php | 349 ++++++++++++++++++
3 files changed, 601 insertions(+)
create mode 100644 src/API/Site/Controllers/Ads/AssetGenerationController.php
create mode 100644 tests/Unit/API/Site/Controllers/Ads/AssetGenerationControllerTest.php
diff --git a/src/API/Site/Controllers/Ads/AssetGenerationController.php b/src/API/Site/Controllers/Ads/AssetGenerationController.php
new file mode 100644
index 0000000000..4c79d89eb6
--- /dev/null
+++ b/src/API/Site/Controllers/Ads/AssetGenerationController.php
@@ -0,0 +1,249 @@
+service = $service;
+ }
+
+ /**
+ * Register rest routes with WordPress.
+ */
+ public function register_routes(): void {
+ $this->register_route(
+ 'ads/assets/generate-text',
+ [
+ [
+ 'methods' => TransportMethods::CREATABLE,
+ 'callback' => $this->get_generate_text_callback(),
+ 'permission_callback' => $this->get_permission_callback(),
+ 'args' => $this->get_generate_text_params(),
+ ],
+ ]
+ );
+
+ $this->register_route(
+ 'ads/assets/generate-images',
+ [
+ [
+ 'methods' => TransportMethods::CREATABLE,
+ 'callback' => $this->get_generate_images_callback(),
+ 'permission_callback' => $this->get_permission_callback(),
+ 'args' => $this->get_generate_images_params(),
+ ],
+ ]
+ );
+ }
+
+ /**
+ * Get the parameters for the generate-text endpoint.
+ *
+ * @return array
+ */
+ protected function get_generate_text_params(): array {
+ return [
+ 'final_url' => [
+ 'description' => __( 'The final URL for asset generation', 'google-listings-and-ads' ),
+ 'type' => 'string',
+ 'sanitize_callback' => 'esc_url_raw',
+ 'validate_callback' => 'rest_validate_request_arg',
+ ],
+ 'types' => [
+ 'description' => __( 'Asset types to generate', 'google-listings-and-ads' ),
+ 'type' => 'array',
+ 'items' => [
+ 'type' => 'string',
+ 'enum' => [ 'headline', 'long_headline', 'description' ],
+ ],
+ 'sanitize_callback' => function ( $types ) {
+ return array_map( 'sanitize_text_field', $types );
+ },
+ 'validate_callback' => 'rest_validate_request_arg',
+ ],
+ ];
+ }
+
+ /**
+ * Get the parameters for the generate-images endpoint.
+ *
+ * @return array
+ */
+ protected function get_generate_images_params(): array {
+ return [
+ 'final_url' => [
+ 'description' => __( 'The final URL for asset generation', 'google-listings-and-ads' ),
+ 'type' => 'string',
+ 'sanitize_callback' => 'esc_url_raw',
+ 'validate_callback' => 'rest_validate_request_arg',
+ ],
+ 'types' => [
+ 'description' => __( 'Asset types to generate', 'google-listings-and-ads' ),
+ 'type' => 'array',
+ 'items' => [
+ 'type' => 'string',
+ 'enum' => [ 'marketing_image', 'square_marketing_image', 'portrait_marketing_image' ],
+ ],
+ 'sanitize_callback' => function ( $types ) {
+ return array_map( 'sanitize_text_field', $types );
+ },
+ 'validate_callback' => 'rest_validate_request_arg',
+ ],
+ ];
+ }
+
+ /**
+ * Get the callback function for the generate-text request.
+ *
+ * @return callable
+ */
+ protected function get_generate_text_callback(): callable {
+ return function ( Request $request ) {
+ try {
+ $final_url = $request->get_param( 'final_url' ) ?: $this->get_site_url();
+ $types = $request->get_param( 'types' ) ?: [ 'headline', 'long_headline', 'description' ];
+
+ // Convert lowercase types to uppercase for service.
+ $uppercase_types = $this->convert_types_to_uppercase( $types );
+
+ // Call service.
+ $items = $this->service->generate_text(
+ [
+ 'final_url' => $final_url,
+ 'asset_field_types' => $uppercase_types,
+ ]
+ );
+
+ // Format response with lowercase types.
+ return $this->format_response( $final_url, $items );
+ } catch ( Exception $e ) {
+ return $this->response_from_exception( $e );
+ }
+ };
+ }
+
+ /**
+ * Get the callback function for the generate-images request.
+ *
+ * @return callable
+ */
+ protected function get_generate_images_callback(): callable {
+ return function ( Request $request ) {
+ try {
+ $final_url = $request->get_param( 'final_url' ) ?: $this->get_site_url();
+ $types = $request->get_param( 'types' ) ?: [];
+
+ // Convert lowercase types to uppercase for service (if provided).
+ $uppercase_types = ! empty( $types ) ? $this->convert_types_to_uppercase( $types ) : [];
+
+ // Call service.
+ $args = [ 'final_url' => $final_url ];
+ if ( ! empty( $uppercase_types ) ) {
+ $args['asset_field_types'] = $uppercase_types;
+ }
+ $items = $this->service->generate_images( $args );
+
+ // Format response with lowercase types.
+ return $this->format_response( $final_url, $items );
+ } catch ( Exception $e ) {
+ return $this->response_from_exception( $e );
+ }
+ };
+ }
+
+ /**
+ * Convert types to uppercase.
+ *
+ * @param array $types Array of lowercase type strings.
+ * @return array Array of uppercase type strings.
+ */
+ protected function convert_types_to_uppercase( array $types ): array {
+ return array_map( 'strtoupper', $types );
+ }
+
+ /**
+ * Format the response with final_url and items.
+ *
+ * @param string $final_url The final URL.
+ * @param array $service_items Items from the service (with uppercase types).
+ * @return array Formatted response with lowercase types.
+ */
+ protected function format_response( string $final_url, array $service_items ): array {
+ $items = [];
+ foreach ( $service_items as $item ) {
+ $item['type'] = strtolower( $item['type'] );
+ $items[] = $item;
+ }
+
+ return [
+ 'final_url' => $final_url,
+ 'items' => $items,
+ ];
+ }
+
+ /**
+ * Get the item schema properties for the controller.
+ *
+ * @return array
+ */
+ protected function get_schema_properties(): array {
+ return [
+ 'final_url' => [
+ 'type' => 'string',
+ 'description' => __( 'The final URL used for generation', 'google-listings-and-ads' ),
+ 'context' => [ 'view' ],
+ 'readonly' => true,
+ ],
+ 'items' => [
+ 'type' => 'array',
+ 'description' => __( 'Generated assets', 'google-listings-and-ads' ),
+ 'context' => [ 'view' ],
+ 'readonly' => true,
+ ],
+ ];
+ }
+
+ /**
+ * Get the item schema name for the controller.
+ *
+ * Used for building the API response schema.
+ *
+ * @return string
+ */
+ protected function get_schema_title(): string {
+ return 'asset_generation';
+ }
+}
diff --git a/src/Internal/DependencyManagement/RESTServiceProvider.php b/src/Internal/DependencyManagement/RESTServiceProvider.php
index 70c044053d..cae384bdc2 100644
--- a/src/Internal/DependencyManagement/RESTServiceProvider.php
+++ b/src/Internal/DependencyManagement/RESTServiceProvider.php
@@ -5,6 +5,7 @@
use Automattic\Jetpack\Connection\Manager;
use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AccountService as AdsAccountService;
+use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AdsAssetGenerationService;
use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AssetSuggestionsService as AdsAssetSuggestionsService;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AdsCampaign;
@@ -23,6 +24,7 @@
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\ReportsController as AdsReportsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\SetupCompleteController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\AssetGroupController as AdsAssetGroupController;
+use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\AssetGenerationController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\AssetSuggestionsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads\RecommendationsController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\GTINMigrationController;
@@ -144,6 +146,7 @@ public function register(): void {
$this->share( DisconnectController::class );
$this->share( SetupCompleteController::class, MerchantMetrics::class );
$this->share( AssetSuggestionsController::class, AdsAssetSuggestionsService::class );
+ $this->share( AssetGenerationController::class, AdsAssetGenerationService::class );
$this->share( SyncableProductsCountController::class, JobRepository::class );
$this->share( PolicyComplianceCheckController::class, PolicyComplianceCheck::class );
$this->share( AttributeMappingDataController::class, AttributeMappingHelper::class );
diff --git a/tests/Unit/API/Site/Controllers/Ads/AssetGenerationControllerTest.php b/tests/Unit/API/Site/Controllers/Ads/AssetGenerationControllerTest.php
new file mode 100644
index 0000000000..25d51c46f3
--- /dev/null
+++ b/tests/Unit/API/Site/Controllers/Ads/AssetGenerationControllerTest.php
@@ -0,0 +1,349 @@
+service = $this->createMock( AdsAssetGenerationService::class );
+ $this->controller = new AssetGenerationController( $this->server, $this->service );
+ $this->controller->register();
+ }
+
+ public function test_generate_text_with_defaults() {
+ // Service expects uppercase types.
+ $this->service->expects( $this->once() )
+ ->method( 'generate_text' )
+ ->with(
+ [
+ 'final_url' => self::TEST_SITE_URL,
+ 'asset_field_types' => [ 'HEADLINE', 'LONG_HEADLINE', 'DESCRIPTION' ],
+ ]
+ )
+ ->willReturn(
+ [
+ [
+ 'text' => 'Test headline',
+ 'type' => 'HEADLINE',
+ ],
+ [
+ 'text' => 'Test long headline',
+ 'type' => 'LONG_HEADLINE',
+ ],
+ [
+ 'text' => 'Test description',
+ 'type' => 'DESCRIPTION',
+ ],
+ ]
+ );
+
+ $response = $this->do_request( self::ROUTE_GENERATE_TEXT, 'POST' );
+
+ $data = $response->get_data();
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertEquals( self::TEST_SITE_URL, $data['final_url'] );
+ $this->assertCount( 3, $data['items'] );
+ // Verify types are lowercase in response.
+ $this->assertEquals( 'headline', $data['items'][0]['type'] );
+ $this->assertEquals( 'long_headline', $data['items'][1]['type'] );
+ $this->assertEquals( 'description', $data['items'][2]['type'] );
+ $this->assertEquals( 'Test headline', $data['items'][0]['text'] );
+ }
+
+ public function test_generate_text_with_custom_url() {
+ $params = [
+ 'final_url' => 'https://custom-url.com',
+ ];
+
+ $this->service->expects( $this->once() )
+ ->method( 'generate_text' )
+ ->with(
+ [
+ 'final_url' => 'https://custom-url.com',
+ 'asset_field_types' => [ 'HEADLINE', 'LONG_HEADLINE', 'DESCRIPTION' ],
+ ]
+ )
+ ->willReturn(
+ [
+ [
+ 'text' => 'Custom headline',
+ 'type' => 'HEADLINE',
+ ],
+ ]
+ );
+
+ $response = $this->do_request( self::ROUTE_GENERATE_TEXT, 'POST', $params );
+
+ $data = $response->get_data();
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertEquals( 'https://custom-url.com', $data['final_url'] );
+ $this->assertEquals( 'headline', $data['items'][0]['type'] );
+ }
+
+ public function test_generate_text_with_specific_types() {
+ $params = [
+ 'types' => [ 'headline' ],
+ ];
+
+ $this->service->expects( $this->once() )
+ ->method( 'generate_text' )
+ ->with(
+ [
+ 'final_url' => self::TEST_SITE_URL,
+ 'asset_field_types' => [ 'HEADLINE' ],
+ ]
+ )
+ ->willReturn(
+ [
+ [
+ 'text' => 'Headline only',
+ 'type' => 'HEADLINE',
+ ],
+ ]
+ );
+
+ $response = $this->do_request( self::ROUTE_GENERATE_TEXT, 'POST', $params );
+
+ $data = $response->get_data();
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertCount( 1, $data['items'] );
+ $this->assertEquals( 'headline', $data['items'][0]['type'] );
+ }
+
+ public function test_generate_text_type_conversion() {
+ $params = [
+ 'types' => [ 'headline', 'description' ],
+ ];
+
+ // Verify lowercase input is converted to uppercase for service.
+ $this->service->expects( $this->once() )
+ ->method( 'generate_text' )
+ ->with(
+ $this->callback(
+ function ( $args ) {
+ return $args['asset_field_types'] === [ 'HEADLINE', 'DESCRIPTION' ];
+ }
+ )
+ )
+ ->willReturn(
+ [
+ [
+ 'text' => 'Test',
+ 'type' => 'HEADLINE',
+ ],
+ [
+ 'text' => 'Test',
+ 'type' => 'DESCRIPTION',
+ ],
+ ]
+ );
+
+ $response = $this->do_request( self::ROUTE_GENERATE_TEXT, 'POST', $params );
+
+ // Verify uppercase response is converted to lowercase.
+ $data = $response->get_data();
+ $this->assertEquals( 'headline', $data['items'][0]['type'] );
+ $this->assertEquals( 'description', $data['items'][1]['type'] );
+ }
+
+ public function test_generate_text_exception() {
+ $this->service
+ ->method( 'generate_text' )
+ ->willThrowException( new Exception( 'Service error', 500 ) );
+
+ $response = $this->do_request( self::ROUTE_GENERATE_TEXT, 'POST' );
+
+ $this->assertEquals( 'Service error', $response->get_data()['message'] );
+ $this->assertEquals( 500, $response->get_status() );
+ }
+
+ public function test_generate_images_with_defaults() {
+ // Service expects empty array for types (API generates all).
+ $this->service->expects( $this->once() )
+ ->method( 'generate_images' )
+ ->with(
+ [
+ 'final_url' => self::TEST_SITE_URL,
+ ]
+ )
+ ->willReturn(
+ [
+ [
+ 'temporary_image_url' => 'https://example.com/image-marketing.jpg',
+ 'type' => 'MARKETING_IMAGE',
+ ],
+ [
+ 'temporary_image_url' => 'https://example.com/image-square.jpg',
+ 'type' => 'SQUARE_MARKETING_IMAGE',
+ ],
+ [
+ 'temporary_image_url' => 'https://example.com/image-portrait.jpg',
+ 'type' => 'PORTRAIT_MARKETING_IMAGE',
+ ],
+ ]
+ );
+
+ $response = $this->do_request( self::ROUTE_GENERATE_IMAGES, 'POST' );
+
+ $data = $response->get_data();
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertEquals( self::TEST_SITE_URL, $data['final_url'] );
+ $this->assertCount( 3, $data['items'] );
+ // Verify types are lowercase in response.
+ $this->assertEquals( 'marketing_image', $data['items'][0]['type'] );
+ $this->assertEquals( 'square_marketing_image', $data['items'][1]['type'] );
+ $this->assertEquals( 'portrait_marketing_image', $data['items'][2]['type'] );
+ }
+
+ public function test_generate_images_with_custom_url() {
+ $params = [
+ 'final_url' => 'https://custom-url.com',
+ ];
+
+ $this->service->expects( $this->once() )
+ ->method( 'generate_images' )
+ ->with(
+ [
+ 'final_url' => 'https://custom-url.com',
+ ]
+ )
+ ->willReturn(
+ [
+ [
+ 'temporary_image_url' => 'https://example.com/custom-image.jpg',
+ 'type' => 'MARKETING_IMAGE',
+ ],
+ ]
+ );
+
+ $response = $this->do_request( self::ROUTE_GENERATE_IMAGES, 'POST', $params );
+
+ $data = $response->get_data();
+ $this->assertEquals( 200, $response->get_status() );
+ $this->assertEquals( 'https://custom-url.com', $data['final_url'] );
+ $this->assertEquals( 'marketing_image', $data['items'][0]['type'] );
+ }
+
+ public function test_generate_images_with_specific_types() {
+ $params = [
+ 'types' => [ 'marketing_image' ],
+ ];
+
+ // Service expects uppercase types.
+ $this->service->expects( $this->once() )
+ ->method( 'generate_images' )
+ ->with(
+ [
+ 'final_url' => self::TEST_SITE_URL,
+ 'asset_field_types' => [ 'MARKETING_IMAGE' ],
+ ]
+ )
+ ->willReturn(
+ [
+ [
+ 'temporary_image_url' => 'https://example.com/image.jpg',
+ 'type' => 'MARKETING_IMAGE',
+ ],
+ ]
+ );
+
+ $response = $this->do_request( self::ROUTE_GENERATE_IMAGES, 'POST', $params );
+
+ $data = $response->get_data();
+ $this->assertEquals( 200, $response->get_status() );
+ // Verify type is lowercase in response.
+ $this->assertEquals( 'marketing_image', $data['items'][0]['type'] );
+ }
+
+ public function test_generate_images_type_conversion() {
+ $params = [
+ 'types' => [ 'marketing_image', 'square_marketing_image' ],
+ ];
+
+ // Verify lowercase input is converted to uppercase for service.
+ $this->service->expects( $this->once() )
+ ->method( 'generate_images' )
+ ->with(
+ $this->callback(
+ function ( $args ) {
+ return $args['asset_field_types'] === [ 'MARKETING_IMAGE', 'SQUARE_MARKETING_IMAGE' ];
+ }
+ )
+ )
+ ->willReturn(
+ [
+ [
+ 'temporary_image_url' => 'https://example.com/image1.jpg',
+ 'type' => 'MARKETING_IMAGE',
+ ],
+ [
+ 'temporary_image_url' => 'https://example.com/image2.jpg',
+ 'type' => 'SQUARE_MARKETING_IMAGE',
+ ],
+ ]
+ );
+
+ $response = $this->do_request( self::ROUTE_GENERATE_IMAGES, 'POST', $params );
+
+ // Verify uppercase response is converted to lowercase.
+ $data = $response->get_data();
+ $this->assertEquals( 'marketing_image', $data['items'][0]['type'] );
+ $this->assertEquals( 'square_marketing_image', $data['items'][1]['type'] );
+ }
+
+ public function test_generate_images_exception() {
+ $this->service
+ ->method( 'generate_images' )
+ ->willThrowException( new Exception( 'Service error', 500 ) );
+
+ $response = $this->do_request( self::ROUTE_GENERATE_IMAGES, 'POST' );
+
+ $this->assertEquals( 'Service error', $response->get_data()['message'] );
+ $this->assertEquals( 500, $response->get_status() );
+ }
+
+ public function test_generate_text_without_permission() {
+ // Remove admin capabilities.
+ wp_set_current_user( 0 );
+
+ $response = $this->do_request( self::ROUTE_GENERATE_TEXT, 'POST' );
+
+ $this->assertEquals( 401, $response->get_status() );
+ }
+
+ public function test_generate_images_without_permission() {
+ // Remove admin capabilities.
+ wp_set_current_user( 0 );
+
+ $response = $this->do_request( self::ROUTE_GENERATE_IMAGES, 'POST' );
+
+ $this->assertEquals( 401, $response->get_status() );
+ }
+}
From dfe7fc0f63f5eef939a87fae85bba9a6f50d9a38 Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Thu, 15 Jan 2026 21:12:51 +0530
Subject: [PATCH 024/123] Use AssetAutomationSetting in place of
url_expansion_opt_out.
---
composer.lock | 627 ++++++++++++++++++++-------------
src/API/Google/AdsCampaign.php | 12 +-
2 files changed, 388 insertions(+), 251 deletions(-)
diff --git a/composer.lock b/composer.lock
index bee4987b18..526d4be5f4 100644
--- a/composer.lock
+++ b/composer.lock
@@ -908,16 +908,16 @@
},
{
"name": "league/iso3166",
- "version": "4.3.2",
+ "version": "4.4.0",
"source": {
"type": "git",
"url": "https://github.com/alcohol/iso3166.git",
- "reference": "5133fed7d54728222f4058702487dccedda20472"
+ "reference": "928ac7ecc569db9123a83ef5b1c6efc279e7cb49"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/alcohol/iso3166/zipball/5133fed7d54728222f4058702487dccedda20472",
- "reference": "5133fed7d54728222f4058702487dccedda20472",
+ "url": "https://api.github.com/repos/alcohol/iso3166/zipball/928ac7ecc569db9123a83ef5b1c6efc279e7cb49",
+ "reference": "928ac7ecc569db9123a83ef5b1c6efc279e7cb49",
"shasum": ""
},
"require": {
@@ -971,7 +971,7 @@
"type": "github"
}
],
- "time": "2024-10-10T07:39:24+00:00"
+ "time": "2026-01-02T09:49:36+00:00"
},
{
"name": "monolog/monolog",
@@ -1077,16 +1077,16 @@
},
{
"name": "paragonie/constant_time_encoding",
- "version": "v2.7.0",
+ "version": "v2.8.2",
"source": {
"type": "git",
"url": "https://github.com/paragonie/constant_time_encoding.git",
- "reference": "52a0d99e69f56b9ec27ace92ba56897fe6993105"
+ "reference": "e30811f7bc69e4b5b6d5783e712c06c8eabf0226"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/52a0d99e69f56b9ec27ace92ba56897fe6993105",
- "reference": "52a0d99e69f56b9ec27ace92ba56897fe6993105",
+ "url": "https://api.github.com/repos/paragonie/constant_time_encoding/zipball/e30811f7bc69e4b5b6d5783e712c06c8eabf0226",
+ "reference": "e30811f7bc69e4b5b6d5783e712c06c8eabf0226",
"shasum": ""
},
"require": {
@@ -1140,7 +1140,7 @@
"issues": "https://github.com/paragonie/constant_time_encoding/issues",
"source": "https://github.com/paragonie/constant_time_encoding"
},
- "time": "2024-05-08T12:18:48+00:00"
+ "time": "2025-09-24T15:12:37+00:00"
},
{
"name": "paragonie/random_compat",
@@ -1256,16 +1256,16 @@
},
{
"name": "phpseclib/phpseclib",
- "version": "3.0.43",
+ "version": "3.0.48",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
- "reference": "709ec107af3cb2f385b9617be72af8cf62441d02"
+ "reference": "64065a5679c50acb886e82c07aa139b0f757bb89"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/709ec107af3cb2f385b9617be72af8cf62441d02",
- "reference": "709ec107af3cb2f385b9617be72af8cf62441d02",
+ "url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/64065a5679c50acb886e82c07aa139b0f757bb89",
+ "reference": "64065a5679c50acb886e82c07aa139b0f757bb89",
"shasum": ""
},
"require": {
@@ -1346,7 +1346,7 @@
],
"support": {
"issues": "https://github.com/phpseclib/phpseclib/issues",
- "source": "https://github.com/phpseclib/phpseclib/tree/3.0.43"
+ "source": "https://github.com/phpseclib/phpseclib/tree/3.0.48"
},
"funding": [
{
@@ -1362,7 +1362,7 @@
"type": "tidelift"
}
],
- "time": "2024-12-14T21:12:59+00:00"
+ "time": "2025-12-15T11:51:42+00:00"
},
{
"name": "psr/cache",
@@ -2037,7 +2037,7 @@
},
{
"name": "symfony/polyfill-php73",
- "version": "v1.31.0",
+ "version": "v1.33.0",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-php73.git",
@@ -2055,8 +2055,8 @@
"type": "library",
"extra": {
"thanks": {
- "name": "symfony/polyfill",
- "url": "https://github.com/symfony/polyfill"
+ "url": "https://github.com/symfony/polyfill",
+ "name": "symfony/polyfill"
}
},
"autoload": {
@@ -2093,7 +2093,7 @@
"shim"
],
"support": {
- "source": "https://github.com/symfony/polyfill-php73/tree/v1.31.0"
+ "source": "https://github.com/symfony/polyfill-php73/tree/v1.33.0"
},
"funding": [
{
@@ -2104,6 +2104,10 @@
"url": "https://github.com/fabpot",
"type": "github"
},
+ {
+ "url": "https://github.com/nicolas-grekas",
+ "type": "github"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
@@ -2297,12 +2301,12 @@
},
"type": "library",
"extra": {
+ "thanks": {
+ "url": "https://github.com/symfony/contracts",
+ "name": "symfony/contracts"
+ },
"branch-alias": {
"dev-main": "2.5-dev"
- },
- "thanks": {
- "name": "symfony/contracts",
- "url": "https://github.com/symfony/contracts"
}
},
"autoload": {
@@ -2545,16 +2549,16 @@
},
{
"name": "dg/bypass-finals",
- "version": "v1.6.0",
+ "version": "v1.9.0",
"source": {
"type": "git",
"url": "https://github.com/dg/bypass-finals.git",
- "reference": "efe2fe04bae9f0de271dd462afc049067889e6d1"
+ "reference": "920a7da2f3c1422fd83f9ec4df007af53dc4018b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/dg/bypass-finals/zipball/efe2fe04bae9f0de271dd462afc049067889e6d1",
- "reference": "efe2fe04bae9f0de271dd462afc049067889e6d1",
+ "url": "https://api.github.com/repos/dg/bypass-finals/zipball/920a7da2f3c1422fd83f9ec4df007af53dc4018b",
+ "reference": "920a7da2f3c1422fd83f9ec4df007af53dc4018b",
"shasum": ""
},
"require": {
@@ -2573,8 +2577,8 @@
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause",
- "GPL-2.0",
- "GPL-3.0"
+ "GPL-2.0-only",
+ "GPL-3.0-only"
],
"authors": [
{
@@ -2592,9 +2596,9 @@
],
"support": {
"issues": "https://github.com/dg/bypass-finals/issues",
- "source": "https://github.com/dg/bypass-finals/tree/v1.6.0"
+ "source": "https://github.com/dg/bypass-finals/tree/v1.9.0"
},
- "time": "2023-11-19T22:19:30+00:00"
+ "time": "2025-01-16T00:46:05+00:00"
},
{
"name": "doctrine/instantiator",
@@ -2726,16 +2730,16 @@
},
{
"name": "gettext/gettext",
- "version": "v4.8.11",
+ "version": "v4.8.12",
"source": {
"type": "git",
"url": "https://github.com/php-gettext/Gettext.git",
- "reference": "b632aaf5e4579d0b2ae8bc61785e238bff4c5156"
+ "reference": "11af89ee6c087db3cf09ce2111a150bca5c46e12"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-gettext/Gettext/zipball/b632aaf5e4579d0b2ae8bc61785e238bff4c5156",
- "reference": "b632aaf5e4579d0b2ae8bc61785e238bff4c5156",
+ "url": "https://api.github.com/repos/php-gettext/Gettext/zipball/11af89ee6c087db3cf09ce2111a150bca5c46e12",
+ "reference": "11af89ee6c087db3cf09ce2111a150bca5c46e12",
"shasum": ""
},
"require": {
@@ -2787,7 +2791,7 @@
"support": {
"email": "oom@oscarotero.com",
"issues": "https://github.com/oscarotero/Gettext/issues",
- "source": "https://github.com/php-gettext/Gettext/tree/v4.8.11"
+ "source": "https://github.com/php-gettext/Gettext/tree/v4.8.12"
},
"funding": [
{
@@ -2803,20 +2807,20 @@
"type": "patreon"
}
],
- "time": "2023-08-14T15:15:05+00:00"
+ "time": "2024-05-18T10:25:07+00:00"
},
{
"name": "gettext/languages",
- "version": "2.10.0",
+ "version": "2.12.1",
"source": {
"type": "git",
"url": "https://github.com/php-gettext/Languages.git",
- "reference": "4d61d67fe83a2ad85959fe6133d6d9ba7dddd1ab"
+ "reference": "0b0b0851c55168e1dfb14305735c64019732b5f1"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/php-gettext/Languages/zipball/4d61d67fe83a2ad85959fe6133d6d9ba7dddd1ab",
- "reference": "4d61d67fe83a2ad85959fe6133d6d9ba7dddd1ab",
+ "url": "https://api.github.com/repos/php-gettext/Languages/zipball/0b0b0851c55168e1dfb14305735c64019732b5f1",
+ "reference": "0b0b0851c55168e1dfb14305735c64019732b5f1",
"shasum": ""
},
"require": {
@@ -2826,7 +2830,8 @@
"phpunit/phpunit": "^4.8 || ^5.7 || ^6.5 || ^7.5 || ^8.4"
},
"bin": [
- "bin/export-plural-rules"
+ "bin/export-plural-rules",
+ "bin/import-cldr-data"
],
"type": "library",
"autoload": {
@@ -2865,7 +2870,7 @@
],
"support": {
"issues": "https://github.com/php-gettext/Languages/issues",
- "source": "https://github.com/php-gettext/Languages/tree/2.10.0"
+ "source": "https://github.com/php-gettext/Languages/tree/2.12.1"
},
"funding": [
{
@@ -2877,20 +2882,20 @@
"type": "github"
}
],
- "time": "2022-10-18T15:00:10+00:00"
+ "time": "2025-03-19T11:14:02+00:00"
},
{
"name": "mck89/peast",
- "version": "v1.15.4",
+ "version": "v1.17.4",
"source": {
"type": "git",
"url": "https://github.com/mck89/peast.git",
- "reference": "1df4dc28a6b5bb7ab117ab073c1712256e954e18"
+ "reference": "c6a63f32410d2e4ee2cd20fe94b35af147fb852d"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/mck89/peast/zipball/1df4dc28a6b5bb7ab117ab073c1712256e954e18",
- "reference": "1df4dc28a6b5bb7ab117ab073c1712256e954e18",
+ "url": "https://api.github.com/repos/mck89/peast/zipball/c6a63f32410d2e4ee2cd20fe94b35af147fb852d",
+ "reference": "c6a63f32410d2e4ee2cd20fe94b35af147fb852d",
"shasum": ""
},
"require": {
@@ -2903,7 +2908,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "1.15.4-dev"
+ "dev-master": "1.17.4-dev"
}
},
"autoload": {
@@ -2924,72 +2929,22 @@
"description": "Peast is PHP library that generates AST for JavaScript code",
"support": {
"issues": "https://github.com/mck89/peast/issues",
- "source": "https://github.com/mck89/peast/tree/v1.15.4"
+ "source": "https://github.com/mck89/peast/tree/v1.17.4"
},
- "time": "2023-08-12T08:29:29+00:00"
- },
- {
- "name": "mustache/mustache",
- "version": "v2.14.2",
- "source": {
- "type": "git",
- "url": "https://github.com/bobthecow/mustache.php.git",
- "reference": "e62b7c3849d22ec55f3ec425507bf7968193a6cb"
- },
- "dist": {
- "type": "zip",
- "url": "https://api.github.com/repos/bobthecow/mustache.php/zipball/e62b7c3849d22ec55f3ec425507bf7968193a6cb",
- "reference": "e62b7c3849d22ec55f3ec425507bf7968193a6cb",
- "shasum": ""
- },
- "require": {
- "php": ">=5.2.4"
- },
- "require-dev": {
- "friendsofphp/php-cs-fixer": "~1.11",
- "phpunit/phpunit": "~3.7|~4.0|~5.0"
- },
- "type": "library",
- "autoload": {
- "psr-0": {
- "Mustache": "src/"
- }
- },
- "notification-url": "https://packagist.org/downloads/",
- "license": [
- "MIT"
- ],
- "authors": [
- {
- "name": "Justin Hileman",
- "email": "justin@justinhileman.info",
- "homepage": "http://justinhileman.com"
- }
- ],
- "description": "A Mustache implementation in PHP.",
- "homepage": "https://github.com/bobthecow/mustache.php",
- "keywords": [
- "mustache",
- "templating"
- ],
- "support": {
- "issues": "https://github.com/bobthecow/mustache.php/issues",
- "source": "https://github.com/bobthecow/mustache.php/tree/v2.14.2"
- },
- "time": "2022-08-23T13:07:01+00:00"
+ "time": "2025-10-10T12:53:17+00:00"
},
{
"name": "myclabs/deep-copy",
- "version": "1.12.1",
+ "version": "1.13.4",
"source": {
"type": "git",
"url": "https://github.com/myclabs/DeepCopy.git",
- "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845"
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/123267b2c49fbf30d78a7b2d333f6be754b94845",
- "reference": "123267b2c49fbf30d78a7b2d333f6be754b94845",
+ "url": "https://api.github.com/repos/myclabs/DeepCopy/zipball/07d290f0c47959fd5eed98c95ee5602db07e0b6a",
+ "reference": "07d290f0c47959fd5eed98c95ee5602db07e0b6a",
"shasum": ""
},
"require": {
@@ -3028,7 +2983,7 @@
],
"support": {
"issues": "https://github.com/myclabs/DeepCopy/issues",
- "source": "https://github.com/myclabs/DeepCopy/tree/1.12.1"
+ "source": "https://github.com/myclabs/DeepCopy/tree/1.13.4"
},
"funding": [
{
@@ -3036,20 +2991,20 @@
"type": "tidelift"
}
],
- "time": "2024-11-08T17:47:46+00:00"
+ "time": "2025-08-01T08:46:24+00:00"
},
{
"name": "nikic/php-parser",
- "version": "v5.3.1",
+ "version": "v5.7.0",
"source": {
"type": "git",
"url": "https://github.com/nikic/PHP-Parser.git",
- "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b"
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/8eea230464783aa9671db8eea6f8c6ac5285794b",
- "reference": "8eea230464783aa9671db8eea6f8c6ac5285794b",
+ "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
+ "reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
"shasum": ""
},
"require": {
@@ -3068,7 +3023,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "5.0-dev"
+ "dev-master": "5.x-dev"
}
},
"autoload": {
@@ -3092,9 +3047,9 @@
],
"support": {
"issues": "https://github.com/nikic/PHP-Parser/issues",
- "source": "https://github.com/nikic/PHP-Parser/tree/v5.3.1"
+ "source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
},
- "time": "2024-10-08T18:51:32+00:00"
+ "time": "2025-12-06T11:56:16+00:00"
},
{
"name": "phar-io/manifest",
@@ -3216,29 +3171,29 @@
},
{
"name": "phpcsstandards/phpcsextra",
- "version": "1.1.2",
+ "version": "1.5.0",
"source": {
"type": "git",
"url": "https://github.com/PHPCSStandards/PHPCSExtra.git",
- "reference": "746c3190ba8eb2f212087c947ba75f4f5b9a58d5"
+ "reference": "b598aa890815b8df16363271b659d73280129101"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/746c3190ba8eb2f212087c947ba75f4f5b9a58d5",
- "reference": "746c3190ba8eb2f212087c947ba75f4f5b9a58d5",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHPCSExtra/zipball/b598aa890815b8df16363271b659d73280129101",
+ "reference": "b598aa890815b8df16363271b659d73280129101",
"shasum": ""
},
"require": {
"php": ">=5.4",
- "phpcsstandards/phpcsutils": "^1.0.8",
- "squizlabs/php_codesniffer": "^3.7.1"
+ "phpcsstandards/phpcsutils": "^1.2.0",
+ "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1"
},
"require-dev": {
"php-parallel-lint/php-console-highlighter": "^1.0",
- "php-parallel-lint/php-parallel-lint": "^1.3.2",
- "phpcsstandards/phpcsdevcs": "^1.1.6",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "phpcsstandards/phpcsdevcs": "^1.2.0",
"phpcsstandards/phpcsdevtools": "^1.2.1",
- "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0"
+ "phpunit/phpunit": "^4.5 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4"
},
"type": "phpcodesniffer-standard",
"extra": {
@@ -3273,35 +3228,54 @@
],
"support": {
"issues": "https://github.com/PHPCSStandards/PHPCSExtra/issues",
+ "security": "https://github.com/PHPCSStandards/PHPCSExtra/security/policy",
"source": "https://github.com/PHPCSStandards/PHPCSExtra"
},
- "time": "2023-09-20T22:06:18+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/PHPCSStandards",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcsstandards",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2025-11-12T23:06:57+00:00"
},
{
"name": "phpcsstandards/phpcsutils",
- "version": "1.0.8",
+ "version": "1.2.2",
"source": {
"type": "git",
"url": "https://github.com/PHPCSStandards/PHPCSUtils.git",
- "reference": "69465cab9d12454e5e7767b9041af0cd8cd13be7"
+ "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/69465cab9d12454e5e7767b9041af0cd8cd13be7",
- "reference": "69465cab9d12454e5e7767b9041af0cd8cd13be7",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHPCSUtils/zipball/c216317e96c8b3f5932808f9b0f1f7a14e3bbf55",
+ "reference": "c216317e96c8b3f5932808f9b0f1f7a14e3bbf55",
"shasum": ""
},
"require": {
"dealerdirect/phpcodesniffer-composer-installer": "^0.4.1 || ^0.5 || ^0.6.2 || ^0.7 || ^1.0",
"php": ">=5.4",
- "squizlabs/php_codesniffer": "^3.7.1 || 4.0.x-dev@dev"
+ "squizlabs/php_codesniffer": "^3.13.5 || ^4.0.1"
},
"require-dev": {
"ext-filter": "*",
"php-parallel-lint/php-console-highlighter": "^1.0",
- "php-parallel-lint/php-parallel-lint": "^1.3.2",
- "phpcsstandards/phpcsdevcs": "^1.1.6",
- "yoast/phpunit-polyfills": "^1.0.5 || ^2.0.0"
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "phpcsstandards/phpcsdevcs": "^1.2.0",
+ "yoast/phpunit-polyfills": "^1.1.0 || ^2.0.0 || ^3.0.0"
},
"type": "phpcodesniffer-standard",
"extra": {
@@ -3338,6 +3312,7 @@
"phpcodesniffer-standard",
"phpcs",
"phpcs3",
+ "phpcs4",
"standards",
"static analysis",
"tokens",
@@ -3346,9 +3321,28 @@
"support": {
"docs": "https://phpcsutils.com/",
"issues": "https://github.com/PHPCSStandards/PHPCSUtils/issues",
+ "security": "https://github.com/PHPCSStandards/PHPCSUtils/security/policy",
"source": "https://github.com/PHPCSStandards/PHPCSUtils"
},
- "time": "2023-07-16T21:39:41+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/PHPCSStandards",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcsstandards",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2025-12-08T14:27:58+00:00"
},
{
"name": "phpunit/php-code-coverage",
@@ -3671,16 +3665,16 @@
},
{
"name": "phpunit/phpunit",
- "version": "9.6.21",
+ "version": "9.6.31",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
- "reference": "de6abf3b6f8dd955fac3caad3af7a9504e8c2ffa"
+ "reference": "945d0b7f346a084ce5549e95289962972c4272e5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/de6abf3b6f8dd955fac3caad3af7a9504e8c2ffa",
- "reference": "de6abf3b6f8dd955fac3caad3af7a9504e8c2ffa",
+ "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/945d0b7f346a084ce5549e95289962972c4272e5",
+ "reference": "945d0b7f346a084ce5549e95289962972c4272e5",
"shasum": ""
},
"require": {
@@ -3691,7 +3685,7 @@
"ext-mbstring": "*",
"ext-xml": "*",
"ext-xmlwriter": "*",
- "myclabs/deep-copy": "^1.12.0",
+ "myclabs/deep-copy": "^1.13.4",
"phar-io/manifest": "^2.0.4",
"phar-io/version": "^3.2.1",
"php": ">=7.3",
@@ -3702,11 +3696,11 @@
"phpunit/php-timer": "^5.0.3",
"sebastian/cli-parser": "^1.0.2",
"sebastian/code-unit": "^1.0.8",
- "sebastian/comparator": "^4.0.8",
+ "sebastian/comparator": "^4.0.9",
"sebastian/diff": "^4.0.6",
"sebastian/environment": "^5.1.5",
- "sebastian/exporter": "^4.0.6",
- "sebastian/global-state": "^5.0.7",
+ "sebastian/exporter": "^4.0.8",
+ "sebastian/global-state": "^5.0.8",
"sebastian/object-enumerator": "^4.0.4",
"sebastian/resource-operations": "^3.0.4",
"sebastian/type": "^3.2.1",
@@ -3754,7 +3748,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
- "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.21"
+ "source": "https://github.com/sebastianbergmann/phpunit/tree/9.6.31"
},
"funding": [
{
@@ -3765,12 +3759,20 @@
"url": "https://github.com/sebastianbergmann",
"type": "github"
},
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
{
"url": "https://tidelift.com/funding/github/packagist/phpunit/phpunit",
"type": "tidelift"
}
],
- "time": "2024-09-19T10:50:18+00:00"
+ "time": "2025-12-06T07:45:52+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -3941,16 +3943,16 @@
},
{
"name": "sebastian/comparator",
- "version": "4.0.8",
+ "version": "4.0.9",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/comparator.git",
- "reference": "fa0f136dd2334583309d32b62544682ee972b51a"
+ "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/fa0f136dd2334583309d32b62544682ee972b51a",
- "reference": "fa0f136dd2334583309d32b62544682ee972b51a",
+ "url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/67a2df3a62639eab2cc5906065e9805d4fd5dfc5",
+ "reference": "67a2df3a62639eab2cc5906065e9805d4fd5dfc5",
"shasum": ""
},
"require": {
@@ -4003,15 +4005,27 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/comparator/issues",
- "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.8"
+ "source": "https://github.com/sebastianbergmann/comparator/tree/4.0.9"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/comparator",
+ "type": "tidelift"
}
],
- "time": "2022-09-14T12:41:17+00:00"
+ "time": "2025-08-10T06:51:50+00:00"
},
{
"name": "sebastian/complexity",
@@ -4201,16 +4215,16 @@
},
{
"name": "sebastian/exporter",
- "version": "4.0.6",
+ "version": "4.0.8",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/exporter.git",
- "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72"
+ "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/78c00df8f170e02473b682df15bfcdacc3d32d72",
- "reference": "78c00df8f170e02473b682df15bfcdacc3d32d72",
+ "url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/14c6ba52f95a36c3d27c835d65efc7123c446e8c",
+ "reference": "14c6ba52f95a36c3d27c835d65efc7123c446e8c",
"shasum": ""
},
"require": {
@@ -4266,28 +4280,40 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/exporter/issues",
- "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.6"
+ "source": "https://github.com/sebastianbergmann/exporter/tree/4.0.8"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/exporter",
+ "type": "tidelift"
}
],
- "time": "2024-03-02T06:33:00+00:00"
+ "time": "2025-09-24T06:03:27+00:00"
},
{
"name": "sebastian/global-state",
- "version": "5.0.7",
+ "version": "5.0.8",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/global-state.git",
- "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9"
+ "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9",
- "reference": "bca7df1f32ee6fe93b4d4a9abbf69e13a4ada2c9",
+ "url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/b6781316bdcd28260904e7cc18ec983d0d2ef4f6",
+ "reference": "b6781316bdcd28260904e7cc18ec983d0d2ef4f6",
"shasum": ""
},
"require": {
@@ -4330,15 +4356,27 @@
],
"support": {
"issues": "https://github.com/sebastianbergmann/global-state/issues",
- "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.7"
+ "source": "https://github.com/sebastianbergmann/global-state/tree/5.0.8"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/global-state",
+ "type": "tidelift"
}
],
- "time": "2024-03-02T06:35:11+00:00"
+ "time": "2025-08-10T07:10:35+00:00"
},
{
"name": "sebastian/lines-of-code",
@@ -4511,16 +4549,16 @@
},
{
"name": "sebastian/recursion-context",
- "version": "4.0.5",
+ "version": "4.0.6",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/recursion-context.git",
- "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1"
+ "reference": "539c6691e0623af6dc6f9c20384c120f963465a0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1",
- "reference": "e75bd0f07204fec2a0af9b0f3cfe97d05f92efc1",
+ "url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/539c6691e0623af6dc6f9c20384c120f963465a0",
+ "reference": "539c6691e0623af6dc6f9c20384c120f963465a0",
"shasum": ""
},
"require": {
@@ -4562,15 +4600,27 @@
"homepage": "https://github.com/sebastianbergmann/recursion-context",
"support": {
"issues": "https://github.com/sebastianbergmann/recursion-context/issues",
- "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.5"
+ "source": "https://github.com/sebastianbergmann/recursion-context/tree/4.0.6"
},
"funding": [
{
"url": "https://github.com/sebastianbergmann",
"type": "github"
+ },
+ {
+ "url": "https://liberapay.com/sebastianbergmann",
+ "type": "liberapay"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/sebastianbergmann",
+ "type": "thanks_dev"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/sebastian/recursion-context",
+ "type": "tidelift"
}
],
- "time": "2023-02-03T06:07:39+00:00"
+ "time": "2025-08-10T06:57:39+00:00"
},
{
"name": "sebastian/resource-operations",
@@ -4737,16 +4787,16 @@
},
{
"name": "squizlabs/php_codesniffer",
- "version": "3.7.2",
+ "version": "3.13.5",
"source": {
"type": "git",
- "url": "https://github.com/squizlabs/PHP_CodeSniffer.git",
- "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879"
+ "url": "https://github.com/PHPCSStandards/PHP_CodeSniffer.git",
+ "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/squizlabs/PHP_CodeSniffer/zipball/ed8e00df0a83aa96acf703f8c2979ff33341f879",
- "reference": "ed8e00df0a83aa96acf703f8c2979ff33341f879",
+ "url": "https://api.github.com/repos/PHPCSStandards/PHP_CodeSniffer/zipball/0ca86845ce43291e8f5692c7356fccf3bcf02bf4",
+ "reference": "0ca86845ce43291e8f5692c7356fccf3bcf02bf4",
"shasum": ""
},
"require": {
@@ -4756,18 +4806,13 @@
"php": ">=5.4.0"
},
"require-dev": {
- "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0"
+ "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0 || ^9.3.4"
},
"bin": [
- "bin/phpcs",
- "bin/phpcbf"
+ "bin/phpcbf",
+ "bin/phpcs"
],
"type": "library",
- "extra": {
- "branch-alias": {
- "dev-master": "3.x-dev"
- }
- },
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
@@ -4775,35 +4820,62 @@
"authors": [
{
"name": "Greg Sherwood",
- "role": "lead"
+ "role": "Former lead"
+ },
+ {
+ "name": "Juliette Reinders Folmer",
+ "role": "Current lead"
+ },
+ {
+ "name": "Contributors",
+ "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer/graphs/contributors"
}
],
"description": "PHP_CodeSniffer tokenizes PHP, JavaScript and CSS files and detects violations of a defined set of coding standards.",
- "homepage": "https://github.com/squizlabs/PHP_CodeSniffer",
+ "homepage": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
"keywords": [
"phpcs",
"standards",
"static analysis"
],
"support": {
- "issues": "https://github.com/squizlabs/PHP_CodeSniffer/issues",
- "source": "https://github.com/squizlabs/PHP_CodeSniffer",
- "wiki": "https://github.com/squizlabs/PHP_CodeSniffer/wiki"
+ "issues": "https://github.com/PHPCSStandards/PHP_CodeSniffer/issues",
+ "security": "https://github.com/PHPCSStandards/PHP_CodeSniffer/security/policy",
+ "source": "https://github.com/PHPCSStandards/PHP_CodeSniffer",
+ "wiki": "https://github.com/PHPCSStandards/PHP_CodeSniffer/wiki"
},
- "time": "2023-02-22T23:07:41+00:00"
+ "funding": [
+ {
+ "url": "https://github.com/PHPCSStandards",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/jrfnl",
+ "type": "github"
+ },
+ {
+ "url": "https://opencollective.com/php_codesniffer",
+ "type": "open_collective"
+ },
+ {
+ "url": "https://thanks.dev/u/gh/phpcsstandards",
+ "type": "thanks_dev"
+ }
+ ],
+ "time": "2025-11-04T16:30:35+00:00"
},
{
"name": "symfony/finder",
- "version": "v5.4.27",
+ "version": "v5.4.45",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
- "reference": "ff4bce3c33451e7ec778070e45bd23f74214cd5d"
+ "reference": "63741784cd7b9967975eec610b256eed3ede022b"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/symfony/finder/zipball/ff4bce3c33451e7ec778070e45bd23f74214cd5d",
- "reference": "ff4bce3c33451e7ec778070e45bd23f74214cd5d",
+ "url": "https://api.github.com/repos/symfony/finder/zipball/63741784cd7b9967975eec610b256eed3ede022b",
+ "reference": "63741784cd7b9967975eec610b256eed3ede022b",
"shasum": ""
},
"require": {
@@ -4837,7 +4909,7 @@
"description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
"support": {
- "source": "https://github.com/symfony/finder/tree/v5.4.27"
+ "source": "https://github.com/symfony/finder/tree/v5.4.45"
},
"funding": [
{
@@ -4853,20 +4925,20 @@
"type": "tidelift"
}
],
- "time": "2023-07-31T08:02:31+00:00"
+ "time": "2024-09-28T13:32:08+00:00"
},
{
"name": "theseer/tokenizer",
- "version": "1.2.3",
+ "version": "1.3.1",
"source": {
"type": "git",
"url": "https://github.com/theseer/tokenizer.git",
- "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2"
+ "reference": "b7489ce515e168639d17feec34b8847c326b0b3c"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/theseer/tokenizer/zipball/737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2",
- "reference": "737eda637ed5e28c3413cb1ebe8bb52cbf1ca7a2",
+ "url": "https://api.github.com/repos/theseer/tokenizer/zipball/b7489ce515e168639d17feec34b8847c326b0b3c",
+ "reference": "b7489ce515e168639d17feec34b8847c326b0b3c",
"shasum": ""
},
"require": {
@@ -4895,7 +4967,7 @@
"description": "A small library for converting tokenized PHP source code into XML and potentially other formats",
"support": {
"issues": "https://github.com/theseer/tokenizer/issues",
- "source": "https://github.com/theseer/tokenizer/tree/1.2.3"
+ "source": "https://github.com/theseer/tokenizer/tree/1.3.1"
},
"funding": [
{
@@ -4903,31 +4975,31 @@
"type": "github"
}
],
- "time": "2024-03-03T12:36:25+00:00"
+ "time": "2025-11-17T20:03:58+00:00"
},
{
"name": "wp-cli/i18n-command",
- "version": "v2.4.4",
+ "version": "v2.6.6",
"source": {
"type": "git",
"url": "https://github.com/wp-cli/i18n-command.git",
- "reference": "7d82e675f271359b1af614e6325d8eeaeb7d7474"
+ "reference": "94f72ddc4be8919f2cea181ba39cd140dd480d64"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/wp-cli/i18n-command/zipball/7d82e675f271359b1af614e6325d8eeaeb7d7474",
- "reference": "7d82e675f271359b1af614e6325d8eeaeb7d7474",
+ "url": "https://api.github.com/repos/wp-cli/i18n-command/zipball/94f72ddc4be8919f2cea181ba39cd140dd480d64",
+ "reference": "94f72ddc4be8919f2cea181ba39cd140dd480d64",
"shasum": ""
},
"require": {
"eftec/bladeone": "3.52",
"gettext/gettext": "^4.8",
"mck89/peast": "^1.13.11",
- "wp-cli/wp-cli": "^2.5"
+ "wp-cli/wp-cli": "^2.12"
},
"require-dev": {
"wp-cli/scaffold-command": "^1.2 || ^2",
- "wp-cli/wp-cli-tests": "^4"
+ "wp-cli/wp-cli-tests": "^5.0.0"
},
"suggest": {
"ext-json": "Used for reading and generating JSON translation files",
@@ -4935,17 +5007,18 @@
},
"type": "wp-cli-package",
"extra": {
- "branch-alias": {
- "dev-main": "2.x-dev"
- },
"bundled": true,
"commands": [
"i18n",
"i18n make-pot",
"i18n make-json",
"i18n make-mo",
+ "i18n make-php",
"i18n update-po"
- ]
+ ],
+ "branch-alias": {
+ "dev-main": "2.x-dev"
+ }
},
"autoload": {
"files": [
@@ -4969,9 +5042,61 @@
"homepage": "https://github.com/wp-cli/i18n-command",
"support": {
"issues": "https://github.com/wp-cli/i18n-command/issues",
- "source": "https://github.com/wp-cli/i18n-command/tree/v2.4.4"
+ "source": "https://github.com/wp-cli/i18n-command/tree/v2.6.6"
+ },
+ "time": "2025-11-21T04:23:34+00:00"
+ },
+ {
+ "name": "wp-cli/mustache",
+ "version": "v2.14.99",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/wp-cli/mustache.php.git",
+ "reference": "ca23b97ac35fbe01c160549eb634396183d04a59"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/wp-cli/mustache.php/zipball/ca23b97ac35fbe01c160549eb634396183d04a59",
+ "reference": "ca23b97ac35fbe01c160549eb634396183d04a59",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.6"
+ },
+ "replace": {
+ "mustache/mustache": "^2.14.2"
+ },
+ "require-dev": {
+ "friendsofphp/php-cs-fixer": "~2.19.3",
+ "yoast/phpunit-polyfills": "^2.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-0": {
+ "Mustache": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Justin Hileman",
+ "email": "justin@justinhileman.info",
+ "homepage": "http://justinhileman.com"
+ }
+ ],
+ "description": "A Mustache implementation in PHP.",
+ "homepage": "https://github.com/bobthecow/mustache.php",
+ "keywords": [
+ "mustache",
+ "templating"
+ ],
+ "support": {
+ "source": "https://github.com/wp-cli/mustache.php/tree/v2.14.99"
},
- "time": "2023-08-30T18:00:10+00:00"
+ "time": "2025-05-06T16:15:37+00:00"
},
{
"name": "wp-cli/mustangostang-spyc",
@@ -5026,29 +5151,29 @@
},
{
"name": "wp-cli/php-cli-tools",
- "version": "v0.11.21",
+ "version": "v0.12.6",
"source": {
"type": "git",
"url": "https://github.com/wp-cli/php-cli-tools.git",
- "reference": "b3457a8d60cd0b1c48cab76ad95df136d266f0b6"
+ "reference": "f12b650d3738e471baed6dd47982d53c5c0ab1c3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/wp-cli/php-cli-tools/zipball/b3457a8d60cd0b1c48cab76ad95df136d266f0b6",
- "reference": "b3457a8d60cd0b1c48cab76ad95df136d266f0b6",
+ "url": "https://api.github.com/repos/wp-cli/php-cli-tools/zipball/f12b650d3738e471baed6dd47982d53c5c0ab1c3",
+ "reference": "f12b650d3738e471baed6dd47982d53c5c0ab1c3",
"shasum": ""
},
"require": {
- "php": ">= 5.3.0"
+ "php": ">= 7.2.24"
},
"require-dev": {
"roave/security-advisories": "dev-latest",
- "wp-cli/wp-cli-tests": "^4"
+ "wp-cli/wp-cli-tests": "^5"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "0.11.x-dev"
+ "dev-master": "0.12.x-dev"
}
},
"autoload": {
@@ -5083,39 +5208,38 @@
],
"support": {
"issues": "https://github.com/wp-cli/php-cli-tools/issues",
- "source": "https://github.com/wp-cli/php-cli-tools/tree/v0.11.21"
+ "source": "https://github.com/wp-cli/php-cli-tools/tree/v0.12.6"
},
- "time": "2023-09-29T15:28:10+00:00"
+ "time": "2025-09-11T12:43:04+00:00"
},
{
"name": "wp-cli/wp-cli",
- "version": "v2.9.0",
+ "version": "v2.12.0",
"source": {
"type": "git",
"url": "https://github.com/wp-cli/wp-cli.git",
- "reference": "8a3befba2d947fbf5cc6d1941edf2dd99da4d4b7"
+ "reference": "03d30d4138d12b4bffd8b507b82e56e129e0523f"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/wp-cli/wp-cli/zipball/8a3befba2d947fbf5cc6d1941edf2dd99da4d4b7",
- "reference": "8a3befba2d947fbf5cc6d1941edf2dd99da4d4b7",
+ "url": "https://api.github.com/repos/wp-cli/wp-cli/zipball/03d30d4138d12b4bffd8b507b82e56e129e0523f",
+ "reference": "03d30d4138d12b4bffd8b507b82e56e129e0523f",
"shasum": ""
},
"require": {
"ext-curl": "*",
- "mustache/mustache": "^2.14.1",
"php": "^5.6 || ^7.0 || ^8.0",
"symfony/finder": ">2.7",
+ "wp-cli/mustache": "^2.14.99",
"wp-cli/mustangostang-spyc": "^0.6.3",
- "wp-cli/php-cli-tools": "~0.11.2"
+ "wp-cli/php-cli-tools": "~0.12.4"
},
"require-dev": {
- "roave/security-advisories": "dev-latest",
"wp-cli/db-command": "^1.3 || ^2",
"wp-cli/entity-command": "^1.2 || ^2",
"wp-cli/extension-command": "^1.1 || ^2",
"wp-cli/package-command": "^1 || ^2",
- "wp-cli/wp-cli-tests": "^4.0.1"
+ "wp-cli/wp-cli-tests": "^4.3.10"
},
"suggest": {
"ext-readline": "Include for a better --prompt implementation",
@@ -5128,7 +5252,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "2.9.x-dev"
+ "dev-main": "2.12.x-dev"
}
},
"autoload": {
@@ -5155,20 +5279,20 @@
"issues": "https://github.com/wp-cli/wp-cli/issues",
"source": "https://github.com/wp-cli/wp-cli"
},
- "time": "2023-10-25T09:06:37+00:00"
+ "time": "2025-05-07T01:16:12+00:00"
},
{
"name": "wp-coding-standards/wpcs",
- "version": "3.0.1",
+ "version": "3.3.0",
"source": {
"type": "git",
"url": "https://github.com/WordPress/WordPress-Coding-Standards.git",
- "reference": "b4caf9689f1a0e4a4c632679a44e638c1c67aff1"
+ "reference": "7795ec6fa05663d716a549d0b44e47ffc8b0d4a6"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/b4caf9689f1a0e4a4c632679a44e638c1c67aff1",
- "reference": "b4caf9689f1a0e4a4c632679a44e638c1c67aff1",
+ "url": "https://api.github.com/repos/WordPress/WordPress-Coding-Standards/zipball/7795ec6fa05663d716a549d0b44e47ffc8b0d4a6",
+ "reference": "7795ec6fa05663d716a549d0b44e47ffc8b0d4a6",
"shasum": ""
},
"require": {
@@ -5176,17 +5300,17 @@
"ext-libxml": "*",
"ext-tokenizer": "*",
"ext-xmlreader": "*",
- "php": ">=5.4",
- "phpcsstandards/phpcsextra": "^1.1.0",
- "phpcsstandards/phpcsutils": "^1.0.8",
- "squizlabs/php_codesniffer": "^3.7.2"
+ "php": ">=7.2",
+ "phpcsstandards/phpcsextra": "^1.5.0",
+ "phpcsstandards/phpcsutils": "^1.1.0",
+ "squizlabs/php_codesniffer": "^3.13.4"
},
"require-dev": {
"php-parallel-lint/php-console-highlighter": "^1.0.0",
- "php-parallel-lint/php-parallel-lint": "^1.3.2",
- "phpcompatibility/php-compatibility": "^9.0",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "phpcompatibility/php-compatibility": "^10.0.0@dev",
"phpcsstandards/phpcsdevtools": "^1.2.0",
- "phpunit/phpunit": "^4.0 || ^5.0 || ^6.0 || ^7.0"
+ "phpunit/phpunit": "^8.0 || ^9.0"
},
"suggest": {
"ext-iconv": "For improved results",
@@ -5217,24 +5341,24 @@
},
"funding": [
{
- "url": "https://opencollective.com/thewpcc/contribute/wp-php-63406",
+ "url": "https://opencollective.com/php_codesniffer",
"type": "custom"
}
],
- "time": "2023-09-14T07:06:09+00:00"
+ "time": "2025-11-25T12:08:04+00:00"
},
{
"name": "yoast/phpunit-polyfills",
- "version": "1.1.0",
+ "version": "1.1.5",
"source": {
"type": "git",
"url": "https://github.com/Yoast/PHPUnit-Polyfills.git",
- "reference": "224e4a1329c03d8bad520e3fc4ec980034a4b212"
+ "reference": "41aaac462fbd80feb8dd129e489f4bbc53fe26b0"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/224e4a1329c03d8bad520e3fc4ec980034a4b212",
- "reference": "224e4a1329c03d8bad520e3fc4ec980034a4b212",
+ "url": "https://api.github.com/repos/Yoast/PHPUnit-Polyfills/zipball/41aaac462fbd80feb8dd129e489f4bbc53fe26b0",
+ "reference": "41aaac462fbd80feb8dd129e489f4bbc53fe26b0",
"shasum": ""
},
"require": {
@@ -5242,12 +5366,14 @@
"phpunit/phpunit": "^4.8.36 || ^5.7.21 || ^6.0 || ^7.0 || ^8.0 || ^9.0"
},
"require-dev": {
- "yoast/yoastcs": "^2.3.0"
+ "php-parallel-lint/php-console-highlighter": "^1.0.0",
+ "php-parallel-lint/php-parallel-lint": "^1.4.0",
+ "yoast/yoastcs": "^3.2.0"
},
"type": "library",
"extra": {
"branch-alias": {
- "dev-main": "2.x-dev"
+ "dev-main": "4.x-dev"
}
},
"autoload": {
@@ -5279,9 +5405,10 @@
],
"support": {
"issues": "https://github.com/Yoast/PHPUnit-Polyfills/issues",
+ "security": "https://github.com/Yoast/PHPUnit-Polyfills/security/policy",
"source": "https://github.com/Yoast/PHPUnit-Polyfills"
},
- "time": "2023-08-19T14:25:08+00:00"
+ "time": "2025-08-10T04:54:36+00:00"
}
],
"aliases": [],
diff --git a/src/API/Google/AdsCampaign.php b/src/API/Google/AdsCampaign.php
index dcca194b79..3bb09cfee4 100644
--- a/src/API/Google/AdsCampaign.php
+++ b/src/API/Google/AdsCampaign.php
@@ -28,6 +28,9 @@
use Google\Ads\GoogleAds\V22\Services\GoogleAdsRow;
use Google\Ads\GoogleAds\V22\Services\MutateGoogleAdsRequest;
use Google\Ads\GoogleAds\V22\Services\MutateOperation;
+use Google\Ads\GoogleAds\V22\Resources\Campaign\AssetAutomationSetting;
+use Google\Ads\GoogleAds\V22\Enums\AssetAutomationTypeEnum\AssetAutomationType;
+use Google\Ads\GoogleAds\V22\Enums\AssetAutomationStatusEnum\AssetAutomationStatus;
use Google\ApiCore\ApiException;
use Google\ApiCore\ValidationException;
use Exception;
@@ -495,7 +498,14 @@ protected function create_operation( string $campaign_name, string $country, boo
'status' => CampaignStatus::number( 'enabled' ),
'campaign_budget' => $this->budget->temporary_resource_name(),
'maximize_conversion_value' => new MaximizeConversionValue(),
- 'url_expansion_opt_out' => false,
+ 'asset_automation_settings' => [
+ new AssetAutomationSetting(
+ [
+ 'asset_automation_type' => AssetAutomationType::FINAL_URL_EXPANSION_TEXT_ASSET_AUTOMATION,
+ 'asset_automation_status' => AssetAutomationStatus::OPTED_IN
+ ]
+ )
+ ],
'shopping_setting' => new ShoppingSetting(
[
'merchant_id' => $this->options->get_merchant_id(),
From 41c81517148eaa47bd8e64468c64c20ee8743628 Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Thu, 15 Jan 2026 21:15:32 +0530
Subject: [PATCH 025/123] Fix: php lint.
---
src/API/Google/AdsCampaign.php | 10 +++++-----
src/Coupon/WCCouponAdapter.php | 1 +
2 files changed, 6 insertions(+), 5 deletions(-)
diff --git a/src/API/Google/AdsCampaign.php b/src/API/Google/AdsCampaign.php
index 3bb09cfee4..a612b11b8f 100644
--- a/src/API/Google/AdsCampaign.php
+++ b/src/API/Google/AdsCampaign.php
@@ -500,11 +500,11 @@ protected function create_operation( string $campaign_name, string $country, boo
'maximize_conversion_value' => new MaximizeConversionValue(),
'asset_automation_settings' => [
new AssetAutomationSetting(
- [
- 'asset_automation_type' => AssetAutomationType::FINAL_URL_EXPANSION_TEXT_ASSET_AUTOMATION,
- 'asset_automation_status' => AssetAutomationStatus::OPTED_IN
- ]
- )
+ [
+ 'asset_automation_type' => AssetAutomationType::FINAL_URL_EXPANSION_TEXT_ASSET_AUTOMATION,
+ 'asset_automation_status' => AssetAutomationStatus::OPTED_IN,
+ ]
+ ),
],
'shopping_setting' => new ShoppingSetting(
[
diff --git a/src/Coupon/WCCouponAdapter.php b/src/Coupon/WCCouponAdapter.php
index 4b1a037148..9851f26534 100644
--- a/src/Coupon/WCCouponAdapter.php
+++ b/src/Coupon/WCCouponAdapter.php
@@ -385,6 +385,7 @@ public function get_wc_coupon_id(): int {
/**
*
* @param string $targetCountry
+ *
* phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
*/
public function setTargetCountry( $targetCountry ) {
From 335504e8ffdd11102edfecded3e68aaa35411dd5 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Thu, 15 Jan 2026 19:49:01 +0400
Subject: [PATCH 026/123] feat(paid-ads): Add AI-generated image picker
---
.../asset-group-editor/asset-group-editor.js | 1 +
.../asset-group-images-section.js | 13 +++
.../gen-ai-image-picker/index.js | 103 ++++++++++++++++++
.../gen-ai-image-picker/index.scss | 47 ++++++++
js/src/images/ai-icon.svg | 1 +
5 files changed, 165 insertions(+)
create mode 100644 js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js
create mode 100644 js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.scss
create mode 100644 js/src/images/ai-icon.svg
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-editor.js b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-editor.js
index df2b5f5236..e4e9a4b231 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-editor.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-editor.js
@@ -104,6 +104,7 @@ export default function AssetGroupEditor() {
/>
+
+
{ renderErrors( spec.key ) }
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js b/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js
new file mode 100644
index 0000000000..788fdd5c83
--- /dev/null
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js
@@ -0,0 +1,103 @@
+/**
+ * External dependencies
+ */
+import { noop } from 'lodash';
+import { __ } from '@wordpress/i18n';
+import { useState } from '@wordpress/element';
+import {
+ Flex,
+ FlexItem,
+ CheckboxControl,
+ FlexBlock,
+} from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import AppButton from '~/components/app-button';
+import AIIcon from '~/images/ai-icon.svg?inline';
+import './index.scss';
+
+export default function GenAIImagePicker( {
+ assetKey,
+ finalUrl,
+ images,
+ onAddSelectedImages = noop,
+} ) {
+ const [ selectedImages, setSelectedImages ] = useState( [] );
+ const handleOnAddSelectedImages = () => {
+ onAddSelectedImages( selectedImages );
+ setSelectedImages( [] );
+ };
+
+ const toggleImageSelection = ( src ) => {
+ setSelectedImages( ( previousImages ) =>
+ previousImages.includes( src )
+ ? previousImages.filter( ( image ) => image !== src )
+ : [ ...previousImages, src ]
+ );
+ };
+
+ return (
+
+
+
+
+
+ { __( 'AI-generated images', 'google-listings-and-ads' ) }
+
+
+ { __(
+ 'Select to add these images to this set for your product.',
+ 'google-listings-and-ads'
+ ) }
+
+
+
+
+
+ { images.map( ( src ) => (
+
+ toggleImageSelection( src ) }
+ >
+
+
+
+ toggleImageSelection( src ) }
+ value={ src }
+ />
+
+ ) ) }
+
+
+
+
+
+
+
+ );
+}
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.scss b/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.scss
new file mode 100644
index 0000000000..94e74c1603
--- /dev/null
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.scss
@@ -0,0 +1,47 @@
+.gla-gen-ai-image-picker {
+ background-color: $gla-color-gray-0;
+ padding: $grid-unit-30;
+ margin: 0 0 $grid-unit-40 0;
+
+ .gla-gen-ai-image-picker__image {
+ position: relative;
+ }
+
+ .gla-gen-ai-image-picker__medium-button {
+ justify-content: center;
+ width: 100px;
+ height: 100px;
+ padding: 0;
+ border: 1px solid $gray-200;
+ border-radius: 4px;
+ overflow: hidden;
+ background-color: $white;
+ position: relative;
+ display: block;
+ }
+
+ .gla-gen-ai-image-picker__checkbox {
+ position: absolute;
+ top: $grid-unit-10;
+ left: $grid-unit-10;
+ width: 16px;
+ height: 16px;
+ }
+
+ .gla-gen-ai-image-picker__title {
+ font-size: $gla-font-base;
+ font-weight: 400;
+ margin: 0;
+
+ svg {
+ color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9));
+ vertical-align: bottom;
+ }
+ }
+
+ .gla-gen-ai-image-picker__description {
+ font-size: $gla-font-smaller;
+ color: $gray-700;
+ margin: $grid-unit-05 0 0;
+ }
+}
diff --git a/js/src/images/ai-icon.svg b/js/src/images/ai-icon.svg
new file mode 100644
index 0000000000..23b02113d1
--- /dev/null
+++ b/js/src/images/ai-icon.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
From bbf5aa11dd22176367cd71adc59c8f2aafa5fe81 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Thu, 15 Jan 2026 21:10:54 +0400
Subject: [PATCH 027/123] feat(paid-ads): Implement GenAIImagePicker component
---
.../asset-group-images-section.js | 12 +--
.../gen-ai-image-picker/index.js | 79 ++++++++++++-------
.../gen-ai-image-picker/index.scss | 5 ++
.../asset-group-editor/images-selector.js | 22 ++++++
4 files changed, 77 insertions(+), 41 deletions(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-images-section.js b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-images-section.js
index 4bcdf97358..d9ad3f5cbf 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-images-section.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-images-section.js
@@ -11,7 +11,6 @@ import ImagesSelector from './images-selector';
import AssetField from './asset-field';
import Section from '~/components/section';
import AppDocumentationLink from '~/components/app-documentation-link';
-import GenAIImagePicker from './gen-ai-image-picker';
import { ASSET_IMAGE_SPECS } from '../../assetSpecs';
/**
@@ -95,6 +94,7 @@ const AssetGroupImagesSection = ( {
initialExpanded={ isSelectedFinalUrl }
>
-
-
{ renderErrors( spec.key ) }
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js b/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js
index 788fdd5c83..23d78c188e 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js
@@ -1,7 +1,6 @@
/**
* External dependencies
*/
-import { noop } from 'lodash';
import { __ } from '@wordpress/i18n';
import { useState } from '@wordpress/element';
import {
@@ -14,17 +13,22 @@ import {
/**
* Internal dependencies
*/
+import { useAdaptiveFormContext } from '~/components/adaptive-form';
import AppButton from '~/components/app-button';
import AIIcon from '~/images/ai-icon.svg?inline';
import './index.scss';
export default function GenAIImagePicker( {
assetKey,
- finalUrl,
images,
- onAddSelectedImages = noop,
+ onAddSelectedImages,
} ) {
+ const { values } = useAdaptiveFormContext();
+ const addedImageUrls = values[ assetKey ] || [];
+ const { final_url: finalUrl } = values;
+
const [ selectedImages, setSelectedImages ] = useState( [] );
+
const handleOnAddSelectedImages = () => {
onAddSelectedImages( selectedImages );
setSelectedImages( [] );
@@ -55,35 +59,50 @@ export default function GenAIImagePicker( {
-
- { images.map( ( src ) => (
-
- toggleImageSelection( src ) }
+
+ { images.map( ( src ) => {
+ if ( addedImageUrls.includes( src ) ) {
+ return null;
+ }
+
+ return (
+
-
-
+
+ toggleImageSelection( src )
+ }
+ >
+
+
- toggleImageSelection( src ) }
- value={ src }
- />
-
- ) ) }
+
+ toggleImageSelection( src )
+ }
+ value={ src }
+ />
+
+ );
+ } ) }
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.scss b/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.scss
index 94e74c1603..72a019efab 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.scss
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.scss
@@ -3,6 +3,10 @@
padding: $grid-unit-30;
margin: 0 0 $grid-unit-40 0;
+ &:has(.gla-gen-ai-image-picker__images:empty) {
+ display: none;
+ }
+
.gla-gen-ai-image-picker__image {
position: relative;
}
@@ -18,6 +22,7 @@
background-color: $white;
position: relative;
display: block;
+
}
.gla-gen-ai-image-picker__checkbox {
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js b/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js
index 92a0b2d69f..e0fec76847 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js
@@ -12,6 +12,7 @@ import useCroppedImageSelector from '~/hooks/useCroppedImageSelector';
import AppTooltip from '~/components/app-tooltip';
import AddAssetItemButton from './add-asset-item-button';
import MediaSelector from './media-selector';
+import GenAIImagePicker from './gen-ai-image-picker';
/**
* @typedef {Object} AssetImageConfig
@@ -25,6 +26,7 @@ import MediaSelector from './media-selector';
* Renders a selector for asset images.
*
* @param {Object} props React props.
+ * @param {string} props.assetKey The asset key.
* @param {AssetImageConfig} props.imageConfig The config of the asset image.
* @param {string[]} props.initialImageUrls The initial image URLs.
* @param {number} [props.maxNumberOfImages=-1] The maximum number of images. -1 by default and it means unlimited number.
@@ -33,6 +35,7 @@ import MediaSelector from './media-selector';
* @param {(urls: Array) => void} [props.onChange] Callback function to be called when the texts are changed.
*/
export default function ImagesSelector( {
+ assetKey,
imageConfig,
initialImageUrls = [],
maxNumberOfImages = -1,
@@ -100,6 +103,15 @@ export default function ImagesSelector( {
handle.openSelector( image?.id );
};
+ const handleOnAddSelectedImages = ( selectedImageUrls ) => {
+ const selectedImages = selectedImageUrls.map( ( url ) => ( {
+ url,
+ id: url,
+ alt: '',
+ } ) );
+ updateImages( [ ...images, ...selectedImages ] );
+ };
+
const renderAddButton = () => {
const disabled =
maxNumberOfImages !== -1 && images.length >= maxNumberOfImages;
@@ -130,6 +142,16 @@ export default function ImagesSelector( {
onRemoveMedia={ handleRemoveImage }
/>
+
+
{ children }
{ renderAddButton() }
From 533e8c994183dba6726eb269a428059a7cef8b8c Mon Sep 17 00:00:00 2001
From: asvinb
Date: Thu, 15 Jan 2026 21:42:44 +0400
Subject: [PATCH 028/123] feat(paid-ads): Implement GenAI image generation for
marketing assets
---
.../asset-group-editor/asset-group-editor.js | 1 -
.../asset-group-images-section.js | 3 +-
.../gen-ai-image-picker/index.js | 14 +++---
.../asset-group-editor/images-selector.js | 46 ++++++++++++++++---
js/src/components/paid-ads/assetSpecs.js | 12 +++++
js/src/data/selectors.js | 12 ++---
6 files changed, 67 insertions(+), 21 deletions(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-editor.js b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-editor.js
index e4e9a4b231..df2b5f5236 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-editor.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-editor.js
@@ -104,7 +104,6 @@ export default function AssetGroupEditor() {
/>
{ renderErrors( spec.key ) }
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js b/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js
index 23d78c188e..87befb95c6 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js
@@ -14,18 +14,16 @@ import {
* Internal dependencies
*/
import { useAdaptiveFormContext } from '~/components/adaptive-form';
+import useGenAIMediaAssets from '~/hooks/useGenAIMediaAssets';
import AppButton from '~/components/app-button';
import AIIcon from '~/images/ai-icon.svg?inline';
import './index.scss';
-export default function GenAIImagePicker( {
- assetKey,
- images,
- onAddSelectedImages,
-} ) {
+export default function GenAIImagePicker( { assetKey, onAddSelectedImages } ) {
const { values } = useAdaptiveFormContext();
const addedImageUrls = values[ assetKey ] || [];
const { final_url: finalUrl } = values;
+ const { assets } = useGenAIMediaAssets( finalUrl, assetKey );
const [ selectedImages, setSelectedImages ] = useState( [] );
@@ -42,6 +40,10 @@ export default function GenAIImagePicker( {
);
};
+ if ( ! assets || assets.length === 0 ) {
+ return null;
+ }
+
return (
@@ -65,7 +67,7 @@ export default function GenAIImagePicker( {
className="gla-gen-ai-image-picker__images"
wrap
>
- { images.map( ( src ) => {
+ { assets.map( ( src ) => {
if ( addedImageUrls.includes( src ) ) {
return null;
}
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js b/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js
index 5ba1c0b576..5c8ad4c405 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js
@@ -8,9 +8,14 @@ import { useState, useEffect, useRef } from '@wordpress/element';
/**
* Internal dependencies
*/
+import { useAppDispatch } from '~/data';
+import useDispatchCoreNotices from '~/hooks/useDispatchCoreNotices';
+import { useAdaptiveFormContext } from '~/components/adaptive-form';
import useCroppedImageSelector from '~/hooks/useCroppedImageSelector';
import AppTooltip from '~/components/app-tooltip';
-import AssetItemActionButton from './asset-item-action-button';
+import AssetItemActionButton, {
+ ACTION_TYPES,
+} from './asset-item-action-button';
import MediaSelector from './media-selector';
import GenAIImagePicker from './gen-ai-image-picker';
@@ -29,6 +34,7 @@ import GenAIImagePicker from './gen-ai-image-picker';
* @param {string} props.assetKey The asset key.
* @param {AssetImageConfig} props.imageConfig The config of the asset image.
* @param {string[]} props.initialImageUrls The initial image URLs.
+ * @param {string} props.generateButtonText The text for the generate button.
* @param {number} [props.maxNumberOfImages=-1] The maximum number of images. -1 by default and it means unlimited number.
* @param {string} [props.reachedMaxNumberTip] The tooltip content floating on the add button when reaching the max number of images.
* @param {JSX.Element} [props.children] Content to be rendered above the add button.
@@ -38,13 +44,18 @@ export default function ImagesSelector( {
assetKey,
imageConfig,
initialImageUrls = [],
+ generateButtonText,
maxNumberOfImages = -1,
reachedMaxNumberTip,
children,
onChange = noop,
} ) {
+ const { values } = useAdaptiveFormContext();
const updateImagesRef = useRef();
+ const [ isGeneratingAssets, setIsGeneratingAssets ] = useState( false );
const [ awaitingActionImage, setAwaitingActionImage ] = useState( null );
+ const { fetchGenAIMediaAssets } = useAppDispatch();
+ const { createNotice } = useDispatchCoreNotices();
const [ images, setImages ] = useState( () =>
// The asset images fetched from Google Ads are only URLs.
initialImageUrls.map( ( url ) => ( { url, id: url, alt: '' } ) )
@@ -134,6 +145,25 @@ export default function ImagesSelector( {
return button;
};
+ const handleGenerateClick = async () => {
+ setIsGeneratingAssets( true );
+
+ try {
+ const { final_url: finalUrl } = values;
+ await fetchGenAIMediaAssets( finalUrl, assetKey );
+ } catch ( error ) {
+ createNotice(
+ 'error',
+ __(
+ 'Something went wrong while generating texts. Please try again.',
+ 'google-listings-and-ads'
+ )
+ );
+ } finally {
+ setIsGeneratingAssets( false );
+ }
+ };
+
return (
{ children }
{ renderAddButton() }
+
+ { generateButtonText && (
+
+ ) }
);
}
diff --git a/js/src/components/paid-ads/assetSpecs.js b/js/src/components/paid-ads/assetSpecs.js
index 383976655f..cd6c488e2e 100644
--- a/js/src/components/paid-ads/assetSpecs.js
+++ b/js/src/components/paid-ads/assetSpecs.js
@@ -64,6 +64,10 @@ const ASSET_MARKETING_IMAGE_SPECS = [
'Lowercase asset field name',
'google-listings-and-ads'
),
+ generateButtonText: __(
+ 'Generate landscape images',
+ 'google-listings-and-ads'
+ ),
},
{
key: ASSET_FORM_KEY.SQUARE_MARKETING_IMAGE,
@@ -93,6 +97,10 @@ const ASSET_MARKETING_IMAGE_SPECS = [
'Lowercase asset field name',
'google-listings-and-ads'
),
+ generateButtonText: __(
+ 'Generate square images',
+ 'google-listings-and-ads'
+ ),
},
{
key: ASSET_FORM_KEY.PORTRAIT_MARKETING_IMAGE,
@@ -122,6 +130,10 @@ const ASSET_MARKETING_IMAGE_SPECS = [
'Lowercase asset field name',
'google-listings-and-ads'
),
+ generateButtonText: __(
+ 'Generate portrait images',
+ 'google-listings-and-ads'
+ ),
},
];
diff --git a/js/src/data/selectors.js b/js/src/data/selectors.js
index 64b6e7740b..cfdebdc4ed 100644
--- a/js/src/data/selectors.js
+++ b/js/src/data/selectors.js
@@ -503,17 +503,17 @@ export const getAdsRecommendations = ( state, types, campaign_id = null ) => {
* @param {Object} state - The Redux state object containing GenAI assets data.
* @param {string} url - The URL associated with the GenAI assets.
* @param {'marketing_image'|'square_marketing_image'|'portrait_marketing_image'|undefined} [assetType] - The type of media asset to retrieve.
- * @return {Object|null} The media assets for the specified URL and type, or null if not found.
+ * @return {Array} The media assets for the specified URL and type, or an empty array if not found.
*/
export const getGenAIMediaAssets = ( state, url, assetType ) => {
const mediaAssets = state.gen_ai_assets?.[ url ]?.media;
if ( ! url || ! mediaAssets ) {
- return null;
+ return [];
}
if ( assetType ) {
- return mediaAssets[ assetType ] ?? null;
+ return mediaAssets[ assetType ] ?? [];
}
return mediaAssets;
@@ -525,17 +525,17 @@ export const getGenAIMediaAssets = ( state, url, assetType ) => {
* @param {Object} state - The Redux state object containing GenAI assets data.
* @param {string} url - The URL associated with the GenAI assets.
* @param {'headline'|'long_headline'|'description'|undefined} [assetType] - The type of text asset to retrieve.
- * @return {Object|null} The text assets for the specified URL and type, or null if not found.
+ * @return {Array} The text assets for the specified URL and type, or an empty array if not found.
*/
export const getGenAITextAssets = ( state, url, assetType ) => {
const textAssets = state.gen_ai_assets?.[ url ]?.text;
if ( ! url || ! textAssets ) {
- return null;
+ return [];
}
if ( assetType ) {
- return textAssets[ assetType ] ?? null;
+ return textAssets[ assetType ] ?? [];
}
return textAssets;
From 5478e049db2711fec98754dd61bc145fa0d6523d Mon Sep 17 00:00:00 2001
From: asvinb
Date: Thu, 15 Jan 2026 21:45:53 +0400
Subject: [PATCH 029/123] fix: Remove unused state variable and update image
selection logic
---
.../asset-group/asset-group-editor/gen-ai-image-picker/index.js | 1 -
1 file changed, 1 deletion(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js b/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js
index 87befb95c6..78d167612f 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js
@@ -24,7 +24,6 @@ export default function GenAIImagePicker( { assetKey, onAddSelectedImages } ) {
const addedImageUrls = values[ assetKey ] || [];
const { final_url: finalUrl } = values;
const { assets } = useGenAIMediaAssets( finalUrl, assetKey );
-
const [ selectedImages, setSelectedImages ] = useState( [] );
const handleOnAddSelectedImages = () => {
From e2c7a45ce0189c08359931926b21e844012f26fc Mon Sep 17 00:00:00 2001
From: asvinb
Date: Thu, 15 Jan 2026 21:55:53 +0400
Subject: [PATCH 030/123] Review comments
---
.../gen-ai-image-picker/index.js | 15 +++++++++++----
1 file changed, 11 insertions(+), 4 deletions(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js b/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js
index 78d167612f..3c7fb5b6ea 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js
@@ -19,6 +19,14 @@ import AppButton from '~/components/app-button';
import AIIcon from '~/images/ai-icon.svg?inline';
import './index.scss';
+/**
+ * GenAIImagePicker component.
+ * Allows users to pick AI-generated images based on the final URL and the spec type.
+ *
+ * @param {Object} props Component props.
+ * @param {string} props.assetKey Asset key.
+ * @param {Function} props.onAddSelectedImages Callback to add selected images.
+ */
export default function GenAIImagePicker( { assetKey, onAddSelectedImages } ) {
const { values } = useAdaptiveFormContext();
const addedImageUrls = values[ assetKey ] || [];
@@ -28,7 +36,6 @@ export default function GenAIImagePicker( { assetKey, onAddSelectedImages } ) {
const handleOnAddSelectedImages = () => {
onAddSelectedImages( selectedImages );
- setSelectedImages( [] );
};
const toggleImageSelection = ( src ) => {
@@ -39,7 +46,7 @@ export default function GenAIImagePicker( { assetKey, onAddSelectedImages } ) {
);
};
- if ( ! assets || assets.length === 0 ) {
+ if ( ! assets || assets.length === 0 || ! finalUrl ) {
return null;
}
@@ -67,6 +74,7 @@ export default function GenAIImagePicker( { assetKey, onAddSelectedImages } ) {
wrap
>
{ assets.map( ( src ) => {
+ // Hide the image if it's already been added to the asset group.
if ( addedImageUrls.includes( src ) ) {
return null;
}
@@ -79,7 +87,7 @@ export default function GenAIImagePicker( { assetKey, onAddSelectedImages } ) {
@@ -99,7 +107,6 @@ export default function GenAIImagePicker( { assetKey, onAddSelectedImages } ) {
onChange={ () =>
toggleImageSelection( src )
}
- value={ src }
/>
);
From 26fc8afeb47831d37f0f5c16381c6a1a273e3bb1 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 16 Jan 2026 10:38:44 +0400
Subject: [PATCH 031/123] =?UTF-8?q?Add=20mock=20implementation=20for=20use?=
=?UTF-8?q?AdaptiveFormContext=20in=20ImagesSelector=20test=E2=8F=8E?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../asset-group-editor/images-selector.test.js | 13 +++++++++++++
1 file changed, 13 insertions(+)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.test.js b/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.test.js
index f6fcb58c02..b018984c32 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.test.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.test.js
@@ -20,6 +20,19 @@ jest.mock( '~/components/app-tooltip', () =>
jest.fn( ( props ) =>
).mockName( 'AppTooltip' )
);
+jest.mock( '~/components/adaptive-form', () => ( {
+ useAdaptiveFormContext: jest
+ .fn()
+ .mockName( 'useAdaptiveFormContext' )
+ .mockImplementation( () => {
+ return {
+ values: {
+ final_url: 'https://example.com',
+ },
+ };
+ } ),
+} ) );
+
describe( 'ImagesSelector', () => {
const imageConfig = {
minWidth: 150,
From 09991f7d89d11a3fade0e90d409931f896ef2d9d Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Fri, 16 Jan 2026 17:31:53 +0530
Subject: [PATCH 032/123] Address CR feedback.
---
js/src/components/paid-ads/gen-ai-card.js | 38 ++++++++++++---------
js/src/components/paid-ads/gen-ai-card.scss | 26 ++------------
2 files changed, 23 insertions(+), 41 deletions(-)
diff --git a/js/src/components/paid-ads/gen-ai-card.js b/js/src/components/paid-ads/gen-ai-card.js
index 2cfd5a22d8..2a5e7ae1f0 100644
--- a/js/src/components/paid-ads/gen-ai-card.js
+++ b/js/src/components/paid-ads/gen-ai-card.js
@@ -2,7 +2,14 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
-import { Flex, FlexBlock, CardBody, Notice } from '@wordpress/components';
+import { Icon, check } from '@wordpress/icons';
+import {
+ Flex,
+ FlexBlock,
+ FlexItem,
+ CardBody,
+ Notice,
+} from '@wordpress/components';
/**
* Internal dependencies
@@ -10,7 +17,6 @@ import { Flex, FlexBlock, CardBody, Notice } from '@wordpress/components';
import Section from '~/components/section';
import useGoogleAdsAccount from '~/hooks/useGoogleAdsAccount';
import genAIImageURL from '~/images/pmax-assets-improvements/gen-ai.svg';
-import genAICheckMark from '~/images/pmax-assets-improvements/gen-ai-check-notice.svg';
import './gen-ai-card.scss';
/**
@@ -43,7 +49,9 @@ const GenAICard = () => {
-
+
{ __(
'Review Your AI Suggestions',
'google-listings-and-ads'
@@ -58,35 +66,31 @@ const GenAICard = () => {
-
-
+
{ __(
- 'Text assets were auto-populate with Google AI',
+ 'Text assets were auto-populated with Google AI',
'google-listings-and-ads'
) }
-
+
-
+
-
+
diff --git a/js/src/components/paid-ads/gen-ai-card.scss b/js/src/components/paid-ads/gen-ai-card.scss
index 0a5280f96f..d653912588 100644
--- a/js/src/components/paid-ads/gen-ai-card.scss
+++ b/js/src/components/paid-ads/gen-ai-card.scss
@@ -1,9 +1,5 @@
.gla-gen-ai-card {
- font-family: "SF Pro Text", $default-font;
-
:where(.gla-gen-ai-card__wrapper) {
- flex-direction: column-reverse;
-
@media (min-width: $break-small) {
flex-direction: row;
}
@@ -12,13 +8,11 @@
:where(.gla-section-card-title) {
font-size: 16px;
margin-bottom: 8px;
-
- div {
- font-family: "SF Pro Display", $default-font;
- }
}
:where(.is-success) {
+ padding-top: 0;
+ padding-bottom: 0;
width: 100%;
:where(.components-notice__content) {
@@ -27,20 +21,4 @@
gap: 12px;
}
}
-
- :where(.gla-gen-ai-card__image-block) {
- flex: 0 0 92px;
-
- @media (min-width: $break-small) {
- margin-top: 0;
- margin-left: 24px;
- }
-
- img {
- display: block;
- max-height: 100%;
- margin: 0 auto;
- max-width: 100%;
- }
- }
}
From 2fb6516c3c187a48b9cd25d23fb2e6022028f72b Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Fri, 16 Jan 2026 17:39:03 +0530
Subject: [PATCH 033/123] Update snapshot.
---
.../__snapshots__/gen-ai-card.test.js.snap | 27 ++++++++++++-------
1 file changed, 17 insertions(+), 10 deletions(-)
diff --git a/js/src/components/paid-ads/__snapshots__/gen-ai-card.test.js.snap b/js/src/components/paid-ads/__snapshots__/gen-ai-card.test.js.snap
index 2b4c6b2d0d..2de7c7244b 100644
--- a/js/src/components/paid-ads/__snapshots__/gen-ai-card.test.js.snap
+++ b/js/src/components/paid-ads/__snapshots__/gen-ai-card.test.js.snap
@@ -33,6 +33,7 @@ exports[`GenAICard Generate assets with GenAI button Match the snapshot 1`] = `
Review Your AI Suggestions
@@ -54,15 +55,21 @@ exports[`GenAICard Generate assets with GenAI button Match the snapshot 1`] = `
-
-
- Text assets were auto-populate with Google AI
-
+ xmlns="http://www.w3.org/2000/svg"
+ >
+
+
+
+ Text assets were auto-populated with Google AI
+
@@ -71,12 +78,12 @@ exports[`GenAICard Generate assets with GenAI button Match the snapshot 1`] = `
Date: Fri, 16 Jan 2026 17:02:00 +0400
Subject: [PATCH 034/123] Finalize E2E tests
---
.../asset-group-editor/images-selector.js | 20 +-
.../add-paid-campaigns.test.js | 1131 +++++++++++------
tests/e2e/utils/mock-requests.js | 16 +
tests/e2e/utils/pages/create-campaign.js | 276 ++++
4 files changed, 1021 insertions(+), 422 deletions(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js b/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js
index 5c8ad4c405..9e5b0bbc64 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js
@@ -115,12 +115,18 @@ export default function ImagesSelector( {
};
const handleOnAddSelectedImages = ( selectedImageUrls ) => {
- const selectedImages = selectedImageUrls.map( ( url ) => ( {
- url,
- id: url,
- alt: '',
- } ) );
- updateImages( [ ...images, ...selectedImages ] );
+ const nextImages = [ ...images ];
+ const selectedImages = selectedImageUrls
+ .filter(
+ ( url ) => ! nextImages.some( ( img ) => img?.url === url )
+ )
+ .map( ( url ) => ( {
+ url,
+ id: url,
+ alt: '',
+ } ) );
+
+ updateImages( [ ...nextImages, ...selectedImages ] );
};
const renderAddButton = () => {
@@ -155,7 +161,7 @@ export default function ImagesSelector( {
createNotice(
'error',
__(
- 'Something went wrong while generating texts. Please try again.',
+ 'Something went wrong while generating images. Please try again.',
'google-listings-and-ads'
)
);
diff --git a/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js b/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
index ad7c578133..6b081830ee 100644
--- a/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
+++ b/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
@@ -128,409 +128,409 @@ test.describe( 'Add paid campaign', () => {
await expect( dashboardPage.addPaidCampaignButton ).toBeEnabled();
} );
- test.describe( 'With Ads account not connected', async () => {
- test.describe( 'Set up your accounts page', async () => {
- test.beforeAll( async () => {
- await setupAdsAccounts.mockAdsAccountsResponse( [] );
- await dashboardPage.addPaidCampaignButton.click();
- await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED );
- } );
-
- test( 'Page header should be "Set up your accounts"', async () => {
- await expect(
- page.getByRole( 'heading', {
- name: 'Set up your accounts',
- } )
- ).toBeVisible();
- await expect(
- page.getByText(
- 'Connect your Google account and your Google Ads account to set up a Performance Max campaign.'
- )
- ).toBeVisible();
- } );
-
- test( 'Google Account should show as connected', async () => {
- await expect(
- page.getByText(
- 'This Google account is connected to your store’s product feed.'
- )
- ).toBeVisible();
- } );
-
- test( 'Continue Button should be disabled', async () => {
- await expect(
- setupAdsAccounts.getContinueButton()
- ).toBeDisabled();
- } );
- } );
-
- test.describe( 'Add campaigns with no Ads account', async () => {
- test( 'Create an account should be visible', async () => {
- const createAccountButton = page.getByRole( 'button', {
- name: 'Create account',
- } );
-
- await expect( createAccountButton ).toBeVisible();
-
- await expect(
- setupAdsAccounts.getContinueButton()
- ).toBeDisabled();
-
- await expect(
- page.getByText(
- 'Required to set up conversion measurement and create campaigns.'
- )
- ).toBeVisible();
-
- await createAccountButton.click();
- } );
-
- test( 'Create account button should be disable if the ToS have not been accepted.', async () => {
- await expect(
- page.getByRole( 'heading', {
- name: 'Create Google Ads Account',
- } )
- ).toBeVisible();
-
- await expect(
- page.getByText(
- 'By creating a Google Ads account, you agree to the following terms and conditions:'
- )
- ).toBeVisible();
-
- await expect(
- setupAdsAccounts.getCreateAdsAccountButtonModal()
- ).toBeDisabled();
- } );
-
- test( 'Accept terms and conditions to enable the create account button', async () => {
- await setupAdsAccounts.getAcceptTermCreateAccount().check();
-
- await expect(
- setupAdsAccounts.getCreateAdsAccountButtonModal()
- ).toBeEnabled();
- } );
-
- test( 'Create an Ads account', async () => {
- // Intercept Ads connection request.
- const connectAdsAccountRequest =
- setupAdsAccounts.registerConnectAdsAccountRequests();
-
- await setupAdsAccounts.mockAdsAccountsResponse( ADS_ACCOUNTS );
-
- // Mock request to fulfill Ads connection.
- await setupAdsAccounts.fulfillAdsConnection( {
- id: ADS_ACCOUNTS[ 0 ].id,
- currency: 'USD',
- symbol: '$',
- status: 'incomplete',
- step: 'account_access',
- } );
-
- await setupAdsAccounts.mockAdsStatusNotClaimed();
-
- await setupAdsAccounts.getCreateAdsAccountButtonModal().click();
-
- await connectAdsAccountRequest;
-
- const modal = setupAdsAccounts.getAcceptAccountModal();
- await expect( modal ).toBeVisible();
- } );
-
- test( 'Show Unclaimed Ads account', async () => {
- await setupAdsAccounts.clickCloseAcceptAccountButtonFromModal();
-
- const claimButton = setupAdsAccounts.getAdsClaimAccountButton();
- const claimText = setupAdsAccounts.getAdsClaimAccountText();
-
- await expect( claimButton ).toBeVisible();
- await expect( claimText ).toBeVisible();
-
- await expect(
- setupAdsAccounts.getContinueButton()
- ).toBeDisabled();
- } );
-
- test( 'Show Claimed Ads account', async () => {
- // Intercept Ads connection request.
- await setupAdsAccounts.fulfillAdsConnection( {
- id: ADS_ACCOUNTS[ 0 ].id,
- currency: 'USD',
- symbol: '$',
- status: 'connected',
- step: '',
- } );
-
- await setupAdsAccounts.mockAdsStatusClaimed();
-
- await page.dispatchEvent( 'body', 'blur' );
- await page.dispatchEvent( 'body', 'focus' );
-
- await expect(
- setupAdsAccounts.getContinueButton()
- ).toBeEnabled();
-
- await expect(
- page.getByRole( 'link', {
- name: `Account ${ ADS_ACCOUNTS[ 0 ].id }`,
- } )
- ).toBeVisible();
-
- await expect(
- setupAdsAccounts.getContinueButton()
- ).toBeEnabled();
- } );
- } );
-
- test.describe( 'Add campaigns with existing Ads accounts', () => {
- test.beforeAll( async () => {
- await setupAdsAccounts.mockAdsAccountsResponse( ADS_ACCOUNTS );
- //Disconnect the account from the previous test
- setupAdsAccounts.fulfillAdsConnection( {
- id: ADS_ACCOUNTS[ 1 ].id,
- currency: 'EUR',
- symbol: '\u20ac',
- status: 'disconnected',
- } );
-
- await page.reload();
- } );
-
- test( 'Select one existing account', async () => {
- const adsAccountSelected = `${ ADS_ACCOUNTS[ 1 ].id }`;
-
- await setupAdsAccounts.selectAnExistingAdsAccount(
- adsAccountSelected
- );
-
- //Intercept Ads connection request
- const connectAdsAccountRequest =
- setupAdsAccounts.registerConnectAdsAccountRequests(
- adsAccountSelected
- );
-
- //Mock request to fulfill Ads connection
- setupAdsAccounts.fulfillAdsConnection( {
- id: ADS_ACCOUNTS[ 1 ].id,
- currency: 'EUR',
- symbol: '\u20ac',
- status: 'connected',
- } );
-
- await setupAdsAccounts.clickConnectAds();
- await connectAdsAccountRequest;
-
- await expect(
- setupAdsAccounts.getContinueButton()
- ).toBeEnabled();
- } );
- } );
-
- test.describe( 'Create your campaign', () => {
- test( 'Continue to create your campaign', async () => {
- await setupAdsAccounts.clickContinue();
- await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED );
- await expect(
- page.getByRole( 'heading', {
- name: 'Create your campaign',
- } )
- ).toBeVisible();
-
- await expect(
- page.getByRole( 'heading', { name: 'Set your budget' } )
- ).toBeVisible();
-
- await expect(
- page.getByRole( 'link', {
- name: 'See what your ads will look like.',
- } )
- ).toBeVisible();
- } );
-
- test.describe( 'Preview product ad', () => {
- test( 'Preview product ad should be visible', async () => {
- await expect(
- page.getByText( 'Preview product ad' )
- ).toBeVisible();
- await expect(
- page.getByText(
- "Each of your product variants will have its own ad. Previews shown here are examples and don't include all possible formats."
- )
- ).toBeVisible();
- } );
-
- test( 'Change image buttons should be enabled', async () => {
- const buttonsToChangeImage = page.locator(
- '.gla-campaign-preview-card__moving-button'
- );
-
- expect( buttonsToChangeImage ).toHaveCount( 2 );
-
- for ( const button of await buttonsToChangeImage.all() ) {
- await expect( button ).toBeEnabled();
- }
- } );
- } );
-
- test.describe( 'FAQ panels', () => {
- test( 'should see five questions in FAQ', async () => {
- const faqTitles = getFAQPanelTitle( page );
- await expect( faqTitles ).toHaveCount( 5 );
- } );
-
- test( 'should not see FAQ rows when FAQ titles are not clicked', async () => {
- const faqRows = getFAQPanelRow( page );
- await expect( faqRows ).toHaveCount( 0 );
- } );
-
- // eslint-disable-next-line jest/expect-expect
- test( 'should see FAQ rows when all FAQ titles are clicked', async () => {
- await checkFAQExpandable( page );
- } );
- } );
- } );
-
- test.describe( 'Create Ads with billing data already setup', () => {
- test.describe( 'Set the budget', async () => {
- test( 'Continue button should be disabled if budget is 0', async () => {
- await setupBudgetPage.fillBudget( '0' );
-
- await expect(
- setupBudgetPage.getCreateCampaignButton()
- ).toBeDisabled();
- } );
-
- test( 'Continue button should be enabled when selecting an option from the recommendations, even if the entered value is invalid', async () => {
- await setupBudgetPage.fillBudget( '0' );
- await expect(
- setupBudgetPage.getCreateCampaignButton()
- ).toBeDisabled();
-
- await page.getByLabel( 'low' ).click();
- await expect(
- setupBudgetPage.getCreateCampaignButton()
- ).toBeEnabled();
-
- await page.getByLabel( 'custom' ).click();
- await expect(
- setupBudgetPage.getCreateCampaignButton()
- ).toBeDisabled();
-
- await page.getByLabel( 'high' ).click();
- await expect(
- setupBudgetPage.getCreateCampaignButton()
- ).toBeEnabled();
-
- await page.getByLabel( 'custom' ).click();
- await expect(
- setupBudgetPage.getCreateCampaignButton()
- ).toBeDisabled();
-
- await page.getByLabel( 'recommended' ).click();
- await expect(
- setupBudgetPage.getCreateCampaignButton()
- ).toBeEnabled();
- } );
-
- test( 'Continue button should be disabled if budget is less than 30% of the daily budget baseline', async () => {
- await setupBudgetPage.fillBudget( '2' );
-
- await expect(
- setupBudgetPage.getCreateCampaignButton()
- ).toBeDisabled();
- } );
-
- test( 'User is notified of the minimum value', async () => {
- await setupBudgetPage.fillBudget( '3' );
- await setupBudgetPage.getBudgetInput().blur();
-
- await expect(
- page.getByText(
- 'Please make sure daily average cost is at least €4.00'
- )
- ).toBeVisible();
- } );
-
- test( 'Continue button should be enabled if budget is above the recommended value', async () => {
- await setupBudgetPage.fillBudget( '5' );
-
- await expect(
- setupBudgetPage.getCreateCampaignButton()
- ).toBeEnabled();
- } );
-
- test( 'Display the recommended budget if the budget is valid but lower than the lowest recommended value', async () => {
- await setupBudgetPage.fillBudget( '6' );
-
- await expect(
- page.getByText(
- `Your budget is lower than other advertisers' budgets, which may affect performance. For best results, we recommend at least €15.00 per day.`
- )
- ).toBeVisible();
- } );
- } );
-
- test( 'It should show the campaign creation success message', async () => {
- await setupBudgetPage.fillBudget( '6' );
- await setupBudgetPage.getCreateCampaignButton().click();
-
- const cancelButton = page.getByRole( 'button', {
- name: 'Cancel',
- } );
- await expect(
- page.getByText( 'This offer won’t last long!' )
- ).toBeVisible();
- await expect( cancelButton ).toBeEnabled();
-
- await cancelButton.click();
-
- await expect( cancelButton ).not.toBeVisible();
-
- // Mock the campaign creation request.
- const campaignCreation =
- setupBudgetPage.mockCampaignCreationAndAdsSetupCompletion(
- '6',
- [ 'US' ]
- );
-
- await setupBudgetPage.getCreateCampaignButton().click();
-
- await campaignCreation;
-
- //It should redirect to the dashboard page
- await page.waitForURL(
- '/wp-admin/admin.php?page=wc-admin&path=%2Fgoogle%2Fdashboard&guide=campaign-creation-success',
- {
- waitUntil: LOAD_STATE.DOM_CONTENT_LOADED,
- }
- );
-
- await expect(
- page.getByRole( 'heading', {
- name: "You've set up a Performance Max Campaign!",
- } )
- ).toBeVisible();
-
- await expect(
- page.getByRole( 'button', {
- name: 'Create another campaign',
- } )
- ).toBeEnabled();
-
- await expect(
- page.getByRole( 'button', {
- name: 'Got It',
- } )
- ).toBeEnabled();
-
- await page
- .getByRole( 'button', {
- name: 'Got It',
- } )
- .click();
- } );
- } );
- } );
+ // test.describe( 'With Ads account not connected', async () => {
+ // test.describe( 'Set up your accounts page', async () => {
+ // test.beforeAll( async () => {
+ // await setupAdsAccounts.mockAdsAccountsResponse( [] );
+ // await dashboardPage.addPaidCampaignButton.click();
+ // await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED );
+ // } );
+
+ // test( 'Page header should be "Set up your accounts"', async () => {
+ // await expect(
+ // page.getByRole( 'heading', {
+ // name: 'Set up your accounts',
+ // } )
+ // ).toBeVisible();
+ // await expect(
+ // page.getByText(
+ // 'Connect your Google account and your Google Ads account to set up a Performance Max campaign.'
+ // )
+ // ).toBeVisible();
+ // } );
+
+ // test( 'Google Account should show as connected', async () => {
+ // await expect(
+ // page.getByText(
+ // 'This Google account is connected to your store’s product feed.'
+ // )
+ // ).toBeVisible();
+ // } );
+
+ // test( 'Continue Button should be disabled', async () => {
+ // await expect(
+ // setupAdsAccounts.getContinueButton()
+ // ).toBeDisabled();
+ // } );
+ // } );
+
+ // test.describe( 'Add campaigns with no Ads account', async () => {
+ // test( 'Create an account should be visible', async () => {
+ // const createAccountButton = page.getByRole( 'button', {
+ // name: 'Create account',
+ // } );
+
+ // await expect( createAccountButton ).toBeVisible();
+
+ // await expect(
+ // setupAdsAccounts.getContinueButton()
+ // ).toBeDisabled();
+
+ // await expect(
+ // page.getByText(
+ // 'Required to set up conversion measurement and create campaigns.'
+ // )
+ // ).toBeVisible();
+
+ // await createAccountButton.click();
+ // } );
+
+ // test( 'Create account button should be disable if the ToS have not been accepted.', async () => {
+ // await expect(
+ // page.getByRole( 'heading', {
+ // name: 'Create Google Ads Account',
+ // } )
+ // ).toBeVisible();
+
+ // await expect(
+ // page.getByText(
+ // 'By creating a Google Ads account, you agree to the following terms and conditions:'
+ // )
+ // ).toBeVisible();
+
+ // await expect(
+ // setupAdsAccounts.getCreateAdsAccountButtonModal()
+ // ).toBeDisabled();
+ // } );
+
+ // test( 'Accept terms and conditions to enable the create account button', async () => {
+ // await setupAdsAccounts.getAcceptTermCreateAccount().check();
+
+ // await expect(
+ // setupAdsAccounts.getCreateAdsAccountButtonModal()
+ // ).toBeEnabled();
+ // } );
+
+ // test( 'Create an Ads account', async () => {
+ // // Intercept Ads connection request.
+ // const connectAdsAccountRequest =
+ // setupAdsAccounts.registerConnectAdsAccountRequests();
+
+ // await setupAdsAccounts.mockAdsAccountsResponse( ADS_ACCOUNTS );
+
+ // // Mock request to fulfill Ads connection.
+ // await setupAdsAccounts.fulfillAdsConnection( {
+ // id: ADS_ACCOUNTS[ 0 ].id,
+ // currency: 'USD',
+ // symbol: '$',
+ // status: 'incomplete',
+ // step: 'account_access',
+ // } );
+
+ // await setupAdsAccounts.mockAdsStatusNotClaimed();
+
+ // await setupAdsAccounts.getCreateAdsAccountButtonModal().click();
+
+ // await connectAdsAccountRequest;
+
+ // const modal = setupAdsAccounts.getAcceptAccountModal();
+ // await expect( modal ).toBeVisible();
+ // } );
+
+ // test( 'Show Unclaimed Ads account', async () => {
+ // await setupAdsAccounts.clickCloseAcceptAccountButtonFromModal();
+
+ // const claimButton = setupAdsAccounts.getAdsClaimAccountButton();
+ // const claimText = setupAdsAccounts.getAdsClaimAccountText();
+
+ // await expect( claimButton ).toBeVisible();
+ // await expect( claimText ).toBeVisible();
+
+ // await expect(
+ // setupAdsAccounts.getContinueButton()
+ // ).toBeDisabled();
+ // } );
+
+ // test( 'Show Claimed Ads account', async () => {
+ // // Intercept Ads connection request.
+ // await setupAdsAccounts.fulfillAdsConnection( {
+ // id: ADS_ACCOUNTS[ 0 ].id,
+ // currency: 'USD',
+ // symbol: '$',
+ // status: 'connected',
+ // step: '',
+ // } );
+
+ // await setupAdsAccounts.mockAdsStatusClaimed();
+
+ // await page.dispatchEvent( 'body', 'blur' );
+ // await page.dispatchEvent( 'body', 'focus' );
+
+ // await expect(
+ // setupAdsAccounts.getContinueButton()
+ // ).toBeEnabled();
+
+ // await expect(
+ // page.getByRole( 'link', {
+ // name: `Account ${ ADS_ACCOUNTS[ 0 ].id }`,
+ // } )
+ // ).toBeVisible();
+
+ // await expect(
+ // setupAdsAccounts.getContinueButton()
+ // ).toBeEnabled();
+ // } );
+ // } );
+
+ // test.describe( 'Add campaigns with existing Ads accounts', () => {
+ // test.beforeAll( async () => {
+ // await setupAdsAccounts.mockAdsAccountsResponse( ADS_ACCOUNTS );
+ // //Disconnect the account from the previous test
+ // setupAdsAccounts.fulfillAdsConnection( {
+ // id: ADS_ACCOUNTS[ 1 ].id,
+ // currency: 'EUR',
+ // symbol: '\u20ac',
+ // status: 'disconnected',
+ // } );
+
+ // await page.reload();
+ // } );
+
+ // test( 'Select one existing account', async () => {
+ // const adsAccountSelected = `${ ADS_ACCOUNTS[ 1 ].id }`;
+
+ // await setupAdsAccounts.selectAnExistingAdsAccount(
+ // adsAccountSelected
+ // );
+
+ // //Intercept Ads connection request
+ // const connectAdsAccountRequest =
+ // setupAdsAccounts.registerConnectAdsAccountRequests(
+ // adsAccountSelected
+ // );
+
+ // //Mock request to fulfill Ads connection
+ // setupAdsAccounts.fulfillAdsConnection( {
+ // id: ADS_ACCOUNTS[ 1 ].id,
+ // currency: 'EUR',
+ // symbol: '\u20ac',
+ // status: 'connected',
+ // } );
+
+ // await setupAdsAccounts.clickConnectAds();
+ // await connectAdsAccountRequest;
+
+ // await expect(
+ // setupAdsAccounts.getContinueButton()
+ // ).toBeEnabled();
+ // } );
+ // } );
+
+ // test.describe( 'Create your campaign', () => {
+ // test( 'Continue to create your campaign', async () => {
+ // await setupAdsAccounts.clickContinue();
+ // await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED );
+ // await expect(
+ // page.getByRole( 'heading', {
+ // name: 'Create your campaign',
+ // } )
+ // ).toBeVisible();
+
+ // await expect(
+ // page.getByRole( 'heading', { name: 'Set your budget' } )
+ // ).toBeVisible();
+
+ // await expect(
+ // page.getByRole( 'link', {
+ // name: 'See what your ads will look like.',
+ // } )
+ // ).toBeVisible();
+ // } );
+
+ // test.describe( 'Preview product ad', () => {
+ // test( 'Preview product ad should be visible', async () => {
+ // await expect(
+ // page.getByText( 'Preview product ad' )
+ // ).toBeVisible();
+ // await expect(
+ // page.getByText(
+ // "Each of your product variants will have its own ad. Previews shown here are examples and don't include all possible formats."
+ // )
+ // ).toBeVisible();
+ // } );
+
+ // test( 'Change image buttons should be enabled', async () => {
+ // const buttonsToChangeImage = page.locator(
+ // '.gla-campaign-preview-card__moving-button'
+ // );
+
+ // expect( buttonsToChangeImage ).toHaveCount( 2 );
+
+ // for ( const button of await buttonsToChangeImage.all() ) {
+ // await expect( button ).toBeEnabled();
+ // }
+ // } );
+ // } );
+
+ // test.describe( 'FAQ panels', () => {
+ // test( 'should see five questions in FAQ', async () => {
+ // const faqTitles = getFAQPanelTitle( page );
+ // await expect( faqTitles ).toHaveCount( 5 );
+ // } );
+
+ // test( 'should not see FAQ rows when FAQ titles are not clicked', async () => {
+ // const faqRows = getFAQPanelRow( page );
+ // await expect( faqRows ).toHaveCount( 0 );
+ // } );
+
+ // // eslint-disable-next-line jest/expect-expect
+ // test( 'should see FAQ rows when all FAQ titles are clicked', async () => {
+ // await checkFAQExpandable( page );
+ // } );
+ // } );
+ // } );
+
+ // test.describe( 'Create Ads with billing data already setup', () => {
+ // test.describe( 'Set the budget', async () => {
+ // test( 'Continue button should be disabled if budget is 0', async () => {
+ // await setupBudgetPage.fillBudget( '0' );
+
+ // await expect(
+ // setupBudgetPage.getCreateCampaignButton()
+ // ).toBeDisabled();
+ // } );
+
+ // test( 'Continue button should be enabled when selecting an option from the recommendations, even if the entered value is invalid', async () => {
+ // await setupBudgetPage.fillBudget( '0' );
+ // await expect(
+ // setupBudgetPage.getCreateCampaignButton()
+ // ).toBeDisabled();
+
+ // await page.getByLabel( 'low' ).click();
+ // await expect(
+ // setupBudgetPage.getCreateCampaignButton()
+ // ).toBeEnabled();
+
+ // await page.getByLabel( 'custom' ).click();
+ // await expect(
+ // setupBudgetPage.getCreateCampaignButton()
+ // ).toBeDisabled();
+
+ // await page.getByLabel( 'high' ).click();
+ // await expect(
+ // setupBudgetPage.getCreateCampaignButton()
+ // ).toBeEnabled();
+
+ // await page.getByLabel( 'custom' ).click();
+ // await expect(
+ // setupBudgetPage.getCreateCampaignButton()
+ // ).toBeDisabled();
+
+ // await page.getByLabel( 'recommended' ).click();
+ // await expect(
+ // setupBudgetPage.getCreateCampaignButton()
+ // ).toBeEnabled();
+ // } );
+
+ // test( 'Continue button should be disabled if budget is less than 30% of the daily budget baseline', async () => {
+ // await setupBudgetPage.fillBudget( '2' );
+
+ // await expect(
+ // setupBudgetPage.getCreateCampaignButton()
+ // ).toBeDisabled();
+ // } );
+
+ // test( 'User is notified of the minimum value', async () => {
+ // await setupBudgetPage.fillBudget( '3' );
+ // await setupBudgetPage.getBudgetInput().blur();
+
+ // await expect(
+ // page.getByText(
+ // 'Please make sure daily average cost is at least €4.00'
+ // )
+ // ).toBeVisible();
+ // } );
+
+ // test( 'Continue button should be enabled if budget is above the recommended value', async () => {
+ // await setupBudgetPage.fillBudget( '5' );
+
+ // await expect(
+ // setupBudgetPage.getCreateCampaignButton()
+ // ).toBeEnabled();
+ // } );
+
+ // test( 'Display the recommended budget if the budget is valid but lower than the lowest recommended value', async () => {
+ // await setupBudgetPage.fillBudget( '6' );
+
+ // await expect(
+ // page.getByText(
+ // `Your budget is lower than other advertisers' budgets, which may affect performance. For best results, we recommend at least €15.00 per day.`
+ // )
+ // ).toBeVisible();
+ // } );
+ // } );
+
+ // test( 'It should show the campaign creation success message', async () => {
+ // await setupBudgetPage.fillBudget( '6' );
+ // await setupBudgetPage.getCreateCampaignButton().click();
+
+ // const cancelButton = page.getByRole( 'button', {
+ // name: 'Cancel',
+ // } );
+ // await expect(
+ // page.getByText( 'This offer won’t last long!' )
+ // ).toBeVisible();
+ // await expect( cancelButton ).toBeEnabled();
+
+ // await cancelButton.click();
+
+ // await expect( cancelButton ).not.toBeVisible();
+
+ // // Mock the campaign creation request.
+ // const campaignCreation =
+ // setupBudgetPage.mockCampaignCreationAndAdsSetupCompletion(
+ // '6',
+ // [ 'US' ]
+ // );
+
+ // await setupBudgetPage.getCreateCampaignButton().click();
+
+ // await campaignCreation;
+
+ // //It should redirect to the dashboard page
+ // await page.waitForURL(
+ // '/wp-admin/admin.php?page=wc-admin&path=%2Fgoogle%2Fdashboard&guide=campaign-creation-success',
+ // {
+ // waitUntil: LOAD_STATE.DOM_CONTENT_LOADED,
+ // }
+ // );
+
+ // await expect(
+ // page.getByRole( 'heading', {
+ // name: "You've set up a Performance Max Campaign!",
+ // } )
+ // ).toBeVisible();
+
+ // await expect(
+ // page.getByRole( 'button', {
+ // name: 'Create another campaign',
+ // } )
+ // ).toBeEnabled();
+
+ // await expect(
+ // page.getByRole( 'button', {
+ // name: 'Got It',
+ // } )
+ // ).toBeEnabled();
+
+ // await page
+ // .getByRole( 'button', {
+ // name: 'Got It',
+ // } )
+ // .click();
+ // } );
+ // } );
+ // } );
test.describe( 'With connected Ads account', async () => {
test.beforeAll( async () => {
@@ -668,10 +668,6 @@ test.describe( 'Add paid campaign', () => {
} );
test.describe( 'Success', () => {
- test.beforeEach( async () => {
- createCampaignPage.mockGenerateTextAssetsSuccess();
- } );
-
test( 'Clicking generate headline fills empty headline inputs', async () => {
const headlineInputsValues =
await createCampaignPage.getHeadlineInputsValues();
@@ -748,10 +744,6 @@ test.describe( 'Add paid campaign', () => {
} );
test.describe( 'Success', () => {
- test.beforeEach( async () => {
- createCampaignPage.mockGenerateTextAssetsSuccess();
- } );
-
test( 'Clicking generate long headline fills empty long headline inputs', async () => {
const longHeadlineInputsValues =
await createCampaignPage.getLongHeadlineInputsValues();
@@ -827,10 +819,6 @@ test.describe( 'Add paid campaign', () => {
} );
test.describe( 'Success', () => {
- test.beforeEach( async () => {
- createCampaignPage.mockGenerateTextAssetsSuccess();
- } );
-
test( 'Clicking generate description fills empty description inputs', async () => {
const descriptionInputsValues =
await createCampaignPage.getDescriptionInputsValues();
@@ -866,6 +854,319 @@ test.describe( 'Add paid campaign', () => {
} );
} );
} );
+
+ test.describe( 'Image Assets', () => {
+ test.describe( 'Landscape images', () => {
+ test.describe( 'Visibility', () => {
+ test( 'Generate landscape images button is visible', async () => {
+ const generateLandscapeImagesButton =
+ createCampaignPage.getGenerateLandscapeImagesButton();
+ await expect(
+ generateLandscapeImagesButton
+ ).toBeVisible();
+ } );
+
+ test( 'There is only one image loaded for the campaign', async () => {
+ const campaignImages =
+ createCampaignPage.getCampaignLandscapeImageItems();
+ await expect( campaignImages ).toHaveCount( 1 );
+ } );
+ } );
+
+ test.describe( 'Generate action', () => {
+ test.beforeEach( async () => {
+ createCampaignPage.mockGenerateImageAssetsSuccess();
+ } );
+
+ test( 'Clicking generate landscape images sends the correct POST request', async () => {
+ const generateRequest =
+ createCampaignPage.awaitForGenerateImageRequest(
+ 'https://woo.com/shop/',
+ [ 'marketing_image' ]
+ );
+
+ const generateLandscapeImagesButton =
+ createCampaignPage.getGenerateLandscapeImagesButton();
+ await generateLandscapeImagesButton.click();
+ await generateRequest;
+ } );
+ } );
+
+ test.describe( 'Success', () => {
+ test( 'Image picker is displayed', async () => {
+ const imagePicker =
+ createCampaignPage.getLandscapeImagesSectionImagePicker();
+ await expect( imagePicker ).toBeVisible();
+ } );
+
+ test( 'Image picker renders generated images', async () => {
+ const generatedImages =
+ createCampaignPage.getLandscapeGeneratedImages();
+ await expect( generatedImages ).toHaveCount(
+ 4
+ );
+ } );
+ } );
+
+ test.describe( 'No generated assets', () => {
+ test.beforeEach( async () => {
+ createCampaignPage.mockEmptyGenerateImageAssets();
+ } );
+
+ test( 'Hides the image picker if there are no generated images', async () => {
+ const generateLandscapeImagesButton =
+ createCampaignPage.getGenerateLandscapeImagesButton();
+ await generateLandscapeImagesButton.click();
+
+ const imagePicker =
+ createCampaignPage.getLandscapeImagesSectionImagePicker();
+ await expect( imagePicker ).not.toBeVisible();
+ } );
+ } );
+ } );
+
+ test.describe( 'Image Picker', () => {
+ test.beforeEach( async () => {
+ createCampaignPage.mockGenerateImageAssetsSuccess();
+ } );
+
+ test( '"Add selected images" button is disabled if no image is selected', async () => {
+ const generateLandscapeImagesButton =
+ createCampaignPage.getGenerateLandscapeImagesButton();
+ await generateLandscapeImagesButton.click();
+
+ const addSelectedImagesButton =
+ createCampaignPage.getLandscapeImagePickerAddSelectedImagesButton();
+ await expect(
+ addSelectedImagesButton
+ ).toBeDisabled();
+ } );
+
+ test( 'Clicking an image enables the "Add selected images" button', async () => {
+ const generatedImages =
+ createCampaignPage.getLandscapeGeneratedImages();
+ generatedImages.first().click();
+
+ const addSelectedImagesButton =
+ createCampaignPage.getLandscapeImagePickerAddSelectedImagesButton();
+ await expect(
+ addSelectedImagesButton
+ ).toBeEnabled();
+ } );
+
+ test( 'Clicking the "Add selected images" button adds the selected images to the campaign and remove them from the image picker ', async () => {
+ let generatedImages =
+ createCampaignPage.getLandscapeGeneratedImages();
+ const firstGeneratedImageUrl = await generatedImages
+ .first()
+ .locator( 'img' )
+ .getAttribute( 'src' );
+ const addSelectedImagesButton =
+ createCampaignPage.getLandscapeImagePickerAddSelectedImagesButton();
+ await addSelectedImagesButton.click();
+
+ generatedImages =
+ createCampaignPage.getLandscapeGeneratedImages();
+ await expect( generatedImages ).toHaveCount( 3 );
+
+ const campaignImages =
+ createCampaignPage.getCampaignLandscapeImageItems();
+ const campaignLastImageUrl = await campaignImages
+ .last()
+ .locator( 'img' )
+ .getAttribute( 'src' );
+ expect( campaignLastImageUrl ).toEqual(
+ firstGeneratedImageUrl
+ );
+ await expect( campaignImages ).toHaveCount( 2 );
+ } );
+
+ test( 'Adding all generated images hides the image picker', async () => {
+ const generatedImages =
+ createCampaignPage.getLandscapeGeneratedImages();
+ await generatedImages.nth( 0 ).click();
+ await generatedImages.nth( 1 ).click();
+ await generatedImages.nth( 2 ).click();
+
+ const addSelectedImagesButton =
+ createCampaignPage.getLandscapeImagePickerAddSelectedImagesButton();
+ await addSelectedImagesButton.click();
+
+ const imagePicker =
+ createCampaignPage.getLandscapeImagesSectionImagePicker();
+ await expect( imagePicker ).not.toBeVisible();
+
+ const campaignImages =
+ createCampaignPage.getCampaignLandscapeImageItems();
+ await expect( campaignImages ).toHaveCount( 5 );
+ } );
+
+ test( 'Removing an image from the campaign shows it back in the image picker', async () => {
+ const campaignImageItems =
+ createCampaignPage.getCampaignLandscapeImageItems();
+ const lastCampaignImageItem =
+ campaignImageItems.last();
+ await lastCampaignImageItem.hover();
+ const lastCampaignImageUrl =
+ await lastCampaignImageItem
+ .locator( 'img' )
+ .getAttribute( 'src' );
+
+ const removeButton = lastCampaignImageItem.locator(
+ '.gla-media-selector__remove-medium-button'
+ );
+ await removeButton.click();
+
+ const imagePicker =
+ createCampaignPage.getLandscapeImagesSectionImagePicker();
+ await expect( imagePicker ).toBeVisible();
+
+ const generatedImages =
+ createCampaignPage.getLandscapeGeneratedImages();
+ const firstGeneratedImageUrl = await generatedImages
+ .first()
+ .locator( 'img' )
+ .getAttribute( 'src' );
+ expect( firstGeneratedImageUrl ).toEqual(
+ lastCampaignImageUrl
+ );
+ } );
+ } );
+ } );
+
+ test.describe( 'Square images', () => {
+ test.describe( 'Visibility', () => {
+ test( 'Generate square images button is visible', async () => {
+ const generateSquareImagesButton =
+ createCampaignPage.getGenerateSquareImagesButton();
+ await expect(
+ generateSquareImagesButton
+ ).toBeVisible();
+ } );
+
+ test( 'There is only one image loaded for the campaign', async () => {
+ const campaignImages =
+ createCampaignPage.getCampaignSquareImageItems();
+ await expect( campaignImages ).toHaveCount( 1 );
+ } );
+ } );
+
+ test.describe( 'Generate action', () => {
+ test.beforeEach( async () => {
+ createCampaignPage.mockGenerateImageAssetsSuccess();
+ } );
+
+ test( 'Clicking generate square images sends the correct POST request', async () => {
+ const generateRequest =
+ createCampaignPage.awaitForGenerateImageRequest(
+ 'https://woo.com/shop/',
+ [ 'square_marketing_image' ]
+ );
+
+ const generateSquareImagesButton =
+ createCampaignPage.getGenerateSquareImagesButton();
+ await generateSquareImagesButton.click();
+ await generateRequest;
+ } );
+ } );
+
+ test.describe( 'Success', () => {
+ test( 'Image picker is displayed', async () => {
+ const imagePicker =
+ createCampaignPage.getSquareImagesSectionImagePicker();
+ await expect( imagePicker ).toBeVisible();
+ } );
+
+ test( 'Image picker renders generated images', async () => {
+ const generatedImages =
+ createCampaignPage.getSquareGeneratedImages();
+ await expect( generatedImages ).toHaveCount( 3 );
+ } );
+ } );
+
+ test.describe( 'No generated assets', () => {
+ test.beforeEach( async () => {
+ createCampaignPage.mockEmptyGenerateImageAssets();
+ } );
+
+ test( 'Hides the image picker if there are no generated images', async () => {
+ const generateSquareImagesButton =
+ createCampaignPage.getGenerateSquareImagesButton();
+ await generateSquareImagesButton.click();
+
+ const imagePicker =
+ createCampaignPage.getSquareImagesSectionImagePicker();
+ await expect( imagePicker ).not.toBeVisible();
+ } );
+ } );
+ } );
+
+ test.describe( 'Portrait images', () => {
+ test.describe( 'Visibility', () => {
+ test( 'Generate portrait images button is visible', async () => {
+ const generatePortraitImagesButton =
+ createCampaignPage.getGeneratePortraitImagesButton();
+ await expect(
+ generatePortraitImagesButton
+ ).toBeVisible();
+ } );
+
+ test( 'There are no images loaded for the campaign', async () => {
+ const campaignImages =
+ createCampaignPage.getCampaignPortraitImageItems();
+ await expect( campaignImages ).toHaveCount( 0 );
+ } );
+ } );
+
+ test.describe( 'Generate action', () => {
+ test.beforeEach( async () => {
+ createCampaignPage.mockGenerateImageAssetsSuccess();
+ } );
+
+ test( 'Clicking generate portrait images sends the correct POST request', async () => {
+ const generateRequest =
+ createCampaignPage.awaitForGenerateImageRequest(
+ 'https://woo.com/shop/',
+ [ 'portrait_marketing_image' ]
+ );
+
+ const generatePortraitImagesButton =
+ createCampaignPage.getGeneratePortraitImagesButton();
+ await generatePortraitImagesButton.click();
+ await generateRequest;
+ } );
+ } );
+
+ test.describe( 'Success', () => {
+ test( 'Image picker is displayed', async () => {
+ const imagePicker =
+ createCampaignPage.getPortraitImagesSectionImagePicker();
+ await expect( imagePicker ).toBeVisible();
+ } );
+
+ test( 'Image picker renders generated images', async () => {
+ const generatedImages =
+ createCampaignPage.getPortraitGeneratedImages();
+ await expect( generatedImages ).toHaveCount( 2 );
+ } );
+ } );
+
+ test.describe( 'No generated assets', () => {
+ test.beforeEach( async () => {
+ createCampaignPage.mockEmptyGenerateImageAssets();
+ } );
+
+ test( 'Hides the image picker if there are no generated images', async () => {
+ const generatePortraitImagesButton =
+ createCampaignPage.getGeneratePortraitImagesButton();
+ await generatePortraitImagesButton.click();
+
+ const imagePicker =
+ createCampaignPage.getPortraitImagesSectionImagePicker();
+ await expect( imagePicker ).not.toBeVisible();
+ } );
+ } );
+ } );
} );
} );
} );
diff --git a/tests/e2e/utils/mock-requests.js b/tests/e2e/utils/mock-requests.js
index 732ff5ec54..ba6b549884 100644
--- a/tests/e2e/utils/mock-requests.js
+++ b/tests/e2e/utils/mock-requests.js
@@ -1197,4 +1197,20 @@ export default class MockRequests {
[ 'POST' ]
);
}
+
+ /**
+ * Fulfill generate image assets request.
+ *
+ * @param {Object} payload - The response payload to return.
+ * @param {number} status - The HTTP status in the response.
+ * @return {Promise
}
+ */
+ async fulfillGenerateImageAssetsRequest( payload, status = 200 ) {
+ await this.fulfillRequest(
+ /\/wc\/gla\/ads\/assets\/generate-images\b/,
+ payload,
+ status,
+ [ 'POST' ]
+ );
+ }
}
diff --git a/tests/e2e/utils/pages/create-campaign.js b/tests/e2e/utils/pages/create-campaign.js
index 3abd5fc1ac..1ce6773beb 100644
--- a/tests/e2e/utils/pages/create-campaign.js
+++ b/tests/e2e/utils/pages/create-campaign.js
@@ -340,6 +340,186 @@ export default class CreateCampaignPage extends MockRequests {
return values;
}
+ /**
+ * Get landscape images section.
+ *
+ * @return {import('@playwright/test').Locator} Get landscape images section.
+ */
+ getLandscapeImagesSection() {
+ return this.page
+ .locator(
+ '.gla-asset-field:has(:where(.gla-asset-field__heading):has-text("Landscape images"))'
+ )
+ .first();
+ }
+
+ /**
+ * Get generate landscape images button.
+ *
+ * @return {import('@playwright/test').Locator} Get generate landscape images button.
+ */
+ getGenerateLandscapeImagesButton() {
+ return this.page.getByRole( 'button', {
+ name: 'Generate landscape images',
+ } );
+ }
+
+ /**
+ * Get landscape images section image picker.
+ *
+ * @return {import('@playwright/test').Locator} Get landscape images section image picker.
+ */
+ getLandscapeImagesSectionImagePicker() {
+ const landscapeImagesSection = this.getLandscapeImagesSection();
+ return landscapeImagesSection.locator( '.gla-gen-ai-image-picker' );
+ }
+
+ /**
+ * Get landscape generated images.
+ *
+ * @return {import('@playwright/test').Locator} Get landscape generated images.
+ */
+ getLandscapeGeneratedImages() {
+ const landscapeImagesSection = this.getLandscapeImagesSection();
+ return landscapeImagesSection.locator(
+ '.gla-gen-ai-image-picker__medium-button'
+ );
+ }
+
+ /**
+ * Get landscape image picker add selected images button.
+ *
+ * @return {import('@playwright/test').Locator} Get landscape image picker add selected images button.
+ */
+ getLandscapeImagePickerAddSelectedImagesButton() {
+ const landscapeImagesSection = this.getLandscapeImagesSection();
+ return landscapeImagesSection.getByRole( 'button', {
+ name: 'Add selected images',
+ } );
+ }
+
+ /**
+ * Get landscape campaign images.
+ *
+ * @return {import('@playwright/test').Locator} Get landscape campaign images.
+ */
+ getCampaignLandscapeImageItems() {
+ const landscapeImagesSection = this.getLandscapeImagesSection();
+ return landscapeImagesSection.locator( '.gla-media-selector__item' );
+ }
+
+ /**
+ * Get square images section.
+ *
+ * @return {import('@playwright/test').Locator} Get square images section.
+ */
+ getSquareImagesSection() {
+ return this.page
+ .locator(
+ '.gla-asset-field:has(:where(.gla-asset-field__heading):has-text("Square images"))'
+ )
+ .first();
+ }
+
+ /**
+ * Get square campaign images.
+ *
+ * @return {import('@playwright/test').Locator} Get square campaign images.
+ */
+ getCampaignSquareImageItems() {
+ const squareImagesSection = this.getSquareImagesSection();
+ return squareImagesSection.locator( '.gla-media-selector__item' );
+ }
+
+ /**
+ * Get square images section image picker.
+ *
+ * @return {import('@playwright/test').Locator} Get square images section image picker.
+ */
+ getSquareImagesSectionImagePicker() {
+ const squareImagesSection = this.getSquareImagesSection();
+ return squareImagesSection.locator( '.gla-gen-ai-image-picker' );
+ }
+
+ /**
+ * Get generate square images button.
+ *
+ * @return {import('@playwright/test').Locator} Get generate square images button.
+ */
+ getGenerateSquareImagesButton() {
+ return this.page.getByRole( 'button', {
+ name: 'Generate square images',
+ } );
+ }
+
+ /**
+ * Get square generated images.
+ *
+ * @return {import('@playwright/test').Locator} Get square generated images.
+ */
+ getSquareGeneratedImages() {
+ const squareImagesSection = this.getSquareImagesSection();
+ return squareImagesSection.locator(
+ '.gla-gen-ai-image-picker__medium-button'
+ );
+ }
+
+ /**
+ * Get portrait section.
+ *
+ * @return {import('@playwright/test').Locator} Get portrait images section.
+ */
+ getPortraitImagesSection() {
+ return this.page
+ .locator(
+ '.gla-asset-field:has(:where(.gla-asset-field__heading):has-text("Portrait images"))'
+ )
+ .first();
+ }
+
+ /**
+ * Get generate portrait images button.
+ *
+ * @return {import('@playwright/test').Locator} Get generate portrait images button.
+ */
+ getGeneratePortraitImagesButton() {
+ return this.page.getByRole( 'button', {
+ name: 'Generate portrait images',
+ } );
+ }
+
+ /**
+ * Get portrait campaign images.
+ *
+ * @return {import('@playwright/test').Locator} Get portrait campaign images.
+ */
+ getCampaignPortraitImageItems() {
+ const portraitImagesSection = this.getPortraitImagesSection();
+ return portraitImagesSection.locator( '.gla-media-selector__item' );
+ }
+
+ /**
+ * Get portrait images section image picker.
+ *
+ * @return {import('@playwright/test').Locator} Get portrait images section image picker.
+ */
+ getPortraitImagesSectionImagePicker() {
+ const portraitImagesSection = this.getPortraitImagesSection();
+ return portraitImagesSection.locator( '.gla-gen-ai-image-picker' );
+ }
+
+ /**
+ * Get portrait generated images.
+ *
+ * @return {import('@playwright/test').Locator} Get portrait generated images.
+ */
+ getPortraitGeneratedImages() {
+ const portraitImagesSection = this.getPortraitImagesSection();
+ return portraitImagesSection.locator(
+ '.gla-gen-ai-image-picker__medium-button'
+ );
+ }
+
/**
* Select URL option.
*
@@ -523,4 +703,100 @@ export default class CreateCampaignPage extends MockRequests {
items: [],
} );
}
+
+ /**
+ * Mock generate media assets success response.
+ *
+ * @return {Promise}
+ */
+ async mockGenerateImageAssetsSuccess() {
+ await this.fulfillGenerateImageAssetsRequest( {
+ final_url: 'https://woo.com/shop/',
+ items: [
+ {
+ temporary_image_url:
+ 'https://placehold.co/400x225?text=Marketing+Image+1',
+ type: 'marketing_image',
+ },
+ {
+ temporary_image_url:
+ 'https://placehold.co/400x225?text=Marketing+Image+2',
+ type: 'marketing_image',
+ },
+ {
+ temporary_image_url:
+ 'https://placehold.co/400x225?text=Marketing+Image+3',
+ type: 'marketing_image',
+ },
+ {
+ temporary_image_url:
+ 'https://placehold.co/400x225?text=Marketing+Image+4',
+ type: 'marketing_image',
+ },
+ {
+ temporary_image_url:
+ 'https://placehold.co/200x200?text=Square+Marketing+Image+1',
+ type: 'square_marketing_image',
+ },
+ {
+ temporary_image_url:
+ 'https://placehold.co/200x200?text=Square+Marketing+Image+2',
+ type: 'square_marketing_image',
+ },
+ {
+ temporary_image_url:
+ 'https://placehold.co/200x200?text=Square+Marketing+Image+3',
+ type: 'square_marketing_image',
+ },
+ {
+ temporary_image_url:
+ 'https://placehold.co/200x300?text=Portrait+Marketing+Image+1',
+ type: 'portrait_marketing_image',
+ },
+ {
+ temporary_image_url:
+ 'https://placehold.co/200x300?text=Portrait+Marketing+Image+2',
+ type: 'portrait_marketing_image',
+ },
+ ],
+ } );
+ }
+
+ /**
+ * Mock generate image assets empty response.
+ *
+ * @return {Promise}
+ */
+ async mockEmptyGenerateImageAssets() {
+ await this.fulfillGenerateImageAssetsRequest( {
+ final_url: 'https://woo.com/shop/',
+ items: [],
+ } );
+ }
+
+ /**
+ * Await for the generate image assets request.
+ *
+ * @param {string} finalUrl The final URL.
+ * @param {Array} types The requested asset types.
+ * @return {Promise} The request.
+ */
+ async awaitForGenerateImageRequest( finalUrl, types ) {
+ return this.page.waitForRequest( ( request ) => {
+ if (
+ ! request.url().includes( '/gla/ads/assets/generate-images' ) ||
+ request.method() !== 'POST'
+ ) {
+ return false;
+ }
+
+ const payload = request.postDataJSON();
+
+ return (
+ payload.final_url === finalUrl &&
+ Array.isArray( payload.types ) &&
+ types.every( ( type ) => payload.types.includes( type ) )
+ );
+ } );
+ }
}
From 5046353f2ac569856c5f7e194be79627b5fb8db2 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 16 Jan 2026 17:02:28 +0400
Subject: [PATCH 035/123] Revert comments
---
.../add-paid-campaigns.test.js | 806 +++++++++---------
1 file changed, 403 insertions(+), 403 deletions(-)
diff --git a/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js b/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
index 6b081830ee..b376eba596 100644
--- a/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
+++ b/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
@@ -128,409 +128,409 @@ test.describe( 'Add paid campaign', () => {
await expect( dashboardPage.addPaidCampaignButton ).toBeEnabled();
} );
- // test.describe( 'With Ads account not connected', async () => {
- // test.describe( 'Set up your accounts page', async () => {
- // test.beforeAll( async () => {
- // await setupAdsAccounts.mockAdsAccountsResponse( [] );
- // await dashboardPage.addPaidCampaignButton.click();
- // await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED );
- // } );
-
- // test( 'Page header should be "Set up your accounts"', async () => {
- // await expect(
- // page.getByRole( 'heading', {
- // name: 'Set up your accounts',
- // } )
- // ).toBeVisible();
- // await expect(
- // page.getByText(
- // 'Connect your Google account and your Google Ads account to set up a Performance Max campaign.'
- // )
- // ).toBeVisible();
- // } );
-
- // test( 'Google Account should show as connected', async () => {
- // await expect(
- // page.getByText(
- // 'This Google account is connected to your store’s product feed.'
- // )
- // ).toBeVisible();
- // } );
-
- // test( 'Continue Button should be disabled', async () => {
- // await expect(
- // setupAdsAccounts.getContinueButton()
- // ).toBeDisabled();
- // } );
- // } );
-
- // test.describe( 'Add campaigns with no Ads account', async () => {
- // test( 'Create an account should be visible', async () => {
- // const createAccountButton = page.getByRole( 'button', {
- // name: 'Create account',
- // } );
-
- // await expect( createAccountButton ).toBeVisible();
-
- // await expect(
- // setupAdsAccounts.getContinueButton()
- // ).toBeDisabled();
-
- // await expect(
- // page.getByText(
- // 'Required to set up conversion measurement and create campaigns.'
- // )
- // ).toBeVisible();
-
- // await createAccountButton.click();
- // } );
-
- // test( 'Create account button should be disable if the ToS have not been accepted.', async () => {
- // await expect(
- // page.getByRole( 'heading', {
- // name: 'Create Google Ads Account',
- // } )
- // ).toBeVisible();
-
- // await expect(
- // page.getByText(
- // 'By creating a Google Ads account, you agree to the following terms and conditions:'
- // )
- // ).toBeVisible();
-
- // await expect(
- // setupAdsAccounts.getCreateAdsAccountButtonModal()
- // ).toBeDisabled();
- // } );
-
- // test( 'Accept terms and conditions to enable the create account button', async () => {
- // await setupAdsAccounts.getAcceptTermCreateAccount().check();
-
- // await expect(
- // setupAdsAccounts.getCreateAdsAccountButtonModal()
- // ).toBeEnabled();
- // } );
-
- // test( 'Create an Ads account', async () => {
- // // Intercept Ads connection request.
- // const connectAdsAccountRequest =
- // setupAdsAccounts.registerConnectAdsAccountRequests();
-
- // await setupAdsAccounts.mockAdsAccountsResponse( ADS_ACCOUNTS );
-
- // // Mock request to fulfill Ads connection.
- // await setupAdsAccounts.fulfillAdsConnection( {
- // id: ADS_ACCOUNTS[ 0 ].id,
- // currency: 'USD',
- // symbol: '$',
- // status: 'incomplete',
- // step: 'account_access',
- // } );
-
- // await setupAdsAccounts.mockAdsStatusNotClaimed();
-
- // await setupAdsAccounts.getCreateAdsAccountButtonModal().click();
-
- // await connectAdsAccountRequest;
-
- // const modal = setupAdsAccounts.getAcceptAccountModal();
- // await expect( modal ).toBeVisible();
- // } );
-
- // test( 'Show Unclaimed Ads account', async () => {
- // await setupAdsAccounts.clickCloseAcceptAccountButtonFromModal();
-
- // const claimButton = setupAdsAccounts.getAdsClaimAccountButton();
- // const claimText = setupAdsAccounts.getAdsClaimAccountText();
-
- // await expect( claimButton ).toBeVisible();
- // await expect( claimText ).toBeVisible();
-
- // await expect(
- // setupAdsAccounts.getContinueButton()
- // ).toBeDisabled();
- // } );
-
- // test( 'Show Claimed Ads account', async () => {
- // // Intercept Ads connection request.
- // await setupAdsAccounts.fulfillAdsConnection( {
- // id: ADS_ACCOUNTS[ 0 ].id,
- // currency: 'USD',
- // symbol: '$',
- // status: 'connected',
- // step: '',
- // } );
-
- // await setupAdsAccounts.mockAdsStatusClaimed();
-
- // await page.dispatchEvent( 'body', 'blur' );
- // await page.dispatchEvent( 'body', 'focus' );
-
- // await expect(
- // setupAdsAccounts.getContinueButton()
- // ).toBeEnabled();
-
- // await expect(
- // page.getByRole( 'link', {
- // name: `Account ${ ADS_ACCOUNTS[ 0 ].id }`,
- // } )
- // ).toBeVisible();
-
- // await expect(
- // setupAdsAccounts.getContinueButton()
- // ).toBeEnabled();
- // } );
- // } );
-
- // test.describe( 'Add campaigns with existing Ads accounts', () => {
- // test.beforeAll( async () => {
- // await setupAdsAccounts.mockAdsAccountsResponse( ADS_ACCOUNTS );
- // //Disconnect the account from the previous test
- // setupAdsAccounts.fulfillAdsConnection( {
- // id: ADS_ACCOUNTS[ 1 ].id,
- // currency: 'EUR',
- // symbol: '\u20ac',
- // status: 'disconnected',
- // } );
-
- // await page.reload();
- // } );
-
- // test( 'Select one existing account', async () => {
- // const adsAccountSelected = `${ ADS_ACCOUNTS[ 1 ].id }`;
-
- // await setupAdsAccounts.selectAnExistingAdsAccount(
- // adsAccountSelected
- // );
-
- // //Intercept Ads connection request
- // const connectAdsAccountRequest =
- // setupAdsAccounts.registerConnectAdsAccountRequests(
- // adsAccountSelected
- // );
-
- // //Mock request to fulfill Ads connection
- // setupAdsAccounts.fulfillAdsConnection( {
- // id: ADS_ACCOUNTS[ 1 ].id,
- // currency: 'EUR',
- // symbol: '\u20ac',
- // status: 'connected',
- // } );
-
- // await setupAdsAccounts.clickConnectAds();
- // await connectAdsAccountRequest;
-
- // await expect(
- // setupAdsAccounts.getContinueButton()
- // ).toBeEnabled();
- // } );
- // } );
-
- // test.describe( 'Create your campaign', () => {
- // test( 'Continue to create your campaign', async () => {
- // await setupAdsAccounts.clickContinue();
- // await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED );
- // await expect(
- // page.getByRole( 'heading', {
- // name: 'Create your campaign',
- // } )
- // ).toBeVisible();
-
- // await expect(
- // page.getByRole( 'heading', { name: 'Set your budget' } )
- // ).toBeVisible();
-
- // await expect(
- // page.getByRole( 'link', {
- // name: 'See what your ads will look like.',
- // } )
- // ).toBeVisible();
- // } );
-
- // test.describe( 'Preview product ad', () => {
- // test( 'Preview product ad should be visible', async () => {
- // await expect(
- // page.getByText( 'Preview product ad' )
- // ).toBeVisible();
- // await expect(
- // page.getByText(
- // "Each of your product variants will have its own ad. Previews shown here are examples and don't include all possible formats."
- // )
- // ).toBeVisible();
- // } );
-
- // test( 'Change image buttons should be enabled', async () => {
- // const buttonsToChangeImage = page.locator(
- // '.gla-campaign-preview-card__moving-button'
- // );
-
- // expect( buttonsToChangeImage ).toHaveCount( 2 );
-
- // for ( const button of await buttonsToChangeImage.all() ) {
- // await expect( button ).toBeEnabled();
- // }
- // } );
- // } );
-
- // test.describe( 'FAQ panels', () => {
- // test( 'should see five questions in FAQ', async () => {
- // const faqTitles = getFAQPanelTitle( page );
- // await expect( faqTitles ).toHaveCount( 5 );
- // } );
-
- // test( 'should not see FAQ rows when FAQ titles are not clicked', async () => {
- // const faqRows = getFAQPanelRow( page );
- // await expect( faqRows ).toHaveCount( 0 );
- // } );
-
- // // eslint-disable-next-line jest/expect-expect
- // test( 'should see FAQ rows when all FAQ titles are clicked', async () => {
- // await checkFAQExpandable( page );
- // } );
- // } );
- // } );
-
- // test.describe( 'Create Ads with billing data already setup', () => {
- // test.describe( 'Set the budget', async () => {
- // test( 'Continue button should be disabled if budget is 0', async () => {
- // await setupBudgetPage.fillBudget( '0' );
-
- // await expect(
- // setupBudgetPage.getCreateCampaignButton()
- // ).toBeDisabled();
- // } );
-
- // test( 'Continue button should be enabled when selecting an option from the recommendations, even if the entered value is invalid', async () => {
- // await setupBudgetPage.fillBudget( '0' );
- // await expect(
- // setupBudgetPage.getCreateCampaignButton()
- // ).toBeDisabled();
-
- // await page.getByLabel( 'low' ).click();
- // await expect(
- // setupBudgetPage.getCreateCampaignButton()
- // ).toBeEnabled();
-
- // await page.getByLabel( 'custom' ).click();
- // await expect(
- // setupBudgetPage.getCreateCampaignButton()
- // ).toBeDisabled();
-
- // await page.getByLabel( 'high' ).click();
- // await expect(
- // setupBudgetPage.getCreateCampaignButton()
- // ).toBeEnabled();
-
- // await page.getByLabel( 'custom' ).click();
- // await expect(
- // setupBudgetPage.getCreateCampaignButton()
- // ).toBeDisabled();
-
- // await page.getByLabel( 'recommended' ).click();
- // await expect(
- // setupBudgetPage.getCreateCampaignButton()
- // ).toBeEnabled();
- // } );
-
- // test( 'Continue button should be disabled if budget is less than 30% of the daily budget baseline', async () => {
- // await setupBudgetPage.fillBudget( '2' );
-
- // await expect(
- // setupBudgetPage.getCreateCampaignButton()
- // ).toBeDisabled();
- // } );
-
- // test( 'User is notified of the minimum value', async () => {
- // await setupBudgetPage.fillBudget( '3' );
- // await setupBudgetPage.getBudgetInput().blur();
-
- // await expect(
- // page.getByText(
- // 'Please make sure daily average cost is at least €4.00'
- // )
- // ).toBeVisible();
- // } );
-
- // test( 'Continue button should be enabled if budget is above the recommended value', async () => {
- // await setupBudgetPage.fillBudget( '5' );
-
- // await expect(
- // setupBudgetPage.getCreateCampaignButton()
- // ).toBeEnabled();
- // } );
-
- // test( 'Display the recommended budget if the budget is valid but lower than the lowest recommended value', async () => {
- // await setupBudgetPage.fillBudget( '6' );
-
- // await expect(
- // page.getByText(
- // `Your budget is lower than other advertisers' budgets, which may affect performance. For best results, we recommend at least €15.00 per day.`
- // )
- // ).toBeVisible();
- // } );
- // } );
-
- // test( 'It should show the campaign creation success message', async () => {
- // await setupBudgetPage.fillBudget( '6' );
- // await setupBudgetPage.getCreateCampaignButton().click();
-
- // const cancelButton = page.getByRole( 'button', {
- // name: 'Cancel',
- // } );
- // await expect(
- // page.getByText( 'This offer won’t last long!' )
- // ).toBeVisible();
- // await expect( cancelButton ).toBeEnabled();
-
- // await cancelButton.click();
-
- // await expect( cancelButton ).not.toBeVisible();
-
- // // Mock the campaign creation request.
- // const campaignCreation =
- // setupBudgetPage.mockCampaignCreationAndAdsSetupCompletion(
- // '6',
- // [ 'US' ]
- // );
-
- // await setupBudgetPage.getCreateCampaignButton().click();
-
- // await campaignCreation;
-
- // //It should redirect to the dashboard page
- // await page.waitForURL(
- // '/wp-admin/admin.php?page=wc-admin&path=%2Fgoogle%2Fdashboard&guide=campaign-creation-success',
- // {
- // waitUntil: LOAD_STATE.DOM_CONTENT_LOADED,
- // }
- // );
-
- // await expect(
- // page.getByRole( 'heading', {
- // name: "You've set up a Performance Max Campaign!",
- // } )
- // ).toBeVisible();
-
- // await expect(
- // page.getByRole( 'button', {
- // name: 'Create another campaign',
- // } )
- // ).toBeEnabled();
-
- // await expect(
- // page.getByRole( 'button', {
- // name: 'Got It',
- // } )
- // ).toBeEnabled();
-
- // await page
- // .getByRole( 'button', {
- // name: 'Got It',
- // } )
- // .click();
- // } );
- // } );
- // } );
+ test.describe( 'With Ads account not connected', async () => {
+ test.describe( 'Set up your accounts page', async () => {
+ test.beforeAll( async () => {
+ await setupAdsAccounts.mockAdsAccountsResponse( [] );
+ await dashboardPage.addPaidCampaignButton.click();
+ await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED );
+ } );
+
+ test( 'Page header should be "Set up your accounts"', async () => {
+ await expect(
+ page.getByRole( 'heading', {
+ name: 'Set up your accounts',
+ } )
+ ).toBeVisible();
+ await expect(
+ page.getByText(
+ 'Connect your Google account and your Google Ads account to set up a Performance Max campaign.'
+ )
+ ).toBeVisible();
+ } );
+
+ test( 'Google Account should show as connected', async () => {
+ await expect(
+ page.getByText(
+ 'This Google account is connected to your store’s product feed.'
+ )
+ ).toBeVisible();
+ } );
+
+ test( 'Continue Button should be disabled', async () => {
+ await expect(
+ setupAdsAccounts.getContinueButton()
+ ).toBeDisabled();
+ } );
+ } );
+
+ test.describe( 'Add campaigns with no Ads account', async () => {
+ test( 'Create an account should be visible', async () => {
+ const createAccountButton = page.getByRole( 'button', {
+ name: 'Create account',
+ } );
+
+ await expect( createAccountButton ).toBeVisible();
+
+ await expect(
+ setupAdsAccounts.getContinueButton()
+ ).toBeDisabled();
+
+ await expect(
+ page.getByText(
+ 'Required to set up conversion measurement and create campaigns.'
+ )
+ ).toBeVisible();
+
+ await createAccountButton.click();
+ } );
+
+ test( 'Create account button should be disable if the ToS have not been accepted.', async () => {
+ await expect(
+ page.getByRole( 'heading', {
+ name: 'Create Google Ads Account',
+ } )
+ ).toBeVisible();
+
+ await expect(
+ page.getByText(
+ 'By creating a Google Ads account, you agree to the following terms and conditions:'
+ )
+ ).toBeVisible();
+
+ await expect(
+ setupAdsAccounts.getCreateAdsAccountButtonModal()
+ ).toBeDisabled();
+ } );
+
+ test( 'Accept terms and conditions to enable the create account button', async () => {
+ await setupAdsAccounts.getAcceptTermCreateAccount().check();
+
+ await expect(
+ setupAdsAccounts.getCreateAdsAccountButtonModal()
+ ).toBeEnabled();
+ } );
+
+ test( 'Create an Ads account', async () => {
+ // Intercept Ads connection request.
+ const connectAdsAccountRequest =
+ setupAdsAccounts.registerConnectAdsAccountRequests();
+
+ await setupAdsAccounts.mockAdsAccountsResponse( ADS_ACCOUNTS );
+
+ // Mock request to fulfill Ads connection.
+ await setupAdsAccounts.fulfillAdsConnection( {
+ id: ADS_ACCOUNTS[ 0 ].id,
+ currency: 'USD',
+ symbol: '$',
+ status: 'incomplete',
+ step: 'account_access',
+ } );
+
+ await setupAdsAccounts.mockAdsStatusNotClaimed();
+
+ await setupAdsAccounts.getCreateAdsAccountButtonModal().click();
+
+ await connectAdsAccountRequest;
+
+ const modal = setupAdsAccounts.getAcceptAccountModal();
+ await expect( modal ).toBeVisible();
+ } );
+
+ test( 'Show Unclaimed Ads account', async () => {
+ await setupAdsAccounts.clickCloseAcceptAccountButtonFromModal();
+
+ const claimButton = setupAdsAccounts.getAdsClaimAccountButton();
+ const claimText = setupAdsAccounts.getAdsClaimAccountText();
+
+ await expect( claimButton ).toBeVisible();
+ await expect( claimText ).toBeVisible();
+
+ await expect(
+ setupAdsAccounts.getContinueButton()
+ ).toBeDisabled();
+ } );
+
+ test( 'Show Claimed Ads account', async () => {
+ // Intercept Ads connection request.
+ await setupAdsAccounts.fulfillAdsConnection( {
+ id: ADS_ACCOUNTS[ 0 ].id,
+ currency: 'USD',
+ symbol: '$',
+ status: 'connected',
+ step: '',
+ } );
+
+ await setupAdsAccounts.mockAdsStatusClaimed();
+
+ await page.dispatchEvent( 'body', 'blur' );
+ await page.dispatchEvent( 'body', 'focus' );
+
+ await expect(
+ setupAdsAccounts.getContinueButton()
+ ).toBeEnabled();
+
+ await expect(
+ page.getByRole( 'link', {
+ name: `Account ${ ADS_ACCOUNTS[ 0 ].id }`,
+ } )
+ ).toBeVisible();
+
+ await expect(
+ setupAdsAccounts.getContinueButton()
+ ).toBeEnabled();
+ } );
+ } );
+
+ test.describe( 'Add campaigns with existing Ads accounts', () => {
+ test.beforeAll( async () => {
+ await setupAdsAccounts.mockAdsAccountsResponse( ADS_ACCOUNTS );
+ //Disconnect the account from the previous test
+ setupAdsAccounts.fulfillAdsConnection( {
+ id: ADS_ACCOUNTS[ 1 ].id,
+ currency: 'EUR',
+ symbol: '\u20ac',
+ status: 'disconnected',
+ } );
+
+ await page.reload();
+ } );
+
+ test( 'Select one existing account', async () => {
+ const adsAccountSelected = `${ ADS_ACCOUNTS[ 1 ].id }`;
+
+ await setupAdsAccounts.selectAnExistingAdsAccount(
+ adsAccountSelected
+ );
+
+ //Intercept Ads connection request
+ const connectAdsAccountRequest =
+ setupAdsAccounts.registerConnectAdsAccountRequests(
+ adsAccountSelected
+ );
+
+ //Mock request to fulfill Ads connection
+ setupAdsAccounts.fulfillAdsConnection( {
+ id: ADS_ACCOUNTS[ 1 ].id,
+ currency: 'EUR',
+ symbol: '\u20ac',
+ status: 'connected',
+ } );
+
+ await setupAdsAccounts.clickConnectAds();
+ await connectAdsAccountRequest;
+
+ await expect(
+ setupAdsAccounts.getContinueButton()
+ ).toBeEnabled();
+ } );
+ } );
+
+ test.describe( 'Create your campaign', () => {
+ test( 'Continue to create your campaign', async () => {
+ await setupAdsAccounts.clickContinue();
+ await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED );
+ await expect(
+ page.getByRole( 'heading', {
+ name: 'Create your campaign',
+ } )
+ ).toBeVisible();
+
+ await expect(
+ page.getByRole( 'heading', { name: 'Set your budget' } )
+ ).toBeVisible();
+
+ await expect(
+ page.getByRole( 'link', {
+ name: 'See what your ads will look like.',
+ } )
+ ).toBeVisible();
+ } );
+
+ test.describe( 'Preview product ad', () => {
+ test( 'Preview product ad should be visible', async () => {
+ await expect(
+ page.getByText( 'Preview product ad' )
+ ).toBeVisible();
+ await expect(
+ page.getByText(
+ "Each of your product variants will have its own ad. Previews shown here are examples and don't include all possible formats."
+ )
+ ).toBeVisible();
+ } );
+
+ test( 'Change image buttons should be enabled', async () => {
+ const buttonsToChangeImage = page.locator(
+ '.gla-campaign-preview-card__moving-button'
+ );
+
+ expect( buttonsToChangeImage ).toHaveCount( 2 );
+
+ for ( const button of await buttonsToChangeImage.all() ) {
+ await expect( button ).toBeEnabled();
+ }
+ } );
+ } );
+
+ test.describe( 'FAQ panels', () => {
+ test( 'should see five questions in FAQ', async () => {
+ const faqTitles = getFAQPanelTitle( page );
+ await expect( faqTitles ).toHaveCount( 5 );
+ } );
+
+ test( 'should not see FAQ rows when FAQ titles are not clicked', async () => {
+ const faqRows = getFAQPanelRow( page );
+ await expect( faqRows ).toHaveCount( 0 );
+ } );
+
+ // eslint-disable-next-line jest/expect-expect
+ test( 'should see FAQ rows when all FAQ titles are clicked', async () => {
+ await checkFAQExpandable( page );
+ } );
+ } );
+ } );
+
+ test.describe( 'Create Ads with billing data already setup', () => {
+ test.describe( 'Set the budget', async () => {
+ test( 'Continue button should be disabled if budget is 0', async () => {
+ await setupBudgetPage.fillBudget( '0' );
+
+ await expect(
+ setupBudgetPage.getCreateCampaignButton()
+ ).toBeDisabled();
+ } );
+
+ test( 'Continue button should be enabled when selecting an option from the recommendations, even if the entered value is invalid', async () => {
+ await setupBudgetPage.fillBudget( '0' );
+ await expect(
+ setupBudgetPage.getCreateCampaignButton()
+ ).toBeDisabled();
+
+ await page.getByLabel( 'low' ).click();
+ await expect(
+ setupBudgetPage.getCreateCampaignButton()
+ ).toBeEnabled();
+
+ await page.getByLabel( 'custom' ).click();
+ await expect(
+ setupBudgetPage.getCreateCampaignButton()
+ ).toBeDisabled();
+
+ await page.getByLabel( 'high' ).click();
+ await expect(
+ setupBudgetPage.getCreateCampaignButton()
+ ).toBeEnabled();
+
+ await page.getByLabel( 'custom' ).click();
+ await expect(
+ setupBudgetPage.getCreateCampaignButton()
+ ).toBeDisabled();
+
+ await page.getByLabel( 'recommended' ).click();
+ await expect(
+ setupBudgetPage.getCreateCampaignButton()
+ ).toBeEnabled();
+ } );
+
+ test( 'Continue button should be disabled if budget is less than 30% of the daily budget baseline', async () => {
+ await setupBudgetPage.fillBudget( '2' );
+
+ await expect(
+ setupBudgetPage.getCreateCampaignButton()
+ ).toBeDisabled();
+ } );
+
+ test( 'User is notified of the minimum value', async () => {
+ await setupBudgetPage.fillBudget( '3' );
+ await setupBudgetPage.getBudgetInput().blur();
+
+ await expect(
+ page.getByText(
+ 'Please make sure daily average cost is at least €4.00'
+ )
+ ).toBeVisible();
+ } );
+
+ test( 'Continue button should be enabled if budget is above the recommended value', async () => {
+ await setupBudgetPage.fillBudget( '5' );
+
+ await expect(
+ setupBudgetPage.getCreateCampaignButton()
+ ).toBeEnabled();
+ } );
+
+ test( 'Display the recommended budget if the budget is valid but lower than the lowest recommended value', async () => {
+ await setupBudgetPage.fillBudget( '6' );
+
+ await expect(
+ page.getByText(
+ `Your budget is lower than other advertisers' budgets, which may affect performance. For best results, we recommend at least €15.00 per day.`
+ )
+ ).toBeVisible();
+ } );
+ } );
+
+ test( 'It should show the campaign creation success message', async () => {
+ await setupBudgetPage.fillBudget( '6' );
+ await setupBudgetPage.getCreateCampaignButton().click();
+
+ const cancelButton = page.getByRole( 'button', {
+ name: 'Cancel',
+ } );
+ await expect(
+ page.getByText( 'This offer won’t last long!' )
+ ).toBeVisible();
+ await expect( cancelButton ).toBeEnabled();
+
+ await cancelButton.click();
+
+ await expect( cancelButton ).not.toBeVisible();
+
+ // Mock the campaign creation request.
+ const campaignCreation =
+ setupBudgetPage.mockCampaignCreationAndAdsSetupCompletion(
+ '6',
+ [ 'US' ]
+ );
+
+ await setupBudgetPage.getCreateCampaignButton().click();
+
+ await campaignCreation;
+
+ //It should redirect to the dashboard page
+ await page.waitForURL(
+ '/wp-admin/admin.php?page=wc-admin&path=%2Fgoogle%2Fdashboard&guide=campaign-creation-success',
+ {
+ waitUntil: LOAD_STATE.DOM_CONTENT_LOADED,
+ }
+ );
+
+ await expect(
+ page.getByRole( 'heading', {
+ name: "You've set up a Performance Max Campaign!",
+ } )
+ ).toBeVisible();
+
+ await expect(
+ page.getByRole( 'button', {
+ name: 'Create another campaign',
+ } )
+ ).toBeEnabled();
+
+ await expect(
+ page.getByRole( 'button', {
+ name: 'Got It',
+ } )
+ ).toBeEnabled();
+
+ await page
+ .getByRole( 'button', {
+ name: 'Got It',
+ } )
+ .click();
+ } );
+ } );
+ } );
test.describe( 'With connected Ads account', async () => {
test.beforeAll( async () => {
From 66ec26acff7ac3f37796a2da01f6c4cde830f5ae Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 16 Jan 2026 17:11:57 +0400
Subject: [PATCH 036/123] chore(package.json): Update max size limit for
index.js
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 9e8489d7f1..b50db87d53 100644
--- a/package.json
+++ b/package.json
@@ -148,7 +148,7 @@
},
{
"path": "./js/build/index.js",
- "maxSize": "19 kB"
+ "maxSize": "19.1 kB"
},
{
"path": "./js/build/commons.js",
From 078fca753c42219b79bda914cf6f81862bd40269 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 16 Jan 2026 18:57:36 +0400
Subject: [PATCH 037/123] Refactor GenAICard component layout and styling
---
js/src/components/paid-ads/gen-ai-card.js | 46 ++++++++++-----------
js/src/components/paid-ads/gen-ai-card.scss | 26 +++++-------
2 files changed, 33 insertions(+), 39 deletions(-)
diff --git a/js/src/components/paid-ads/gen-ai-card.js b/js/src/components/paid-ads/gen-ai-card.js
index 2a5e7ae1f0..baec6d18fd 100644
--- a/js/src/components/paid-ads/gen-ai-card.js
+++ b/js/src/components/paid-ads/gen-ai-card.js
@@ -15,7 +15,6 @@ import {
* Internal dependencies
*/
import Section from '~/components/section';
-import useGoogleAdsAccount from '~/hooks/useGoogleAdsAccount';
import genAIImageURL from '~/images/pmax-assets-improvements/gen-ai.svg';
import './gen-ai-card.scss';
@@ -28,23 +27,13 @@ import './gen-ai-card.scss';
* @return {JSX.Element} The rendered GenAICard component.
*/
const GenAICard = () => {
- const { googleAdsAccount } = useGoogleAdsAccount();
- const queryArgs = {};
-
- if ( googleAdsAccount?.ocid ) {
- queryArgs.ocid = googleAdsAccount.ocid;
- } else if ( googleAdsAccount?.id ) {
- queryArgs.ecid = googleAdsAccount.id;
- }
-
return (
@@ -66,27 +55,36 @@ const GenAICard = () => {
-
-
- { __(
- 'Text assets were auto-populated with Google AI',
- 'google-listings-and-ads'
- ) }
-
+
+
+
+
+
+
+
+ { __(
+ 'Text assets were auto-populated with Google AI',
+ 'google-listings-and-ads'
+ ) }
+
+
+
-
+
diff --git a/js/src/components/paid-ads/gen-ai-card.scss b/js/src/components/paid-ads/gen-ai-card.scss
index d653912588..b9f63b840d 100644
--- a/js/src/components/paid-ads/gen-ai-card.scss
+++ b/js/src/components/paid-ads/gen-ai-card.scss
@@ -1,24 +1,20 @@
.gla-gen-ai-card {
- :where(.gla-gen-ai-card__wrapper) {
- @media (min-width: $break-small) {
- flex-direction: row;
- }
- }
-
:where(.gla-section-card-title) {
font-size: 16px;
- margin-bottom: 8px;
}
- :where(.is-success) {
- padding-top: 0;
- padding-bottom: 0;
- width: 100%;
+ .components-notice__content {
+ :where(svg) {
+ display: block;
+ fill: $gla-color-green-70;
+ }
- :where(.components-notice__content) {
- display: flex;
- align-items: center;
- gap: 12px;
+ p {
+ margin: 0;
}
}
+
+ :where(.is-success) {
+ width: 100%;
+ }
}
From 17b651f0d2ea14daf90316c69402a5a737fe4307 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 16 Jan 2026 18:59:50 +0400
Subject: [PATCH 038/123] Update snapshot
---
.../__snapshots__/gen-ai-card.test.js.snap | 52 +++++++++++++------
1 file changed, 36 insertions(+), 16 deletions(-)
diff --git a/js/src/components/paid-ads/__snapshots__/gen-ai-card.test.js.snap b/js/src/components/paid-ads/__snapshots__/gen-ai-card.test.js.snap
index 2de7c7244b..a4de36ac77 100644
--- a/js/src/components/paid-ads/__snapshots__/gen-ai-card.test.js.snap
+++ b/js/src/components/paid-ads/__snapshots__/gen-ai-card.test.js.snap
@@ -16,7 +16,7 @@ exports[`GenAICard Generate assets with GenAI button Match the snapshot 1`] = `
data-wp-component="CardBody"
>
@@ -55,21 +55,39 @@ exports[`GenAICard Generate assets with GenAI button Match the snapshot 1`] = `
-
-
-
-
- Text assets were auto-populated with Google AI
-
+
+
+
+ Text assets were auto-populated with Google AI
+
+
+
@@ -78,13 +96,15 @@ exports[`GenAICard Generate assets with GenAI button Match the snapshot 1`] = `
From c4f5fd744517fefdfd99ab4e9bdef4a77642dd2b Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Fri, 16 Jan 2026 21:34:38 +0530
Subject: [PATCH 039/123] Add GenAI progress bar component.
---
.../paid-ads/asset-group/asset-group.js | 2 +
js/src/components/paid-ads/gen-ai-progress.js | 40 ++++++++++
.../components/paid-ads/gen-ai-progress.scss | 24 ++++++
.../gen-ai-progress.svg | 74 +++++++++++++++++++
4 files changed, 140 insertions(+)
create mode 100644 js/src/components/paid-ads/gen-ai-progress.js
create mode 100644 js/src/components/paid-ads/gen-ai-progress.scss
create mode 100644 js/src/images/pmax-assets-improvements/gen-ai-progress.svg
diff --git a/js/src/components/paid-ads/asset-group/asset-group.js b/js/src/components/paid-ads/asset-group/asset-group.js
index 3590234848..8278a07e50 100644
--- a/js/src/components/paid-ads/asset-group/asset-group.js
+++ b/js/src/components/paid-ads/asset-group/asset-group.js
@@ -18,6 +18,7 @@ import { recordGlaEvent } from '~/utils/tracks';
import useTargetAudienceFinalCountryCodes from '~/hooks/useTargetAudienceFinalCountryCodes';
import AssetGroupHeader from './asset-group-header';
import AssetGroupEditor from './asset-group-editor';
+import GenAIProgress from '../gen-ai-progress';
import { upsertActionedCampaign } from '~/utils/actionedCampaignsCache';
import './asset-group.scss';
@@ -154,6 +155,7 @@ export default function AssetGroup( { campaign } ) {
) }
/>
+
diff --git a/js/src/components/paid-ads/gen-ai-progress.js b/js/src/components/paid-ads/gen-ai-progress.js
new file mode 100644
index 0000000000..b31ce0ea25
--- /dev/null
+++ b/js/src/components/paid-ads/gen-ai-progress.js
@@ -0,0 +1,40 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { Flex, FlexBlock, ProgressBar } from '@wordpress/components';
+
+/**
+ * Internal dependencies
+ */
+import ProgressGraphics from '~/images/pmax-assets-improvements/gen-ai-progress.svg';
+import './gen-ai-progress.scss';
+
+const GenAIProgress = () => {
+ return (
+
+
+
+
+
+ { __( 'Generating assets', 'google-listings-and-ads' ) }
+
+
+
+ { __(
+ 'Google AI is analyzing your campaign’s URL to automatically generate your ad assets',
+ 'google-listings-and-ads'
+ ) }
+
+
+
+
+ );
+};
+
+export default GenAIProgress;
diff --git a/js/src/components/paid-ads/gen-ai-progress.scss b/js/src/components/paid-ads/gen-ai-progress.scss
new file mode 100644
index 0000000000..9baff3dea5
--- /dev/null
+++ b/js/src/components/paid-ads/gen-ai-progress.scss
@@ -0,0 +1,24 @@
+.gen-ai-progress {
+ align-items: center;
+ display: flex;
+ flex-direction: column;
+ padding: $grid-unit-80 0;
+
+ :where(.gen-ai-progress__bar) {
+ max-width: 520px;
+ height: 4px;
+ width: 100%;
+
+ > div:first-child {
+ background-color: #3858E9;
+ }
+ }
+
+ :where(.gen-ai-progress__text-content) {
+ text-align: center;
+
+ h2{
+ font-size: $gla-font-small-medium;
+ }
+ }
+}
\ No newline at end of file
diff --git a/js/src/images/pmax-assets-improvements/gen-ai-progress.svg b/js/src/images/pmax-assets-improvements/gen-ai-progress.svg
new file mode 100644
index 0000000000..d0ba29c20b
--- /dev/null
+++ b/js/src/images/pmax-assets-improvements/gen-ai-progress.svg
@@ -0,0 +1,74 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From 8ab751ace6e02b64628b87ff8027b3f2d7e3a926 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 16 Jan 2026 20:20:34 +0400
Subject: [PATCH 040/123] Add SVG mock support in Jest config
---
jest.config.js | 3 ++-
tests/mocks/assets/svgFileMock.js | 1 +
2 files changed, 3 insertions(+), 1 deletion(-)
create mode 100644 tests/mocks/assets/svgFileMock.js
diff --git a/jest.config.js b/jest.config.js
index f03a9dd236..1695b281e3 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -12,7 +12,8 @@ module.exports = {
],
moduleNameMapper: {
'\\.(png|jpg)$': '/tests/mocks/assets/imageMock.js',
- '\\.svg(\\?inline)?$': '/tests/mocks/assets/svgrMock.js',
+ '\\.svg\\?inline$': '/tests/mocks/assets/svgrMock.js',
+ '\\.svg$': '/tests/mocks/assets/svgFileMock.js',
'\\.scss$': '/tests/mocks/assets/styleMock.js',
// Transform our `~/` alias.
'^~/(.*)$': '/js/src/$1',
diff --git a/tests/mocks/assets/svgFileMock.js b/tests/mocks/assets/svgFileMock.js
new file mode 100644
index 0000000000..95b0b6d997
--- /dev/null
+++ b/tests/mocks/assets/svgFileMock.js
@@ -0,0 +1 @@
+module.exports = 'SvgrURL';
From a8e4a1da23765cf0c98f010016ec064ebc955769 Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Fri, 16 Jan 2026 22:25:36 +0530
Subject: [PATCH 041/123] Suppress ESLint.
ProgressBar is available, but false reporting by eslint.
---
js/src/components/paid-ads/gen-ai-progress.js | 1 +
1 file changed, 1 insertion(+)
diff --git a/js/src/components/paid-ads/gen-ai-progress.js b/js/src/components/paid-ads/gen-ai-progress.js
index b31ce0ea25..73813fefac 100644
--- a/js/src/components/paid-ads/gen-ai-progress.js
+++ b/js/src/components/paid-ads/gen-ai-progress.js
@@ -2,6 +2,7 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
+// eslint-disable-next-line import/named, @woocommerce/dependency-group
import { Flex, FlexBlock, ProgressBar } from '@wordpress/components';
/**
From e66a39e504581955ad84b4da5499ffe6ef339a01 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 16 Jan 2026 21:10:35 +0400
Subject: [PATCH 042/123] Add AIIcon for AI generated texts.
---
.../asset-group-editor/texts-editor.js | 15 +++++++++++++++
.../asset-group-editor/texts-editor.scss | 4 ++++
2 files changed, 19 insertions(+)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
index 2d1f7deabc..a154806faf 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
@@ -11,11 +11,13 @@ import GridiconCrossSmall from 'gridicons/dist/cross-small';
*/
import { useAppDispatch } from '~/data';
import useDispatchCoreNotices from '~/hooks/useDispatchCoreNotices';
+import useGenAITextAssets from '~/hooks/useGenAITextAssets';
import AppButton from '~/components/app-button';
import AppInputControl from '~/components/app-input-control';
import AssetItemActionButton, {
ACTION_TYPES,
} from './asset-item-action-button';
+import AIIcon from '~/images/ai-icon.svg?inline';
import './texts-editor.scss';
function normalizeNumberOfTexts( texts, minNumberOfTexts, maxNumberOfTexts ) {
@@ -119,6 +121,10 @@ export default function TextsEditor( {
const { fetchGenAITextAssets } = useAppDispatch();
const [ texts, setTexts ] = useState( initialTexts );
const [ isGeneratingAssets, setIsGeneratingAssets ] = useState( false );
+ const { assets: genAITextAssets } = useGenAITextAssets(
+ finalUrl,
+ assetKey
+ );
const updateTexts = ( nextTexts ) => {
setTexts( nextTexts );
@@ -227,6 +233,15 @@ export default function TextsEditor( {
placeholder={ placeholder }
data-index={ index }
onChange={ handleChange }
+ suffix={
+ genAITextAssets?.includes( text ) && (
+
+ )
+ }
/>
{ index + 1 > minNumberOfTexts && (
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.scss b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.scss
index c062d29336..e5c12ccd5a 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.scss
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.scss
@@ -37,4 +37,8 @@
top: 50%;
transform: translate(-50%, -50%);
}
+
+ .gla-texts-editor__ai-icon {
+ color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9));
+ }
}
From 1de2c0284cee8c81d861bf78baf76c916d5baa2b Mon Sep 17 00:00:00 2001
From: asvinb
Date: Mon, 19 Jan 2026 13:32:39 +0400
Subject: [PATCH 043/123] Add tests for AI icon.
---
.../add-paid-campaigns.test.js | 23 +++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js b/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
index ad7c578133..db9fba37c0 100644
--- a/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
+++ b/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
@@ -845,6 +845,29 @@ test.describe( 'Add paid campaign', () => {
} );
} );
+ test.describe( 'AI Icon', () => {
+ test( 'is visible next to generated text assets and not visible if changed', async () => {
+ const descriptionInputs =
+ createCampaignPage.getDescriptionInputs();
+ const lastDescriptionInput =
+ descriptionInputs.last();
+
+ // Move one level up
+ const row = lastDescriptionInput.locator( '..' );
+ const aiIcon = row.locator(
+ '.gla-texts-editor__ai-icon'
+ );
+
+ await expect( aiIcon ).toHaveCount( 1 );
+
+ await lastDescriptionInput.fill(
+ 'Custom description text'
+ );
+
+ await expect( aiIcon ).toHaveCount( 0 );
+ } );
+ } );
+
test.describe( 'Error', () => {
test.beforeEach( async () => {
createCampaignPage.mockEmptyGenerateTextAssets();
From efd68f318cea4f37be8634f69dc2063d4aad4f91 Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Mon, 19 Jan 2026 15:41:07 +0530
Subject: [PATCH 044/123] Fix: ConversionValueRule class cleanup should not
happen.
---
bin/GoogleAdsCleanupServices.php | 11 +++++------
1 file changed, 5 insertions(+), 6 deletions(-)
diff --git a/bin/GoogleAdsCleanupServices.php b/bin/GoogleAdsCleanupServices.php
index 239ac5443a..99484946fe 100644
--- a/bin/GoogleAdsCleanupServices.php
+++ b/bin/GoogleAdsCleanupServices.php
@@ -52,12 +52,11 @@ class GoogleAdsCleanupServices {
* @var string[] List of Service to NOT remove even when usage is not found.
*/
protected $avoid_cleanup = [
- // Some methods like `ResourceNames::forGeoTargetConstant` are changed to use
- // `BatchJobServiceClient` class instead of `GoogleAdsServiceClient` when
- // upgrading from v18 to v20, so we need to keep this service. See:
- // - https://github.com/googleads/google-ads-php/blob/v28.0.0/src/Google/Ads/GoogleAds/Util/V18/ResourceNames.php#L1704-L1710
- // - https://github.com/googleads/google-ads-php/blob/v28.0.0/src/Google/Ads/GoogleAds/Util/V20/ResourceNames.php#L1433-L1439
- 'BatchJob',
+ // ConversionValueRuleService is now used in `ResourceNames::forGeoTargetConstant` in V22.
+ // instead of the previous BatchJobServiceClient. See:
+ // - https://github.com/googleads/google-ads-php/blob/v28.0.0/src/Google/Ads/GoogleAds/Util/V20/ResourceNames.php#L1433-L1439
+ // - https://github.com/googleads/google-ads-php/blob/v31.1.0/src/Google/Ads/GoogleAds/Util/V22/ResourceNames.php#L1460
+ 'ConversionValueRule',
];
/**
From b1241f09cf89d8047bf85d026a6fd61c3d90ba8f Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Mon, 19 Jan 2026 16:14:14 +0530
Subject: [PATCH 045/123] Fix: php lint.
---
bin/GoogleAdsCleanupServices.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/bin/GoogleAdsCleanupServices.php b/bin/GoogleAdsCleanupServices.php
index 99484946fe..6f28316c37 100644
--- a/bin/GoogleAdsCleanupServices.php
+++ b/bin/GoogleAdsCleanupServices.php
@@ -54,7 +54,7 @@ class GoogleAdsCleanupServices {
protected $avoid_cleanup = [
// ConversionValueRuleService is now used in `ResourceNames::forGeoTargetConstant` in V22.
// instead of the previous BatchJobServiceClient. See:
- // - https://github.com/googleads/google-ads-php/blob/v28.0.0/src/Google/Ads/GoogleAds/Util/V20/ResourceNames.php#L1433-L1439
+ // - https://github.com/googleads/google-ads-php/blob/v28.0.0/src/Google/Ads/GoogleAds/Util/V20/ResourceNames.php#L1433-L1439
// - https://github.com/googleads/google-ads-php/blob/v31.1.0/src/Google/Ads/GoogleAds/Util/V22/ResourceNames.php#L1460
'ConversionValueRule',
];
From e6c8f8ab9059b8e5733e4bebd9d681f495e6fd25 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Mon, 19 Jan 2026 16:20:19 +0400
Subject: [PATCH 046/123] Tweak to styling.
---
js/src/components/paid-ads/gen-ai-progress.js | 31 +++++++--------
.../components/paid-ads/gen-ai-progress.scss | 38 +++++++++----------
2 files changed, 35 insertions(+), 34 deletions(-)
diff --git a/js/src/components/paid-ads/gen-ai-progress.js b/js/src/components/paid-ads/gen-ai-progress.js
index 73813fefac..b971dccde9 100644
--- a/js/src/components/paid-ads/gen-ai-progress.js
+++ b/js/src/components/paid-ads/gen-ai-progress.js
@@ -3,7 +3,7 @@
*/
import { __ } from '@wordpress/i18n';
// eslint-disable-next-line import/named, @woocommerce/dependency-group
-import { Flex, FlexBlock, ProgressBar } from '@wordpress/components';
+import { ProgressBar } from '@wordpress/components';
/**
* Internal dependencies
@@ -20,20 +20,21 @@ const GenAIProgress = () => {
width={ 212 }
height={ 212 }
/>
-
-
-
- { __( 'Generating assets', 'google-listings-and-ads' ) }
-
-
-
- { __(
- 'Google AI is analyzing your campaign’s URL to automatically generate your ad assets',
- 'google-listings-and-ads'
- ) }
-
-
-
+
+
+
+ { __( 'Generating assets', 'google-listings-and-ads' ) }
+
+
+
+
+
+ { __(
+ 'Google AI is analyzing your campaign’s URL to automatically generate your ad assets',
+ 'google-listings-and-ads'
+ ) }
+
+
);
};
diff --git a/js/src/components/paid-ads/gen-ai-progress.scss b/js/src/components/paid-ads/gen-ai-progress.scss
index 9baff3dea5..50c4b0d225 100644
--- a/js/src/components/paid-ads/gen-ai-progress.scss
+++ b/js/src/components/paid-ads/gen-ai-progress.scss
@@ -1,24 +1,24 @@
.gen-ai-progress {
- align-items: center;
- display: flex;
- flex-direction: column;
- padding: $grid-unit-80 0;
+ --wp-components-color-foreground: #3858e9;
+ text-align: center;
+ padding: $grid-unit-80 0;
- :where(.gen-ai-progress__bar) {
- max-width: 520px;
- height: 4px;
- width: 100%;
+ .gen-ai-progress__bar {
+ height: $grid-unit-05;
+ width: 100%;
+ }
- > div:first-child {
- background-color: #3858E9;
- }
- }
+ .gen-ai-progress__text-content {
+ margin: $grid-unit-40 auto $grid-unit-20;
+ max-width: 520px;
- :where(.gen-ai-progress__text-content) {
- text-align: center;
+ h2 {
+ font-size: $gla-font-small-medium;
+ margin: 0 0 $grid-unit-20;
+ }
- h2{
- font-size: $gla-font-small-medium;
- }
- }
-}
\ No newline at end of file
+ p {
+ margin: $grid-unit-20 0 0;
+ }
+ }
+}
From e4a9675a11596af07fc5153cea68c9e80b60255b Mon Sep 17 00:00:00 2001
From: asvinb
Date: Mon, 19 Jan 2026 16:22:30 +0400
Subject: [PATCH 047/123] Added comment.
---
js/src/components/paid-ads/gen-ai-progress.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/js/src/components/paid-ads/gen-ai-progress.js b/js/src/components/paid-ads/gen-ai-progress.js
index b971dccde9..a1dadd304d 100644
--- a/js/src/components/paid-ads/gen-ai-progress.js
+++ b/js/src/components/paid-ads/gen-ai-progress.js
@@ -2,7 +2,7 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
-// eslint-disable-next-line import/named, @woocommerce/dependency-group
+// eslint-disable-next-line import/named, @woocommerce/dependency-group -- ProgressBar exists in @wordpress/components build output but isn't exported from index.ts (not part of the public API maybe).
import { ProgressBar } from '@wordpress/components';
/**
From cbb31726752923f3d234b72557d838fb6a22ce60 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Mon, 19 Jan 2026 17:12:33 +0400
Subject: [PATCH 048/123] Add images to existing images.
---
js/src/data/reducer.js | 7 ++++++-
1 file changed, 6 insertions(+), 1 deletion(-)
diff --git a/js/src/data/reducer.js b/js/src/data/reducer.js
index 5c3bbbdfec..85afadcc19 100644
--- a/js/src/data/reducer.js
+++ b/js/src/data/reducer.js
@@ -635,7 +635,12 @@ const reducer = ( state = DEFAULT_STATE, action ) => {
case TYPES.RECEIVE_GEN_AI_MEDIA_ASSETS: {
const { url, data } = action;
- return setIn( state, [ 'gen_ai_assets', url, 'media' ], data );
+ const existingMedia = state.gen_ai_assets?.[ url ]?.media ?? {};
+
+ return setIn( state, [ 'gen_ai_assets', url, 'media' ], {
+ ...existingMedia,
+ ...data,
+ } );
}
case TYPES.RECEIVE_GEN_AI_TEXT_ASSETS: {
From cba94cd42aba61f75a599be4eec768fdb9924d6b Mon Sep 17 00:00:00 2001
From: asvinb
Date: Mon, 19 Jan 2026 17:21:49 +0400
Subject: [PATCH 049/123] Update bundle size.
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 0513341331..1a8ae93de2 100644
--- a/package.json
+++ b/package.json
@@ -149,7 +149,7 @@
},
{
"path": "./js/build/index.js",
- "maxSize": "19.1 kB"
+ "maxSize": "19.2 kB"
},
{
"path": "./js/build/commons.js",
From bde0be5916fc28b3037624ddbb02647e5abe9a55 Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Mon, 19 Jan 2026 22:39:50 +0530
Subject: [PATCH 050/123] Select home url on mount.
---
.../asset-group-header/assets-loader.js | 43 ++++++++++++-------
1 file changed, 27 insertions(+), 16 deletions(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js b/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js
index 13f19c5ed2..62557a5257 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js
@@ -4,7 +4,7 @@
import { __ } from '@wordpress/i18n';
import apiFetch from '@wordpress/api-fetch';
import { addQueryArgs } from '@wordpress/url';
-import { useState, useRef } from '@wordpress/element';
+import { useState, useRef, useEffect } from '@wordpress/element';
import { Spinner } from '@woocommerce/components';
/**
@@ -105,6 +105,31 @@ export default function AssetsLoader( { onAssetsLoaded } ) {
const [ fetching, setFetching ] = useState( false );
const { createNotice } = useDispatchCoreNotices();
+ // Reusable loader for suggested assets by final URL descriptor.
+ const loadSuggestedAssets = ( { id, type } ) => {
+ setFetching( true );
+ return fetchSuggestedAssets( id, type )
+ .then( ( assets ) => {
+ onAssetsLoaded( assets );
+ } )
+ .catch( () => {
+ setFetching( false );
+ createNotice(
+ 'error',
+ __(
+ 'Unable to load assets data from the selected page.',
+ 'google-listings-and-ads'
+ )
+ );
+ } );
+ };
+
+ // On mount, prefetch suggested assets for homepage.
+ useEffect( () => {
+ loadSuggestedAssets( { id: -1, type: 'homepage' } );
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [] );
+
// To have the searching state and keep the entered search value, this handler needs to
// be called immediately after keying values. Therefore, it also needs to implement the
// debounce here.
@@ -154,21 +179,7 @@ export default function AssetsLoader( { onAssetsLoaded } ) {
const handleClick = async () => {
const { finalUrl } = selectedOptions[ 0 ];
-
- setFetching( true );
-
- fetchSuggestedAssets( finalUrl.id, finalUrl.type )
- .then( onAssetsLoaded )
- .catch( () => {
- setFetching( false );
- createNotice(
- 'error',
- __(
- 'Unable to load assets data from the selected page.',
- 'google-listings-and-ads'
- )
- );
- } );
+ loadSuggestedAssets( { id: finalUrl.id, type: finalUrl.type } );
};
const { finalUrl } = selectedOptions[ 0 ] || {};
From 41ca59d54cc65cd087c4a97a493395ef32460eed Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Mon, 19 Jan 2026 22:55:19 +0530
Subject: [PATCH 051/123] Update to async function.
---
.../asset-group-header/assets-loader.js | 28 ++++++++-----------
1 file changed, 11 insertions(+), 17 deletions(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js b/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js
index 62557a5257..d28d1e7562 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js
@@ -105,26 +105,20 @@ export default function AssetsLoader( { onAssetsLoaded } ) {
const [ fetching, setFetching ] = useState( false );
const { createNotice } = useDispatchCoreNotices();
- // Reusable loader for suggested assets by final URL descriptor.
- const loadSuggestedAssets = ( { id, type } ) => {
+ const loadSuggestedAssets = async ( { id, type } ) => {
setFetching( true );
- return fetchSuggestedAssets( id, type )
- .then( ( assets ) => {
- onAssetsLoaded( assets );
- } )
- .catch( () => {
- setFetching( false );
- createNotice(
- 'error',
- __(
- 'Unable to load assets data from the selected page.',
- 'google-listings-and-ads'
- )
- );
- } );
+ try {
+ const assets = await fetchSuggestedAssets( id, type );
+ onAssetsLoaded( assets );
+ } catch ( error ) {
+ setFetching( false );
+ createNotice(
+ 'error',
+ __( 'Unable to load assets data.', 'google-listings-and-ads' )
+ );
+ }
};
- // On mount, prefetch suggested assets for homepage.
useEffect( () => {
loadSuggestedAssets( { id: -1, type: 'homepage' } );
// eslint-disable-next-line react-hooks/exhaustive-deps
From 85fb6985889794a6847f678b32be84e0519fa0cf Mon Sep 17 00:00:00 2001
From: James Morrison
Date: Tue, 20 Jan 2026 10:49:54 +0000
Subject: [PATCH 052/123] PR Feedback.
---
src/Ads/AdsAssetGenerationService.php | 49 +++++++++----------
.../Ads/AdsAssetGenerationServiceTest.php | 4 +-
2 files changed, 25 insertions(+), 28 deletions(-)
diff --git a/src/Ads/AdsAssetGenerationService.php b/src/Ads/AdsAssetGenerationService.php
index 352448795e..2a02d83de2 100644
--- a/src/Ads/AdsAssetGenerationService.php
+++ b/src/Ads/AdsAssetGenerationService.php
@@ -38,17 +38,17 @@ class AdsAssetGenerationService implements OptionsAwareInterface, Service {
protected $client;
/**
- * Mapping from uppercase input strings to AssetFieldType constants.
+ * Mapping from lowercase input strings to AssetFieldType constants.
*
* @var array
*/
protected const TYPE_MAPPING = [
- 'HEADLINE' => AssetFieldType::HEADLINE,
- 'LONG_HEADLINE' => AssetFieldType::LONG_HEADLINE,
- 'DESCRIPTION' => AssetFieldType::DESCRIPTION,
- 'MARKETING_IMAGE' => AssetFieldType::MARKETING_IMAGE,
- 'SQUARE_MARKETING_IMAGE' => AssetFieldType::SQUARE_MARKETING_IMAGE,
- 'PORTRAIT_MARKETING_IMAGE' => AssetFieldType::PORTRAIT_MARKETING_IMAGE,
+ 'headline' => AssetFieldType::HEADLINE,
+ 'long_headline' => AssetFieldType::LONG_HEADLINE,
+ 'description' => AssetFieldType::DESCRIPTION,
+ 'marketing_image' => AssetFieldType::MARKETING_IMAGE,
+ 'square_marketing_image' => AssetFieldType::SQUARE_MARKETING_IMAGE,
+ 'portrait_marketing_image' => AssetFieldType::PORTRAIT_MARKETING_IMAGE,
];
/**
@@ -67,7 +67,7 @@ public function __construct( GoogleAdsClient $client ) {
* Optional. Arguments for generating text assets.
*
* @type string $final_url The final URL - defaults to the Site URL.
- * @type array $asset_field_types Can be one or more of: HEADLINE, LONG_HEADLINE, DESCRIPTION.
+ * @type array $asset_field_types Can be one or more of: headline, long_headline, description.
* }
* @return array Array of generated text objects with 'text' and 'type' keys.
* @throws Exception If the text assets can't be generated.
@@ -80,8 +80,14 @@ public function generate_text( array $args = [] ): array {
$final_url = $args['final_url'] ?? $this->get_site_url();
- // Convert asset field types from uppercase strings to enum numbers.
- $asset_field_types = $this->convert_text_types_to_enums( $args['asset_field_types'] ?? [] );
+ // Set default types if not provided.
+ $types = $args['asset_field_types'] ?? [];
+ if ( empty( $types ) ) {
+ $types = [ 'headline', 'long_headline', 'description' ];
+ }
+
+ // Convert asset field types from lowercase strings to enum numbers.
+ $asset_field_types = $this->convert_text_types_to_enums( $types );
$request = new GenerateTextRequest(
[
@@ -120,7 +126,7 @@ public function generate_text( array $args = [] ): array {
* Optional. Arguments for generating image assets.
*
* @type string $final_url The final URL - defaults to the Site URL.
- * @type array $asset_field_types Can be one or more of: MARKETING_IMAGE, SQUARE_MARKETING_IMAGE, PORTRAIT_MARKETING_IMAGE.
+ * @type array $asset_field_types Can be one or more of: marketing_image, square_marketing_image, portrait_marketing_image.
* }
* @return array Array of generated image objects with 'temporary_image_url' and 'type' keys.
* @throws Exception If the image assets can't be generated.
@@ -133,7 +139,7 @@ public function generate_images( array $args = [] ): array {
$final_url = $args['final_url'] ?? $this->get_site_url();
- // Convert asset field types from uppercase strings to enum numbers (if provided).
+ // Convert asset field types from lowercase strings to enum numbers (if provided).
$asset_field_types = [];
if ( ! empty( $args['asset_field_types'] ) ) {
$asset_field_types = $this->convert_image_types_to_enums( $args['asset_field_types'] );
@@ -181,21 +187,12 @@ public function generate_images( array $args = [] ): array {
}
/**
- * Convert text asset field types from uppercase strings to enum numbers.
+ * Convert text asset field types from lowercase strings to enum numbers.
*
- * @param array $types Array of uppercase type strings (HEADLINE, LONG_HEADLINE, DESCRIPTION).
- * @return array Array of enum numbers. Defaults to all text types if empty.
+ * @param array $types Array of lowercase type strings (headline, long_headline, description).
+ * @return array Array of enum numbers.
*/
protected function convert_text_types_to_enums( array $types ): array {
- if ( empty( $types ) ) {
- // Default to all text types.
- return [
- AssetFieldType::number( AssetFieldType::HEADLINE ),
- AssetFieldType::number( AssetFieldType::LONG_HEADLINE ),
- AssetFieldType::number( AssetFieldType::DESCRIPTION ),
- ];
- }
-
$enums = [];
foreach ( $types as $type ) {
if ( ! isset( self::TYPE_MAPPING[ $type ] ) ) {
@@ -213,9 +210,9 @@ protected function convert_text_types_to_enums( array $types ): array {
}
/**
- * Convert image asset field types from uppercase strings to enum numbers.
+ * Convert image asset field types from lowercase strings to enum numbers.
*
- * @param array $types Array of uppercase type strings (MARKETING_IMAGE, SQUARE_MARKETING_IMAGE, PORTRAIT_MARKETING_IMAGE).
+ * @param array $types Array of lowercase type strings (marketing_image, square_marketing_image, portrait_marketing_image).
* @return array Array of enum numbers.
*/
protected function convert_image_types_to_enums( array $types ): array {
diff --git a/tests/Unit/Ads/AdsAssetGenerationServiceTest.php b/tests/Unit/Ads/AdsAssetGenerationServiceTest.php
index f79759844a..ed6e3438c8 100644
--- a/tests/Unit/Ads/AdsAssetGenerationServiceTest.php
+++ b/tests/Unit/Ads/AdsAssetGenerationServiceTest.php
@@ -95,7 +95,7 @@ public function test_generate_text_with_specific_types() {
$this->generate_text_assets_mock( $expected_text_assets );
- $result = $this->service->generate_text( [ 'asset_field_types' => [ 'HEADLINE' ] ] );
+ $result = $this->service->generate_text( [ 'asset_field_types' => [ 'headline' ] ] );
$this->assertEquals( $expected_text_assets, $result );
}
@@ -170,7 +170,7 @@ public function test_generate_images_with_specific_types() {
$this->generate_image_assets_mock( $expected_image_assets );
- $result = $this->service->generate_images( [ 'asset_field_types' => [ 'MARKETING_IMAGE' ] ] );
+ $result = $this->service->generate_images( [ 'asset_field_types' => [ 'marketing_image' ] ] );
$this->assertEquals( $expected_image_assets, $result );
}
From ed1573a80e2383b5f04afd10a6004afec9f8f991 Mon Sep 17 00:00:00 2001
From: James Morrison
Date: Tue, 20 Jan 2026 10:58:05 +0000
Subject: [PATCH 053/123] Merge convert functions.
---
src/Ads/AdsAssetGenerationService.php | 39 ++++++++-------------------
1 file changed, 11 insertions(+), 28 deletions(-)
diff --git a/src/Ads/AdsAssetGenerationService.php b/src/Ads/AdsAssetGenerationService.php
index 2a02d83de2..c16116369b 100644
--- a/src/Ads/AdsAssetGenerationService.php
+++ b/src/Ads/AdsAssetGenerationService.php
@@ -87,7 +87,8 @@ public function generate_text( array $args = [] ): array {
}
// Convert asset field types from lowercase strings to enum numbers.
- $asset_field_types = $this->convert_text_types_to_enums( $types );
+ $allowed_types = [ AssetFieldType::HEADLINE, AssetFieldType::LONG_HEADLINE, AssetFieldType::DESCRIPTION ];
+ $asset_field_types = $this->convert_types_to_enums( $types, $allowed_types );
$request = new GenerateTextRequest(
[
@@ -142,7 +143,8 @@ public function generate_images( array $args = [] ): array {
// Convert asset field types from lowercase strings to enum numbers (if provided).
$asset_field_types = [];
if ( ! empty( $args['asset_field_types'] ) ) {
- $asset_field_types = $this->convert_image_types_to_enums( $args['asset_field_types'] );
+ $allowed_types = [ AssetFieldType::MARKETING_IMAGE, AssetFieldType::SQUARE_MARKETING_IMAGE, AssetFieldType::PORTRAIT_MARKETING_IMAGE ];
+ $asset_field_types = $this->convert_types_to_enums( $args['asset_field_types'], $allowed_types );
}
$request_data = [
@@ -187,12 +189,13 @@ public function generate_images( array $args = [] ): array {
}
/**
- * Convert text asset field types from lowercase strings to enum numbers.
+ * Convert asset field types from lowercase strings to enum numbers.
*
- * @param array $types Array of lowercase type strings (headline, long_headline, description).
+ * @param array $types Array of lowercase type strings.
+ * @param array $allowed_types Optional. Array of AssetFieldType constants to filter by.
* @return array Array of enum numbers.
*/
- protected function convert_text_types_to_enums( array $types ): array {
+ protected function convert_types_to_enums( array $types, array $allowed_types = [] ): array {
$enums = [];
foreach ( $types as $type ) {
if ( ! isset( self::TYPE_MAPPING[ $type ] ) ) {
@@ -200,33 +203,13 @@ protected function convert_text_types_to_enums( array $types ): array {
}
$internal_type = self::TYPE_MAPPING[ $type ];
- // Only include text types.
- if ( in_array( $internal_type, [ AssetFieldType::HEADLINE, AssetFieldType::LONG_HEADLINE, AssetFieldType::DESCRIPTION ], true ) ) {
- $enums[] = AssetFieldType::number( $internal_type );
- }
- }
- return $enums;
- }
-
- /**
- * Convert image asset field types from lowercase strings to enum numbers.
- *
- * @param array $types Array of lowercase type strings (marketing_image, square_marketing_image, portrait_marketing_image).
- * @return array Array of enum numbers.
- */
- protected function convert_image_types_to_enums( array $types ): array {
- $enums = [];
- foreach ( $types as $type ) {
- if ( ! isset( self::TYPE_MAPPING[ $type ] ) ) {
+ // Filter by allowed types if specified.
+ if ( ! empty( $allowed_types ) && ! in_array( $internal_type, $allowed_types, true ) ) {
continue;
}
- $internal_type = self::TYPE_MAPPING[ $type ];
- // Only include image types.
- if ( in_array( $internal_type, [ AssetFieldType::MARKETING_IMAGE, AssetFieldType::SQUARE_MARKETING_IMAGE, AssetFieldType::PORTRAIT_MARKETING_IMAGE ], true ) ) {
- $enums[] = AssetFieldType::number( $internal_type );
- }
+ $enums[] = AssetFieldType::number( $internal_type );
}
return $enums;
From 965a9352601b324507aad60f50f67751b6f76e3f Mon Sep 17 00:00:00 2001
From: James Morrison
Date: Tue, 20 Jan 2026 11:27:07 +0000
Subject: [PATCH 054/123] PR feedback.
---
.../Ads/AssetGenerationController.php | 39 ++++++++-----------
.../Ads/AssetGenerationControllerTest.php | 14 +++----
2 files changed, 23 insertions(+), 30 deletions(-)
diff --git a/src/API/Site/Controllers/Ads/AssetGenerationController.php b/src/API/Site/Controllers/Ads/AssetGenerationController.php
index 4c79d89eb6..d541a8b78c 100644
--- a/src/API/Site/Controllers/Ads/AssetGenerationController.php
+++ b/src/API/Site/Controllers/Ads/AssetGenerationController.php
@@ -4,6 +4,7 @@
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AdsAssetGenerationService;
+use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AssetFieldType;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\ResponseFromExceptionTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
@@ -87,7 +88,11 @@ protected function get_generate_text_params(): array {
'type' => 'array',
'items' => [
'type' => 'string',
- 'enum' => [ 'headline', 'long_headline', 'description' ],
+ 'enum' => [
+ AssetFieldType::HEADLINE,
+ AssetFieldType::LONG_HEADLINE,
+ AssetFieldType::DESCRIPTION,
+ ],
],
'sanitize_callback' => function ( $types ) {
return array_map( 'sanitize_text_field', $types );
@@ -115,7 +120,11 @@ protected function get_generate_images_params(): array {
'type' => 'array',
'items' => [
'type' => 'string',
- 'enum' => [ 'marketing_image', 'square_marketing_image', 'portrait_marketing_image' ],
+ 'enum' => [
+ AssetFieldType::MARKETING_IMAGE,
+ AssetFieldType::SQUARE_MARKETING_IMAGE,
+ AssetFieldType::PORTRAIT_MARKETING_IMAGE,
+ ],
],
'sanitize_callback' => function ( $types ) {
return array_map( 'sanitize_text_field', $types );
@@ -136,14 +145,11 @@ protected function get_generate_text_callback(): callable {
$final_url = $request->get_param( 'final_url' ) ?: $this->get_site_url();
$types = $request->get_param( 'types' ) ?: [ 'headline', 'long_headline', 'description' ];
- // Convert lowercase types to uppercase for service.
- $uppercase_types = $this->convert_types_to_uppercase( $types );
-
- // Call service.
+ // Call service with lowercase types.
$items = $this->service->generate_text(
[
'final_url' => $final_url,
- 'asset_field_types' => $uppercase_types,
+ 'asset_field_types' => $types,
]
);
@@ -166,13 +172,10 @@ protected function get_generate_images_callback(): callable {
$final_url = $request->get_param( 'final_url' ) ?: $this->get_site_url();
$types = $request->get_param( 'types' ) ?: [];
- // Convert lowercase types to uppercase for service (if provided).
- $uppercase_types = ! empty( $types ) ? $this->convert_types_to_uppercase( $types ) : [];
-
- // Call service.
+ // Call service with lowercase types.
$args = [ 'final_url' => $final_url ];
- if ( ! empty( $uppercase_types ) ) {
- $args['asset_field_types'] = $uppercase_types;
+ if ( ! empty( $types ) ) {
+ $args['asset_field_types'] = $types;
}
$items = $this->service->generate_images( $args );
@@ -184,16 +187,6 @@ protected function get_generate_images_callback(): callable {
};
}
- /**
- * Convert types to uppercase.
- *
- * @param array $types Array of lowercase type strings.
- * @return array Array of uppercase type strings.
- */
- protected function convert_types_to_uppercase( array $types ): array {
- return array_map( 'strtoupper', $types );
- }
-
/**
* Format the response with final_url and items.
*
diff --git a/tests/Unit/API/Site/Controllers/Ads/AssetGenerationControllerTest.php b/tests/Unit/API/Site/Controllers/Ads/AssetGenerationControllerTest.php
index 25d51c46f3..873437b395 100644
--- a/tests/Unit/API/Site/Controllers/Ads/AssetGenerationControllerTest.php
+++ b/tests/Unit/API/Site/Controllers/Ads/AssetGenerationControllerTest.php
@@ -38,13 +38,13 @@ public function setUp(): void {
}
public function test_generate_text_with_defaults() {
- // Service expects uppercase types.
+ // Service expects lowercase types.
$this->service->expects( $this->once() )
->method( 'generate_text' )
->with(
[
'final_url' => self::TEST_SITE_URL,
- 'asset_field_types' => [ 'HEADLINE', 'LONG_HEADLINE', 'DESCRIPTION' ],
+ 'asset_field_types' => [ 'headline', 'long_headline', 'description' ],
]
)
->willReturn(
@@ -87,7 +87,7 @@ public function test_generate_text_with_custom_url() {
->with(
[
'final_url' => 'https://custom-url.com',
- 'asset_field_types' => [ 'HEADLINE', 'LONG_HEADLINE', 'DESCRIPTION' ],
+ 'asset_field_types' => [ 'headline', 'long_headline', 'description' ],
]
)
->willReturn(
@@ -117,7 +117,7 @@ public function test_generate_text_with_specific_types() {
->with(
[
'final_url' => self::TEST_SITE_URL,
- 'asset_field_types' => [ 'HEADLINE' ],
+ 'asset_field_types' => [ 'headline' ],
]
)
->willReturn(
@@ -148,7 +148,7 @@ public function test_generate_text_type_conversion() {
->with(
$this->callback(
function ( $args ) {
- return $args['asset_field_types'] === [ 'HEADLINE', 'DESCRIPTION' ];
+ return $args['asset_field_types'] === [ 'headline', 'description' ];
}
)
)
@@ -262,7 +262,7 @@ public function test_generate_images_with_specific_types() {
->with(
[
'final_url' => self::TEST_SITE_URL,
- 'asset_field_types' => [ 'MARKETING_IMAGE' ],
+ 'asset_field_types' => [ 'marketing_image' ],
]
)
->willReturn(
@@ -293,7 +293,7 @@ public function test_generate_images_type_conversion() {
->with(
$this->callback(
function ( $args ) {
- return $args['asset_field_types'] === [ 'MARKETING_IMAGE', 'SQUARE_MARKETING_IMAGE' ];
+ return $args['asset_field_types'] === [ 'marketing_image', 'square_marketing_image' ];
}
)
)
From f0e61f11b9f19b15cb0d434ca486a9e0b788dc1b Mon Sep 17 00:00:00 2001
From: James Morrison
Date: Tue, 20 Jan 2026 11:51:21 +0000
Subject: [PATCH 055/123] PR feedback: API controller handles defaults,
generation service validates required types are set.
---
src/Ads/AdsAssetGenerationService.php | 9 ++++-----
.../Ads/AdsAssetGenerationServiceTest.php | 20 ++++++++++++++++---
2 files changed, 21 insertions(+), 8 deletions(-)
diff --git a/src/Ads/AdsAssetGenerationService.php b/src/Ads/AdsAssetGenerationService.php
index c16116369b..ca64b9e39e 100644
--- a/src/Ads/AdsAssetGenerationService.php
+++ b/src/Ads/AdsAssetGenerationService.php
@@ -80,15 +80,14 @@ public function generate_text( array $args = [] ): array {
$final_url = $args['final_url'] ?? $this->get_site_url();
- // Set default types if not provided.
- $types = $args['asset_field_types'] ?? [];
- if ( empty( $types ) ) {
- $types = [ 'headline', 'long_headline', 'description' ];
+ // Validate that asset field types are provided.
+ if ( empty( $args['asset_field_types'] ) ) {
+ throw new Exception( __( 'Asset field types are required for text generation.', 'google-listings-and-ads' ) );
}
// Convert asset field types from lowercase strings to enum numbers.
$allowed_types = [ AssetFieldType::HEADLINE, AssetFieldType::LONG_HEADLINE, AssetFieldType::DESCRIPTION ];
- $asset_field_types = $this->convert_types_to_enums( $types, $allowed_types );
+ $asset_field_types = $this->convert_types_to_enums( $args['asset_field_types'], $allowed_types );
$request = new GenerateTextRequest(
[
diff --git a/tests/Unit/Ads/AdsAssetGenerationServiceTest.php b/tests/Unit/Ads/AdsAssetGenerationServiceTest.php
index ed6e3438c8..d4a5c9495c 100644
--- a/tests/Unit/Ads/AdsAssetGenerationServiceTest.php
+++ b/tests/Unit/Ads/AdsAssetGenerationServiceTest.php
@@ -64,7 +64,9 @@ public function test_generate_text_with_defaults() {
$this->generate_text_assets_mock( $expected_text_assets );
- $result = $this->service->generate_text( [] );
+ $result = $this->service->generate_text( [
+ 'asset_field_types' => [ 'headline', 'long_headline', 'description' ],
+ ] );
$this->assertEquals( $expected_text_assets, $result );
}
@@ -80,7 +82,10 @@ public function test_generate_text_with_custom_final_url() {
$this->generate_text_assets_mock( $expected_text_assets );
- $result = $this->service->generate_text( [ 'final_url' => $final_url ] );
+ $result = $this->service->generate_text( [
+ 'final_url' => $final_url,
+ 'asset_field_types' => [ 'headline', 'long_headline', 'description' ],
+ ] );
$this->assertEquals( $expected_text_assets, $result );
}
@@ -108,7 +113,9 @@ public function test_generate_text_exception() {
$this->expectException( Exception::class );
$this->expectExceptionMessage( 'Unable to generate text assets' );
- $this->service->generate_text( [] );
+ $this->service->generate_text( [
+ 'asset_field_types' => [ 'headline' ],
+ ] );
$this->assertEquals( 1, did_action( 'woocommerce_gla_ads_client_exception' ) );
}
@@ -121,6 +128,13 @@ public function test_generate_text_no_ads_id() {
$this->service->generate_text( [] );
}
+ public function test_generate_text_no_types_provided() {
+ $this->expectException( Exception::class );
+ $this->expectExceptionMessage( 'Asset field types are required for text generation' );
+
+ $this->service->generate_text( [ 'final_url' => 'https://example.com' ] );
+ }
+
public function test_generate_images_with_defaults() {
$expected_image_assets = [
[
From 3030629bd06d432af4cb81909ebc14779a2cdd49 Mon Sep 17 00:00:00 2001
From: James Morrison
Date: Tue, 20 Jan 2026 11:54:38 +0000
Subject: [PATCH 056/123] PHPCS fixes.
---
.../Ads/AdsAssetGenerationServiceTest.php | 31 +++++++++++++------
1 file changed, 21 insertions(+), 10 deletions(-)
diff --git a/tests/Unit/Ads/AdsAssetGenerationServiceTest.php b/tests/Unit/Ads/AdsAssetGenerationServiceTest.php
index d4a5c9495c..b3ebf15c03 100644
--- a/tests/Unit/Ads/AdsAssetGenerationServiceTest.php
+++ b/tests/Unit/Ads/AdsAssetGenerationServiceTest.php
@@ -64,9 +64,12 @@ public function test_generate_text_with_defaults() {
$this->generate_text_assets_mock( $expected_text_assets );
- $result = $this->service->generate_text( [
- 'asset_field_types' => [ 'headline', 'long_headline', 'description' ],
- ] );
+ $result = $this->service->generate_text(
+ [
+ 'final_url' => self::TEST_SITE_URL,
+ 'asset_field_types' => [ 'headline', 'long_headline', 'description' ],
+ ]
+ );
$this->assertEquals( $expected_text_assets, $result );
}
@@ -82,10 +85,16 @@ public function test_generate_text_with_custom_final_url() {
$this->generate_text_assets_mock( $expected_text_assets );
- $result = $this->service->generate_text( [
- 'final_url' => $final_url,
- 'asset_field_types' => [ 'headline', 'long_headline', 'description' ],
- ] );
+ $result = $this->service->generate_text(
+ [
+ 'final_url' => $final_url,
+ 'asset_field_types' => [
+ 'headline',
+ 'long_headline',
+ 'description',
+ ],
+ ]
+ );
$this->assertEquals( $expected_text_assets, $result );
}
@@ -113,9 +122,11 @@ public function test_generate_text_exception() {
$this->expectException( Exception::class );
$this->expectExceptionMessage( 'Unable to generate text assets' );
- $this->service->generate_text( [
- 'asset_field_types' => [ 'headline' ],
- ] );
+ $this->service->generate_text(
+ [
+ 'asset_field_types' => [ 'headline' ],
+ ]
+ );
$this->assertEquals( 1, did_action( 'woocommerce_gla_ads_client_exception' ) );
}
From 2fb2a74ed3ae9fcc5d3caedabaeefcda4541904f Mon Sep 17 00:00:00 2001
From: asvinb
Date: Tue, 20 Jan 2026 18:51:37 +0400
Subject: [PATCH 057/123] Add loading logic.
---
.../asset-group-images-section.js | 2 +-
.../asset-group-header/asset-group-header.js | 16 ++-
.../paid-ads/asset-group/asset-group.js | 131 ++++++++++--------
.../paid-ads/campaign-assets-form.js | 73 ++++++++++
.../pages/create-paid-ads-campaign/index.js | 25 ++--
5 files changed, 177 insertions(+), 70 deletions(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-images-section.js b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-images-section.js
index 6bd7c5701d..fdfee677cb 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-images-section.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/asset-group-images-section.js
@@ -35,7 +35,7 @@ const AssetGroupImagesSection = ( {
renderErrors,
} ) => {
const { values, getInputProps, adapter } = useAdaptiveFormContext();
- const showTip = adapter.hasImportedAssets;
+ const showTip = adapter.hasAISuggestedMediaAssets;
return (
- { showTip && (
+ { hasImportedAssets && (
{ __(
@@ -92,9 +96,11 @@ export default function AssetGroupHeader() {
-
-
-
+ { hasAISuggestedTextAssets && hasAISuggestedMediaAssets && (
+
+
+
+ ) }
diff --git a/js/src/components/paid-ads/asset-group/asset-group.js b/js/src/components/paid-ads/asset-group/asset-group.js
index 8278a07e50..d04998a973 100644
--- a/js/src/components/paid-ads/asset-group/asset-group.js
+++ b/js/src/components/paid-ads/asset-group/asset-group.js
@@ -72,7 +72,13 @@ export default function AssetGroup( { campaign } ) {
const { isValidForm, handleSubmit, adapter, values } =
useAdaptiveFormContext();
const { data: countryCodes } = useTargetAudienceFinalCountryCodes();
- const { isValidAssetGroup, isSubmitting, isSubmitted, submitter } = adapter;
+ const {
+ isValidAssetGroup,
+ isSubmitting,
+ isSubmitted,
+ submitter,
+ isFetchingGenAIAssets,
+ } = adapter;
const currentAction = submitter?.dataset.action;
const hasRaiseBudgetRecommendation = () => {
@@ -155,60 +161,77 @@ export default function AssetGroup( { campaign } ) {
) }
/>
-
-
-
-
-
-
- { ( isCreation || adapter.isEmptyAssetEntityGroup ) && (
- // Currently, the PMax Assets feature in this extension doesn't offer the function
- // to delete the asset entity group, so it needs to hide the skip button if the editing
- // asset group is not considered empty.
-
- { __(
- 'Skip this step',
- 'google-listings-and-ads'
+ { isFetchingGenAIAssets && }
+
+ { ! isFetchingGenAIAssets && (
+ <>
+
+
+
+
+
+ { ( isCreation ||
+ adapter.isEmptyAssetEntityGroup ) && (
+ // Currently, the PMax Assets feature in this extension doesn't offer the function
+ // to delete the asset entity group, so it needs to hide the skip button if the editing
+ // asset group is not considered empty.
+
+ { __(
+ 'Skip this step',
+ 'google-listings-and-ads'
+ ) }
+
) }
-
- ) }
-
- { isCreation
- ? __( 'Create campaign', 'google-listings-and-ads' )
- : __( 'Save changes', 'google-listings-and-ads' ) }
-
-
-
-
+
+ { isCreation
+ ? __(
+ 'Create campaign',
+ 'google-listings-and-ads'
+ )
+ : __(
+ 'Save changes',
+ 'google-listings-and-ads'
+ ) }
+
+
+
+
+ >
+ ) }
);
}
diff --git a/js/src/components/paid-ads/campaign-assets-form.js b/js/src/components/paid-ads/campaign-assets-form.js
index b49d43f491..5a1ca039a4 100644
--- a/js/src/components/paid-ads/campaign-assets-form.js
+++ b/js/src/components/paid-ads/campaign-assets-form.js
@@ -16,6 +16,7 @@ import useAdsCurrency from '~/hooks/useAdsCurrency';
import useBudgetRecommendation from '~/hooks/useBudgetRecommendation';
import useRaiseBudgetRecommendations from '~/hooks/useRaiseBudgetRecommendations';
import useEventPropertiesFilter from '~/hooks/useEventPropertiesFilter';
+import { useAppDispatch } from '~/data';
import { FILTER_BUDGET_RECOMMENDATIONS } from '~/utils/tracks';
import round from '~/utils/round';
@@ -41,6 +42,18 @@ const emptyAssetGroup = {
[ ASSET_FORM_KEY.YOUTUBE_VIDEO ]: [],
};
+const REQUIRED_TEXT_ASSET_KEYS = [
+ ASSET_FORM_KEY.LONG_HEADLINE,
+ ASSET_FORM_KEY.HEADLINE,
+ ASSET_FORM_KEY.DESCRIPTION,
+];
+
+const REQUIRED_MEDIA_ASSET_KEYS = [
+ ASSET_FORM_KEY.MARKETING_IMAGE,
+ ASSET_FORM_KEY.SQUARE_MARKETING_IMAGE,
+ ASSET_FORM_KEY.PORTRAIT_MARKETING_IMAGE,
+];
+
/**
* Converts the asset entity group data to the assets form values.
*
@@ -143,6 +156,30 @@ function resolveInitialCampaign(
return injectDailyBudget( values, budgetRecommendation );
}
+function hasValidAIGeneratedAssets( assetKeys, data ) {
+ if ( ! data || typeof data !== 'object' ) {
+ return false;
+ }
+
+ // Ensure object isn't empty
+ if ( Object.keys( data ).length === 0 ) {
+ return false;
+ }
+
+ // Ensure required keys exist + contain at least 1 non-empty string
+ return assetKeys.every( ( key ) => {
+ const value = data[ key ];
+
+ return (
+ Array.isArray( value ) &&
+ value.length > 0 &&
+ value.some(
+ ( item ) => typeof item === 'string' && item.trim().length > 0
+ )
+ );
+ } );
+}
+
/**
* Renders a form based on AdaptiveForm for managing campaign and assets.
*
@@ -158,12 +195,19 @@ export default function CampaignAssetsForm( {
countryCodes,
...adaptiveFormProps
} ) {
+ const { fetchGenAIMediaAssets, fetchGenAITextAssets } = useAppDispatch();
+ const [ isFetchingGenAIAssets, setIsFetchingGenAIAssets ] =
+ useState( false );
const initialAssetGroup = useMemo( () => {
return convertAssetEntityGroupToFormValues( assetEntityGroup );
}, [ assetEntityGroup ] );
const [ baseAssetGroup, setBaseAssetGroup ] = useState( initialAssetGroup );
const [ hasImportedAssets, setHasImportedAssets ] = useState( false );
+ const [ hasAISuggestedTextAssets, setHasAISuggestedTextAssets ] =
+ useState( false );
+ const [ hasAISuggestedMediaAssets, setHasAISuggestedMediaAssets ] =
+ useState( false );
const { formatAmount } = useAdsCurrency();
const { data: budgetRecommendationData, hasResolved } =
useBudgetRecommendation( countryCodes );
@@ -234,8 +278,37 @@ export default function CampaignAssetsForm( {
setHasImportedAssets( hasNonEmptyAssets );
setBaseAssetGroup( nextAssetGroup );
+ setHasAISuggestedTextAssets( false );
+ setHasAISuggestedMediaAssets( false );
formContext.adapter.hideValidation();
},
+ isFetchingGenAIAssets,
+ hasAISuggestedTextAssets,
+ hasAISuggestedMediaAssets,
+ async fetchGenAIAssets() {
+ try {
+ setIsFetchingGenAIAssets( true );
+ const { data: textAssetsData } =
+ await fetchGenAITextAssets( finalUrl );
+ const { data: mediaAssetsData } =
+ await fetchGenAIMediaAssets( finalUrl );
+
+ const hasSuggestedTextAssets = hasValidAIGeneratedAssets(
+ REQUIRED_TEXT_ASSET_KEYS,
+ textAssetsData
+ );
+
+ const hasSuggestedMediaAssets = hasValidAIGeneratedAssets(
+ REQUIRED_MEDIA_ASSET_KEYS,
+ mediaAssetsData
+ );
+
+ setHasAISuggestedTextAssets( hasSuggestedTextAssets );
+ setHasAISuggestedMediaAssets( hasSuggestedMediaAssets );
+ } finally {
+ setIsFetchingGenAIAssets( false );
+ }
+ },
};
};
diff --git a/js/src/pages/create-paid-ads-campaign/index.js b/js/src/pages/create-paid-ads-campaign/index.js
index c714408811..5ab3a57395 100644
--- a/js/src/pages/create-paid-ads-campaign/index.js
+++ b/js/src/pages/create-paid-ads-campaign/index.js
@@ -152,16 +152,21 @@ const CreatePaidAdsCampaign = () => {
'google-listings-and-ads'
) }
context={ eventContext }
- continueButton={ ( formContext ) => (
- {
- handleContinueClick(
- STEP.ASSET_GROUP
- );
- } }
- />
- ) }
+ continueButton={ ( formContext ) => {
+ const { adapter } = formContext;
+
+ return (
+ {
+ adapter.fetchGenAIAssets();
+ handleContinueClick(
+ STEP.ASSET_GROUP
+ );
+ } }
+ />
+ );
+ } }
/>
),
onClick: handleStepperClick,
From c3bb96884d6da9ea5e6dc4a442f4899d940f21d1 Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Tue, 20 Jan 2026 20:28:42 +0530
Subject: [PATCH 058/123] Add E2E test coverage.
---
.../add-paid-campaigns.test.js | 92 ++++++++++++++++++-
tests/e2e/utils/mock-requests.js | 9 ++
2 files changed, 100 insertions(+), 1 deletion(-)
diff --git a/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js b/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
index 9ca407bf4b..5ae1a3d10c 100644
--- a/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
+++ b/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
@@ -6,7 +6,12 @@ import { expect, test } from '@playwright/test';
/**
* Internal dependencies
*/
-import { clearOnboardedMerchant, setOnboardedMerchant } from '../../utils/api';
+import {
+ clearCompletedAdsSetup,
+ clearOnboardedMerchant,
+ setOnboardedMerchant,
+ setCompletedAdsSetup,
+} from '../../utils/api';
import DashboardPage from '../../utils/pages/dashboard';
import SetupAdsAccountsPage from '../../utils/pages/ads-onboarding/setup-ads-accounts';
import SetupBudgetPage from '../../utils/pages/ads-onboarding/setup-budget';
@@ -498,6 +503,91 @@ test.describe( 'Set up Ads account', () => {
name: 'Got It',
} )
.click();
+
+ await expect( page.getByRole( 'dialog' ) ).not.toBeVisible();
+ } );
+ } );
+
+ test.describe( 'Create campaign after ads setup is completed', async () => {
+ test.beforeAll( async () => {
+ await setCompletedAdsSetup();
+ await dashboardPage.mockAdsAccountConnected();
+ await dashboardPage.fulfillAdsCampaignsRequest( [
+ {
+ id: 111111111,
+ name: 'Test Campaign',
+ status: 'enabled',
+ type: 'performance_max',
+ amount: 1,
+ country: 'US',
+ targeted_locations: [ 'US' ],
+ },
+ ] );
+ await dashboardPage.fulfillAssetsSuggestions( {
+ logo: [],
+ business_name: 'test-business-entity-01',
+ square_marketing_image: [ 'https://placehold.co/600x600.png' ],
+ marketing_image: [ 'https://placehold.co/1200x628.png' ],
+ portrait_marketing_image: [
+ 'https://placehold.co/600x750.png',
+ 'https://placehold.co/600x750.png',
+ ],
+ call_to_action_selection: 'contact_us',
+ final_url: 'https://example.com',
+ youtube_video: [],
+ display_url_path: [ 'test', 'path' ],
+ headline: [
+ 'Sample Headline One',
+ 'Sample Headline Two',
+ 'Sample Headline Three',
+ ],
+ description: [
+ 'This is a primary test description for the asset.',
+ 'This is a secondary test description for the asset.',
+ ],
+ long_headline: [
+ 'This is a sample long headline for testing purposes',
+ ],
+ } );
+ await page.addInitScript( () => {
+ // Ensure global exists and set the flag as early as possible.
+ window.glaData = window.glaData || {};
+ window.glaData.adsSetupComplete = true;
+ } );
+ await dashboardPage.goto();
+ await page.reload( { waitUntil: LOAD_STATE.DOM_CONTENT_LOADED } );
+ } );
+
+ test.afterAll( async () => {
+ await clearCompletedAdsSetup();
+ } );
+
+ test( 'Heading should be "Create your campaign"', async () => {
+ await dashboardPage.addPaidCampaignButton.click();
+ await expect(
+ page.getByRole( 'heading', {
+ level: 1,
+ name: 'Create your campaign',
+ } )
+ ).toBeVisible();
+ } );
+
+ test( 'Next step should be "Optimize your campaign"', async () => {
+ await page.getByRole( 'button', { name: 'Continue' } ).click();
+ await page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED );
+
+ await expect(
+ page.getByRole( 'heading', {
+ level: 1,
+ name: 'Optimize your campaign',
+ } )
+ ).toBeVisible();
+
+ // Get Final URL card having class `gla-final-url-card`
+ const finalUrlCard = page.locator( '.gla-final-url-card' );
+
+ // Card should contain the link with url `https://example.com`
+ await expect( finalUrlCard ).toContainText( 'https://example.com' );
} );
} );
} );
diff --git a/tests/e2e/utils/mock-requests.js b/tests/e2e/utils/mock-requests.js
index ab83eaa7fa..4ff0cc9a57 100644
--- a/tests/e2e/utils/mock-requests.js
+++ b/tests/e2e/utils/mock-requests.js
@@ -1111,4 +1111,13 @@ export default class MockRequests {
[ 'GET' ]
);
}
+
+ async fulfillAssetsSuggestions( payload, status = 200 ) {
+ await this.fulfillRequest(
+ /\/wc\/gla\/assets\/suggestions\b/,
+ payload,
+ status,
+ [ 'GET' ]
+ );
+ }
}
From f11217adadeaa1827fbadf6fa476191fed70f5a1 Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Tue, 20 Jan 2026 20:29:20 +0530
Subject: [PATCH 059/123] Add docblock.
---
tests/e2e/utils/mock-requests.js | 7 +++++++
1 file changed, 7 insertions(+)
diff --git a/tests/e2e/utils/mock-requests.js b/tests/e2e/utils/mock-requests.js
index 4ff0cc9a57..6501db3f2d 100644
--- a/tests/e2e/utils/mock-requests.js
+++ b/tests/e2e/utils/mock-requests.js
@@ -1112,6 +1112,13 @@ export default class MockRequests {
);
}
+ /**
+ * Mocks the API request for asset suggestions.
+ *
+ * @param {Object} payload - The mock response payload to return.
+ * @param {number} [status=200] - The HTTP status code to return.
+ * @return {Promise} Resolves when the mock request has been fulfilled.
+ */
async fulfillAssetsSuggestions( payload, status = 200 ) {
await this.fulfillRequest(
/\/wc\/gla\/assets\/suggestions\b/,
From 781cac9d4d4984b5a30a01848f91989e7990545a Mon Sep 17 00:00:00 2001
From: asvinb
Date: Wed, 21 Jan 2026 13:38:33 +0400
Subject: [PATCH 060/123] refactor(paid-ads): extract GenAI asset fetch and
trigger on change
---
.../paid-ads/campaign-assets-form.js | 70 ++++++++++++-------
1 file changed, 46 insertions(+), 24 deletions(-)
diff --git a/js/src/components/paid-ads/campaign-assets-form.js b/js/src/components/paid-ads/campaign-assets-form.js
index 5a1ca039a4..c9e1cb6fde 100644
--- a/js/src/components/paid-ads/campaign-assets-form.js
+++ b/js/src/components/paid-ads/campaign-assets-form.js
@@ -240,6 +240,43 @@ export default function CampaignAssetsForm( {
const assetGroupErrors = validateAssetGroup( formContext.values );
const finalUrl = assetEntityGroup?.[ ASSET_GROUP_KEY.FINAL_URL ];
+ const fetchGenAIAssets = async ( url, assetGroupValues ) => {
+ try {
+ setIsFetchingGenAIAssets( true );
+ const { data: textAssetsData } =
+ await fetchGenAITextAssets( url );
+ const { data: mediaAssetsData } =
+ await fetchGenAIMediaAssets( url );
+
+ const hasSuggestedTextAssets = hasValidAIGeneratedAssets(
+ REQUIRED_TEXT_ASSET_KEYS,
+ textAssetsData
+ );
+
+ const hasSuggestedMediaAssets = hasValidAIGeneratedAssets(
+ REQUIRED_MEDIA_ASSET_KEYS,
+ mediaAssetsData
+ );
+
+ const nextValues = {
+ ...( hasSuggestedTextAssets ? textAssetsData : {} ),
+ ...( hasSuggestedMediaAssets ? mediaAssetsData : {} ),
+ };
+
+ if ( Object.keys( nextValues ).length ) {
+ formContext.setValues( {
+ ...assetGroupValues,
+ ...nextValues,
+ } );
+ }
+
+ setHasAISuggestedTextAssets( hasSuggestedTextAssets );
+ setHasAISuggestedMediaAssets( hasSuggestedMediaAssets );
+ } finally {
+ setIsFetchingGenAIAssets( false );
+ }
+ };
+
return {
countryCodes,
budgetRecommendation: selectedBudgetRecommendation,
@@ -280,35 +317,20 @@ export default function CampaignAssetsForm( {
setBaseAssetGroup( nextAssetGroup );
setHasAISuggestedTextAssets( false );
setHasAISuggestedMediaAssets( false );
+
+ if ( nextAssetGroup.final_url ) {
+ fetchGenAIAssets(
+ nextAssetGroup.final_url,
+ updatedContextValues
+ );
+ }
+
formContext.adapter.hideValidation();
},
isFetchingGenAIAssets,
hasAISuggestedTextAssets,
hasAISuggestedMediaAssets,
- async fetchGenAIAssets() {
- try {
- setIsFetchingGenAIAssets( true );
- const { data: textAssetsData } =
- await fetchGenAITextAssets( finalUrl );
- const { data: mediaAssetsData } =
- await fetchGenAIMediaAssets( finalUrl );
-
- const hasSuggestedTextAssets = hasValidAIGeneratedAssets(
- REQUIRED_TEXT_ASSET_KEYS,
- textAssetsData
- );
-
- const hasSuggestedMediaAssets = hasValidAIGeneratedAssets(
- REQUIRED_MEDIA_ASSET_KEYS,
- mediaAssetsData
- );
-
- setHasAISuggestedTextAssets( hasSuggestedTextAssets );
- setHasAISuggestedMediaAssets( hasSuggestedMediaAssets );
- } finally {
- setIsFetchingGenAIAssets( false );
- }
- },
+ fetchGenAIAssets,
};
};
From de395f7109519cf1c6c4d1d6ade7d90a3eb5917c Mon Sep 17 00:00:00 2001
From: asvinb
Date: Wed, 21 Jan 2026 16:32:42 +0400
Subject: [PATCH 061/123] refactor(asset-group): Extract asset slot filling
utility
---
.../asset-group-editor/texts-editor.js | 65 ++-----------------
.../utils/fill-empty-asset-slots.js | 51 +++++++++++++++
.../utils/fill-empty-asset-slots.test.js | 46 +++++++++++++
3 files changed, 102 insertions(+), 60 deletions(-)
create mode 100644 js/src/components/paid-ads/asset-group/asset-group-editor/utils/fill-empty-asset-slots.js
create mode 100644 js/src/components/paid-ads/asset-group/asset-group-editor/utils/fill-empty-asset-slots.test.js
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
index 2d1f7deabc..e3474c246b 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
@@ -16,6 +16,7 @@ import AppInputControl from '~/components/app-input-control';
import AssetItemActionButton, {
ACTION_TYPES,
} from './asset-item-action-button';
+import fillEmptyAssetSlots from './utils/fill-empty-asset-slots';
import './texts-editor.scss';
function normalizeNumberOfTexts( texts, minNumberOfTexts, maxNumberOfTexts ) {
@@ -28,61 +29,6 @@ function normalizeNumberOfTexts( texts, minNumberOfTexts, maxNumberOfTexts ) {
return texts.concat( supplement ).slice( ...sliceArgs );
}
-/**
- * Result returned by fillEmptyAssetSlotsWithUniqueValues.
- *
- * @typedef {Object} FillEmptyAssetSlotsResult
- * @property {string[]} assets Updated asset list.
- * @property {number} updatedCount Number of empty ("") slots that were filled.
- */
-
-/**
- * Fill empty asset slots (represented by empty strings "") with unique
- * generated values.
- *
- * Existing non-empty values are preserved.
- * Empty slots that cannot be filled remain as "".
- *
- * @param {string[]} currentAssets Current asset values, where "" represents an empty slot.
- * @param {string[]} generatedAssets Newly generated candidate asset values.
- *
- * @return {FillEmptyAssetSlotsResult} Result containing updated assets and count of filled slots.
- */
-export function fillEmptyAssetSlotsWithUniqueValues(
- currentAssets,
- generatedAssets
-) {
- const existingAssetValues = new Set( currentAssets.filter( Boolean ) );
-
- let generatedIndex = 0;
- let updatedCount = 0;
-
- const assets = currentAssets.map( ( assetValue ) => {
- if ( assetValue !== '' ) {
- return assetValue;
- }
-
- while (
- generatedIndex < generatedAssets.length &&
- existingAssetValues.has( generatedAssets[ generatedIndex ] )
- ) {
- generatedIndex++;
- }
-
- if ( generatedIndex < generatedAssets.length ) {
- const nextGeneratedValue = generatedAssets[ generatedIndex ];
- existingAssetValues.add( nextGeneratedValue );
- generatedIndex++;
- updatedCount++;
- return nextGeneratedValue;
- }
-
- return '';
- } );
-
- return { assets, updatedCount };
-}
-
/**
* Renders a list of text inputs for managing the single type of asset texts.
*
@@ -166,11 +112,10 @@ export default function TextsEditor( {
const response = await fetchGenAITextAssets( finalUrl, assetKey );
const generatedTextAssets = response?.data?.[ assetKey ] ?? [];
- const { assets: updatedTexts, updatedCount } =
- fillEmptyAssetSlotsWithUniqueValues(
- texts,
- generatedTextAssets
- );
+ const { assets: updatedTexts, updatedCount } = fillEmptyAssetSlots(
+ texts,
+ generatedTextAssets
+ );
if ( updatedCount > 0 ) {
updateTexts( updatedTexts );
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/utils/fill-empty-asset-slots.js b/js/src/components/paid-ads/asset-group/asset-group-editor/utils/fill-empty-asset-slots.js
new file mode 100644
index 0000000000..0b96eb7e44
--- /dev/null
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/utils/fill-empty-asset-slots.js
@@ -0,0 +1,51 @@
+/**
+ * Result returned by fillEmptyAssetSlots.
+ *
+ * @typedef {Object} FillEmptyAssetSlotsResult
+ * @property {string[]} assets Updated asset list.
+ * @property {number} updatedCount Number of empty ("") slots that were filled.
+ */
+
+/**
+ * Fill empty asset slots (represented by empty strings "") with unique
+ * generated values.
+ *
+ * Existing non-empty values are preserved.
+ * Empty slots that cannot be filled remain as "".
+ *
+ * @param {string[]} currentAssets Current asset values, where "" represents an empty slot.
+ * @param {string[]} generatedAssets Newly generated candidate asset values.
+ *
+ * @return {FillEmptyAssetSlotsResult} Result containing updated assets and count of filled slots.
+ */
+export default function fillEmptyAssetSlots( currentAssets, generatedAssets ) {
+ const existingAssetValues = new Set( currentAssets.filter( Boolean ) );
+
+ let generatedIndex = 0;
+ let updatedCount = 0;
+
+ const assets = currentAssets.map( ( assetValue ) => {
+ if ( assetValue !== '' ) {
+ return assetValue;
+ }
+
+ while (
+ generatedIndex < generatedAssets.length &&
+ existingAssetValues.has( generatedAssets[ generatedIndex ] )
+ ) {
+ generatedIndex++;
+ }
+
+ if ( generatedIndex < generatedAssets.length ) {
+ const nextGeneratedValue = generatedAssets[ generatedIndex ];
+ existingAssetValues.add( nextGeneratedValue );
+ generatedIndex++;
+ updatedCount++;
+ return nextGeneratedValue;
+ }
+
+ return '';
+ } );
+
+ return { assets, updatedCount };
+}
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/utils/fill-empty-asset-slots.test.js b/js/src/components/paid-ads/asset-group/asset-group-editor/utils/fill-empty-asset-slots.test.js
new file mode 100644
index 0000000000..2bd58a6862
--- /dev/null
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/utils/fill-empty-asset-slots.test.js
@@ -0,0 +1,46 @@
+/**
+ * Internal dependencies
+ */
+import fillEmptyAssetSlots from './fill-empty-asset-slots';
+
+describe( 'fillEmptyAssetSlots', () => {
+ it( 'fills empty slots with generated values preserving non-empty values', () => {
+ const current = [ 'a', '', 'b', '' ];
+ const generated = [ 'x', 'y' ];
+ const result = fillEmptyAssetSlots( current, generated );
+ expect( result.assets ).toEqual( [ 'a', 'x', 'b', 'y' ] );
+ expect( result.updatedCount ).toBe( 2 );
+ } );
+
+ it( 'skips generated values that duplicate existing assets and duplicates in generated list', () => {
+ const current = [ 'a', '', '', 'b' ];
+ const generated = [ 'a', 'c', 'c', 'd' ];
+ const result = fillEmptyAssetSlots( current, generated );
+ expect( result.assets ).toEqual( [ 'a', 'c', 'd', 'b' ] );
+ expect( result.updatedCount ).toBe( 2 );
+ } );
+
+ it( 'leaves slots empty when not enough unique generated assets', () => {
+ const current = [ '', '', '' ];
+ const generated = [ 'a' ];
+ const result = fillEmptyAssetSlots( current, generated );
+ expect( result.assets ).toEqual( [ 'a', '', '' ] );
+ expect( result.updatedCount ).toBe( 1 );
+ } );
+
+ it( 'returns unchanged when there are no empty slots', () => {
+ const current = [ 'a', 'b' ];
+ const generated = [ 'x', 'y' ];
+ const result = fillEmptyAssetSlots( current, generated );
+ expect( result.assets ).toEqual( [ 'a', 'b' ] );
+ expect( result.updatedCount ).toBe( 0 );
+ } );
+
+ it( 'does not reuse the same generated value when it appears multiple times in generatedAssets', () => {
+ const current = [ '', '' ];
+ const generated = [ 'x', 'x' ];
+ const result = fillEmptyAssetSlots( current, generated );
+ expect( result.assets ).toEqual( [ 'x', '' ] );
+ expect( result.updatedCount ).toBe( 1 );
+ } );
+} );
From 3b05c76c7beed51a29acf8c788cb8149b9d3673b Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Wed, 21 Jan 2026 18:48:19 +0530
Subject: [PATCH 062/123] Fix: Select another URL triggers homepage url
selection.
---
.../asset-group-header/assets-loader.js | 46 ++++++++++++-------
1 file changed, 29 insertions(+), 17 deletions(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js b/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js
index d28d1e7562..f340de7d49 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js
@@ -4,7 +4,7 @@
import { __ } from '@wordpress/i18n';
import apiFetch from '@wordpress/api-fetch';
import { addQueryArgs } from '@wordpress/url';
-import { useState, useRef, useEffect } from '@wordpress/element';
+import { useState, useRef, useEffect, useCallback } from '@wordpress/element';
import { Spinner } from '@woocommerce/components';
/**
@@ -16,6 +16,8 @@ import SearchableSelectControl from '~/components/searchable-select-control';
import { API_NAMESPACE } from '~/data/constants';
import './assets-loader.scss';
+let HAS_LOADED_HOMEPAGE_ASSETS = false;
+
/**
* @typedef {import('~/data/types.js').SuggestedAssets} SuggestedAssets
*/
@@ -105,24 +107,34 @@ export default function AssetsLoader( { onAssetsLoaded } ) {
const [ fetching, setFetching ] = useState( false );
const { createNotice } = useDispatchCoreNotices();
- const loadSuggestedAssets = async ( { id, type } ) => {
- setFetching( true );
- try {
- const assets = await fetchSuggestedAssets( id, type );
- onAssetsLoaded( assets );
- } catch ( error ) {
- setFetching( false );
- createNotice(
- 'error',
- __( 'Unable to load assets data.', 'google-listings-and-ads' )
- );
- }
- };
+ const loadSuggestedAssets = useCallback(
+ async ( { id, type } ) => {
+ setFetching( true );
+ try {
+ const assets = await fetchSuggestedAssets( id, type );
+ onAssetsLoaded( assets );
+ } catch ( error ) {
+ setFetching( false );
+ createNotice(
+ 'error',
+ __(
+ 'Unable to load assets data.',
+ 'google-listings-and-ads'
+ )
+ );
+ }
+ },
+ [ onAssetsLoaded, createNotice ]
+ );
useEffect( () => {
- loadSuggestedAssets( { id: -1, type: 'homepage' } );
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [] );
+ if ( HAS_LOADED_HOMEPAGE_ASSETS ) {
+ return;
+ }
+
+ HAS_LOADED_HOMEPAGE_ASSETS = true;
+ loadSuggestedAssets( { id: 0, type: 'homepage' } );
+ }, [ loadSuggestedAssets ] );
// To have the searching state and keep the entered search value, this handler needs to
// be called immediately after keying values. Therefore, it also needs to implement the
From 1c4060a54237f0d75c651122a0ae1623e4e275cd Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Thu, 22 Jan 2026 15:17:32 +0530
Subject: [PATCH 063/123] Update composer lock.
---
composer.lock | 22 +++++++++++-----------
1 file changed, 11 insertions(+), 11 deletions(-)
diff --git a/composer.lock b/composer.lock
index 526d4be5f4..669add3c5e 100644
--- a/composer.lock
+++ b/composer.lock
@@ -441,12 +441,12 @@
"source": {
"type": "git",
"url": "https://github.com/googleads/google-ads-php.git",
- "reference": "73a755b69f8088a22da27fd0124e2a2d43ec7b82"
+ "reference": "21ca3b959893a027145fa72b4c882ecd463e2de3"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/googleads/google-ads-php/zipball/73a755b69f8088a22da27fd0124e2a2d43ec7b82",
- "reference": "73a755b69f8088a22da27fd0124e2a2d43ec7b82",
+ "url": "https://api.github.com/repos/googleads/google-ads-php/zipball/21ca3b959893a027145fa72b4c882ecd463e2de3",
+ "reference": "21ca3b959893a027145fa72b4c882ecd463e2de3",
"shasum": ""
},
"require": {
@@ -497,7 +497,7 @@
"issues": "https://github.com/googleads/google-ads-php/issues",
"source": "https://github.com/googleads/google-ads-php/tree/legacy-v31.1.0"
},
- "time": "2026-01-09T20:17:46+00:00"
+ "time": "2026-01-21T18:06:16+00:00"
},
{
"name": "guzzlehttp/guzzle",
@@ -5151,16 +5151,16 @@
},
{
"name": "wp-cli/php-cli-tools",
- "version": "v0.12.6",
+ "version": "v0.12.7",
"source": {
"type": "git",
"url": "https://github.com/wp-cli/php-cli-tools.git",
- "reference": "f12b650d3738e471baed6dd47982d53c5c0ab1c3"
+ "reference": "5cc6ef2e93cfcd939813eb420ae23bc116d9be2a"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/wp-cli/php-cli-tools/zipball/f12b650d3738e471baed6dd47982d53c5c0ab1c3",
- "reference": "f12b650d3738e471baed6dd47982d53c5c0ab1c3",
+ "url": "https://api.github.com/repos/wp-cli/php-cli-tools/zipball/5cc6ef2e93cfcd939813eb420ae23bc116d9be2a",
+ "reference": "5cc6ef2e93cfcd939813eb420ae23bc116d9be2a",
"shasum": ""
},
"require": {
@@ -5173,7 +5173,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-master": "0.12.x-dev"
+ "dev-main": "0.12.x-dev"
}
},
"autoload": {
@@ -5208,9 +5208,9 @@
],
"support": {
"issues": "https://github.com/wp-cli/php-cli-tools/issues",
- "source": "https://github.com/wp-cli/php-cli-tools/tree/v0.12.6"
+ "source": "https://github.com/wp-cli/php-cli-tools/tree/v0.12.7"
},
- "time": "2025-09-11T12:43:04+00:00"
+ "time": "2026-01-20T20:31:49+00:00"
},
{
"name": "wp-cli/wp-cli",
From f04eb6d171c5ba2354927bde5adc8b28f3f442b6 Mon Sep 17 00:00:00 2001
From: James Morrison
Date: Thu, 22 Jan 2026 10:39:41 +0000
Subject: [PATCH 064/123] PHPCS fixes.
---
src/API/Site/Controllers/Ads/SetupCompleteController.php | 2 +-
src/API/Site/Controllers/DisconnectController.php | 2 +-
.../Controllers/MerchantCenter/SettingsSyncController.php | 2 +-
.../MerchantCenter/SyncableProductsCountController.php | 2 +-
src/Coupon/WCCouponAdapter.php | 2 +-
tests/Unit/API/ClientTest.php | 4 ++--
tests/Unit/API/Google/MiddlewareTest.php | 2 +-
tests/Unit/Product/ProductFilterTest.php | 4 ++--
8 files changed, 10 insertions(+), 10 deletions(-)
diff --git a/src/API/Site/Controllers/Ads/SetupCompleteController.php b/src/API/Site/Controllers/Ads/SetupCompleteController.php
index e6585d59a6..6f67a0b426 100644
--- a/src/API/Site/Controllers/Ads/SetupCompleteController.php
+++ b/src/API/Site/Controllers/Ads/SetupCompleteController.php
@@ -62,7 +62,7 @@ public function register_routes() {
* @return callable
*/
protected function get_setup_complete_callback(): callable {
- return function ( Request $request ) {
+ return function ( Request $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
do_action( 'woocommerce_gla_ads_setup_completed' );
/**
diff --git a/src/API/Site/Controllers/DisconnectController.php b/src/API/Site/Controllers/DisconnectController.php
index c0d48efc60..78cc20f7eb 100644
--- a/src/API/Site/Controllers/DisconnectController.php
+++ b/src/API/Site/Controllers/DisconnectController.php
@@ -40,7 +40,7 @@ public function register_routes() {
* @return callable
*/
protected function get_disconnect_callback(): callable {
- return function ( Request $request ) {
+ return function ( Request $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
$endpoints = [
'ads/connection',
'mc/connection',
diff --git a/src/API/Site/Controllers/MerchantCenter/SettingsSyncController.php b/src/API/Site/Controllers/MerchantCenter/SettingsSyncController.php
index a49c7b82b0..5784a6c350 100644
--- a/src/API/Site/Controllers/MerchantCenter/SettingsSyncController.php
+++ b/src/API/Site/Controllers/MerchantCenter/SettingsSyncController.php
@@ -61,7 +61,7 @@ public function register_routes() {
* @return callable
*/
protected function get_sync_endpoint_callback(): callable {
- return function ( Request $request ) {
+ return function ( Request $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
try {
$this->settings->sync_taxes();
$this->settings->sync_shipping();
diff --git a/src/API/Site/Controllers/MerchantCenter/SyncableProductsCountController.php b/src/API/Site/Controllers/MerchantCenter/SyncableProductsCountController.php
index ccf4e03798..889d96ca20 100644
--- a/src/API/Site/Controllers/MerchantCenter/SyncableProductsCountController.php
+++ b/src/API/Site/Controllers/MerchantCenter/SyncableProductsCountController.php
@@ -86,7 +86,7 @@ protected function get_syncable_products_count_callback(): callable {
* @return callable
*/
protected function update_syncable_products_count_callback(): callable {
- return function ( Request $request ) {
+ return function ( Request $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
$this->options->delete( OptionsInterface::SYNCABLE_PRODUCTS_COUNT );
$this->options->delete( OptionsInterface::SYNCABLE_PRODUCTS_COUNT_INTERMEDIATE_DATA );
diff --git a/src/Coupon/WCCouponAdapter.php b/src/Coupon/WCCouponAdapter.php
index 9851f26534..cc1a8a7b67 100644
--- a/src/Coupon/WCCouponAdapter.php
+++ b/src/Coupon/WCCouponAdapter.php
@@ -420,7 +420,7 @@ private function get_product_ids_in_brand( WC_Coupon $wc_coupon, bool $is_exclud
$meta_key = $is_exclude ? 'exclude_product_brands' : 'product_brands';
// Get the brand term IDs if brand restriction is set.
- $brand_term_ids = get_post_meta( $coupon_id, $meta_key );
+ $brand_term_ids = get_post_meta( $coupon_id, $meta_key, true );
if ( ! is_array( $brand_term_ids ) ) {
return [];
diff --git a/tests/Unit/API/ClientTest.php b/tests/Unit/API/ClientTest.php
index 732329e785..62f5a11a4f 100644
--- a/tests/Unit/API/ClientTest.php
+++ b/tests/Unit/API/ClientTest.php
@@ -206,7 +206,7 @@ public function test_add_auth_header() {
$this->note->expects( $this->once() )->method( 'delete' );
$this->invoke_handler( 'add_auth_header' )(
- function ( $request, $options ) {
+ function ( $request, $options ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
$this->assertStringStartsWith( 'X_JP_Auth token=', $request->getHeader( 'Authorization' )[0] );
}
)( $request, [] );
@@ -238,7 +238,7 @@ public function test_plugin_version_headers(): void {
$request = new Request( 'GET', 'https://testing.local' );
$this->invoke_handler( 'add_plugin_version_header' )(
- function ( $request, $options ) {
+ function ( $request, $options ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed
$this->assertEquals( $this->get_client_name(), $request->getHeader( 'x-client-name' )[0] );
$this->assertEquals( $this->get_version(), $request->getHeader( 'x-client-version' )[0] );
}
diff --git a/tests/Unit/API/Google/MiddlewareTest.php b/tests/Unit/API/Google/MiddlewareTest.php
index 51f2b4b8fe..a9f52877dd 100644
--- a/tests/Unit/API/Google/MiddlewareTest.php
+++ b/tests/Unit/API/Google/MiddlewareTest.php
@@ -427,7 +427,7 @@ public function test_get_sdi_merchant_update_endpoint() {
public function test_get_sdi_merchant_update_endpoint_with_site_url_having_path() {
add_filter(
'woocommerce_gla_site_url',
- function ( $home_url ) {
+ function ( $home_url ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
return 'http://example.org/shop';
}
);
diff --git a/tests/Unit/Product/ProductFilterTest.php b/tests/Unit/Product/ProductFilterTest.php
index 851270bfd0..7e6e727140 100644
--- a/tests/Unit/Product/ProductFilterTest.php
+++ b/tests/Unit/Product/ProductFilterTest.php
@@ -79,7 +79,7 @@ public function test_filter_sync_ready_products_with_no_filters_but_failed_sync(
public function test_filter_sync_ready_products_with_pre_filter() {
add_filter(
'woocommerce_gla_get_sync_ready_products_pre_filter',
- function ( $products ) {
+ function ( $products ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
return [];
}
);
@@ -96,7 +96,7 @@ function ( $products ) {
public function test_filter_sync_ready_products_with_post_filter() {
add_filter(
'woocommerce_gla_get_sync_ready_products_filter',
- function ( $products ) {
+ function ( $products ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
return [];
}
);
From 13bdf4aac0b44a8ae9d1d191f7438c457e4ee0a0 Mon Sep 17 00:00:00 2001
From: James Morrison
Date: Thu, 22 Jan 2026 10:40:35 +0000
Subject: [PATCH 065/123] PHPCS fixes.
---
tests/Unit/Coupon/WCCouponAdapterTest.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/Unit/Coupon/WCCouponAdapterTest.php b/tests/Unit/Coupon/WCCouponAdapterTest.php
index e8e0932aef..d00adc985c 100644
--- a/tests/Unit/Coupon/WCCouponAdapterTest.php
+++ b/tests/Unit/Coupon/WCCouponAdapterTest.php
@@ -280,13 +280,13 @@ public function test_brand_restrictions() {
$coupon->set_product_ids( [ $product_3_id ] );
// Include brand 1 (product 1 and 2) for the coupon.
- update_post_meta( $coupon->get_id(), 'product_brands', $brand_1['term_id'] );
+ update_post_meta( $coupon->get_id(), 'product_brands', [ $brand_1['term_id'] ] );
// Exclude product 2 for the coupon.
$coupon->set_excluded_product_ids( [ $product_2_id ] );
// Exclude brand 2 (product 3) for the coupon.
- update_post_meta( $coupon->get_id(), 'exclude_product_brands', $brand_2['term_id'] );
+ update_post_meta( $coupon->get_id(), 'exclude_product_brands', [ $brand_2['term_id'] ] );
$coupon->save();
From 61b8ca1e5f7eeb9caef9ba634fc97c84cffe5ed0 Mon Sep 17 00:00:00 2001
From: James Morrison
Date: Thu, 22 Jan 2026 10:52:21 +0000
Subject: [PATCH 066/123] Composer update.
---
composer.lock | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/composer.lock b/composer.lock
index 669add3c5e..76f49a8b7b 100644
--- a/composer.lock
+++ b/composer.lock
@@ -5422,9 +5422,9 @@
"php": ">=7.4",
"ext-json": "*"
},
- "platform-dev": [],
+ "platform-dev": {},
"platform-overrides": {
"php": "7.4.30"
},
- "plugin-api-version": "2.3.0"
+ "plugin-api-version": "2.9.0"
}
From 220584a5f836445a60167e51d41c5b7ac56fbf8d Mon Sep 17 00:00:00 2001
From: asvinb
Date: Thu, 22 Jan 2026 16:05:09 +0400
Subject: [PATCH 067/123] refactor(paid-ads): Refine asset generation logic and
tests
---
.../paid-ads/campaign-assets-form.js | 1 -
.../add-paid-campaigns.test.js | 145 ++++--------------
tests/e2e/utils/pages/create-campaign.js | 2 +-
3 files changed, 32 insertions(+), 116 deletions(-)
diff --git a/js/src/components/paid-ads/campaign-assets-form.js b/js/src/components/paid-ads/campaign-assets-form.js
index c9e1cb6fde..4822b38fa2 100644
--- a/js/src/components/paid-ads/campaign-assets-form.js
+++ b/js/src/components/paid-ads/campaign-assets-form.js
@@ -260,7 +260,6 @@ export default function CampaignAssetsForm( {
const nextValues = {
...( hasSuggestedTextAssets ? textAssetsData : {} ),
- ...( hasSuggestedMediaAssets ? mediaAssetsData : {} ),
};
if ( Object.keys( nextValues ).length ) {
diff --git a/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js b/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
index b6bd4b2922..a35355db17 100644
--- a/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
+++ b/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
@@ -537,6 +537,8 @@ test.describe( 'Add paid campaign', () => {
await setCompletedAdsSetup();
await createCampaignPage.mockRequests();
await createCampaignPage.mockOptimizeCampaignRequests();
+ await createCampaignPage.mockGenerateTextAssetsSuccess();
+ await createCampaignPage.mockGenerateImageAssetsSuccess();
createCampaignPage.goto();
} );
@@ -617,34 +619,20 @@ test.describe( 'Add paid campaign', () => {
} );
test( 'Generate headline button is visible when at least one input is empty', async () => {
- const addHeadlineButton =
- createCampaignPage.getAddHeadlineButton();
- await addHeadlineButton.click();
-
const generateHeadlineButton =
createCampaignPage.getGenerateHeadlineButton();
await expect(
generateHeadlineButton
- ).toBeVisible();
-
- const generateHeadlinesButton =
- createCampaignPage.getGenerateHeadlinesButton();
- await expect(
- generateHeadlinesButton
).not.toBeVisible();
- const headlineInputsValues =
- await createCampaignPage.getHeadlineInputsValues();
+ const headlineInputs =
+ await createCampaignPage.getHeadlineInputs();
+ const lastHeadlineInput = headlineInputs.last();
+ await lastHeadlineInput.fill( '' );
await expect(
- headlineInputsValues
- ).toHaveLength( 4 );
-
- const lastValue =
- headlineInputsValues[
- headlineInputsValues.length - 1
- ];
- expect( lastValue ).toBe( '' );
+ generateHeadlineButton
+ ).toBeVisible();
} );
} );
@@ -676,7 +664,7 @@ test.describe( 'Add paid campaign', () => {
headlineInputsValues.length - 1
];
expect( lastValue ).toBe(
- 'Shop the Latest Deals'
+ 'Fast Shipping Available'
);
} );
} );
@@ -693,34 +681,21 @@ test.describe( 'Add paid campaign', () => {
} );
test( 'Generate long headline button is visible when at least one input is empty', async () => {
- const addLongHeadlineButton =
- createCampaignPage.getAddLongHeadlineButton();
- await addLongHeadlineButton.click();
-
const generateLongHeadlineButton =
createCampaignPage.getGenerateLongHeadlineButton();
await expect(
generateLongHeadlineButton
- ).toBeVisible();
-
- const generateLongHeadlinesButton =
- createCampaignPage.getGenerateLongHeadlinesButton();
- await expect(
- generateLongHeadlinesButton
).not.toBeVisible();
- const longHeadlineInputsValues =
- await createCampaignPage.getLongHeadlineInputsValues();
+ const longHeadlineInputs =
+ await createCampaignPage.getLongHeadlineInputs();
+ const lastLongHeadlineInput =
+ longHeadlineInputs.last();
+ await lastLongHeadlineInput.fill( '' );
await expect(
- longHeadlineInputsValues
- ).toHaveLength( 2 );
-
- const lastValue =
- longHeadlineInputsValues[
- longHeadlineInputsValues.length - 1
- ];
- expect( lastValue ).toBe( '' );
+ generateLongHeadlineButton
+ ).toBeVisible();
} );
} );
@@ -752,7 +727,7 @@ test.describe( 'Add paid campaign', () => {
longHeadlineInputsValues.length - 1
];
expect( lastValue ).toBe(
- 'Discover quality products at great prices'
+ 'Smart shopping starts right here'
);
} );
} );
@@ -769,33 +744,21 @@ test.describe( 'Add paid campaign', () => {
} );
test( 'Generate description button is visible when at least one input is empty', async () => {
- const addDescriptionButton =
- createCampaignPage.getAddDescriptionButton();
- await addDescriptionButton.click();
-
const generateDescriptionButton =
createCampaignPage.getGenerateDescriptionButton();
await expect(
generateDescriptionButton
- ).toBeVisible();
-
- const generateDescriptionsButton =
- createCampaignPage.getGenerateDescriptionsButton();
- await expect(
- generateDescriptionsButton
).not.toBeVisible();
- const descriptionInputsValues =
- await createCampaignPage.getDescriptionInputsValues();
- await expect(
- descriptionInputsValues
- ).toHaveLength( 3 );
+ const descriptionInputs =
+ await createCampaignPage.getDescriptionInputs();
+ const lastDescriptionInput =
+ descriptionInputs.last();
+ await lastDescriptionInput.fill( '' );
- const lastValue =
- descriptionInputsValues[
- descriptionInputsValues.length - 1
- ];
- expect( lastValue ).toBe( '' );
+ await expect(
+ generateDescriptionButton
+ ).toBeVisible();
} );
} );
@@ -827,7 +790,7 @@ test.describe( 'Add paid campaign', () => {
descriptionInputsValues.length - 1
];
expect( lastValue ).toBe(
- 'Browse top picks and enjoy exclusive savings.'
+ 'Quality products backed by great support.'
);
} );
} );
@@ -862,9 +825,11 @@ test.describe( 'Add paid campaign', () => {
} );
test( 'Displays error message when there are no more generated text', async () => {
- const addDescriptionButton =
- createCampaignPage.getAddDescriptionButton();
- await addDescriptionButton.click();
+ const descriptionInputs =
+ await createCampaignPage.getDescriptionInputs();
+ const lastDescriptionInput =
+ descriptionInputs.last();
+ await lastDescriptionInput.fill( '' );
const generateDescriptionButton =
createCampaignPage.getGenerateDescriptionButton();
@@ -930,22 +895,6 @@ test.describe( 'Add paid campaign', () => {
);
} );
} );
-
- test.describe( 'No generated assets', () => {
- test.beforeEach( async () => {
- createCampaignPage.mockEmptyGenerateImageAssets();
- } );
-
- test( 'Hides the image picker if there are no generated images', async () => {
- const generateLandscapeImagesButton =
- createCampaignPage.getGenerateLandscapeImagesButton();
- await generateLandscapeImagesButton.click();
-
- const imagePicker =
- createCampaignPage.getLandscapeImagesSectionImagePicker();
- await expect( imagePicker ).not.toBeVisible();
- } );
- } );
} );
test.describe( 'Image Picker', () => {
@@ -1106,22 +1055,6 @@ test.describe( 'Add paid campaign', () => {
await expect( generatedImages ).toHaveCount( 3 );
} );
} );
-
- test.describe( 'No generated assets', () => {
- test.beforeEach( async () => {
- createCampaignPage.mockEmptyGenerateImageAssets();
- } );
-
- test( 'Hides the image picker if there are no generated images', async () => {
- const generateSquareImagesButton =
- createCampaignPage.getGenerateSquareImagesButton();
- await generateSquareImagesButton.click();
-
- const imagePicker =
- createCampaignPage.getSquareImagesSectionImagePicker();
- await expect( imagePicker ).not.toBeVisible();
- } );
- } );
} );
test.describe( 'Portrait images', () => {
@@ -1173,22 +1106,6 @@ test.describe( 'Add paid campaign', () => {
await expect( generatedImages ).toHaveCount( 2 );
} );
} );
-
- test.describe( 'No generated assets', () => {
- test.beforeEach( async () => {
- createCampaignPage.mockEmptyGenerateImageAssets();
- } );
-
- test( 'Hides the image picker if there are no generated images', async () => {
- const generatePortraitImagesButton =
- createCampaignPage.getGeneratePortraitImagesButton();
- await generatePortraitImagesButton.click();
-
- const imagePicker =
- createCampaignPage.getPortraitImagesSectionImagePicker();
- await expect( imagePicker ).not.toBeVisible();
- } );
- } );
} );
} );
} );
diff --git a/tests/e2e/utils/pages/create-campaign.js b/tests/e2e/utils/pages/create-campaign.js
index 1ce6773beb..9495a57fef 100644
--- a/tests/e2e/utils/pages/create-campaign.js
+++ b/tests/e2e/utils/pages/create-campaign.js
@@ -625,7 +625,7 @@ export default class CreateCampaignPage extends MockRequests {
items: [
// Headlines
{
- text: 'Shop the Latest Deals',
+ text: 'Latest Deals',
type: 'headline',
},
{
From 8b849fa4a8990a46f28fe1c0991378da406d0004 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Thu, 22 Jan 2026 16:11:16 +0400
Subject: [PATCH 068/123] build: Update commons.js maxSize
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 1a8ae93de2..e364c39f94 100644
--- a/package.json
+++ b/package.json
@@ -153,7 +153,7 @@
},
{
"path": "./js/build/commons.js",
- "maxSize": "63.6 kB"
+ "maxSize": "63.9 kB"
},
{
"path": "./js/build/vendors.js",
From 228f378e7366eeedbc196c3e7b0c848bad94665f Mon Sep 17 00:00:00 2001
From: James Morrison
Date: Thu, 22 Jan 2026 14:51:31 +0000
Subject: [PATCH 069/123] Merged 406 /
feature/GOOWOO-406-create-an-AdsAssetGenerationService; set defaults, added
missing type.
---
.../Controllers/Ads/AssetGenerationController.php | 7 ++++++-
.../Ads/AssetGenerationControllerTest.php | 14 +++++++++++---
2 files changed, 17 insertions(+), 4 deletions(-)
diff --git a/src/API/Site/Controllers/Ads/AssetGenerationController.php b/src/API/Site/Controllers/Ads/AssetGenerationController.php
index d541a8b78c..99b313cc57 100644
--- a/src/API/Site/Controllers/Ads/AssetGenerationController.php
+++ b/src/API/Site/Controllers/Ads/AssetGenerationController.php
@@ -80,12 +80,14 @@ protected function get_generate_text_params(): array {
'final_url' => [
'description' => __( 'The final URL for asset generation', 'google-listings-and-ads' ),
'type' => 'string',
+ 'default' => '',
'sanitize_callback' => 'esc_url_raw',
'validate_callback' => 'rest_validate_request_arg',
],
'types' => [
'description' => __( 'Asset types to generate', 'google-listings-and-ads' ),
'type' => 'array',
+ 'default' => [],
'items' => [
'type' => 'string',
'enum' => [
@@ -112,18 +114,21 @@ protected function get_generate_images_params(): array {
'final_url' => [
'description' => __( 'The final URL for asset generation', 'google-listings-and-ads' ),
'type' => 'string',
+ 'default' => '',
'sanitize_callback' => 'esc_url_raw',
'validate_callback' => 'rest_validate_request_arg',
],
'types' => [
'description' => __( 'Asset types to generate', 'google-listings-and-ads' ),
'type' => 'array',
+ 'default' => [],
'items' => [
'type' => 'string',
'enum' => [
AssetFieldType::MARKETING_IMAGE,
AssetFieldType::SQUARE_MARKETING_IMAGE,
AssetFieldType::PORTRAIT_MARKETING_IMAGE,
+ AssetFieldType::TALL_PORTRAIT_MARKETING_IMAGE,
],
],
'sanitize_callback' => function ( $types ) {
@@ -143,7 +148,7 @@ protected function get_generate_text_callback(): callable {
return function ( Request $request ) {
try {
$final_url = $request->get_param( 'final_url' ) ?: $this->get_site_url();
- $types = $request->get_param( 'types' ) ?: [ 'headline', 'long_headline', 'description' ];
+ $types = $request->get_param( 'types' ) ?: [];
// Call service with lowercase types.
$items = $this->service->generate_text(
diff --git a/tests/Unit/API/Site/Controllers/Ads/AssetGenerationControllerTest.php b/tests/Unit/API/Site/Controllers/Ads/AssetGenerationControllerTest.php
index 873437b395..f8ba6dbddc 100644
--- a/tests/Unit/API/Site/Controllers/Ads/AssetGenerationControllerTest.php
+++ b/tests/Unit/API/Site/Controllers/Ads/AssetGenerationControllerTest.php
@@ -32,19 +32,27 @@ class AssetGenerationControllerTest extends RESTControllerUnitTest {
public function setUp(): void {
parent::setUp();
+ // Mock the site URL filter to return TEST_SITE_URL.
+ add_filter(
+ 'woocommerce_gla_site_url',
+ function () {
+ return self::TEST_SITE_URL;
+ }
+ );
+
$this->service = $this->createMock( AdsAssetGenerationService::class );
$this->controller = new AssetGenerationController( $this->server, $this->service );
$this->controller->register();
}
public function test_generate_text_with_defaults() {
- // Service expects lowercase types.
+ // Service expects empty array when no types provided (service handles defaults).
$this->service->expects( $this->once() )
->method( 'generate_text' )
->with(
[
'final_url' => self::TEST_SITE_URL,
- 'asset_field_types' => [ 'headline', 'long_headline', 'description' ],
+ 'asset_field_types' => [],
]
)
->willReturn(
@@ -87,7 +95,7 @@ public function test_generate_text_with_custom_url() {
->with(
[
'final_url' => 'https://custom-url.com',
- 'asset_field_types' => [ 'headline', 'long_headline', 'description' ],
+ 'asset_field_types' => [],
]
)
->willReturn(
From cc679255bdc090c11fd52e0848e7e9cb7cc92038 Mon Sep 17 00:00:00 2001
From: James Morrison
Date: Thu, 22 Jan 2026 15:27:21 +0000
Subject: [PATCH 070/123] Added 90 second timeout for AI generated content;
tests timed out with default (30s)
---
src/API/Site/Controllers/Ads/AssetGenerationController.php | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/src/API/Site/Controllers/Ads/AssetGenerationController.php b/src/API/Site/Controllers/Ads/AssetGenerationController.php
index 99b313cc57..499636dd2a 100644
--- a/src/API/Site/Controllers/Ads/AssetGenerationController.php
+++ b/src/API/Site/Controllers/Ads/AssetGenerationController.php
@@ -146,6 +146,8 @@ protected function get_generate_images_params(): array {
*/
protected function get_generate_text_callback(): callable {
return function ( Request $request ) {
+ set_time_limit( 90 ); // AI text generation can take time.
+
try {
$final_url = $request->get_param( 'final_url' ) ?: $this->get_site_url();
$types = $request->get_param( 'types' ) ?: [];
@@ -173,6 +175,8 @@ protected function get_generate_text_callback(): callable {
*/
protected function get_generate_images_callback(): callable {
return function ( Request $request ) {
+ set_time_limit( 90 ); // AI image generation can take time.
+
try {
$final_url = $request->get_param( 'final_url' ) ?: $this->get_site_url();
$types = $request->get_param( 'types' ) ?: [];
From f118c84a05e7480e2c5a73c47634a2e4158bc09a Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Fri, 23 Jan 2026 15:01:39 +0530
Subject: [PATCH 071/123] Use the useRef in parent component.
---
.../asset-group-header/assets-loader.js | 53 +++-------------
.../asset-group-header/final-url-card.js | 61 +++++++++++++++++--
2 files changed, 62 insertions(+), 52 deletions(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js b/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js
index f340de7d49..983f83dcd0 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js
@@ -4,20 +4,17 @@
import { __ } from '@wordpress/i18n';
import apiFetch from '@wordpress/api-fetch';
import { addQueryArgs } from '@wordpress/url';
-import { useState, useRef, useEffect, useCallback } from '@wordpress/element';
+import { useState, useRef } from '@wordpress/element';
import { Spinner } from '@woocommerce/components';
/**
* Internal dependencies
*/
-import useDispatchCoreNotices from '~/hooks/useDispatchCoreNotices';
import AppButton from '~/components/app-button';
import SearchableSelectControl from '~/components/searchable-select-control';
import { API_NAMESPACE } from '~/data/constants';
import './assets-loader.scss';
-let HAS_LOADED_HOMEPAGE_ASSETS = false;
-
/**
* @typedef {import('~/data/types.js').SuggestedAssets} SuggestedAssets
*/
@@ -34,12 +31,6 @@ function fetchFinalUrls( search ) {
return apiFetch( { path: addQueryArgs( endPoint, query ) } );
}
-function fetchSuggestedAssets( id, type ) {
- const endPoint = `${ API_NAMESPACE }/assets/suggestions`;
- const query = { id, type };
- return apiFetch( { path: addQueryArgs( endPoint, query ) } );
-}
-
function mapFinalUrlsToOptions( finalUrls, search ) {
const options = finalUrls.map( ( finalUrl ) => ( {
finalUrl,
@@ -91,11 +82,12 @@ function mapFinalUrlsToOptions( finalUrls, search ) {
* and then loading the suggested assets.
*
* @param {Object} props React props.
- * @param {(suggestedAssets: SuggestedAssets) => void} props.onAssetsLoaded Callback function when the suggested assets are loaded.
+ * @param {boolean} props.isFetching Whether the assets are currently being fetched.
+ * @param {Function} props.loadSuggestedAssets Function to load suggested assets.
*
* @fires gla_import_assets_by_final_url_button_click
*/
-export default function AssetsLoader( { onAssetsLoaded } ) {
+export default function AssetsLoader( { isFetching, loadSuggestedAssets } ) {
const cacheRef = useRef( {} );
const latestSearchRef = useRef();
@@ -104,37 +96,6 @@ export default function AssetsLoader( { onAssetsLoaded } ) {
// Ref: https://github.com/woocommerce/woocommerce/blob/6.9.0/packages/js/components/src/select-control/index.js#L137-L141
const [ selectedOptions, setSelectedOptions ] = useState( [] );
const [ searching, setSearching ] = useState( false );
- const [ fetching, setFetching ] = useState( false );
- const { createNotice } = useDispatchCoreNotices();
-
- const loadSuggestedAssets = useCallback(
- async ( { id, type } ) => {
- setFetching( true );
- try {
- const assets = await fetchSuggestedAssets( id, type );
- onAssetsLoaded( assets );
- } catch ( error ) {
- setFetching( false );
- createNotice(
- 'error',
- __(
- 'Unable to load assets data.',
- 'google-listings-and-ads'
- )
- );
- }
- },
- [ onAssetsLoaded, createNotice ]
- );
-
- useEffect( () => {
- if ( HAS_LOADED_HOMEPAGE_ASSETS ) {
- return;
- }
-
- HAS_LOADED_HOMEPAGE_ASSETS = true;
- loadSuggestedAssets( { id: 0, type: 'homepage' } );
- }, [ loadSuggestedAssets ] );
// To have the searching state and keep the entered search value, this handler needs to
// be called immediately after keying values. Therefore, it also needs to implement the
@@ -204,7 +165,7 @@ export default function AssetsLoader( { onAssetsLoaded } ) {
isSearchable
hideBeforeSearch
excludeSelectedOptions={ false }
- disabled={ fetching }
+ disabled={ isFetching }
options={ [] } // The actual options will be provided via the callback results of `onSearch`.
selected={ selectedOptions }
onSearch={ debouncedHandleSearch }
@@ -214,12 +175,12 @@ export default function AssetsLoader( { onAssetsLoaded } ) {
>
diff --git a/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js b/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js
index 2a7076cb87..6fe6661b06 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js
@@ -1,9 +1,11 @@
/**
* External dependencies
*/
+import apiFetch from '@wordpress/api-fetch';
import { __ } from '@wordpress/i18n';
+import { addQueryArgs } from '@wordpress/url';
import classnames from 'classnames';
-import { useState } from '@wordpress/element';
+import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
import { ExternalLink } from '@wordpress/components';
/**
@@ -14,7 +16,9 @@ import Section from '~/components/section';
import AccountCard, { APPEARANCE } from '~/components/account-card';
import AppButton from '~/components/app-button';
import AssetsLoader from './assets-loader';
+import { API_NAMESPACE } from '~/data/constants';
import './final-url-card.scss';
+import useDispatchCoreNotices from '~/hooks/useDispatchCoreNotices';
/**
* @typedef {import('~/data/types.js').SuggestedAssets} SuggestedAssets
@@ -26,6 +30,12 @@ import './final-url-card.scss';
* @event gla_reselect_another_final_url_button_click
*/
+function fetchSuggestedAssets( id, type ) {
+ const endPoint = `${ API_NAMESPACE }/assets/suggestions`;
+ const query = { id, type };
+ return apiFetch( { path: addQueryArgs( endPoint, query ) } );
+}
+
/**
* Renders the Card UI for managing the final URL and getting the suggested assets.
*
@@ -41,7 +51,10 @@ export default function FinalUrlCard( {
initialFinalUrl,
hideFooter = false,
} ) {
+ const [ fetching, setFetching ] = useState( false );
const [ finalUrl, setFinalUrl ] = useState( initialFinalUrl || null );
+ const didInitialLoadRef = useRef( false );
+ const { createNotice } = useDispatchCoreNotices();
const description = finalUrl ? (
{ finalUrl }
@@ -52,10 +65,13 @@ export default function FinalUrlCard( {
)
);
- const handleAssetsLoaded = ( suggestedAssets ) => {
- setFinalUrl( suggestedAssets[ ASSET_GROUP_KEY.FINAL_URL ] );
- onAssetsChange( suggestedAssets );
- };
+ const handleAssetsLoaded = useCallback(
+ ( suggestedAssets ) => {
+ setFinalUrl( suggestedAssets[ ASSET_GROUP_KEY.FINAL_URL ] );
+ onAssetsChange( suggestedAssets );
+ },
+ [ onAssetsChange ]
+ );
const handleReselectClick = () => {
setFinalUrl( null );
@@ -67,6 +83,36 @@ export default function FinalUrlCard( {
'gla-final-url-card--has-selected-url': finalUrl,
} );
+ const loadSuggestedAssets = useCallback(
+ async ( { id, type } ) => {
+ setFetching( true );
+ try {
+ const assets = await fetchSuggestedAssets( id, type );
+ handleAssetsLoaded( assets );
+ } catch ( error ) {
+ createNotice(
+ 'error',
+ __(
+ 'Unable to load assets data.',
+ 'google-listings-and-ads'
+ )
+ );
+ } finally {
+ setFetching( false );
+ }
+ },
+ [ createNotice, handleAssetsLoaded ]
+ );
+
+ useEffect( () => {
+ if ( didInitialLoadRef.current ) {
+ return;
+ }
+
+ didInitialLoadRef.current = true;
+ loadSuggestedAssets( { id: 0, type: 'homepage' } );
+ }, [ loadSuggestedAssets ] );
+
return (
) : (
-
+
) }
From d95803b4d3fd231ee9651a215307654eda7e8a12 Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Fri, 23 Jan 2026 15:04:59 +0530
Subject: [PATCH 072/123] Remove redundant code
---
tests/e2e/utils/mock-requests.js | 16 ----------------
1 file changed, 16 deletions(-)
diff --git a/tests/e2e/utils/mock-requests.js b/tests/e2e/utils/mock-requests.js
index 39e217131e..ba6b549884 100644
--- a/tests/e2e/utils/mock-requests.js
+++ b/tests/e2e/utils/mock-requests.js
@@ -1213,20 +1213,4 @@ export default class MockRequests {
[ 'POST' ]
);
}
-
- /**
- * Mocks the API request for asset suggestions.
- *
- * @param {Object} payload - The mock response payload to return.
- * @param {number} [status=200] - The HTTP status code to return.
- * @return {Promise} Resolves when the mock request has been fulfilled.
- */
- async fulfillAssetsSuggestions( payload, status = 200 ) {
- await this.fulfillRequest(
- /\/wc\/gla\/assets\/suggestions\b/,
- payload,
- status,
- [ 'GET' ]
- );
- }
}
From 00594c842eef122050acb82570c7109512e8ef12 Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Fri, 23 Jan 2026 19:04:43 +0530
Subject: [PATCH 073/123] Fix: JS test.
---
.../asset-group-header.test.js | 40 +++++++++++--------
1 file changed, 23 insertions(+), 17 deletions(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-header/asset-group-header.test.js b/js/src/components/paid-ads/asset-group/asset-group-header/asset-group-header.test.js
index 571a903773..cdb563c942 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-header/asset-group-header.test.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-header/asset-group-header.test.js
@@ -18,7 +18,7 @@ jest.mock( '~/components/adaptive-form', () => ( {
* External dependencies
*/
import '@testing-library/jest-dom';
-import { render, screen } from '@testing-library/react';
+import { render, screen, waitFor } from '@testing-library/react';
/**
* Internal dependencies
@@ -27,23 +27,27 @@ import AssetGroupHeader from './asset-group-header';
import { useAdaptiveFormContext } from '~/components/adaptive-form';
describe( 'AssetGroupHeader', () => {
- test( 'Component renders', () => {
+ test( 'Component renders', async () => {
render( );
- expect(
- screen.getByText( /Add additional assets/i )
- ).toBeInTheDocument();
+ await waitFor( () => {
+ expect(
+ screen.getByText( /Add additional assets/i )
+ ).toBeInTheDocument();
+ } );
} );
- test( 'Component not showing Tip if there are no imported assets', () => {
+ test( 'Component not showing Tip if there are no imported assets', async () => {
render( );
- expect(
- screen.queryByText(
- "We've used your final URL to auto-populate some assets for you. For the best results, we recommend that you add more assets."
- )
- ).not.toBeInTheDocument();
+ await waitFor( () => {
+ expect(
+ screen.queryByText(
+ "We've used your final URL to auto-populate some assets for you. For the best results, we recommend that you add more assets."
+ )
+ ).not.toBeInTheDocument();
+ } );
} );
- test( 'Component showing Tip if there are imported assets', () => {
+ test( 'Component showing Tip if there are imported assets', async () => {
useAdaptiveFormContext.mockImplementation( () => {
return {
adapter: {
@@ -55,10 +59,12 @@ describe( 'AssetGroupHeader', () => {
};
} );
render( );
- expect(
- screen.getByText(
- "We've used your final URL to auto-populate some assets for you. For the best results, we recommend that you add more assets."
- )
- ).toBeInTheDocument();
+ await waitFor( () => {
+ expect(
+ screen.getByText(
+ "We've used your final URL to auto-populate some assets for you. For the best results, we recommend that you add more assets."
+ )
+ ).toBeInTheDocument();
+ } );
} );
} );
From 481ae7ab4dc47b846c2f6f4ac3043037cda46c97 Mon Sep 17 00:00:00 2001
From: Ankit Gade
Date: Fri, 23 Jan 2026 19:11:10 +0530
Subject: [PATCH 074/123] Add comment
---
.../paid-ads/asset-group/asset-group-header/final-url-card.js | 2 ++
1 file changed, 2 insertions(+)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js b/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js
index 6fe6661b06..9c18c8850c 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js
@@ -110,6 +110,8 @@ export default function FinalUrlCard( {
}
didInitialLoadRef.current = true;
+
+ // When the type `homepage` is passed, `id` is ignore, but because of typing, we need to pass it as zero.
loadSuggestedAssets( { id: 0, type: 'homepage' } );
}, [ loadSuggestedAssets ] );
From 72f18680cc95ebbae337c837d4c2a8e66c900bbe Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 23 Jan 2026 19:53:02 +0400
Subject: [PATCH 075/123] refactor(asset-group): Refactor AssetsLoader props
and selection logic
---
.../asset-group-header/assets-loader.js | 14 +++++------
.../asset-group-header/final-url-card.js | 23 ++++++++++++++-----
2 files changed, 24 insertions(+), 13 deletions(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js b/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js
index 983f83dcd0..435eeaa5f3 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js
@@ -82,12 +82,12 @@ function mapFinalUrlsToOptions( finalUrls, search ) {
* and then loading the suggested assets.
*
* @param {Object} props React props.
- * @param {boolean} props.isFetching Whether the assets are currently being fetched.
- * @param {Function} props.loadSuggestedAssets Function to load suggested assets.
+ * @param {boolean} props.loading Whether the assets are being loaded.
+ * @param {Function} props.onSelectFinalUrl Callback when a final URL is selected.
*
* @fires gla_import_assets_by_final_url_button_click
*/
-export default function AssetsLoader( { isFetching, loadSuggestedAssets } ) {
+export default function AssetsLoader( { loading, onSelectFinalUrl } ) {
const cacheRef = useRef( {} );
const latestSearchRef = useRef();
@@ -146,7 +146,7 @@ export default function AssetsLoader( { isFetching, loadSuggestedAssets } ) {
const handleClick = async () => {
const { finalUrl } = selectedOptions[ 0 ];
- loadSuggestedAssets( { id: finalUrl.id, type: finalUrl.type } );
+ onSelectFinalUrl( finalUrl );
};
const { finalUrl } = selectedOptions[ 0 ] || {};
@@ -165,7 +165,7 @@ export default function AssetsLoader( { isFetching, loadSuggestedAssets } ) {
isSearchable
hideBeforeSearch
excludeSelectedOptions={ false }
- disabled={ isFetching }
+ disabled={ loading }
options={ [] } // The actual options will be provided via the callback results of `onSearch`.
selected={ selectedOptions }
onSearch={ debouncedHandleSearch }
@@ -175,12 +175,12 @@ export default function AssetsLoader( { isFetching, loadSuggestedAssets } ) {
>
diff --git a/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js b/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js
index 9c18c8850c..24e5079a6c 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js
@@ -53,7 +53,7 @@ export default function FinalUrlCard( {
} ) {
const [ fetching, setFetching ] = useState( false );
const [ finalUrl, setFinalUrl ] = useState( initialFinalUrl || null );
- const didInitialLoadRef = useRef( false );
+ const hasLoadedInitialHomepageAssetsRef = useRef( false );
const { createNotice } = useDispatchCoreNotices();
const description = finalUrl ? (
@@ -105,16 +105,27 @@ export default function FinalUrlCard( {
);
useEffect( () => {
- if ( didInitialLoadRef.current ) {
+ if ( hasLoadedInitialHomepageAssetsRef.current ) {
return;
}
- didInitialLoadRef.current = true;
+ hasLoadedInitialHomepageAssetsRef.current = true;
- // When the type `homepage` is passed, `id` is ignore, but because of typing, we need to pass it as zero.
+ // Load homepage assets on first render by passing `id: 0` and a `type` other than `post` or `term`.
+ // `id` is a required parameter, but it is ignored when loading homepage assets.
+ // Related: https://github.com/woocommerce/google-listings-and-ads/blob/d23bdb504bce1ed8a10a4bd92608aeb5137fbe60/src/Ads/AssetSuggestionsService.php#L210-L216
loadSuggestedAssets( { id: 0, type: 'homepage' } );
}, [ loadSuggestedAssets ] );
+ const handleSelectFinalUrl = ( selectedFinalUrl ) => {
+ const { id, type } = selectedFinalUrl;
+
+ loadSuggestedAssets( {
+ id,
+ type,
+ } );
+ };
+
return (
) : (
) }
From 8ec402cb01d8dca9e4adfb189cd7dbd356bd83c6 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 23 Jan 2026 20:22:42 +0400
Subject: [PATCH 076/123] test: Mock FinalUrlCard and remove waitFor from tests
---
.../asset-group-header.test.js | 45 +++++++++----------
1 file changed, 22 insertions(+), 23 deletions(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-header/asset-group-header.test.js b/js/src/components/paid-ads/asset-group/asset-group-header/asset-group-header.test.js
index cdb563c942..7ed7e6feae 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-header/asset-group-header.test.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-header/asset-group-header.test.js
@@ -14,11 +14,16 @@ jest.mock( '~/components/adaptive-form', () => ( {
} ),
} ) );
+jest.mock(
+ '~/components/paid-ads/asset-group/asset-group-header/final-url-card',
+ () => () =>
+);
+
/**
* External dependencies
*/
import '@testing-library/jest-dom';
-import { render, screen, waitFor } from '@testing-library/react';
+import { render, screen } from '@testing-library/react';
/**
* Internal dependencies
@@ -27,27 +32,23 @@ import AssetGroupHeader from './asset-group-header';
import { useAdaptiveFormContext } from '~/components/adaptive-form';
describe( 'AssetGroupHeader', () => {
- test( 'Component renders', async () => {
+ test( 'Component renders', () => {
render( );
- await waitFor( () => {
- expect(
- screen.getByText( /Add additional assets/i )
- ).toBeInTheDocument();
- } );
+ expect(
+ screen.getByText( /Add additional assets/i )
+ ).toBeInTheDocument();
} );
- test( 'Component not showing Tip if there are no imported assets', async () => {
+ test( 'Component not showing Tip if there are no imported assets', () => {
render( );
- await waitFor( () => {
- expect(
- screen.queryByText(
- "We've used your final URL to auto-populate some assets for you. For the best results, we recommend that you add more assets."
- )
- ).not.toBeInTheDocument();
- } );
+ expect(
+ screen.queryByText(
+ "We've used your final URL to auto-populate some assets for you. For the best results, we recommend that you add more assets."
+ )
+ ).not.toBeInTheDocument();
} );
- test( 'Component showing Tip if there are imported assets', async () => {
+ test( 'Component showing Tip if there are imported assets', () => {
useAdaptiveFormContext.mockImplementation( () => {
return {
adapter: {
@@ -59,12 +60,10 @@ describe( 'AssetGroupHeader', () => {
};
} );
render( );
- await waitFor( () => {
- expect(
- screen.getByText(
- "We've used your final URL to auto-populate some assets for you. For the best results, we recommend that you add more assets."
- )
- ).toBeInTheDocument();
- } );
+ expect(
+ screen.getByText(
+ "We've used your final URL to auto-populate some assets for you. For the best results, we recommend that you add more assets."
+ )
+ ).toBeInTheDocument();
} );
} );
From 46a69f0483501d91d994ed5f2084c60142cbbd50 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 23 Jan 2026 20:40:19 +0400
Subject: [PATCH 077/123] test(e2e): Streamline 'Optimize your campaign' final
URL tests
---
.../add-paid-campaigns.test.js | 34 ++-----------------
tests/e2e/utils/pages/create-campaign.js | 9 +++++
2 files changed, 11 insertions(+), 32 deletions(-)
diff --git a/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js b/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
index 76b525e88d..43e6e81358 100644
--- a/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
+++ b/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
@@ -575,40 +575,10 @@ test.describe( 'Add paid campaign', () => {
test.describe( 'Optimize your campaign step', async () => {
test( 'Final URL should be selected to homepage by default', async () => {
- await expect(
- page.getByRole( 'heading', {
- level: 1,
- name: 'Optimize your campaign',
- } )
- ).toBeVisible();
-
- const finalUrlCard = page.locator( '.gla-final-url-card' );
+ const finalUrlCard = createCampaignPage.getFinalUrlCard();
await expect( finalUrlCard ).toContainText(
'https://woo.com/shop/'
);
-
- const selectDifferentFinalUrlButton = page.getByRole(
- 'button',
- {
- name: 'Or, select a different Final URL',
- }
- );
-
- await selectDifferentFinalUrlButton.click();
- } );
-
- test( 'Create Campaign button should be disabled if no URL selected', async () => {
- const createCampaignButton =
- createCampaignPage.getCreateCampaignButton();
- await expect( createCampaignButton ).toBeDisabled();
- } );
-
- test( 'Selecting final URL enables Create Campaign button', async () => {
- await createCampaignPage.selectUrlOption();
-
- const createCampaignButton =
- createCampaignPage.getCreateCampaignButton();
- await expect( createCampaignButton ).toBeEnabled();
} );
test( 'Selecting the "Or, select a different Final URL" button disables the Create Campaign button', async () => {
@@ -621,7 +591,7 @@ test.describe( 'Add paid campaign', () => {
await expect( createCampaignButton ).toBeDisabled();
} );
- test( 'Selecting the Final URL again enables the Create Campaign button', async () => {
+ test( 'Selecting final URL enables Create Campaign button', async () => {
await createCampaignPage.selectUrlOption();
const createCampaignButton =
diff --git a/tests/e2e/utils/pages/create-campaign.js b/tests/e2e/utils/pages/create-campaign.js
index 1ce6773beb..81635ab3c7 100644
--- a/tests/e2e/utils/pages/create-campaign.js
+++ b/tests/e2e/utils/pages/create-campaign.js
@@ -520,6 +520,15 @@ export default class CreateCampaignPage extends MockRequests {
);
}
+ /**
+ * Get final URL card.
+ *
+ * @return {import('@playwright/test').Locator} Get final URL card.
+ */
+ getFinalUrlCard() {
+ return this.page.locator( '.gla-final-url-card' );
+ }
+
/**
* Select URL option.
*
From 30947ca51307e578fd3f1fe188424e922add49ac Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 23 Jan 2026 20:47:54 +0400
Subject: [PATCH 078/123] style: Reorder import statements
---
.../paid-ads/asset-group/asset-group-header/final-url-card.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js b/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js
index 24e5079a6c..fcdd0635cb 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js
@@ -17,8 +17,8 @@ import AccountCard, { APPEARANCE } from '~/components/account-card';
import AppButton from '~/components/app-button';
import AssetsLoader from './assets-loader';
import { API_NAMESPACE } from '~/data/constants';
-import './final-url-card.scss';
import useDispatchCoreNotices from '~/hooks/useDispatchCoreNotices';
+import './final-url-card.scss';
/**
* @typedef {import('~/data/types.js').SuggestedAssets} SuggestedAssets
From c2366320a8cd39b67549a2b8c0fd1ffa3fc2b347 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 23 Jan 2026 20:49:10 +0400
Subject: [PATCH 079/123] refactor: Inline API path variables in
fetchSuggestedAssets
---
.../asset-group/asset-group-header/final-url-card.js | 9 ++++++---
1 file changed, 6 insertions(+), 3 deletions(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js b/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js
index fcdd0635cb..641919ce4d 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js
@@ -31,9 +31,12 @@ import './final-url-card.scss';
*/
function fetchSuggestedAssets( id, type ) {
- const endPoint = `${ API_NAMESPACE }/assets/suggestions`;
- const query = { id, type };
- return apiFetch( { path: addQueryArgs( endPoint, query ) } );
+ const path = addQueryArgs( `${ API_NAMESPACE }/assets/suggestions`, {
+ id,
+ type,
+ } );
+
+ return apiFetch( { path } );
}
/**
From 9a03ce57426f00d875739a6568b96ea63c3253d5 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 23 Jan 2026 20:52:28 +0400
Subject: [PATCH 080/123] refactor(asset-group): Inline fetchSuggestedAssets
function
---
.../asset-group-header/final-url-card.js | 20 +++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js b/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js
index 641919ce4d..40219aa4a8 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js
@@ -30,15 +30,6 @@ import './final-url-card.scss';
* @event gla_reselect_another_final_url_button_click
*/
-function fetchSuggestedAssets( id, type ) {
- const path = addQueryArgs( `${ API_NAMESPACE }/assets/suggestions`, {
- id,
- type,
- } );
-
- return apiFetch( { path } );
-}
-
/**
* Renders the Card UI for managing the final URL and getting the suggested assets.
*
@@ -89,8 +80,17 @@ export default function FinalUrlCard( {
const loadSuggestedAssets = useCallback(
async ( { id, type } ) => {
setFetching( true );
+
try {
- const assets = await fetchSuggestedAssets( id, type );
+ const path = addQueryArgs(
+ `${ API_NAMESPACE }/assets/suggestions`,
+ {
+ id,
+ type,
+ }
+ );
+
+ const assets = await apiFetch( { path } );
handleAssetsLoaded( assets );
} catch ( error ) {
createNotice(
From f2dd36f2ca08428ff986bd9483bc8407532d2241 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 23 Jan 2026 21:05:05 +0400
Subject: [PATCH 081/123] docs: Adds JSDoc for FinalUrl type and updates
onSelectFinalUrl prop
---
.../asset-group/asset-group-header/assets-loader.js | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js b/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js
index 435eeaa5f3..1e79114270 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js
@@ -19,6 +19,16 @@ import './assets-loader.scss';
* @typedef {import('~/data/types.js').SuggestedAssets} SuggestedAssets
*/
+/**
+ * A selectable final URL option.
+ *
+ * @typedef {Object} FinalUrl
+ * @property {number} id The entity ID (e.g. post/term ID).
+ * @property {string} type The final URL type.
+ * @property {string} title Display title for the final URL.
+ * @property {string} url Absolute final URL.
+ */
+
function allowAllResults() {
// Make it result in `new RegExp('.', 'i')` to avoid any custom results in
// the mapFinalUrlsToOptions function being filtered out.
@@ -83,7 +93,7 @@ function mapFinalUrlsToOptions( finalUrls, search ) {
*
* @param {Object} props React props.
* @param {boolean} props.loading Whether the assets are being loaded.
- * @param {Function} props.onSelectFinalUrl Callback when a final URL is selected.
+ * @param {(finalUrl: FinalUrl) => void} props.onSelectFinalUrl Callback fired when a final URL is selected. Receives the selected final URL as the first argument.
*
* @fires gla_import_assets_by_final_url_button_click
*/
From 5f0371561e36bd1d961a387d60c63f84ae76ef3e Mon Sep 17 00:00:00 2001
From: James Morrison
Date: Tue, 27 Jan 2026 11:26:26 +0000
Subject: [PATCH 082/123] PR feedback.
---
src/API/Google/AssetFieldType.php | 36 ++++++----------
src/Ads/AdsAssetGenerationService.php | 43 +++++++++----------
.../HelperTrait/GoogleAdsClientTrait.php | 7 ++-
3 files changed, 38 insertions(+), 48 deletions(-)
diff --git a/src/API/Google/AssetFieldType.php b/src/API/Google/AssetFieldType.php
index 0cef4d9a5b..160298f6ce 100644
--- a/src/API/Google/AssetFieldType.php
+++ b/src/API/Google/AssetFieldType.php
@@ -95,13 +95,6 @@ class AssetFieldType extends StatusMapping {
*/
public const PORTRAIT_MARKETING_IMAGE = 'portrait_marketing_image';
- /**
- * The asset is linked for use as a tall portrait marketing image.
- *
- * @var string
- */
- public const TALL_PORTRAIT_MARKETING_IMAGE = 'tall_portrait_marketing_image';
-
/**
* The asset is linked for use as a landscape logo.
*
@@ -129,21 +122,20 @@ class AssetFieldType extends StatusMapping {
* @var string
*/
protected const MAPPING = [
- AdsAssetFieldType::UNSPECIFIED => self::UNSPECIFIED,
- AdsAssetFieldType::UNKNOWN => self::UNKNOWN,
- AdsAssetFieldType::HEADLINE => self::HEADLINE,
- AdsAssetFieldType::DESCRIPTION => self::DESCRIPTION,
- AdsAssetFieldType::MARKETING_IMAGE => self::MARKETING_IMAGE,
- AdsAssetFieldType::LONG_HEADLINE => self::LONG_HEADLINE,
- AdsAssetFieldType::BUSINESS_NAME => self::BUSINESS_NAME,
- AdsAssetFieldType::SQUARE_MARKETING_IMAGE => self::SQUARE_MARKETING_IMAGE,
- AdsAssetFieldType::LOGO => self::LOGO,
- AdsAssetFieldType::CALL_TO_ACTION_SELECTION => self::CALL_TO_ACTION_SELECTION,
- AdsAssetFieldType::PORTRAIT_MARKETING_IMAGE => self::PORTRAIT_MARKETING_IMAGE,
- AdsAssetFieldType::TALL_PORTRAIT_MARKETING_IMAGE => self::TALL_PORTRAIT_MARKETING_IMAGE,
- AdsAssetFieldType::LANDSCAPE_LOGO => self::LANDSCAPE_LOGO,
- AdsAssetFieldType::YOUTUBE_VIDEO => self::YOUTUBE_VIDEO,
- AdsAssetFieldType::MEDIA_BUNDLE => self::MEDIA_BUNDLE,
+ AdsAssetFieldType::UNSPECIFIED => self::UNSPECIFIED,
+ AdsAssetFieldType::UNKNOWN => self::UNKNOWN,
+ AdsAssetFieldType::HEADLINE => self::HEADLINE,
+ AdsAssetFieldType::DESCRIPTION => self::DESCRIPTION,
+ AdsAssetFieldType::MARKETING_IMAGE => self::MARKETING_IMAGE,
+ AdsAssetFieldType::LONG_HEADLINE => self::LONG_HEADLINE,
+ AdsAssetFieldType::BUSINESS_NAME => self::BUSINESS_NAME,
+ AdsAssetFieldType::SQUARE_MARKETING_IMAGE => self::SQUARE_MARKETING_IMAGE,
+ AdsAssetFieldType::LOGO => self::LOGO,
+ AdsAssetFieldType::CALL_TO_ACTION_SELECTION => self::CALL_TO_ACTION_SELECTION,
+ AdsAssetFieldType::PORTRAIT_MARKETING_IMAGE => self::PORTRAIT_MARKETING_IMAGE,
+ AdsAssetFieldType::LANDSCAPE_LOGO => self::LANDSCAPE_LOGO,
+ AdsAssetFieldType::YOUTUBE_VIDEO => self::YOUTUBE_VIDEO,
+ AdsAssetFieldType::MEDIA_BUNDLE => self::MEDIA_BUNDLE,
];
diff --git a/src/Ads/AdsAssetGenerationService.php b/src/Ads/AdsAssetGenerationService.php
index 91b2dbf812..1809cdb4b7 100644
--- a/src/Ads/AdsAssetGenerationService.php
+++ b/src/Ads/AdsAssetGenerationService.php
@@ -45,18 +45,25 @@ class AdsAssetGenerationService implements OptionsAwareInterface, Service {
protected $google_ads_client;
/**
- * Mapping from lowercase input strings to AssetFieldType constants.
+ * Valid text asset field types.
*
* @var array
*/
- protected const TYPE_MAPPING = [
- 'headline' => AssetFieldType::HEADLINE,
- 'long_headline' => AssetFieldType::LONG_HEADLINE,
- 'description' => AssetFieldType::DESCRIPTION,
- 'marketing_image' => AssetFieldType::MARKETING_IMAGE,
- 'square_marketing_image' => AssetFieldType::SQUARE_MARKETING_IMAGE,
- 'portrait_marketing_image' => AssetFieldType::PORTRAIT_MARKETING_IMAGE,
- 'tall_portrait_marketing_image' => AssetFieldType::TALL_PORTRAIT_MARKETING_IMAGE,
+ protected const VALID_TEXT_TYPES = [
+ AssetFieldType::HEADLINE,
+ AssetFieldType::LONG_HEADLINE,
+ AssetFieldType::DESCRIPTION,
+ ];
+
+ /**
+ * Valid image asset field types.
+ *
+ * @var array
+ */
+ protected const VALID_IMAGE_TYPES = [
+ AssetFieldType::MARKETING_IMAGE,
+ AssetFieldType::SQUARE_MARKETING_IMAGE,
+ AssetFieldType::PORTRAIT_MARKETING_IMAGE,
];
/**
@@ -95,8 +102,7 @@ public function generate_text( array $args = [] ): array {
}
// Convert asset field types from lowercase strings to enum numbers.
- $allowed_types = [ AssetFieldType::HEADLINE, AssetFieldType::LONG_HEADLINE, AssetFieldType::DESCRIPTION ];
- $asset_field_types = $this->convert_types_to_enums( $args['asset_field_types'], $allowed_types );
+ $asset_field_types = $this->convert_types_to_enums( $args['asset_field_types'], self::VALID_TEXT_TYPES );
$request = new GenerateTextRequest(
[
@@ -134,7 +140,7 @@ public function generate_text( array $args = [] ): array {
* Optional. Arguments for generating image assets.
*
* @type string $final_url The final URL - defaults to the Site URL.
- * @type array $asset_field_types Can be one or more of: marketing_image, square_marketing_image, portrait_marketing_image, tall_portrait_marketing_image.
+ * @type array $asset_field_types Can be one or more of: marketing_image, square_marketing_image, portrait_marketing_image.
* }
* @return array Array of generated image objects with 'temporary_image_url' and 'type' keys.
* @throws Exception If the image assets can't be generated.
@@ -150,8 +156,7 @@ public function generate_images( array $args = [] ): array {
// Convert asset field types from lowercase strings to enum numbers (if provided).
$asset_field_types = [];
if ( ! empty( $args['asset_field_types'] ) ) {
- $allowed_types = [ AssetFieldType::MARKETING_IMAGE, AssetFieldType::SQUARE_MARKETING_IMAGE, AssetFieldType::PORTRAIT_MARKETING_IMAGE, AssetFieldType::TALL_PORTRAIT_MARKETING_IMAGE ];
- $asset_field_types = $this->convert_types_to_enums( $args['asset_field_types'], $allowed_types );
+ $asset_field_types = $this->convert_types_to_enums( $args['asset_field_types'], self::VALID_IMAGE_TYPES );
}
$request_data = [
@@ -201,18 +206,12 @@ public function generate_images( array $args = [] ): array {
protected function convert_types_to_enums( array $types, array $allowed_types = [] ): array {
$enums = [];
foreach ( $types as $type ) {
- if ( ! isset( self::TYPE_MAPPING[ $type ] ) ) {
- continue;
- }
-
- $internal_type = self::TYPE_MAPPING[ $type ];
-
// Filter by allowed types if specified.
- if ( ! empty( $allowed_types ) && ! in_array( $internal_type, $allowed_types, true ) ) {
+ if ( ! empty( $allowed_types ) && ! in_array( $type, $allowed_types, true ) ) {
continue;
}
- $enums[] = AssetFieldType::number( $internal_type );
+ $enums[] = AssetFieldType::number( $type );
}
return $enums;
diff --git a/tests/Tools/HelperTrait/GoogleAdsClientTrait.php b/tests/Tools/HelperTrait/GoogleAdsClientTrait.php
index 1b600e419a..1c897caba4 100644
--- a/tests/Tools/HelperTrait/GoogleAdsClientTrait.php
+++ b/tests/Tools/HelperTrait/GoogleAdsClientTrait.php
@@ -1212,10 +1212,9 @@ protected function generate_text_assets_mock_exception( ApiException $exception
*/
protected function generate_image_assets_mock( array $image_assets ) {
$type_mapping = [
- 'MARKETING_IMAGE' => AssetFieldType::MARKETING_IMAGE,
- 'SQUARE_MARKETING_IMAGE' => AssetFieldType::SQUARE_MARKETING_IMAGE,
- 'PORTRAIT_MARKETING_IMAGE' => AssetFieldType::PORTRAIT_MARKETING_IMAGE,
- 'TALL_PORTRAIT_MARKETING_IMAGE' => AssetFieldType::TALL_PORTRAIT_MARKETING_IMAGE,
+ 'MARKETING_IMAGE' => AssetFieldType::MARKETING_IMAGE,
+ 'SQUARE_MARKETING_IMAGE' => AssetFieldType::SQUARE_MARKETING_IMAGE,
+ 'PORTRAIT_MARKETING_IMAGE' => AssetFieldType::PORTRAIT_MARKETING_IMAGE,
];
$image_asset_objects = [];
From 497cea809809aae3fa7859ef37ed7f777df92179 Mon Sep 17 00:00:00 2001
From: James Morrison
Date: Tue, 27 Jan 2026 12:20:41 +0000
Subject: [PATCH 083/123] Removed TALL_PORTRAIT_MARKETING_IMAGE / fix tests.
---
src/API/Site/Controllers/Ads/AssetGenerationController.php | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/API/Site/Controllers/Ads/AssetGenerationController.php b/src/API/Site/Controllers/Ads/AssetGenerationController.php
index 499636dd2a..af1b520a39 100644
--- a/src/API/Site/Controllers/Ads/AssetGenerationController.php
+++ b/src/API/Site/Controllers/Ads/AssetGenerationController.php
@@ -128,7 +128,6 @@ protected function get_generate_images_params(): array {
AssetFieldType::MARKETING_IMAGE,
AssetFieldType::SQUARE_MARKETING_IMAGE,
AssetFieldType::PORTRAIT_MARKETING_IMAGE,
- AssetFieldType::TALL_PORTRAIT_MARKETING_IMAGE,
],
],
'sanitize_callback' => function ( $types ) {
From 7639885c8f8c0c968d57267b76310f7ef1191a2c Mon Sep 17 00:00:00 2001
From: James Morrison
Date: Tue, 27 Jan 2026 13:30:54 +0000
Subject: [PATCH 084/123] PR feedback.
---
.../Ads/AssetGenerationController.php | 46 +++++--------------
src/Ads/AdsAssetGenerationService.php | 8 ++--
.../HelperTrait/GoogleAdsClientTrait.php | 16 +++----
.../Ads/AssetGenerationControllerTest.php | 28 +++++------
.../Ads/AdsAssetGenerationServiceTest.php | 26 +++++------
5 files changed, 51 insertions(+), 73 deletions(-)
diff --git a/src/API/Site/Controllers/Ads/AssetGenerationController.php b/src/API/Site/Controllers/Ads/AssetGenerationController.php
index af1b520a39..54f8f07263 100644
--- a/src/API/Site/Controllers/Ads/AssetGenerationController.php
+++ b/src/API/Site/Controllers/Ads/AssetGenerationController.php
@@ -4,7 +4,6 @@
namespace Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\Ads;
use Automattic\WooCommerce\GoogleListingsAndAds\Ads\AdsAssetGenerationService;
-use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AssetFieldType;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\BaseController;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Site\Controllers\ResponseFromExceptionTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\API\TransportMethods;
@@ -54,6 +53,7 @@ public function register_routes(): void {
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_generate_text_params(),
],
+ 'schema' => $this->get_api_response_schema_callback(),
]
);
@@ -66,6 +66,7 @@ public function register_routes(): void {
'permission_callback' => $this->get_permission_callback(),
'args' => $this->get_generate_images_params(),
],
+ 'schema' => $this->get_api_response_schema_callback(),
]
);
}
@@ -90,11 +91,7 @@ protected function get_generate_text_params(): array {
'default' => [],
'items' => [
'type' => 'string',
- 'enum' => [
- AssetFieldType::HEADLINE,
- AssetFieldType::LONG_HEADLINE,
- AssetFieldType::DESCRIPTION,
- ],
+ 'enum' => AdsAssetGenerationService::VALID_TEXT_TYPES,
],
'sanitize_callback' => function ( $types ) {
return array_map( 'sanitize_text_field', $types );
@@ -124,11 +121,7 @@ protected function get_generate_images_params(): array {
'default' => [],
'items' => [
'type' => 'string',
- 'enum' => [
- AssetFieldType::MARKETING_IMAGE,
- AssetFieldType::SQUARE_MARKETING_IMAGE,
- AssetFieldType::PORTRAIT_MARKETING_IMAGE,
- ],
+ 'enum' => AdsAssetGenerationService::VALID_IMAGE_TYPES,
],
'sanitize_callback' => function ( $types ) {
return array_map( 'sanitize_text_field', $types );
@@ -159,8 +152,10 @@ protected function get_generate_text_callback(): callable {
]
);
- // Format response with lowercase types.
- return $this->format_response( $final_url, $items );
+ return [
+ 'final_url' => $final_url,
+ 'items' => $items,
+ ];
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
@@ -187,33 +182,16 @@ protected function get_generate_images_callback(): callable {
}
$items = $this->service->generate_images( $args );
- // Format response with lowercase types.
- return $this->format_response( $final_url, $items );
+ return [
+ 'final_url' => $final_url,
+ 'items' => $items,
+ ];
} catch ( Exception $e ) {
return $this->response_from_exception( $e );
}
};
}
- /**
- * Format the response with final_url and items.
- *
- * @param string $final_url The final URL.
- * @param array $service_items Items from the service (with uppercase types).
- * @return array Formatted response with lowercase types.
- */
- protected function format_response( string $final_url, array $service_items ): array {
- $items = [];
- foreach ( $service_items as $item ) {
- $item['type'] = strtolower( $item['type'] );
- $items[] = $item;
- }
-
- return [
- 'final_url' => $final_url,
- 'items' => $items,
- ];
- }
/**
* Get the item schema properties for the controller.
diff --git a/src/Ads/AdsAssetGenerationService.php b/src/Ads/AdsAssetGenerationService.php
index 1809cdb4b7..4b7b055b65 100644
--- a/src/Ads/AdsAssetGenerationService.php
+++ b/src/Ads/AdsAssetGenerationService.php
@@ -49,7 +49,7 @@ class AdsAssetGenerationService implements OptionsAwareInterface, Service {
*
* @var array
*/
- protected const VALID_TEXT_TYPES = [
+ public const VALID_TEXT_TYPES = [
AssetFieldType::HEADLINE,
AssetFieldType::LONG_HEADLINE,
AssetFieldType::DESCRIPTION,
@@ -60,7 +60,7 @@ class AdsAssetGenerationService implements OptionsAwareInterface, Service {
*
* @var array
*/
- protected const VALID_IMAGE_TYPES = [
+ public const VALID_IMAGE_TYPES = [
AssetFieldType::MARKETING_IMAGE,
AssetFieldType::SQUARE_MARKETING_IMAGE,
AssetFieldType::PORTRAIT_MARKETING_IMAGE,
@@ -122,7 +122,7 @@ public function generate_text( array $args = [] ): array {
$asset_field_type_label = AssetFieldType::label( $asset_field_type_number );
$results[] = [
'text' => $text_asset->getText(),
- 'type' => AssetFieldType::name( $asset_field_type_label ),
+ 'type' => $asset_field_type_label,
];
}
@@ -185,7 +185,7 @@ public function generate_images( array $args = [] ): array {
$asset_field_type_label = AssetFieldType::label( $asset_field_type_number );
$results[] = [
'temporary_image_url' => $image_asset->getImageTemporaryUrl(),
- 'type' => AssetFieldType::name( $asset_field_type_label ),
+ 'type' => $asset_field_type_label,
];
}
diff --git a/tests/Tools/HelperTrait/GoogleAdsClientTrait.php b/tests/Tools/HelperTrait/GoogleAdsClientTrait.php
index 1c897caba4..abaf4f8aaa 100644
--- a/tests/Tools/HelperTrait/GoogleAdsClientTrait.php
+++ b/tests/Tools/HelperTrait/GoogleAdsClientTrait.php
@@ -1170,13 +1170,13 @@ protected function generate_location_ids_mock( array $locations ) {
/**
* Generates a mocked response for text asset generation.
*
- * @param array $text_assets Array of text assets with 'text' and 'type' keys (type in uppercase like 'HEADLINE').
+ * @param array $text_assets Array of text assets with 'text' and 'type' keys (type in lowercase like 'headline').
*/
protected function generate_text_assets_mock( array $text_assets ) {
$type_mapping = [
- 'HEADLINE' => AssetFieldType::HEADLINE,
- 'LONG_HEADLINE' => AssetFieldType::LONG_HEADLINE,
- 'DESCRIPTION' => AssetFieldType::DESCRIPTION,
+ 'headline' => AssetFieldType::HEADLINE,
+ 'long_headline' => AssetFieldType::LONG_HEADLINE,
+ 'description' => AssetFieldType::DESCRIPTION,
];
$text_asset_objects = [];
@@ -1208,13 +1208,13 @@ protected function generate_text_assets_mock_exception( ApiException $exception
/**
* Generates a mocked response for image asset generation.
*
- * @param array $image_assets Array of image assets with 'temporary_image_url' and 'type' keys (type in uppercase like 'MARKETING_IMAGE').
+ * @param array $image_assets Array of image assets with 'temporary_image_url' and 'type' keys (type in lowercase like 'marketing_image').
*/
protected function generate_image_assets_mock( array $image_assets ) {
$type_mapping = [
- 'MARKETING_IMAGE' => AssetFieldType::MARKETING_IMAGE,
- 'SQUARE_MARKETING_IMAGE' => AssetFieldType::SQUARE_MARKETING_IMAGE,
- 'PORTRAIT_MARKETING_IMAGE' => AssetFieldType::PORTRAIT_MARKETING_IMAGE,
+ 'marketing_image' => AssetFieldType::MARKETING_IMAGE,
+ 'square_marketing_image' => AssetFieldType::SQUARE_MARKETING_IMAGE,
+ 'portrait_marketing_image' => AssetFieldType::PORTRAIT_MARKETING_IMAGE,
];
$image_asset_objects = [];
diff --git a/tests/Unit/API/Site/Controllers/Ads/AssetGenerationControllerTest.php b/tests/Unit/API/Site/Controllers/Ads/AssetGenerationControllerTest.php
index f8ba6dbddc..c7a8768599 100644
--- a/tests/Unit/API/Site/Controllers/Ads/AssetGenerationControllerTest.php
+++ b/tests/Unit/API/Site/Controllers/Ads/AssetGenerationControllerTest.php
@@ -59,15 +59,15 @@ public function test_generate_text_with_defaults() {
[
[
'text' => 'Test headline',
- 'type' => 'HEADLINE',
+ 'type' => 'headline',
],
[
'text' => 'Test long headline',
- 'type' => 'LONG_HEADLINE',
+ 'type' => 'long_headline',
],
[
'text' => 'Test description',
- 'type' => 'DESCRIPTION',
+ 'type' => 'description',
],
]
);
@@ -102,7 +102,7 @@ public function test_generate_text_with_custom_url() {
[
[
'text' => 'Custom headline',
- 'type' => 'HEADLINE',
+ 'type' => 'headline',
],
]
);
@@ -132,7 +132,7 @@ public function test_generate_text_with_specific_types() {
[
[
'text' => 'Headline only',
- 'type' => 'HEADLINE',
+ 'type' => 'headline',
],
]
);
@@ -164,11 +164,11 @@ function ( $args ) {
[
[
'text' => 'Test',
- 'type' => 'HEADLINE',
+ 'type' => 'headline',
],
[
'text' => 'Test',
- 'type' => 'DESCRIPTION',
+ 'type' => 'description',
],
]
);
@@ -205,15 +205,15 @@ public function test_generate_images_with_defaults() {
[
[
'temporary_image_url' => 'https://example.com/image-marketing.jpg',
- 'type' => 'MARKETING_IMAGE',
+ 'type' => 'marketing_image',
],
[
'temporary_image_url' => 'https://example.com/image-square.jpg',
- 'type' => 'SQUARE_MARKETING_IMAGE',
+ 'type' => 'square_marketing_image',
],
[
'temporary_image_url' => 'https://example.com/image-portrait.jpg',
- 'type' => 'PORTRAIT_MARKETING_IMAGE',
+ 'type' => 'portrait_marketing_image',
],
]
);
@@ -246,7 +246,7 @@ public function test_generate_images_with_custom_url() {
[
[
'temporary_image_url' => 'https://example.com/custom-image.jpg',
- 'type' => 'MARKETING_IMAGE',
+ 'type' => 'marketing_image',
],
]
);
@@ -277,7 +277,7 @@ public function test_generate_images_with_specific_types() {
[
[
'temporary_image_url' => 'https://example.com/image.jpg',
- 'type' => 'MARKETING_IMAGE',
+ 'type' => 'marketing_image',
],
]
);
@@ -309,11 +309,11 @@ function ( $args ) {
[
[
'temporary_image_url' => 'https://example.com/image1.jpg',
- 'type' => 'MARKETING_IMAGE',
+ 'type' => 'marketing_image',
],
[
'temporary_image_url' => 'https://example.com/image2.jpg',
- 'type' => 'SQUARE_MARKETING_IMAGE',
+ 'type' => 'square_marketing_image',
],
]
);
diff --git a/tests/Unit/Ads/AdsAssetGenerationServiceTest.php b/tests/Unit/Ads/AdsAssetGenerationServiceTest.php
index b54e7db7d0..bf56872435 100644
--- a/tests/Unit/Ads/AdsAssetGenerationServiceTest.php
+++ b/tests/Unit/Ads/AdsAssetGenerationServiceTest.php
@@ -50,15 +50,15 @@ public function test_generate_text_with_defaults() {
$expected_text_assets = [
[
'text' => 'Generated headline text example.',
- 'type' => 'HEADLINE',
+ 'type' => 'headline',
],
[
'text' => 'Generated long headline text example.',
- 'type' => 'LONG_HEADLINE',
+ 'type' => 'long_headline',
],
[
'text' => 'Generated description text example.',
- 'type' => 'DESCRIPTION',
+ 'type' => 'description',
],
];
@@ -79,7 +79,7 @@ public function test_generate_text_with_custom_final_url() {
$expected_text_assets = [
[
'text' => 'Custom headline',
- 'type' => 'HEADLINE',
+ 'type' => 'headline',
],
];
@@ -103,7 +103,7 @@ public function test_generate_text_with_specific_types() {
$expected_text_assets = [
[
'text' => 'Headline only',
- 'type' => 'HEADLINE',
+ 'type' => 'headline',
],
];
@@ -146,15 +146,15 @@ public function test_generate_text_uses_defaults_when_no_types_provided() {
$expected_text_assets = [
[
'text' => 'Default headline',
- 'type' => 'HEADLINE',
+ 'type' => 'headline',
],
[
'text' => 'Default long headline',
- 'type' => 'LONG_HEADLINE',
+ 'type' => 'long_headline',
],
[
'text' => 'Default description',
- 'type' => 'DESCRIPTION',
+ 'type' => 'description',
],
];
@@ -169,15 +169,15 @@ public function test_generate_images_with_defaults() {
$expected_image_assets = [
[
'temporary_image_url' => 'https://example.com/temporary_image_url-marketing.jpg',
- 'type' => 'MARKETING_IMAGE',
+ 'type' => 'marketing_image',
],
[
'temporary_image_url' => 'https://example.com/temporary_image_url-square.jpg',
- 'type' => 'SQUARE_MARKETING_IMAGE',
+ 'type' => 'square_marketing_image',
],
[
'temporary_image_url' => 'https://example.com/temporary_image_url-portrait.jpg',
- 'type' => 'PORTRAIT_MARKETING_IMAGE',
+ 'type' => 'portrait_marketing_image',
],
];
@@ -193,7 +193,7 @@ public function test_generate_images_with_custom_final_url() {
$expected_image_assets = [
[
'temporary_image_url' => 'https://example.com/custom-image.jpg',
- 'type' => 'MARKETING_IMAGE',
+ 'type' => 'marketing_image',
],
];
@@ -208,7 +208,7 @@ public function test_generate_images_with_specific_types() {
$expected_image_assets = [
[
'temporary_image_url' => 'https://example.com/marketing-image.jpg',
- 'type' => 'MARKETING_IMAGE',
+ 'type' => 'marketing_image',
],
];
From 8fbda495efbaf01b9a5e0b139c384a648640845f Mon Sep 17 00:00:00 2001
From: asvinb
Date: Tue, 27 Jan 2026 18:53:13 +0400
Subject: [PATCH 085/123] refactor(asset-group): Centralize asset fetching and
integrate loading state
---
.../asset-group-header/asset-group-header.js | 44 +++++++++-
.../asset-group-header/assets-loader.js | 9 +--
.../asset-group-header/final-url-card.js | 81 +++++--------------
.../paid-ads/asset-group/asset-group.js | 7 +-
.../paid-ads/campaign-assets-form.js | 71 +++++++++-------
.../pages/create-paid-ads-campaign/index.js | 3 -
js/src/pages/edit-paid-ads-campaign/index.js | 1 +
7 files changed, 111 insertions(+), 105 deletions(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-header/asset-group-header.js b/js/src/components/paid-ads/asset-group/asset-group-header/asset-group-header.js
index e920346800..8515ebbcc5 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-header/asset-group-header.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-header/asset-group-header.js
@@ -2,7 +2,12 @@
* External dependencies
*/
import { __ } from '@wordpress/i18n';
-import { createInterpolateElement } from '@wordpress/element';
+import {
+ createInterpolateElement,
+ useRef,
+ useEffect,
+ useCallback,
+} from '@wordpress/element';
import { Tip, Flex, FlexItem } from '@wordpress/components';
/**
@@ -14,6 +19,7 @@ import Section from '~/components/section';
import FinalUrlCard from './final-url-card';
import AppDocumentationLink from '~/components/app-documentation-link';
import GenAICard from '../../gen-ai-card';
+import GenAIProgress from '../../gen-ai-progress';
/**
* Renders the header section for the asset group form where the user selects the URL to manage the assets for.
@@ -22,13 +28,49 @@ import GenAICard from '../../gen-ai-card';
* so it expects a `CampaignAssetsForm` to existing in its parents.
*/
export default function AssetGroupHeader() {
+ const hasLoadedInitialHomepageAssetsRef = useRef( false );
const { adapter } = useAdaptiveFormContext();
const {
hasImportedAssets,
hasAISuggestedTextAssets,
hasAISuggestedMediaAssets,
+ fetchAssets,
+ isFetchingAssets,
} = adapter;
+ const fetchCampaignAssets = useCallback(
+ async ( id, type ) => {
+ const suggestedAssets = await fetchAssets( id, type );
+ adapter.resetAssetGroup( suggestedAssets );
+ },
+ [ fetchAssets, adapter ]
+ );
+
+ useEffect( () => {
+ console.log( adapter.baseAssetGroup[ ASSET_FORM_KEY.FINAL_URL ] );
+ async function loadAssets() {
+ if (
+ hasLoadedInitialHomepageAssetsRef.current ||
+ adapter.baseAssetGroup[ ASSET_FORM_KEY.FINAL_URL ]
+ ) {
+ return;
+ }
+
+ hasLoadedInitialHomepageAssetsRef.current = true;
+
+ // Load homepage assets on first render by passing `id: 0` and a `type` other than `post` or `term`.
+ // `id` is a required parameter, but it is ignored when loading homepage assets.
+ // Related: https://github.com/woocommerce/google-listings-and-ads/blob/d23bdb504bce1ed8a10a4bd92608aeb5137fbe60/src/Ads/AssetSuggestionsService.php#L210-L216
+ await fetchCampaignAssets( 0, 'homepage' );
+ }
+
+ loadAssets();
+ }, [ fetchCampaignAssets, adapter.baseAssetGroup ] );
+
+ if ( isFetchingAssets ) {
+ return ;
+ }
+
return (
void} props.onSelectFinalUrl Callback fired when a final URL is selected. Receives the selected final URL as the first argument.
*
* @fires gla_import_assets_by_final_url_button_click
*/
-export default function AssetsLoader( { loading, onSelectFinalUrl } ) {
+export default function AssetsLoader( { onSelectFinalUrl } ) {
const cacheRef = useRef( {} );
const latestSearchRef = useRef();
@@ -175,7 +174,6 @@ export default function AssetsLoader( { loading, onSelectFinalUrl } ) {
isSearchable
hideBeforeSearch
excludeSelectedOptions={ false }
- disabled={ loading }
options={ [] } // The actual options will be provided via the callback results of `onSearch`.
selected={ selectedOptions }
onSearch={ debouncedHandleSearch }
@@ -184,13 +182,10 @@ export default function AssetsLoader( { loading, onSelectFinalUrl } ) {
/>
>
diff --git a/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js b/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js
index 40219aa4a8..4367fa9df8 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js
@@ -1,23 +1,20 @@
/**
* External dependencies
*/
-import apiFetch from '@wordpress/api-fetch';
import { __ } from '@wordpress/i18n';
-import { addQueryArgs } from '@wordpress/url';
import classnames from 'classnames';
-import { useCallback, useEffect, useRef, useState } from '@wordpress/element';
+import { useCallback, useEffect, useState } from '@wordpress/element';
import { ExternalLink } from '@wordpress/components';
/**
* Internal dependencies
*/
import { ASSET_GROUP_KEY } from '~/constants';
+import { useAdaptiveFormContext } from '~/components/adaptive-form';
import Section from '~/components/section';
import AccountCard, { APPEARANCE } from '~/components/account-card';
import AppButton from '~/components/app-button';
import AssetsLoader from './assets-loader';
-import { API_NAMESPACE } from '~/data/constants';
-import useDispatchCoreNotices from '~/hooks/useDispatchCoreNotices';
import './final-url-card.scss';
/**
@@ -45,10 +42,9 @@ export default function FinalUrlCard( {
initialFinalUrl,
hideFooter = false,
} ) {
- const [ fetching, setFetching ] = useState( false );
- const [ finalUrl, setFinalUrl ] = useState( initialFinalUrl || null );
- const hasLoadedInitialHomepageAssetsRef = useRef( false );
- const { createNotice } = useDispatchCoreNotices();
+ const { adapter } = useAdaptiveFormContext();
+ const [ finalUrl, setFinalUrl ] = useState( null );
+ const { fetchAssets } = adapter;
const description = finalUrl ? (
{ finalUrl }
@@ -59,6 +55,10 @@ export default function FinalUrlCard( {
)
);
+ useEffect( () => {
+ setFinalUrl( initialFinalUrl );
+ }, [ initialFinalUrl ] );
+
const handleAssetsLoaded = useCallback(
( suggestedAssets ) => {
setFinalUrl( suggestedAssets[ ASSET_GROUP_KEY.FINAL_URL ] );
@@ -72,61 +72,23 @@ export default function FinalUrlCard( {
onAssetsChange( null );
};
+ const fetchCampaignAssets = useCallback(
+ async ( id, type ) => {
+ const suggestedAssets = await fetchAssets( id, type );
+ handleAssetsLoaded( suggestedAssets );
+ },
+ [ fetchAssets, handleAssetsLoaded ]
+ );
+
const className = classnames( {
'gla-final-url-card': true,
'gla-final-url-card--has-selected-url': finalUrl,
} );
- const loadSuggestedAssets = useCallback(
- async ( { id, type } ) => {
- setFetching( true );
-
- try {
- const path = addQueryArgs(
- `${ API_NAMESPACE }/assets/suggestions`,
- {
- id,
- type,
- }
- );
-
- const assets = await apiFetch( { path } );
- handleAssetsLoaded( assets );
- } catch ( error ) {
- createNotice(
- 'error',
- __(
- 'Unable to load assets data.',
- 'google-listings-and-ads'
- )
- );
- } finally {
- setFetching( false );
- }
- },
- [ createNotice, handleAssetsLoaded ]
- );
-
- useEffect( () => {
- if ( hasLoadedInitialHomepageAssetsRef.current ) {
- return;
- }
-
- hasLoadedInitialHomepageAssetsRef.current = true;
-
- // Load homepage assets on first render by passing `id: 0` and a `type` other than `post` or `term`.
- // `id` is a required parameter, but it is ignored when loading homepage assets.
- // Related: https://github.com/woocommerce/google-listings-and-ads/blob/d23bdb504bce1ed8a10a4bd92608aeb5137fbe60/src/Ads/AssetSuggestionsService.php#L210-L216
- loadSuggestedAssets( { id: 0, type: 'homepage' } );
- }, [ loadSuggestedAssets ] );
-
- const handleSelectFinalUrl = ( selectedFinalUrl ) => {
+ const handleSelectFinalUrl = async ( selectedFinalUrl ) => {
const { id, type } = selectedFinalUrl;
- loadSuggestedAssets( {
- id,
- type,
- } );
+ await fetchCampaignAssets( id, type );
};
return (
@@ -148,10 +110,7 @@ export default function FinalUrlCard( {
onClick={ handleReselectClick }
/>
) : (
-
+
) }
diff --git a/js/src/components/paid-ads/asset-group/asset-group.js b/js/src/components/paid-ads/asset-group/asset-group.js
index d04998a973..92a39b6a57 100644
--- a/js/src/components/paid-ads/asset-group/asset-group.js
+++ b/js/src/components/paid-ads/asset-group/asset-group.js
@@ -77,7 +77,7 @@ export default function AssetGroup( { campaign } ) {
isSubmitting,
isSubmitted,
submitter,
- isFetchingGenAIAssets,
+ isFetchingAssets,
} = adapter;
const currentAction = submitter?.dataset.action;
@@ -161,11 +161,10 @@ export default function AssetGroup( { campaign } ) {
) }
/>
- { isFetchingGenAIAssets && }
+
- { ! isFetchingGenAIAssets && (
+ { ! isFetchingAssets && (
<>
-
diff --git a/js/src/components/paid-ads/campaign-assets-form.js b/js/src/components/paid-ads/campaign-assets-form.js
index 4822b38fa2..d10656d4af 100644
--- a/js/src/components/paid-ads/campaign-assets-form.js
+++ b/js/src/components/paid-ads/campaign-assets-form.js
@@ -1,6 +1,9 @@
/**
* External dependencies
*/
+import apiFetch from '@wordpress/api-fetch';
+import { __ } from '@wordpress/i18n';
+import { addQueryArgs } from '@wordpress/url';
import { useState, useMemo } from '@wordpress/element';
import { isPlainObject } from 'lodash';
@@ -16,8 +19,10 @@ import useAdsCurrency from '~/hooks/useAdsCurrency';
import useBudgetRecommendation from '~/hooks/useBudgetRecommendation';
import useRaiseBudgetRecommendations from '~/hooks/useRaiseBudgetRecommendations';
import useEventPropertiesFilter from '~/hooks/useEventPropertiesFilter';
+import useDispatchCoreNotices from '~/hooks/useDispatchCoreNotices';
import { useAppDispatch } from '~/data';
import { FILTER_BUDGET_RECOMMENDATIONS } from '~/utils/tracks';
+import { API_NAMESPACE } from '~/data/constants';
import round from '~/utils/round';
/**
@@ -196,8 +201,7 @@ export default function CampaignAssetsForm( {
...adaptiveFormProps
} ) {
const { fetchGenAIMediaAssets, fetchGenAITextAssets } = useAppDispatch();
- const [ isFetchingGenAIAssets, setIsFetchingGenAIAssets ] =
- useState( false );
+ const [ isFetchingAssets, setIsFetchingAssets ] = useState( false );
const initialAssetGroup = useMemo( () => {
return convertAssetEntityGroupToFormValues( assetEntityGroup );
}, [ assetEntityGroup ] );
@@ -211,6 +215,7 @@ export default function CampaignAssetsForm( {
const { formatAmount } = useAdsCurrency();
const { data: budgetRecommendationData, hasResolved } =
useBudgetRecommendation( countryCodes );
+ const { createNotice } = useDispatchCoreNotices();
const budgetRecommendation = budgetRecommendationData || {};
@@ -240,13 +245,26 @@ export default function CampaignAssetsForm( {
const assetGroupErrors = validateAssetGroup( formContext.values );
const finalUrl = assetEntityGroup?.[ ASSET_GROUP_KEY.FINAL_URL ];
- const fetchGenAIAssets = async ( url, assetGroupValues ) => {
+ const fetchAssets = async ( id, type ) => {
try {
- setIsFetchingGenAIAssets( true );
- const { data: textAssetsData } =
- await fetchGenAITextAssets( url );
- const { data: mediaAssetsData } =
- await fetchGenAIMediaAssets( url );
+ setIsFetchingAssets( true );
+
+ const path = addQueryArgs(
+ `${ API_NAMESPACE }/assets/suggestions`,
+ {
+ id,
+ type,
+ }
+ );
+
+ const assetSuggestions = await apiFetch( { path } );
+ const url = assetSuggestions[ ASSET_GROUP_KEY.FINAL_URL ];
+
+ const [ { data: textAssetsData }, { data: mediaAssetsData } ] =
+ await Promise.all( [
+ fetchGenAITextAssets( url ),
+ fetchGenAIMediaAssets( url ),
+ ] );
const hasSuggestedTextAssets = hasValidAIGeneratedAssets(
REQUIRED_TEXT_ASSET_KEYS,
@@ -258,21 +276,23 @@ export default function CampaignAssetsForm( {
mediaAssetsData
);
- const nextValues = {
- ...( hasSuggestedTextAssets ? textAssetsData : {} ),
- };
-
- if ( Object.keys( nextValues ).length ) {
- formContext.setValues( {
- ...assetGroupValues,
- ...nextValues,
- } );
- }
-
setHasAISuggestedTextAssets( hasSuggestedTextAssets );
setHasAISuggestedMediaAssets( hasSuggestedMediaAssets );
+
+ return {
+ ...assetSuggestions,
+ ...( hasSuggestedTextAssets ? textAssetsData : {} ),
+ };
+ } catch ( error ) {
+ createNotice(
+ 'error',
+ __(
+ 'Unable to load assets data.',
+ 'google-listings-and-ads'
+ )
+ );
} finally {
- setIsFetchingGenAIAssets( false );
+ setIsFetchingAssets( false );
}
};
@@ -317,19 +337,12 @@ export default function CampaignAssetsForm( {
setHasAISuggestedTextAssets( false );
setHasAISuggestedMediaAssets( false );
- if ( nextAssetGroup.final_url ) {
- fetchGenAIAssets(
- nextAssetGroup.final_url,
- updatedContextValues
- );
- }
-
formContext.adapter.hideValidation();
},
- isFetchingGenAIAssets,
+ isFetchingAssets,
hasAISuggestedTextAssets,
hasAISuggestedMediaAssets,
- fetchGenAIAssets,
+ fetchAssets,
};
};
diff --git a/js/src/pages/create-paid-ads-campaign/index.js b/js/src/pages/create-paid-ads-campaign/index.js
index 5ab3a57395..873ca2db3b 100644
--- a/js/src/pages/create-paid-ads-campaign/index.js
+++ b/js/src/pages/create-paid-ads-campaign/index.js
@@ -153,13 +153,10 @@ const CreatePaidAdsCampaign = () => {
) }
context={ eventContext }
continueButton={ ( formContext ) => {
- const { adapter } = formContext;
-
return (
{
- adapter.fetchGenAIAssets();
handleContinueClick(
STEP.ASSET_GROUP
);
diff --git a/js/src/pages/edit-paid-ads-campaign/index.js b/js/src/pages/edit-paid-ads-campaign/index.js
index 7de3f8fdb8..f544c7e6cc 100644
--- a/js/src/pages/edit-paid-ads-campaign/index.js
+++ b/js/src/pages/edit-paid-ads-campaign/index.js
@@ -220,6 +220,7 @@ const EditPaidAdsCampaign = () => {
getHistory().push( getDashboardUrl() );
};
+ assetEntityGroup.final_url = 'https://asvin-10upv2.ngrok.app/shop/';
return (
<>
Date: Wed, 28 Jan 2026 10:38:03 +0400
Subject: [PATCH 086/123] fix(paid-ads): Adjust asset group editor logic for
editing state
---
.../paid-ads/asset-group/asset-group-editor/texts-editor.js | 2 +-
.../asset-group/asset-group-header/asset-group-header.js | 5 +++--
js/src/pages/edit-paid-ads-campaign/index.js | 1 -
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
index 5c1feebe95..e2b510048d 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
@@ -221,7 +221,7 @@ export default function TextsEditor( {
onClick={ handleAddClick }
/>
- { emptyFieldsCount > 0 && (
+ { emptyFieldsCount > 0 && generateButtonText && (
{
- console.log( adapter.baseAssetGroup[ ASSET_FORM_KEY.FINAL_URL ] );
async function loadAssets() {
if (
hasLoadedInitialHomepageAssetsRef.current ||
- adapter.baseAssetGroup[ ASSET_FORM_KEY.FINAL_URL ]
+ adapter.baseAssetGroup[ ASSET_FORM_KEY.FINAL_URL ] ||
+ isEditing
) {
return;
}
diff --git a/js/src/pages/edit-paid-ads-campaign/index.js b/js/src/pages/edit-paid-ads-campaign/index.js
index f544c7e6cc..7de3f8fdb8 100644
--- a/js/src/pages/edit-paid-ads-campaign/index.js
+++ b/js/src/pages/edit-paid-ads-campaign/index.js
@@ -220,7 +220,6 @@ const EditPaidAdsCampaign = () => {
getHistory().push( getDashboardUrl() );
};
- assetEntityGroup.final_url = 'https://asvin-10upv2.ngrok.app/shop/';
return (
<>
Date: Wed, 28 Jan 2026 10:58:36 +0400
Subject: [PATCH 087/123] build: Remove unused import and adjust commons.js
maxSize
---
js/src/components/paid-ads/asset-group/asset-group.js | 1 -
package.json | 2 +-
2 files changed, 1 insertion(+), 2 deletions(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group.js b/js/src/components/paid-ads/asset-group/asset-group.js
index 92a39b6a57..2b2c0b45f3 100644
--- a/js/src/components/paid-ads/asset-group/asset-group.js
+++ b/js/src/components/paid-ads/asset-group/asset-group.js
@@ -18,7 +18,6 @@ import { recordGlaEvent } from '~/utils/tracks';
import useTargetAudienceFinalCountryCodes from '~/hooks/useTargetAudienceFinalCountryCodes';
import AssetGroupHeader from './asset-group-header';
import AssetGroupEditor from './asset-group-editor';
-import GenAIProgress from '../gen-ai-progress';
import { upsertActionedCampaign } from '~/utils/actionedCampaignsCache';
import './asset-group.scss';
diff --git a/package.json b/package.json
index e364c39f94..71481adc27 100644
--- a/package.json
+++ b/package.json
@@ -153,7 +153,7 @@
},
{
"path": "./js/build/commons.js",
- "maxSize": "63.9 kB"
+ "maxSize": "64 kB"
},
{
"path": "./js/build/vendors.js",
From 6a8b5a794884803378ceea93b04a567364dcab6c Mon Sep 17 00:00:00 2001
From: asvinb
Date: Thu, 29 Jan 2026 12:49:55 +0400
Subject: [PATCH 088/123] fix(asset-group-header): Add isEditing to effect
dependencies
---
.../asset-group/asset-group-header/asset-group-header.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-header/asset-group-header.js b/js/src/components/paid-ads/asset-group/asset-group-header/asset-group-header.js
index 72f436d5b0..c67e30e18d 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-header/asset-group-header.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-header/asset-group-header.js
@@ -66,7 +66,7 @@ export default function AssetGroupHeader() {
}
loadAssets();
- }, [ fetchCampaignAssets, adapter.baseAssetGroup ] );
+ }, [ fetchCampaignAssets, adapter.baseAssetGroup, isEditing ] );
if ( isFetchingAssets ) {
return ;
From a5d02f6f93f7c0e535721c13a8d0776efdc955a2 Mon Sep 17 00:00:00 2001
From: jjgrainger
Date: Thu, 29 Jan 2026 15:16:02 +0000
Subject: [PATCH 089/123] Provide response data in exceptions
---
src/Ads/AdsAssetGenerationService.php | 25 +++++++++++++++++++++++--
1 file changed, 23 insertions(+), 2 deletions(-)
diff --git a/src/Ads/AdsAssetGenerationService.php b/src/Ads/AdsAssetGenerationService.php
index 4b7b055b65..7dd90a2231 100644
--- a/src/Ads/AdsAssetGenerationService.php
+++ b/src/Ads/AdsAssetGenerationService.php
@@ -9,6 +9,8 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
use Automattic\WooCommerce\GoogleListingsAndAds\PluginHelper;
use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\AssetFieldType;
+use Automattic\WooCommerce\GoogleListingsAndAds\API\Google\ExceptionTrait;
+use Automattic\WooCommerce\GoogleListingsAndAds\Exception\ExceptionWithResponseData;
use Google\Ads\GoogleAds\V22\Services\GenerateTextRequest;
use Google\Ads\GoogleAds\V22\Services\GenerateImagesRequest;
use Google\Ads\GoogleAds\V22\Services\FinalUrlImageGenerationInput;
@@ -29,6 +31,7 @@ class AdsAssetGenerationService implements OptionsAwareInterface, Service {
use OptionsAwareTrait;
use PluginHelper;
+ use ExceptionTrait;
/**
* The Asset Generation Service Client.
@@ -129,7 +132,16 @@ public function generate_text( array $args = [] ): array {
return $results;
} catch ( ApiException $e ) {
do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
- throw new Exception( __( 'Unable to generate text assets.', 'google-listings-and-ads' ) . ' ' . $e->getMessage(), $e->getCode() );
+
+ $errors = $this->get_exception_errors( $e );
+
+ throw new ExceptionWithResponseData(
+ /* translators: %s Error message */
+ sprintf( __( 'Unable to generate text assets: %s', 'google-listings-and-ads' ), reset( $errors ) ),
+ $this->map_grpc_code_to_http_status_code( $e ),
+ $e,
+ [ 'errors' => $errors ]
+ );
}
}
@@ -192,7 +204,16 @@ public function generate_images( array $args = [] ): array {
return $results;
} catch ( ApiException $e ) {
do_action( 'woocommerce_gla_ads_client_exception', $e, __METHOD__ );
- throw new Exception( __( 'Unable to generate image assets.', 'google-listings-and-ads' ) . ' ' . $e->getMessage(), $e->getCode() );
+
+ $errors = $this->get_exception_errors( $e );
+
+ throw new ExceptionWithResponseData(
+ /* translators: %s Error message */
+ sprintf( __( 'Unable to generate image assets: %s', 'google-listings-and-ads' ), reset( $errors ) ),
+ $this->map_grpc_code_to_http_status_code( $e ),
+ $e,
+ [ 'errors' => $errors ]
+ );
}
}
From 4185b9f3fa65f10d879dc382eb194cc5e05c94a8 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 30 Jan 2026 17:55:24 +0400
Subject: [PATCH 090/123] feat(data): Apply character limits to GenAI text
assets
---
js/src/data/adapters.js | 11 +++++-
js/src/data/utils.js | 84 +++++++++++++++++++++++++++++++++++++++++
2 files changed, 93 insertions(+), 2 deletions(-)
diff --git a/js/src/data/adapters.js b/js/src/data/adapters.js
index 35a51d73a6..925236e3a3 100644
--- a/js/src/data/adapters.js
+++ b/js/src/data/adapters.js
@@ -3,7 +3,10 @@
*/
import { ASSET_TEXT_SPECS } from '~/components/paid-ads/assetSpecs';
import getCharacterCounter from '~/utils/getCharacterCounter';
-import { convertKeysFromSnakeCaseToCamelCase } from './utils';
+import {
+ convertKeysFromSnakeCaseToCamelCase,
+ applyAssetTextCharacterLimits,
+} from './utils';
/**
* @typedef {import('~/data/actions').Campaign} Campaign
@@ -267,7 +270,7 @@ export function adaptRaiseAdsBudgetRecommendations( rawData ) {
* Formats raw API items into a grouped object by type.
* @param {Array} items The raw items array from API.
* @param {string} valueKey The key to extract (e.g., 'text' or 'temporary_image_url').
- * @param {string} [filterType] Optional type to filter by.
+ * @param {string} [filterType] Optional type to filter by. Possible values can be headline, description, long_headline, marketing_image, square_marketing_image, portrait_marketing_image.
* @return {Object} Groups of assets keyed by their type.
*/
export function adaptGenAIAssets( items = [], valueKey, filterType ) {
@@ -290,5 +293,9 @@ export function adaptGenAIAssets( items = [], valueKey, filterType ) {
data[ type ].push( value );
}
+ if ( valueKey === 'text' ) {
+ return applyAssetTextCharacterLimits( data, ASSET_TEXT_SPECS );
+ }
+
return data;
}
diff --git a/js/src/data/utils.js b/js/src/data/utils.js
index 7aef933ca1..57cf06f65e 100644
--- a/js/src/data/utils.js
+++ b/js/src/data/utils.js
@@ -263,6 +263,90 @@ export function convertKeysFromSnakeCaseToCamelCase( data ) {
}, {} );
}
+/**
+ * Applies character limits to asset texts based on provided specifications.
+ *
+ * @param {Object} assets The asset texts to apply character limits to.
+ * @param {Array} specs The specifications defining character limits for each asset type.
+ * @return {Object} The asset texts with character limits applied.
+ */
+export function applyAssetTextCharacterLimits( assets, specs ) {
+ return Object.fromEntries(
+ Object.entries( assets ).map( ( [ type, values ] ) => {
+ const spec = specs.find( ( s ) => s.key === type );
+ if ( ! spec ) {
+ return [ type, values ];
+ }
+
+ const limits = Array.isArray( spec.maxCharacterCounts )
+ ? spec.maxCharacterCounts
+ : Array.from(
+ { length: values.length },
+ () => spec.maxCharacterCounts
+ );
+
+ const ellipsis = '…';
+
+ // Prepare positions with numeric limits (we’ll fill these first).
+ const positions = limits
+ .map( ( max, index ) =>
+ typeof max === 'number' ? { index, max } : null
+ )
+ .filter( Boolean );
+
+ // Sort positions by max ascending (tightest slots first).
+ positions.sort( ( a, b ) => a.max - b.max );
+
+ // Keep texts with their original index so ties preserve original order.
+ const texts = values.map( ( text, index ) => ( { text, index } ) );
+
+ // Sort texts by length ascending (shortest first).
+ // Tie-breaker keeps original order.
+ texts.sort(
+ ( a, b ) => a.text.length - b.text.length || a.index - b.index
+ );
+
+ const out = new Array( values.length );
+ const usedTextIndexes = new Set();
+
+ // Assign shortest texts to tightest positions.
+ for ( let i = 0; i < positions.length; i++ ) {
+ const { index: posIndex, max } = positions[ i ];
+ const picked = texts[ i ];
+
+ if ( ! picked ) {
+ break;
+ }
+
+ usedTextIndexes.add( picked.index );
+
+ if ( picked.text.length <= max ) {
+ out[ posIndex ] = picked.text;
+ } else {
+ const sliceLength = Math.max( max - ellipsis.length, 0 );
+ out[ posIndex ] =
+ picked.text.slice( 0, sliceLength ) + ellipsis;
+ }
+ }
+
+ // Fill any remaining slots (including positions without max limits)
+ // with remaining texts in original order.
+ const remainingTexts = values.filter(
+ ( _, i ) => ! usedTextIndexes.has( i )
+ );
+
+ let r = 0;
+ for ( let i = 0; i < out.length; i++ ) {
+ if ( out[ i ] === undefined ) {
+ out[ i ] = remainingTexts[ r++ ];
+ }
+ }
+
+ return [ type, out ];
+ } )
+ );
+}
+
/**
* Report fields fetched from report API.
*
From 67e3823e330d579ff5a81caf155fdb46f9677f15 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 30 Jan 2026 18:54:35 +0400
Subject: [PATCH 091/123] fix(paid-ads): Reset AI asset flags on import failure
---
js/src/components/paid-ads/campaign-assets-form.js | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/js/src/components/paid-ads/campaign-assets-form.js b/js/src/components/paid-ads/campaign-assets-form.js
index d10656d4af..52c77205c9 100644
--- a/js/src/components/paid-ads/campaign-assets-form.js
+++ b/js/src/components/paid-ads/campaign-assets-form.js
@@ -284,6 +284,9 @@ export default function CampaignAssetsForm( {
...( hasSuggestedTextAssets ? textAssetsData : {} ),
};
} catch ( error ) {
+ setHasAISuggestedTextAssets( false );
+ setHasAISuggestedMediaAssets( false );
+
createNotice(
'error',
__(
@@ -334,8 +337,6 @@ export default function CampaignAssetsForm( {
setHasImportedAssets( hasNonEmptyAssets );
setBaseAssetGroup( nextAssetGroup );
- setHasAISuggestedTextAssets( false );
- setHasAISuggestedMediaAssets( false );
formContext.adapter.hideValidation();
},
From 1e5f7b3fc386fbfb9d8edb46486ec9452505cb3a Mon Sep 17 00:00:00 2001
From: Mukesh Panchal
Date: Tue, 3 Feb 2026 14:57:02 +0530
Subject: [PATCH 092/123] Use __return_empty_array function for filter
---
tests/Unit/Product/ProductFilterTest.php | 14 ++------------
1 file changed, 2 insertions(+), 12 deletions(-)
diff --git a/tests/Unit/Product/ProductFilterTest.php b/tests/Unit/Product/ProductFilterTest.php
index 7e6e727140..dad296db08 100644
--- a/tests/Unit/Product/ProductFilterTest.php
+++ b/tests/Unit/Product/ProductFilterTest.php
@@ -77,12 +77,7 @@ public function test_filter_sync_ready_products_with_no_filters_but_failed_sync(
}
public function test_filter_sync_ready_products_with_pre_filter() {
- add_filter(
- 'woocommerce_gla_get_sync_ready_products_pre_filter',
- function ( $products ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
- return [];
- }
- );
+ add_filter( 'woocommerce_gla_get_sync_ready_products_pre_filter', '__return_empty_array' );
$this->product_helper->expects( $this->never() )->method( 'is_sync_ready' );
$this->product_helper->expects( $this->never() )->method( 'is_sync_failed_recently' );
@@ -94,12 +89,7 @@ function ( $products ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionPara
}
public function test_filter_sync_ready_products_with_post_filter() {
- add_filter(
- 'woocommerce_gla_get_sync_ready_products_filter',
- function ( $products ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.Found
- return [];
- }
- );
+ add_filter( 'woocommerce_gla_get_sync_ready_products_filter', '__return_empty_array' );
[ $product_a, $product_b, $product_c ] = $this->products;
From e514030ef163a0663a983f463d98327b5c9935cd Mon Sep 17 00:00:00 2001
From: Mukesh Panchal
Date: Tue, 3 Feb 2026 14:58:21 +0530
Subject: [PATCH 093/123] Revert unwanted changes
---
tests/Unit/Coupon/WCCouponAdapterTest.php | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/Unit/Coupon/WCCouponAdapterTest.php b/tests/Unit/Coupon/WCCouponAdapterTest.php
index d00adc985c..e8e0932aef 100644
--- a/tests/Unit/Coupon/WCCouponAdapterTest.php
+++ b/tests/Unit/Coupon/WCCouponAdapterTest.php
@@ -280,13 +280,13 @@ public function test_brand_restrictions() {
$coupon->set_product_ids( [ $product_3_id ] );
// Include brand 1 (product 1 and 2) for the coupon.
- update_post_meta( $coupon->get_id(), 'product_brands', [ $brand_1['term_id'] ] );
+ update_post_meta( $coupon->get_id(), 'product_brands', $brand_1['term_id'] );
// Exclude product 2 for the coupon.
$coupon->set_excluded_product_ids( [ $product_2_id ] );
// Exclude brand 2 (product 3) for the coupon.
- update_post_meta( $coupon->get_id(), 'exclude_product_brands', [ $brand_2['term_id'] ] );
+ update_post_meta( $coupon->get_id(), 'exclude_product_brands', $brand_2['term_id'] );
$coupon->save();
From c316bac3491f35620b54bc9006b6e518e50aa8bc Mon Sep 17 00:00:00 2001
From: Mukesh Panchal
Date: Tue, 3 Feb 2026 14:59:16 +0530
Subject: [PATCH 094/123] Update doc
---
bin/GoogleAdsCleanupServices.php | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/bin/GoogleAdsCleanupServices.php b/bin/GoogleAdsCleanupServices.php
index 6f28316c37..097d972357 100644
--- a/bin/GoogleAdsCleanupServices.php
+++ b/bin/GoogleAdsCleanupServices.php
@@ -55,7 +55,7 @@ class GoogleAdsCleanupServices {
// ConversionValueRuleService is now used in `ResourceNames::forGeoTargetConstant` in V22.
// instead of the previous BatchJobServiceClient. See:
// - https://github.com/googleads/google-ads-php/blob/v28.0.0/src/Google/Ads/GoogleAds/Util/V20/ResourceNames.php#L1433-L1439
- // - https://github.com/googleads/google-ads-php/blob/v31.1.0/src/Google/Ads/GoogleAds/Util/V22/ResourceNames.php#L1460
+ // - https://github.com/googleads/google-ads-php/blob/v31.1.0/src/Google/Ads/GoogleAds/Util/V22/ResourceNames.php#L1457-L1463
'ConversionValueRule',
];
From 5b694f3f684f2247889af61907a0570258a48f14 Mon Sep 17 00:00:00 2001
From: Mukesh Panchal
Date: Tue, 3 Feb 2026 15:02:27 +0530
Subject: [PATCH 095/123] Revert unwanted changes
---
src/Coupon/WCCouponAdapter.php | 1 -
1 file changed, 1 deletion(-)
diff --git a/src/Coupon/WCCouponAdapter.php b/src/Coupon/WCCouponAdapter.php
index cc1a8a7b67..18e53b5bf6 100644
--- a/src/Coupon/WCCouponAdapter.php
+++ b/src/Coupon/WCCouponAdapter.php
@@ -385,7 +385,6 @@ public function get_wc_coupon_id(): int {
/**
*
* @param string $targetCountry
- *
* phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
*/
public function setTargetCountry( $targetCountry ) {
From 4cc5288d1dd91530f53c4ea9a2fc1f6c8291ee48 Mon Sep 17 00:00:00 2001
From: Mukesh Panchal
Date: Tue, 3 Feb 2026 15:12:48 +0530
Subject: [PATCH 096/123] Minor fix
---
src/Coupon/WCCouponAdapter.php | 4 +++-
tests/Unit/Coupon/WCCouponAdapterTest.php | 4 ++--
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/src/Coupon/WCCouponAdapter.php b/src/Coupon/WCCouponAdapter.php
index 18e53b5bf6..f33359090f 100644
--- a/src/Coupon/WCCouponAdapter.php
+++ b/src/Coupon/WCCouponAdapter.php
@@ -383,9 +383,11 @@ public function get_wc_coupon_id(): int {
}
/**
+ * Set the target country for the coupon.
*
* @param string $targetCountry
- * phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
+ *
+ * phpcs:disable WordPress.NamingConventions.ValidVariableName.VariableNotSnakeCase
*/
public function setTargetCountry( $targetCountry ) {
// set the new target country
diff --git a/tests/Unit/Coupon/WCCouponAdapterTest.php b/tests/Unit/Coupon/WCCouponAdapterTest.php
index e8e0932aef..d00adc985c 100644
--- a/tests/Unit/Coupon/WCCouponAdapterTest.php
+++ b/tests/Unit/Coupon/WCCouponAdapterTest.php
@@ -280,13 +280,13 @@ public function test_brand_restrictions() {
$coupon->set_product_ids( [ $product_3_id ] );
// Include brand 1 (product 1 and 2) for the coupon.
- update_post_meta( $coupon->get_id(), 'product_brands', $brand_1['term_id'] );
+ update_post_meta( $coupon->get_id(), 'product_brands', [ $brand_1['term_id'] ] );
// Exclude product 2 for the coupon.
$coupon->set_excluded_product_ids( [ $product_2_id ] );
// Exclude brand 2 (product 3) for the coupon.
- update_post_meta( $coupon->get_id(), 'exclude_product_brands', $brand_2['term_id'] );
+ update_post_meta( $coupon->get_id(), 'exclude_product_brands', [ $brand_2['term_id'] ] );
$coupon->save();
From 717c3bd78b21aa602c4f0a866b13b68ee7675591 Mon Sep 17 00:00:00 2001
From: Mukesh Panchal
Date: Tue, 3 Feb 2026 15:46:53 +0530
Subject: [PATCH 097/123] Increase maxSize for build index.js
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index eb4a60c468..a7ecd51e09 100644
--- a/package.json
+++ b/package.json
@@ -149,7 +149,7 @@
},
{
"path": "./js/build/index.js",
- "maxSize": "19.2 kB"
+ "maxSize": "19.42 kB"
},
{
"path": "./js/build/commons.js",
From 762d56ef4cfb5b7191fe20ff7ae571feeafb072a Mon Sep 17 00:00:00 2001
From: Alejandro Perez Martin
Date: Wed, 4 Feb 2026 13:54:06 +0100
Subject: [PATCH 098/123] Reset selected images after adding them
---
.../asset-group/asset-group-editor/gen-ai-image-picker/index.js | 1 +
1 file changed, 1 insertion(+)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js b/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js
index 3c7fb5b6ea..d472121eac 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js
@@ -36,6 +36,7 @@ export default function GenAIImagePicker( { assetKey, onAddSelectedImages } ) {
const handleOnAddSelectedImages = () => {
onAddSelectedImages( selectedImages );
+ setSelectedImages( [] );
};
const toggleImageSelection = ( src ) => {
From 79fe9814d2cff6a8f8bdbf787ba91eb0fbb01415 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Thu, 5 Feb 2026 23:05:56 +0400
Subject: [PATCH 099/123] refactor(gen-ai): Consolidate asset generation into
new hook
---
.../paid-ads/campaign-assets-form.js | 28 ++-
js/src/constants.js | 5 +
js/src/data/actions.js | 44 +++--
js/src/hooks/useCreateGenAIAssets.js | 167 ++++++++++++++++++
4 files changed, 226 insertions(+), 18 deletions(-)
create mode 100644 js/src/hooks/useCreateGenAIAssets.js
diff --git a/js/src/components/paid-ads/campaign-assets-form.js b/js/src/components/paid-ads/campaign-assets-form.js
index 52c77205c9..1b62a35d13 100644
--- a/js/src/components/paid-ads/campaign-assets-form.js
+++ b/js/src/components/paid-ads/campaign-assets-form.js
@@ -10,7 +10,11 @@ import { isPlainObject } from 'lodash';
/**
* Internal dependencies
*/
-import { ASSET_GROUP_KEY, ASSET_FORM_KEY } from '~/constants';
+import {
+ ASSET_GROUP_KEY,
+ ASSET_FORM_KEY,
+ GEN_AI_ASSET_TYPES,
+} from '~/constants';
import AdaptiveForm from '~/components/adaptive-form';
import AppSpinner from '~/components/app-spinner';
import validateCampaign from '~/components/paid-ads/validateCampaign';
@@ -20,7 +24,7 @@ import useBudgetRecommendation from '~/hooks/useBudgetRecommendation';
import useRaiseBudgetRecommendations from '~/hooks/useRaiseBudgetRecommendations';
import useEventPropertiesFilter from '~/hooks/useEventPropertiesFilter';
import useDispatchCoreNotices from '~/hooks/useDispatchCoreNotices';
-import { useAppDispatch } from '~/data';
+import useCreateGenAIAssets from '~/hooks/useCreateGenAIAssets';
import { FILTER_BUDGET_RECOMMENDATIONS } from '~/utils/tracks';
import { API_NAMESPACE } from '~/data/constants';
import round from '~/utils/round';
@@ -200,7 +204,7 @@ export default function CampaignAssetsForm( {
countryCodes,
...adaptiveFormProps
} ) {
- const { fetchGenAIMediaAssets, fetchGenAITextAssets } = useAppDispatch();
+ const [ generateGenAIAssets ] = useCreateGenAIAssets();
const [ isFetchingAssets, setIsFetchingAssets ] = useState( false );
const initialAssetGroup = useMemo( () => {
return convertAssetEntityGroupToFormValues( assetEntityGroup );
@@ -260,11 +264,19 @@ export default function CampaignAssetsForm( {
const assetSuggestions = await apiFetch( { path } );
const url = assetSuggestions[ ASSET_GROUP_KEY.FINAL_URL ];
- const [ { data: textAssetsData }, { data: mediaAssetsData } ] =
- await Promise.all( [
- fetchGenAITextAssets( url ),
- fetchGenAIMediaAssets( url ),
- ] );
+ if ( ! url ) {
+ return assetSuggestions;
+ }
+
+ const generatedGenAIAssets = await generateGenAIAssets( url, [
+ { type: GEN_AI_ASSET_TYPES.TEXT },
+ { type: GEN_AI_ASSET_TYPES.MEDIA },
+ ] );
+
+ const textAssetsData =
+ generatedGenAIAssets[ GEN_AI_ASSET_TYPES.TEXT ];
+ const mediaAssetsData =
+ generatedGenAIAssets[ GEN_AI_ASSET_TYPES.MEDIA ];
const hasSuggestedTextAssets = hasValidAIGeneratedAssets(
REQUIRED_TEXT_ASSET_KEYS,
diff --git a/js/src/constants.js b/js/src/constants.js
index b6dd22737d..5f59eaa0ea 100644
--- a/js/src/constants.js
+++ b/js/src/constants.js
@@ -145,3 +145,8 @@ export const CAMPAIGN_BUDGET = 'CAMPAIGN_BUDGET';
export const MARGINAL_ROI_CAMPAIGN_BUDGET = 'MARGINAL_ROI_CAMPAIGN_BUDGET';
export const PMAX_IMPROVE_PERFORMANCE_MAX_AD_STRENGTH =
'IMPROVE_PERFORMANCE_MAX_AD_STRENGTH';
+
+export const GEN_AI_ASSET_TYPES = {
+ TEXT: 'text',
+ MEDIA: 'media',
+};
diff --git a/js/src/data/actions.js b/js/src/data/actions.js
index 678d2f4bf6..c4d42d2282 100644
--- a/js/src/data/actions.js
+++ b/js/src/data/actions.js
@@ -1284,6 +1284,22 @@ export function* receiveAdsRecommendations(
};
}
+export function* receiveGenAIMediaAssets( url, data, assetType ) {
+ if ( ! data?.items ) {
+ return {
+ type: TYPES.RECEIVE_GEN_AI_MEDIA_ASSETS,
+ url,
+ data: {},
+ };
+ }
+
+ return {
+ type: TYPES.RECEIVE_GEN_AI_MEDIA_ASSETS,
+ url,
+ data: adaptGenAIAssets( data.items, 'temporary_image_url', assetType ),
+ };
+}
+
/**
* Fetches Gen AI media assets. If no asset type is provided, it will fetch all asset types.
*
@@ -1309,11 +1325,7 @@ export function* fetchGenAIMediaAssets( url, assetType ) {
assetType
);
- return {
- type: TYPES.RECEIVE_GEN_AI_MEDIA_ASSETS,
- url,
- data: formattedData,
- };
+ return yield receiveGenAIMediaAssets( url, formattedData );
} catch ( error ) {
handleApiError(
error,
@@ -1326,6 +1338,22 @@ export function* fetchGenAIMediaAssets( url, assetType ) {
}
}
+export function* receiveGenAITextAssets( url, data, assetType ) {
+ if ( ! data?.items ) {
+ return {
+ type: TYPES.RECEIVE_GEN_AI_TEXT_ASSETS,
+ url,
+ data: {},
+ };
+ }
+
+ return {
+ type: TYPES.RECEIVE_GEN_AI_TEXT_ASSETS,
+ url,
+ data: adaptGenAIAssets( data.items, 'text', assetType ),
+ };
+}
+
/**
* Fetches Gen AI text assets. If no asset type is provided, it will fetch all asset types.
*
@@ -1350,11 +1378,7 @@ export function* fetchGenAITextAssets( url, assetType ) {
assetType
);
- return {
- type: TYPES.RECEIVE_GEN_AI_TEXT_ASSETS,
- url,
- data: formattedData,
- };
+ return yield receiveGenAITextAssets( url, formattedData );
} catch ( error ) {
handleApiError(
error,
diff --git a/js/src/hooks/useCreateGenAIAssets.js b/js/src/hooks/useCreateGenAIAssets.js
new file mode 100644
index 0000000000..a55f4536a2
--- /dev/null
+++ b/js/src/hooks/useCreateGenAIAssets.js
@@ -0,0 +1,167 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+import { useCallback, useState } from '@wordpress/element';
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Internal dependencies
+ */
+import { GEN_AI_ASSET_TYPES } from '~/constants';
+import { useAppDispatch } from '~/data';
+import { API_NAMESPACE, REQUEST_ACTIONS } from '~/data/constants';
+import useDispatchCoreNotices from '~/hooks/useDispatchCoreNotices';
+
+const useCreateGenAIAssets = () => {
+ const [ loading, setLoading ] = useState( false );
+ const { createNotice } = useDispatchCoreNotices();
+ const { receiveGenAITextAssets, receiveGenAIMediaAssets } =
+ useAppDispatch();
+
+ /**
+ * Helper function to process Gen AI API responses, handling both success and error cases.
+ *
+ * @param {Object} result - The result object from Promise.allSettled.
+ * @return {Object|null} - The parsed JSON data from the response, or null if there was an error.
+ */
+ const processGenAIResponse = useCallback(
+ async ( result ) => {
+ // Handle rejected promises (Network errors or apiFetch-thrown errors)
+ if ( result.status === 'rejected' ) {
+ const errorResponse = result.reason;
+
+ // Silently handle 400 errors (URL not eligible for suggestions)
+ if ( errorResponse && errorResponse.status === 400 ) {
+ return null;
+ }
+
+ createNotice(
+ 'error',
+ errorResponse?.statusText ||
+ __(
+ 'Unable to load AI-generated assets suggestions.',
+ 'google-listings-and-ads'
+ )
+ );
+
+ return null;
+ }
+
+ const response = result.value;
+ try {
+ const responseClone = response.clone();
+ return await responseClone.json();
+ } catch ( e ) {
+ createNotice(
+ 'error',
+ __(
+ 'An error occurred while processing AI-generated assets suggestions.',
+ 'google-listings-and-ads'
+ )
+ );
+ return null;
+ }
+ },
+ [ createNotice ]
+ );
+
+ /**
+ * Generates Gen AI assets based on the provided URL and asset requests.
+ *
+ * @param {string} url - The final URL for which to generate assets.
+ * @param {Array} requests - An array of asset generation requests, each containing a type and an optional assetKey. type can be 'text' or 'media'. assetKey can be 'headline' for text or 'marketing_image' for media, or it can be undefined to fetch all types.
+ * @return {Promise} - A promise that resolves to the generated assets data, or undefined if no requests are processed.
+ */
+ const generateAssets = useCallback(
+ async ( url, requests = [] ) => {
+ if ( ! url || requests.length === 0 ) {
+ return;
+ }
+
+ setLoading( true );
+
+ // Initialize as empty arrays to avoid overwriting multiple requests of same type
+ const generatedAssets = {
+ [ GEN_AI_ASSET_TYPES.TEXT ]: [],
+ [ GEN_AI_ASSET_TYPES.MEDIA ]: [],
+ };
+
+ try {
+ const promises = requests.map( ( request ) => {
+ const isText = request.type === GEN_AI_ASSET_TYPES.TEXT;
+ const path = isText
+ ? `${ API_NAMESPACE }/ads/assets/generate-text`
+ : `${ API_NAMESPACE }/ads/assets/generate-images`;
+
+ return apiFetch( {
+ path,
+ method: REQUEST_ACTIONS.POST,
+ parse: false,
+ data: {
+ final_url: url,
+ ...( request.assetKey
+ ? { types: [ request.assetKey ] }
+ : {} ),
+ },
+ } );
+ } );
+
+ const results = await Promise.allSettled( promises );
+
+ for ( let index = 0; index < results.length; index++ ) {
+ const { type, assetKey } = requests[ index ];
+ const data = await processGenAIResponse( results[ index ] );
+
+ if ( ! data || ! data.items ) {
+ continue;
+ }
+
+ if ( type === GEN_AI_ASSET_TYPES.TEXT ) {
+ const { data: textData } = receiveGenAITextAssets(
+ url,
+ data,
+ assetKey
+ );
+ // Spread into array to keep results from multiple requests
+ generatedAssets[ GEN_AI_ASSET_TYPES.TEXT ].push(
+ ...( textData || [] )
+ );
+ } else if ( type === GEN_AI_ASSET_TYPES.MEDIA ) {
+ const { data: mediaData } = receiveGenAIMediaAssets(
+ url,
+ data,
+ assetKey
+ );
+ generatedAssets[ GEN_AI_ASSET_TYPES.MEDIA ].push(
+ ...( mediaData || [] )
+ );
+ }
+ }
+
+ return generatedAssets;
+ } catch ( error ) {
+ // Catch unexpected runtime errors
+ createNotice(
+ 'error',
+ __(
+ 'An unexpected error occurred.',
+ 'google-listings-and-ads'
+ )
+ );
+ } finally {
+ setLoading( false );
+ }
+ },
+ [
+ processGenAIResponse,
+ receiveGenAITextAssets,
+ receiveGenAIMediaAssets,
+ createNotice,
+ ]
+ );
+
+ return [ generateAssets, loading ];
+};
+
+export default useCreateGenAIAssets;
From fa90d7826255fa8b18791366ff07c5fabe6f3616 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Thu, 5 Feb 2026 23:14:15 +0400
Subject: [PATCH 100/123] refactor: Use new hook for GenAI asset generation
---
.../asset-group-editor/images-selector.js | 16 ++++++-------
.../asset-group-editor/texts-editor.js | 24 +++++++++++--------
js/src/hooks/useCreateGenAIAssets.js | 8 +++----
3 files changed, 25 insertions(+), 23 deletions(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js b/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js
index 9e5b0bbc64..51d40595b0 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js
@@ -8,9 +8,10 @@ import { useState, useEffect, useRef } from '@wordpress/element';
/**
* Internal dependencies
*/
-import { useAppDispatch } from '~/data';
+import { GEN_AI_ASSET_TYPES } from '~/constants';
import useDispatchCoreNotices from '~/hooks/useDispatchCoreNotices';
import { useAdaptiveFormContext } from '~/components/adaptive-form';
+import useCreateGenAIAssets from '~/hooks/useCreateGenAIAssets';
import useCroppedImageSelector from '~/hooks/useCroppedImageSelector';
import AppTooltip from '~/components/app-tooltip';
import AssetItemActionButton, {
@@ -52,9 +53,8 @@ export default function ImagesSelector( {
} ) {
const { values } = useAdaptiveFormContext();
const updateImagesRef = useRef();
- const [ isGeneratingAssets, setIsGeneratingAssets ] = useState( false );
const [ awaitingActionImage, setAwaitingActionImage ] = useState( null );
- const { fetchGenAIMediaAssets } = useAppDispatch();
+ const [ generateAssets, isGenerating ] = useCreateGenAIAssets();
const { createNotice } = useDispatchCoreNotices();
const [ images, setImages ] = useState( () =>
// The asset images fetched from Google Ads are only URLs.
@@ -152,11 +152,11 @@ export default function ImagesSelector( {
};
const handleGenerateClick = async () => {
- setIsGeneratingAssets( true );
-
try {
const { final_url: finalUrl } = values;
- await fetchGenAIMediaAssets( finalUrl, assetKey );
+ await generateAssets( finalUrl, [
+ { type: GEN_AI_ASSET_TYPES.MEDIA, assetKey },
+ ] );
} catch ( error ) {
createNotice(
'error',
@@ -165,8 +165,6 @@ export default function ImagesSelector( {
'google-listings-and-ads'
)
);
- } finally {
- setIsGeneratingAssets( false );
}
};
@@ -191,7 +189,7 @@ export default function ImagesSelector( {
action={ ACTION_TYPES.GENERATE }
text={ generateButtonText }
onClick={ handleGenerateClick }
- loading={ isGeneratingAssets }
+ loading={ isGenerating }
/>
) }
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
index e2b510048d..6bc72ecf98 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
@@ -9,9 +9,10 @@ import GridiconCrossSmall from 'gridicons/dist/cross-small';
/**
* Internal dependencies
*/
-import { useAppDispatch } from '~/data';
+import { GEN_AI_ASSET_TYPES } from '~/constants';
import useDispatchCoreNotices from '~/hooks/useDispatchCoreNotices';
import useGenAITextAssets from '~/hooks/useGenAITextAssets';
+import useCreateGenAIAssets from '~/hooks/useCreateGenAIAssets';
import AppButton from '~/components/app-button';
import AppInputControl from '~/components/app-input-control';
import AssetItemActionButton, {
@@ -64,9 +65,8 @@ export default function TextsEditor( {
} ) {
const updateTextsRef = useRef();
const { createNotice } = useDispatchCoreNotices();
- const { fetchGenAITextAssets } = useAppDispatch();
+ const [ generateAssets, isGenerating ] = useCreateGenAIAssets();
const [ texts, setTexts ] = useState( initialTexts );
- const [ isGeneratingAssets, setIsGeneratingAssets ] = useState( false );
const { assets: genAITextAssets } = useGenAITextAssets(
finalUrl,
assetKey
@@ -112,11 +112,17 @@ export default function TextsEditor( {
};
const handleGenerateClick = async () => {
- setIsGeneratingAssets( true );
-
try {
- const response = await fetchGenAITextAssets( finalUrl, assetKey );
- const generatedTextAssets = response?.data?.[ assetKey ] ?? [];
+ const generatedAssets = await generateAssets( finalUrl, [
+ {
+ type: GEN_AI_ASSET_TYPES.TEXT,
+ assetKey,
+ },
+ ] );
+
+ const generatedTextAssets =
+ generatedAssets?.[ GEN_AI_ASSET_TYPES.TEXT ]?.[ assetKey ] ??
+ [];
const { assets: updatedTexts, updatedCount } = fillEmptyAssetSlots(
texts,
@@ -142,8 +148,6 @@ export default function TextsEditor( {
'google-listings-and-ads'
)
);
- } finally {
- setIsGeneratingAssets( false );
}
};
@@ -226,7 +230,7 @@ export default function TextsEditor( {
action={ ACTION_TYPES.GENERATE }
text={ generateButtonText }
onClick={ handleGenerateClick }
- loading={ isGeneratingAssets }
+ loading={ isGenerating }
/>
) }
diff --git a/js/src/hooks/useCreateGenAIAssets.js b/js/src/hooks/useCreateGenAIAssets.js
index a55f4536a2..2ca45e7ae1 100644
--- a/js/src/hooks/useCreateGenAIAssets.js
+++ b/js/src/hooks/useCreateGenAIAssets.js
@@ -14,7 +14,7 @@ import { API_NAMESPACE, REQUEST_ACTIONS } from '~/data/constants';
import useDispatchCoreNotices from '~/hooks/useDispatchCoreNotices';
const useCreateGenAIAssets = () => {
- const [ loading, setLoading ] = useState( false );
+ const [ isGenerating, setIsGenerating ] = useState( false );
const { createNotice } = useDispatchCoreNotices();
const { receiveGenAITextAssets, receiveGenAIMediaAssets } =
useAppDispatch();
@@ -79,7 +79,7 @@ const useCreateGenAIAssets = () => {
return;
}
- setLoading( true );
+ setIsGenerating( true );
// Initialize as empty arrays to avoid overwriting multiple requests of same type
const generatedAssets = {
@@ -150,7 +150,7 @@ const useCreateGenAIAssets = () => {
)
);
} finally {
- setLoading( false );
+ setIsGenerating( false );
}
},
[
@@ -161,7 +161,7 @@ const useCreateGenAIAssets = () => {
]
);
- return [ generateAssets, loading ];
+ return [ generateAssets, isGenerating ];
};
export default useCreateGenAIAssets;
From 2996bc853088c94497211746ea9cca87ae0e67fd Mon Sep 17 00:00:00 2001
From: asvinb
Date: Thu, 5 Feb 2026 23:15:49 +0400
Subject: [PATCH 101/123] refactor: Remove unused Gen AI asset fetching
functions
---
js/src/data/actions.js | 75 ------------------------------------------
1 file changed, 75 deletions(-)
diff --git a/js/src/data/actions.js b/js/src/data/actions.js
index c4d42d2282..620104b16b 100644
--- a/js/src/data/actions.js
+++ b/js/src/data/actions.js
@@ -1300,44 +1300,6 @@ export function* receiveGenAIMediaAssets( url, data, assetType ) {
};
}
-/**
- * Fetches Gen AI media assets. If no asset type is provided, it will fetch all asset types.
- *
- * @param {string} url The final URL for which to generate media assets.
- * @param {'marketing_image'|'square_marketing_image'|'portrait_marketing_image'|undefined} [assetType] - The type of media asset to retrieve.
- * @return {Object} Action object to save generated media assets.
- * @throws Will throw an error if the request failed.
- */
-export function* fetchGenAIMediaAssets( url, assetType ) {
- try {
- const response = yield apiFetch( {
- path: `${ API_NAMESPACE }/ads/assets/generate-images`,
- method: REQUEST_ACTIONS.POST,
- data: {
- final_url: url,
- types: assetType ? [ assetType ] : undefined,
- },
- } );
-
- const formattedData = adaptGenAIAssets(
- response.items,
- 'temporary_image_url',
- assetType
- );
-
- return yield receiveGenAIMediaAssets( url, formattedData );
- } catch ( error ) {
- handleApiError(
- error,
- __(
- 'There was an error generating media assets.',
- 'google-listings-and-ads'
- )
- );
- throw error;
- }
-}
-
export function* receiveGenAITextAssets( url, data, assetType ) {
if ( ! data?.items ) {
return {
@@ -1353,40 +1315,3 @@ export function* receiveGenAITextAssets( url, data, assetType ) {
data: adaptGenAIAssets( data.items, 'text', assetType ),
};
}
-
-/**
- * Fetches Gen AI text assets. If no asset type is provided, it will fetch all asset types.
- *
- * @param {string} url The final URL for which to generate text assets.
- * @param {'headline'|'long_headline'|'description'|undefined} [assetType] - The type of text asset to retrieve.
- * @return {Object} Action object to save generated text assets.
- */
-export function* fetchGenAITextAssets( url, assetType ) {
- try {
- const response = yield apiFetch( {
- path: `${ API_NAMESPACE }/ads/assets/generate-text`,
- method: REQUEST_ACTIONS.POST,
- data: {
- final_url: url,
- types: assetType ? [ assetType ] : undefined,
- },
- } );
-
- const formattedData = adaptGenAIAssets(
- response.items,
- 'text',
- assetType
- );
-
- return yield receiveGenAITextAssets( url, formattedData );
- } catch ( error ) {
- handleApiError(
- error,
- __(
- 'There was an error generating text assets.',
- 'google-listings-and-ads'
- )
- );
- throw error;
- }
-}
From 82c200f1ced854e3d25fab55a28d373f68f2001a Mon Sep 17 00:00:00 2001
From: asvinb
Date: Thu, 5 Feb 2026 23:59:31 +0400
Subject: [PATCH 102/123] refactor(gen-ai-assets): Store assets by type,
deduplicate, and handle errors
---
.../asset-group-editor/texts-editor.js | 10 +++
.../paid-ads/campaign-assets-form.js | 67 ++++++++++++-------
js/src/data/actions.js | 4 ++
js/src/data/reducer.js | 61 ++++++++++++++---
js/src/hooks/useCreateGenAIAssets.js | 30 +++++----
5 files changed, 125 insertions(+), 47 deletions(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
index 6bc72ecf98..2a5b36e1e1 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
@@ -123,6 +123,16 @@ export default function TextsEditor( {
const generatedTextAssets =
generatedAssets?.[ GEN_AI_ASSET_TYPES.TEXT ]?.[ assetKey ] ??
[];
+ console.log(
+ 'Generated text assets:',
+ generatedTextAssets,
+ 'for URL:',
+ finalUrl,
+ 'and asset key:',
+ assetKey,
+ 'with response:',
+ generatedAssets
+ ); // Debug log
const { assets: updatedTexts, updatedCount } = fillEmptyAssetSlots(
texts,
diff --git a/js/src/components/paid-ads/campaign-assets-form.js b/js/src/components/paid-ads/campaign-assets-form.js
index 1b62a35d13..4b16dc3ab8 100644
--- a/js/src/components/paid-ads/campaign-assets-form.js
+++ b/js/src/components/paid-ads/campaign-assets-form.js
@@ -268,33 +268,48 @@ export default function CampaignAssetsForm( {
return assetSuggestions;
}
- const generatedGenAIAssets = await generateGenAIAssets( url, [
- { type: GEN_AI_ASSET_TYPES.TEXT },
- { type: GEN_AI_ASSET_TYPES.MEDIA },
- ] );
-
- const textAssetsData =
- generatedGenAIAssets[ GEN_AI_ASSET_TYPES.TEXT ];
- const mediaAssetsData =
- generatedGenAIAssets[ GEN_AI_ASSET_TYPES.MEDIA ];
-
- const hasSuggestedTextAssets = hasValidAIGeneratedAssets(
- REQUIRED_TEXT_ASSET_KEYS,
- textAssetsData
- );
-
- const hasSuggestedMediaAssets = hasValidAIGeneratedAssets(
- REQUIRED_MEDIA_ASSET_KEYS,
- mediaAssetsData
- );
+ try {
+ const generatedGenAIAssets = await generateGenAIAssets(
+ url,
+ [
+ { type: GEN_AI_ASSET_TYPES.TEXT },
+ { type: GEN_AI_ASSET_TYPES.MEDIA },
+ ]
+ );
+
+ const textAssetsData =
+ generatedGenAIAssets[ GEN_AI_ASSET_TYPES.TEXT ];
+ const mediaAssetsData =
+ generatedGenAIAssets[ GEN_AI_ASSET_TYPES.MEDIA ];
+
+ const hasSuggestedTextAssets = hasValidAIGeneratedAssets(
+ REQUIRED_TEXT_ASSET_KEYS,
+ textAssetsData
+ );
+
+ const hasSuggestedMediaAssets = hasValidAIGeneratedAssets(
+ REQUIRED_MEDIA_ASSET_KEYS,
+ mediaAssetsData
+ );
+
+ setHasAISuggestedTextAssets( hasSuggestedTextAssets );
+ setHasAISuggestedMediaAssets( hasSuggestedMediaAssets );
+
+ return {
+ ...assetSuggestions,
+ ...( hasSuggestedTextAssets ? textAssetsData : {} ),
+ };
+ } catch ( genAIError ) {
+ createNotice(
+ 'error',
+ __(
+ 'Unable to generate AI suggested assets.',
+ 'google-listings-and-ads'
+ )
+ );
- setHasAISuggestedTextAssets( hasSuggestedTextAssets );
- setHasAISuggestedMediaAssets( hasSuggestedMediaAssets );
-
- return {
- ...assetSuggestions,
- ...( hasSuggestedTextAssets ? textAssetsData : {} ),
- };
+ return assetSuggestions;
+ }
} catch ( error ) {
setHasAISuggestedTextAssets( false );
setHasAISuggestedMediaAssets( false );
diff --git a/js/src/data/actions.js b/js/src/data/actions.js
index 620104b16b..bcbc6ec0e1 100644
--- a/js/src/data/actions.js
+++ b/js/src/data/actions.js
@@ -1289,6 +1289,7 @@ export function* receiveGenAIMediaAssets( url, data, assetType ) {
return {
type: TYPES.RECEIVE_GEN_AI_MEDIA_ASSETS,
url,
+ assetType,
data: {},
};
}
@@ -1296,6 +1297,7 @@ export function* receiveGenAIMediaAssets( url, data, assetType ) {
return {
type: TYPES.RECEIVE_GEN_AI_MEDIA_ASSETS,
url,
+ assetType,
data: adaptGenAIAssets( data.items, 'temporary_image_url', assetType ),
};
}
@@ -1305,6 +1307,7 @@ export function* receiveGenAITextAssets( url, data, assetType ) {
return {
type: TYPES.RECEIVE_GEN_AI_TEXT_ASSETS,
url,
+ assetType,
data: {},
};
}
@@ -1312,6 +1315,7 @@ export function* receiveGenAITextAssets( url, data, assetType ) {
return {
type: TYPES.RECEIVE_GEN_AI_TEXT_ASSETS,
url,
+ assetType,
data: adaptGenAIAssets( data.items, 'text', assetType ),
};
}
diff --git a/js/src/data/reducer.js b/js/src/data/reducer.js
index 85afadcc19..7f09db3e07 100644
--- a/js/src/data/reducer.js
+++ b/js/src/data/reducer.js
@@ -633,20 +633,65 @@ const reducer = ( state = DEFAULT_STATE, action ) => {
}
case TYPES.RECEIVE_GEN_AI_MEDIA_ASSETS: {
- const { url, data } = action;
-
+ const { url, data, assetType } = action;
const existingMedia = state.gen_ai_assets?.[ url ]?.media ?? {};
- return setIn( state, [ 'gen_ai_assets', url, 'media' ], {
- ...existingMedia,
- ...data,
- } );
+ let updatedMedia = {};
+
+ if ( assetType ) {
+ const currentList = existingMedia[ assetType ] ?? [];
+ const newList = data[ assetType ] ?? [];
+
+ // De-duplicate based on the 'url' property of the media item
+ const deDuplicated = [
+ ...new Map(
+ [ ...currentList, ...newList ].map( ( item ) => [
+ item.url,
+ item,
+ ] )
+ ).values(),
+ ];
+
+ updatedMedia = {
+ ...existingMedia,
+ [ assetType ]: deDuplicated,
+ };
+ } else {
+ updatedMedia = {
+ ...existingMedia,
+ ...data,
+ };
+ }
+
+ return setIn(
+ state,
+ [ 'gen_ai_assets', url, 'media' ],
+ updatedMedia
+ );
}
case TYPES.RECEIVE_GEN_AI_TEXT_ASSETS: {
- const { url, data } = action;
+ const { url, data, assetType } = action;
+ const existingText = state.gen_ai_assets?.[ url ]?.text ?? {};
+
+ const updatedText = assetType
+ ? {
+ ...existingText,
+ [ assetType ]: [
+ ...( existingText[ assetType ] ?? [] ),
+ ...( data[ assetType ] ?? [] ),
+ ],
+ }
+ : {
+ ...existingText,
+ ...data,
+ };
- return setIn( state, [ 'gen_ai_assets', url, 'text' ], data );
+ return setIn(
+ state,
+ [ 'gen_ai_assets', url, 'text' ],
+ updatedText
+ );
}
// Page will be reloaded after all accounts have been disconnected, so no need to mutate state.
diff --git a/js/src/hooks/useCreateGenAIAssets.js b/js/src/hooks/useCreateGenAIAssets.js
index 2ca45e7ae1..dbe7e3f8d5 100644
--- a/js/src/hooks/useCreateGenAIAssets.js
+++ b/js/src/hooks/useCreateGenAIAssets.js
@@ -118,24 +118,28 @@ const useCreateGenAIAssets = () => {
}
if ( type === GEN_AI_ASSET_TYPES.TEXT ) {
- const { data: textData } = receiveGenAITextAssets(
+ const { data: textData } = await receiveGenAITextAssets(
url,
data,
assetKey
);
- // Spread into array to keep results from multiple requests
- generatedAssets[ GEN_AI_ASSET_TYPES.TEXT ].push(
- ...( textData || [] )
- );
+
+ generatedAssets[ GEN_AI_ASSET_TYPES.TEXT ] = {
+ ...generatedAssets[ GEN_AI_ASSET_TYPES.TEXT ],
+ ...textData,
+ };
} else if ( type === GEN_AI_ASSET_TYPES.MEDIA ) {
- const { data: mediaData } = receiveGenAIMediaAssets(
- url,
- data,
- assetKey
- );
- generatedAssets[ GEN_AI_ASSET_TYPES.MEDIA ].push(
- ...( mediaData || [] )
- );
+ const { data: mediaData } =
+ await receiveGenAIMediaAssets(
+ url,
+ data,
+ assetKey
+ );
+
+ generatedAssets[ GEN_AI_ASSET_TYPES.MEDIA ] = {
+ ...generatedAssets[ GEN_AI_ASSET_TYPES.MEDIA ],
+ ...mediaData,
+ };
}
}
From 87772a1063d0e869fd21b49a6b048467066b2710 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 6 Feb 2026 10:48:39 +0400
Subject: [PATCH 103/123] refactor(data): Streamline Gen AI asset merging and
cleanup
---
.../asset-group-editor/texts-editor.js | 10 -----
js/src/data/adapters.js | 4 +-
js/src/data/reducer.js | 40 +++++++------------
3 files changed, 16 insertions(+), 38 deletions(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
index 2a5b36e1e1..6bc72ecf98 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
@@ -123,16 +123,6 @@ export default function TextsEditor( {
const generatedTextAssets =
generatedAssets?.[ GEN_AI_ASSET_TYPES.TEXT ]?.[ assetKey ] ??
[];
- console.log(
- 'Generated text assets:',
- generatedTextAssets,
- 'for URL:',
- finalUrl,
- 'and asset key:',
- assetKey,
- 'with response:',
- generatedAssets
- ); // Debug log
const { assets: updatedTexts, updatedCount } = fillEmptyAssetSlots(
texts,
diff --git a/js/src/data/adapters.js b/js/src/data/adapters.js
index 925236e3a3..6861f11cf3 100644
--- a/js/src/data/adapters.js
+++ b/js/src/data/adapters.js
@@ -280,8 +280,8 @@ export function adaptGenAIAssets( items = [], valueKey, filterType ) {
const { type, [ valueKey ]: value } = item;
// Skip if:
- // 1. We have a filter and it doesn't match
- // 2. The value for the specified key is empty/null
+ // We have a filter and it doesn't match
+ // The value for the specified key is empty/null
if ( ( filterType && type !== filterType ) || ! value ) {
continue;
}
diff --git a/js/src/data/reducer.js b/js/src/data/reducer.js
index 7f09db3e07..19f14d8ca2 100644
--- a/js/src/data/reducer.js
+++ b/js/src/data/reducer.js
@@ -636,32 +636,20 @@ const reducer = ( state = DEFAULT_STATE, action ) => {
const { url, data, assetType } = action;
const existingMedia = state.gen_ai_assets?.[ url ]?.media ?? {};
- let updatedMedia = {};
-
- if ( assetType ) {
- const currentList = existingMedia[ assetType ] ?? [];
- const newList = data[ assetType ] ?? [];
-
- // De-duplicate based on the 'url' property of the media item
- const deDuplicated = [
- ...new Map(
- [ ...currentList, ...newList ].map( ( item ) => [
- item.url,
- item,
- ] )
- ).values(),
- ];
-
- updatedMedia = {
- ...existingMedia,
- [ assetType ]: deDuplicated,
- };
- } else {
- updatedMedia = {
- ...existingMedia,
- ...data,
- };
- }
+ const updatedMedia = assetType
+ ? {
+ ...existingMedia,
+ [ assetType ]: [
+ ...new Set( [
+ ...( existingMedia[ assetType ] ?? [] ),
+ ...( data[ assetType ] ?? [] ),
+ ] ),
+ ],
+ }
+ : {
+ ...existingMedia,
+ ...data,
+ };
return setIn(
state,
From 58296970aa06c5017a6ce0d48cd44f1722e64750 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 6 Feb 2026 10:53:14 +0400
Subject: [PATCH 104/123] refactor(gen-ai): Rename asset generation state and
function variables
---
.../asset-group-editor/images-selector.js | 4 ++--
.../asset-group/asset-group-editor/texts-editor.js | 4 ++--
js/src/components/paid-ads/campaign-assets-form.js | 13 +++++--------
js/src/hooks/useCreateGenAIAssets.js | 8 ++++----
4 files changed, 13 insertions(+), 16 deletions(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js b/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js
index 51d40595b0..491b6fab09 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js
@@ -54,7 +54,7 @@ export default function ImagesSelector( {
const { values } = useAdaptiveFormContext();
const updateImagesRef = useRef();
const [ awaitingActionImage, setAwaitingActionImage ] = useState( null );
- const [ generateAssets, isGenerating ] = useCreateGenAIAssets();
+ const [ generateAssets, isGeneratingAssets ] = useCreateGenAIAssets();
const { createNotice } = useDispatchCoreNotices();
const [ images, setImages ] = useState( () =>
// The asset images fetched from Google Ads are only URLs.
@@ -189,7 +189,7 @@ export default function ImagesSelector( {
action={ ACTION_TYPES.GENERATE }
text={ generateButtonText }
onClick={ handleGenerateClick }
- loading={ isGenerating }
+ loading={ isGeneratingAssets }
/>
) }
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
index 6bc72ecf98..b0096108d0 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
@@ -65,7 +65,7 @@ export default function TextsEditor( {
} ) {
const updateTextsRef = useRef();
const { createNotice } = useDispatchCoreNotices();
- const [ generateAssets, isGenerating ] = useCreateGenAIAssets();
+ const [ generateAssets, isGeneratingAssets ] = useCreateGenAIAssets();
const [ texts, setTexts ] = useState( initialTexts );
const { assets: genAITextAssets } = useGenAITextAssets(
finalUrl,
@@ -230,7 +230,7 @@ export default function TextsEditor( {
action={ ACTION_TYPES.GENERATE }
text={ generateButtonText }
onClick={ handleGenerateClick }
- loading={ isGenerating }
+ loading={ isGeneratingAssets }
/>
) }
diff --git a/js/src/components/paid-ads/campaign-assets-form.js b/js/src/components/paid-ads/campaign-assets-form.js
index 4b16dc3ab8..da31f17e7c 100644
--- a/js/src/components/paid-ads/campaign-assets-form.js
+++ b/js/src/components/paid-ads/campaign-assets-form.js
@@ -204,7 +204,7 @@ export default function CampaignAssetsForm( {
countryCodes,
...adaptiveFormProps
} ) {
- const [ generateGenAIAssets ] = useCreateGenAIAssets();
+ const [ generateAssets ] = useCreateGenAIAssets();
const [ isFetchingAssets, setIsFetchingAssets ] = useState( false );
const initialAssetGroup = useMemo( () => {
return convertAssetEntityGroupToFormValues( assetEntityGroup );
@@ -269,13 +269,10 @@ export default function CampaignAssetsForm( {
}
try {
- const generatedGenAIAssets = await generateGenAIAssets(
- url,
- [
- { type: GEN_AI_ASSET_TYPES.TEXT },
- { type: GEN_AI_ASSET_TYPES.MEDIA },
- ]
- );
+ const generatedGenAIAssets = await generateAssets( url, [
+ { type: GEN_AI_ASSET_TYPES.TEXT },
+ { type: GEN_AI_ASSET_TYPES.MEDIA },
+ ] );
const textAssetsData =
generatedGenAIAssets[ GEN_AI_ASSET_TYPES.TEXT ];
diff --git a/js/src/hooks/useCreateGenAIAssets.js b/js/src/hooks/useCreateGenAIAssets.js
index dbe7e3f8d5..95f7191467 100644
--- a/js/src/hooks/useCreateGenAIAssets.js
+++ b/js/src/hooks/useCreateGenAIAssets.js
@@ -14,7 +14,7 @@ import { API_NAMESPACE, REQUEST_ACTIONS } from '~/data/constants';
import useDispatchCoreNotices from '~/hooks/useDispatchCoreNotices';
const useCreateGenAIAssets = () => {
- const [ isGenerating, setIsGenerating ] = useState( false );
+ const [ isGeneratingAssets, setIsGeneratingAssets ] = useState( false );
const { createNotice } = useDispatchCoreNotices();
const { receiveGenAITextAssets, receiveGenAIMediaAssets } =
useAppDispatch();
@@ -79,7 +79,7 @@ const useCreateGenAIAssets = () => {
return;
}
- setIsGenerating( true );
+ setIsGeneratingAssets( true );
// Initialize as empty arrays to avoid overwriting multiple requests of same type
const generatedAssets = {
@@ -154,7 +154,7 @@ const useCreateGenAIAssets = () => {
)
);
} finally {
- setIsGenerating( false );
+ setIsGeneratingAssets( false );
}
},
[
@@ -165,7 +165,7 @@ const useCreateGenAIAssets = () => {
]
);
- return [ generateAssets, isGenerating ];
+ return [ generateAssets, isGeneratingAssets ];
};
export default useCreateGenAIAssets;
From 9c248291aed488a0f137ff3db74a3f2a535c4d3b Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 6 Feb 2026 11:01:55 +0400
Subject: [PATCH 105/123] docs(hooks): Add JSDoc to useCreateGenAIAssets hook
---
js/src/hooks/useCreateGenAIAssets.js | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
diff --git a/js/src/hooks/useCreateGenAIAssets.js b/js/src/hooks/useCreateGenAIAssets.js
index 95f7191467..38c06206cd 100644
--- a/js/src/hooks/useCreateGenAIAssets.js
+++ b/js/src/hooks/useCreateGenAIAssets.js
@@ -1,18 +1,23 @@
/**
* External dependencies
*/
+import apiFetch from '@wordpress/api-fetch';
import { __ } from '@wordpress/i18n';
import { useCallback, useState } from '@wordpress/element';
-import apiFetch from '@wordpress/api-fetch';
/**
* Internal dependencies
*/
-import { GEN_AI_ASSET_TYPES } from '~/constants';
import { useAppDispatch } from '~/data';
+import { GEN_AI_ASSET_TYPES } from '~/constants';
import { API_NAMESPACE, REQUEST_ACTIONS } from '~/data/constants';
import useDispatchCoreNotices from '~/hooks/useDispatchCoreNotices';
+/**
+ * Custom hook to generate Gen AI assets for a given URL and asset requests.
+ *
+ * @return {Array} - An array containing the generateAssets function and a boolean indicating if assets are currently being generated.
+ */
const useCreateGenAIAssets = () => {
const [ isGeneratingAssets, setIsGeneratingAssets ] = useState( false );
const { createNotice } = useDispatchCoreNotices();
From 919b355697ecf94becce1edbaa43877a9de07d8d Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 6 Feb 2026 11:21:08 +0400
Subject: [PATCH 106/123] build: Update bundle max sizes
---
package.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/package.json b/package.json
index a7ecd51e09..1ddfe5948d 100644
--- a/package.json
+++ b/package.json
@@ -149,11 +149,11 @@
},
{
"path": "./js/build/index.js",
- "maxSize": "19.42 kB"
+ "maxSize": "19.6 kB"
},
{
"path": "./js/build/commons.js",
- "maxSize": "64 kB"
+ "maxSize": "65 kB"
},
{
"path": "./js/build/vendors.js",
From e540522878a7b78f9ede36c94fc9ef629fe71f9e Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 6 Feb 2026 11:49:55 +0400
Subject: [PATCH 107/123] test(e2e): Update expected ad copy in paid campaign
tests
---
tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js b/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
index bbba8a43a5..708a9469a0 100644
--- a/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
+++ b/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
@@ -722,7 +722,7 @@ test.describe( 'Add paid campaign', () => {
longHeadlineInputsValues.length - 1
];
expect( lastValue ).toBe(
- 'Smart shopping starts right here'
+ 'Upgrade your everyday shopping experience'
);
} );
} );
@@ -785,7 +785,7 @@ test.describe( 'Add paid campaign', () => {
descriptionInputsValues.length - 1
];
expect( lastValue ).toBe(
- 'Quality products backed by great support.'
+ 'Browse top picks and enjoy exclusive savings.'
);
} );
} );
From 86df4ff4480b0537a29840d58bfc73b18e5b06e2 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Mon, 23 Feb 2026 12:45:31 +0400
Subject: [PATCH 108/123] add(paid-ads): Increase headline asset max character
count
---
js/src/components/paid-ads/assetSpecs.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/js/src/components/paid-ads/assetSpecs.js b/js/src/components/paid-ads/assetSpecs.js
index cd6c488e2e..2eb452c231 100644
--- a/js/src/components/paid-ads/assetSpecs.js
+++ b/js/src/components/paid-ads/assetSpecs.js
@@ -212,7 +212,7 @@ const ASSET_TEXT_SPECS = [
key: ASSET_FORM_KEY.HEADLINE,
min: 3,
max: 5,
- maxCharacterCounts: [ 15, 30, 30, 30, 30 ],
+ maxCharacterCounts: [ 30, 30, 30, 30, 30 ],
heading: _x(
'Headlines',
'Plural asset field name as the heading',
From d4d747ebc29419669acbcb801a75c21c46369ae7 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Mon, 23 Feb 2026 18:44:12 +0400
Subject: [PATCH 109/123] refactor(paid-ads): simplify headline max character
counts
---
js/src/components/paid-ads/assetSpecs.js | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/js/src/components/paid-ads/assetSpecs.js b/js/src/components/paid-ads/assetSpecs.js
index 2eb452c231..0bea983862 100644
--- a/js/src/components/paid-ads/assetSpecs.js
+++ b/js/src/components/paid-ads/assetSpecs.js
@@ -212,7 +212,7 @@ const ASSET_TEXT_SPECS = [
key: ASSET_FORM_KEY.HEADLINE,
min: 3,
max: 5,
- maxCharacterCounts: [ 30, 30, 30, 30, 30 ],
+ maxCharacterCounts: 30,
heading: _x(
'Headlines',
'Plural asset field name as the heading',
From e0dda579a22d8e1cac9557fa65fe5853365d3729 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Mon, 23 Feb 2026 18:52:06 +0400
Subject: [PATCH 110/123] test: Remove obsolete headline validation test
snapshot
---
.../paid-ads/__snapshots__/validateAssetGroup.test.js.snap | 6 ------
1 file changed, 6 deletions(-)
diff --git a/js/src/components/paid-ads/__snapshots__/validateAssetGroup.test.js.snap b/js/src/components/paid-ads/__snapshots__/validateAssetGroup.test.js.snap
index 1013e6a8ba..d7fb6b0b7d 100644
--- a/js/src/components/paid-ads/__snapshots__/validateAssetGroup.test.js.snap
+++ b/js/src/components/paid-ads/__snapshots__/validateAssetGroup.test.js.snap
@@ -52,12 +52,6 @@ exports[`validateAssetGroup Text assets When the first value of description is a
]
`;
-exports[`validateAssetGroup Text assets When the first value of headline is an empty string, it should not pass 1`] = `
-[
- "The headline in the first field is required",
-]
-`;
-
exports[`validateAssetGroup Text assets When the length of values.business_name is less than 1 after omitting empty strings, it should not pass 1`] = `
[
"The business name is required",
From efff038c8431ecf4aed7b876ffc933f558553680 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Mon, 23 Feb 2026 19:02:59 +0400
Subject: [PATCH 111/123] test: Remove irrelevant headline assertions from
adaptAssetGroup test
---
js/src/data/adapters.test.js | 13 -------------
1 file changed, 13 deletions(-)
diff --git a/js/src/data/adapters.test.js b/js/src/data/adapters.test.js
index 0e59a3acc0..9dd00ecd4d 100644
--- a/js/src/data/adapters.test.js
+++ b/js/src/data/adapters.test.js
@@ -345,23 +345,10 @@ describe( 'adaptAssetGroup', () => {
it( 'When the first text has an invalid character count, it should move the valid one to the first', () => {
assetGroup.assets[ DESCRIPTION ].reverse();
- assetGroup.assets[ HEADLINE ] = [
- { content: text20Count },
- { content: text30Count },
- { content: text15Count },
- { content: text10Count },
- ];
const { assets } = adaptAssetGroup( assetGroup );
const descriptions = assets[ DESCRIPTION ].map( mapContent );
- const headlines = assets[ HEADLINE ].map( mapContent );
expect( descriptions ).toEqual( [ text60Count, text90Count ] );
- expect( headlines ).toEqual( [
- text15Count,
- text20Count,
- text30Count,
- text10Count,
- ] );
} );
} );
} );
From 0853366cc76d3fd08c7cb3d7b955938176d9c725 Mon Sep 17 00:00:00 2001
From: James Morrison
Date: Mon, 9 Mar 2026 11:27:27 +0000
Subject: [PATCH 112/123] Increase max bundle size for '/js/build/index.js'.
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index d8f400e5b3..e6a6916c90 100644
--- a/package.json
+++ b/package.json
@@ -149,7 +149,7 @@
},
{
"path": "./js/build/index.js",
- "maxSize": "19.6 kB"
+ "maxSize": "19.68 kB"
},
{
"path": "./js/build/commons.js",
From b494517f43c1789e532ff0ee1311ec29cd945689 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Tue, 10 Mar 2026 13:32:33 +0400
Subject: [PATCH 113/123] Revert changes during merge conflict resolution
---
.../paid-ads/asset-group/asset-group.js | 110 +++++++++++-------
.../add-paid-campaigns.test.js | 2 +-
.../step-3-optimize-campaign.test.js | 24 ++--
...step-2-create-campaign-ads-account-only.js | 32 +++++
4 files changed, 113 insertions(+), 55 deletions(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group.js b/js/src/components/paid-ads/asset-group/asset-group.js
index c41d839eac..ccdc8d8b12 100644
--- a/js/src/components/paid-ads/asset-group/asset-group.js
+++ b/js/src/components/paid-ads/asset-group/asset-group.js
@@ -18,6 +18,7 @@ import Faqs from './faqs';
import { recordGlaEvent, CONTEXT_ADS_ONLY_ONBOARDING } from '~/utils/tracks';
import useTargetAudienceFinalCountryCodes from '~/hooks/useTargetAudienceFinalCountryCodes';
import AssetGroupHeader from './asset-group-header';
+import AssetGroupEditor from './asset-group-editor';
import { upsertActionedCampaign } from '~/utils/actionedCampaignsCache';
import useGoogleMCAccount from '~/hooks/useGoogleMCAccount';
import './asset-group.scss';
@@ -78,7 +79,13 @@ export default function AssetGroup( {
const { isValidForm, handleSubmit, adapter, values } =
useAdaptiveFormContext();
const { data: countryCodes } = useTargetAudienceFinalCountryCodes();
- const { isValidAssetGroup, isSubmitting, isSubmitted, submitter } = adapter;
+ const {
+ isValidAssetGroup,
+ isSubmitting,
+ isSubmitted,
+ submitter,
+ isFetchingAssets,
+ } = adapter;
const { hasGoogleMCConnection } = useGoogleMCAccount();
const currentAction = submitter?.dataset.action;
@@ -168,58 +175,77 @@ export default function AssetGroup( {
-
-
- { ( isCreation || adapter.isEmptyAssetEntityGroup ) &&
- hasGoogleMCConnection && (
- // Currently, the PMax Assets feature in this extension doesn't offer the function
- // to delete the asset entity group, so it needs to hide the skip button if the editing
- // asset group is not considered empty.
+ { ! isFetchingAssets && (
+ <>
+
+
+
+
+ { ( isCreation ||
+ adapter.isEmptyAssetEntityGroup ) &&
+ hasGoogleMCConnection && (
+ // Currently, the PMax Assets feature in this extension doesn't offer the function
+ // to delete the asset entity group, so it needs to hide the skip button if the editing
+ // asset group is not considered empty.
+
+ { __(
+ 'Skip this step',
+ 'google-listings-and-ads'
+ ) }
+
+ ) }
- { __(
- 'Skip this step',
- 'google-listings-and-ads'
- ) }
+ { isCreation
+ ? __(
+ 'Create campaign',
+ 'google-listings-and-ads'
+ )
+ : __(
+ 'Save changes',
+ 'google-listings-and-ads'
+ ) }
- ) }
-
- { isCreation
- ? __( 'Create campaign', 'google-listings-and-ads' )
- : __( 'Save changes', 'google-listings-and-ads' ) }
-
-
-
-
+
+
+
+ >
+ ) }
);
}
diff --git a/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js b/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
index 644df1c476..9448e3e148 100644
--- a/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
+++ b/tests/e2e/specs/add-paid-campaigns/add-paid-campaigns.test.js
@@ -577,7 +577,7 @@ test.describe( 'Add paid campaign', () => {
} );
test.describe( 'Optimize your campaign step', async () => {
- test( 'Final URL should be selected to homepage by default', async () => {
+ test( 'Final URL should be selected by default', async () => {
const finalUrlCard = createCampaignPage.getFinalUrlCard();
await expect( finalUrlCard ).toContainText(
'https://woo.com/shop/'
diff --git a/tests/e2e/specs/onboarding-ads-only/step-3-optimize-campaign.test.js b/tests/e2e/specs/onboarding-ads-only/step-3-optimize-campaign.test.js
index 4668f323ee..c4251a33b6 100644
--- a/tests/e2e/specs/onboarding-ads-only/step-3-optimize-campaign.test.js
+++ b/tests/e2e/specs/onboarding-ads-only/step-3-optimize-campaign.test.js
@@ -108,7 +108,18 @@ test.describe( 'Optimize campaign for Ads only merchants', () => {
} );
test.describe( 'Optimize campaign', () => {
- test( 'Create Campaign button should be disabled if no URL selected', async () => {
+ test( 'Final URL should be selected by default', async () => {
+ const finalUrlCard = createCampaignPage.getFinalUrlCard();
+ await expect( finalUrlCard ).toContainText(
+ 'https://woo.com/shop/'
+ );
+ } );
+
+ test( 'Selecting the "Or, select a different Final URL" button disables the Create Campaign button', async () => {
+ const selectDifferentFinalUrlButton =
+ optimizeCampaignPage.getSelectDifferentFinalUrlButton();
+ await selectDifferentFinalUrlButton.click();
+
const createCampaignButton =
optimizeCampaignPage.getCreateCampaignButton();
await expect( createCampaignButton ).toBeDisabled();
@@ -122,23 +133,12 @@ test.describe( 'Optimize campaign for Ads only merchants', () => {
await expect( createCampaignButton ).toBeEnabled();
} );
- test( 'Selecting the "Or, select a different Final URL" button disables the Create Campaign button', async () => {
- const selectDifferentFinalUrlButton =
- optimizeCampaignPage.getSelectDifferentFinalUrlButton();
- await selectDifferentFinalUrlButton.click();
-
- const createCampaignButton =
- optimizeCampaignPage.getCreateCampaignButton();
- await expect( createCampaignButton ).toBeDisabled();
- } );
-
test( '"Skip this step" button should not be present in the last step of onboarding', async () => {
const skipThisStepButton = page.locator( 'text="Skip this step"' );
await expect( skipThisStepButton ).toHaveCount( 0 );
} );
test( 'Clicking the "Create Campaign" button navigates to the dashboard and should see the setup success modal', async () => {
- await optimizeCampaignPage.selectUrlOption();
const createCampaignButton =
optimizeCampaignPage.getCreateCampaignButton();
diff --git a/tests/e2e/utils/pages/onboarding/step-2-create-campaign-ads-account-only.js b/tests/e2e/utils/pages/onboarding/step-2-create-campaign-ads-account-only.js
index bc0f9c19cb..2590aec4ad 100644
--- a/tests/e2e/utils/pages/onboarding/step-2-create-campaign-ads-account-only.js
+++ b/tests/e2e/utils/pages/onboarding/step-2-create-campaign-ads-account-only.js
@@ -38,4 +38,36 @@ export default class CreateCampaign extends CompleteCampaign {
await button.click();
await this.page.waitForLoadState( LOAD_STATE.DOM_CONTENT_LOADED );
}
+
+ /**
+ * Get final URL card.
+ *
+ * @return {import('@playwright/test').Locator} Get final URL card.
+ */
+ getFinalUrlCard() {
+ return this.page.locator( '.gla-final-url-card' );
+ }
+
+ /**
+ * Get select different final URL button.
+ *
+ * @return {import('@playwright/test').Locator} Get select different final URL button.
+ */
+ getSelectDifferentFinalUrlButton() {
+ return this.page.getByRole( 'button', {
+ name: 'Or, select a different Final URL',
+ } );
+ }
+
+ /**
+ * Get create campaign button.
+ *
+ * @return {import('@playwright/test').Locator} Get create campaign button.
+ */
+ getCreateCampaignButton() {
+ // Intentionally not using getByRole here, as another button with the same accessible name exists in the Stepper header.
+ return this.page.locator(
+ 'button[data-action="submit-campaign-and-assets"]'
+ );
+ }
}
From 0a9a59b382164835fda354047bc405b27ea18283 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Tue, 10 Mar 2026 13:57:12 +0400
Subject: [PATCH 114/123] build: Update commons.js max bundle size
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index e6a6916c90..2c8bce56d6 100644
--- a/package.json
+++ b/package.json
@@ -153,7 +153,7 @@
},
{
"path": "./js/build/commons.js",
- "maxSize": "65 kB"
+ "maxSize": "68 kB"
},
{
"path": "./js/build/vendors.js",
From 51f8d3847dcdab02fb4a1b7c84f6182165e9d792 Mon Sep 17 00:00:00 2001
From: Joe McGill
Date: Thu, 12 Mar 2026 16:21:02 -0500
Subject: [PATCH 115/123] Handle additional v22 uprades
---
src/API/Google/AdsCampaignAsset.php | 10 +++++-----
1 file changed, 5 insertions(+), 5 deletions(-)
diff --git a/src/API/Google/AdsCampaignAsset.php b/src/API/Google/AdsCampaignAsset.php
index e4e13a61a1..67ad3ecb98 100644
--- a/src/API/Google/AdsCampaignAsset.php
+++ b/src/API/Google/AdsCampaignAsset.php
@@ -6,11 +6,11 @@
use Automattic\WooCommerce\GoogleListingsAndAds\Google\Ads\GoogleAdsClient;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareInterface;
use Automattic\WooCommerce\GoogleListingsAndAds\Options\OptionsAwareTrait;
-use Google\Ads\GoogleAds\Util\V20\ResourceNames;
-use Google\Ads\GoogleAds\V20\Resources\CampaignAsset;
-use Google\Ads\GoogleAds\V20\Services\CampaignAssetOperation;
-use Google\Ads\GoogleAds\V20\Services\MutateOperation;
-use Google\Ads\GoogleAds\V20\Enums\AssetFieldTypeEnum\AssetFieldType as AssetFieldTypeEnum;
+use Google\Ads\GoogleAds\Util\V22\ResourceNames;
+use Google\Ads\GoogleAds\V22\Resources\CampaignAsset;
+use Google\Ads\GoogleAds\V22\Services\CampaignAssetOperation;
+use Google\Ads\GoogleAds\V22\Services\MutateOperation;
+use Google\Ads\GoogleAds\V22\Enums\AssetFieldTypeEnum\AssetFieldType as AssetFieldTypeEnum;
/**
* Class AdsCampaignAsset
From 012299bd025b78cd655b3733ef160df45b608bed Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 13 Mar 2026 17:17:52 +0400
Subject: [PATCH 116/123] feat(gen-ai): Allow users to skip asset generation
---
.../asset-group-editor/images-selector.js | 2 +-
.../asset-group-editor/texts-editor.js | 2 +-
.../paid-ads/campaign-assets-form.js | 9 ++++-
.../index.js} | 12 ++++++-
.../index.scss} | 4 +++
.../paid-ads/gen-ai-progress/skip-button.js | 34 +++++++++++++++++++
js/src/hooks/useCreateGenAIAssets.js | 28 +++++++++++++--
7 files changed, 84 insertions(+), 7 deletions(-)
rename js/src/components/paid-ads/{gen-ai-progress.js => gen-ai-progress/index.js} (76%)
rename js/src/components/paid-ads/{gen-ai-progress.scss => gen-ai-progress/index.scss} (84%)
create mode 100644 js/src/components/paid-ads/gen-ai-progress/skip-button.js
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js b/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js
index 491b6fab09..2de634ba3c 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/images-selector.js
@@ -54,7 +54,7 @@ export default function ImagesSelector( {
const { values } = useAdaptiveFormContext();
const updateImagesRef = useRef();
const [ awaitingActionImage, setAwaitingActionImage ] = useState( null );
- const [ generateAssets, isGeneratingAssets ] = useCreateGenAIAssets();
+ const { generateAssets, isGeneratingAssets } = useCreateGenAIAssets();
const { createNotice } = useDispatchCoreNotices();
const [ images, setImages ] = useState( () =>
// The asset images fetched from Google Ads are only URLs.
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
index b0096108d0..9328007fb8 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
@@ -65,7 +65,7 @@ export default function TextsEditor( {
} ) {
const updateTextsRef = useRef();
const { createNotice } = useDispatchCoreNotices();
- const [ generateAssets, isGeneratingAssets ] = useCreateGenAIAssets();
+ const { generateAssets, isGeneratingAssets } = useCreateGenAIAssets();
const [ texts, setTexts ] = useState( initialTexts );
const { assets: genAITextAssets } = useGenAITextAssets(
finalUrl,
diff --git a/js/src/components/paid-ads/campaign-assets-form.js b/js/src/components/paid-ads/campaign-assets-form.js
index da31f17e7c..1ea1a7b6e4 100644
--- a/js/src/components/paid-ads/campaign-assets-form.js
+++ b/js/src/components/paid-ads/campaign-assets-form.js
@@ -204,7 +204,8 @@ export default function CampaignAssetsForm( {
countryCodes,
...adaptiveFormProps
} ) {
- const [ generateAssets ] = useCreateGenAIAssets();
+ const { generateAssets, isGeneratingAssets, abortGenerateAssets } =
+ useCreateGenAIAssets();
const [ isFetchingAssets, setIsFetchingAssets ] = useState( false );
const initialAssetGroup = useMemo( () => {
return convertAssetEntityGroupToFormValues( assetEntityGroup );
@@ -274,6 +275,10 @@ export default function CampaignAssetsForm( {
{ type: GEN_AI_ASSET_TYPES.MEDIA },
] );
+ if ( ! generatedGenAIAssets ) {
+ return assetSuggestions;
+ }
+
const textAssetsData =
generatedGenAIAssets[ GEN_AI_ASSET_TYPES.TEXT ];
const mediaAssetsData =
@@ -365,9 +370,11 @@ export default function CampaignAssetsForm( {
formContext.adapter.hideValidation();
},
isFetchingAssets,
+ isGeneratingAssets,
hasAISuggestedTextAssets,
hasAISuggestedMediaAssets,
fetchAssets,
+ abortGenerateAssets,
};
};
diff --git a/js/src/components/paid-ads/gen-ai-progress.js b/js/src/components/paid-ads/gen-ai-progress/index.js
similarity index 76%
rename from js/src/components/paid-ads/gen-ai-progress.js
rename to js/src/components/paid-ads/gen-ai-progress/index.js
index a1dadd304d..7784880860 100644
--- a/js/src/components/paid-ads/gen-ai-progress.js
+++ b/js/src/components/paid-ads/gen-ai-progress/index.js
@@ -9,8 +9,14 @@ import { ProgressBar } from '@wordpress/components';
* Internal dependencies
*/
import ProgressGraphics from '~/images/pmax-assets-improvements/gen-ai-progress.svg';
-import './gen-ai-progress.scss';
+import SkipButton from './skip-button';
+import './index.scss';
+/**
+ * Component to display the progress of Gen AI asset generation, including a progress bar and a skip button.
+ *
+ * @return {JSX.Element} The GenAIProgress component.
+ */
const GenAIProgress = () => {
return (
@@ -34,6 +40,10 @@ const GenAIProgress = () => {
'google-listings-and-ads'
) }
+
+
+
+
);
diff --git a/js/src/components/paid-ads/gen-ai-progress.scss b/js/src/components/paid-ads/gen-ai-progress/index.scss
similarity index 84%
rename from js/src/components/paid-ads/gen-ai-progress.scss
rename to js/src/components/paid-ads/gen-ai-progress/index.scss
index 50c4b0d225..d3ca3757fa 100644
--- a/js/src/components/paid-ads/gen-ai-progress.scss
+++ b/js/src/components/paid-ads/gen-ai-progress/index.scss
@@ -21,4 +21,8 @@
margin: $grid-unit-20 0 0;
}
}
+
+ .gen-ai-progress__actions {
+ min-height: $gla-size-control-height;
+ }
}
diff --git a/js/src/components/paid-ads/gen-ai-progress/skip-button.js b/js/src/components/paid-ads/gen-ai-progress/skip-button.js
new file mode 100644
index 0000000000..5f2a330600
--- /dev/null
+++ b/js/src/components/paid-ads/gen-ai-progress/skip-button.js
@@ -0,0 +1,34 @@
+/**
+ * External dependencies
+ */
+import { __ } from '@wordpress/i18n';
+
+/**
+ * Internal dependencies
+ */
+import { useAdaptiveFormContext } from '~/components/adaptive-form';
+import AppButton from '~/components/app-button';
+
+/**
+ * Component for the skip button displayed during Gen AI asset generation progress.
+ *
+ * This button allows users to abort the asset generation process if they choose to skip it.
+ *
+ * @return {JSX.Element|null} The SkipButton component, or null if not currently generating assets.
+ */
+const SkipButton = () => {
+ const { adapter } = useAdaptiveFormContext();
+ const { abortGenerateAssets, isGeneratingAssets } = adapter;
+
+ if ( ! isGeneratingAssets ) {
+ return null;
+ }
+
+ return (
+
+ { __( 'Skip', 'google-listings-and-ads' ) }
+
+ );
+};
+
+export default SkipButton;
diff --git a/js/src/hooks/useCreateGenAIAssets.js b/js/src/hooks/useCreateGenAIAssets.js
index 38c06206cd..866a46a9b9 100644
--- a/js/src/hooks/useCreateGenAIAssets.js
+++ b/js/src/hooks/useCreateGenAIAssets.js
@@ -3,7 +3,7 @@
*/
import apiFetch from '@wordpress/api-fetch';
import { __ } from '@wordpress/i18n';
-import { useCallback, useState } from '@wordpress/element';
+import { useCallback, useState, useRef } from '@wordpress/element';
/**
* Internal dependencies
@@ -16,14 +16,24 @@ import useDispatchCoreNotices from '~/hooks/useDispatchCoreNotices';
/**
* Custom hook to generate Gen AI assets for a given URL and asset requests.
*
- * @return {Array} - An array containing the generateAssets function and a boolean indicating if assets are currently being generated.
+ * @return {Object} An object containing the generateAssets function, isGeneratingAssets boolean, and abortGenerateAssets function.
*/
const useCreateGenAIAssets = () => {
const [ isGeneratingAssets, setIsGeneratingAssets ] = useState( false );
const { createNotice } = useDispatchCoreNotices();
+ const abortControllerRef = useRef( null );
const { receiveGenAITextAssets, receiveGenAIMediaAssets } =
useAppDispatch();
+ /**
+ * Aborts any ongoing Gen AI asset generation requests.
+ */
+ const abortGenerateAssets = useCallback( () => {
+ if ( abortControllerRef.current ) {
+ abortControllerRef.current.abort();
+ }
+ }, [] );
+
/**
* Helper function to process Gen AI API responses, handling both success and error cases.
*
@@ -84,6 +94,9 @@ const useCreateGenAIAssets = () => {
return;
}
+ abortControllerRef.current = new AbortController();
+ const { signal } = abortControllerRef.current;
+
setIsGeneratingAssets( true );
// Initialize as empty arrays to avoid overwriting multiple requests of same type
@@ -101,6 +114,7 @@ const useCreateGenAIAssets = () => {
return apiFetch( {
path,
+ signal,
method: REQUEST_ACTIONS.POST,
parse: false,
data: {
@@ -114,6 +128,10 @@ const useCreateGenAIAssets = () => {
const results = await Promise.allSettled( promises );
+ if ( signal.aborted ) {
+ return;
+ }
+
for ( let index = 0; index < results.length; index++ ) {
const { type, assetKey } = requests[ index ];
const data = await processGenAIResponse( results[ index ] );
@@ -150,6 +168,10 @@ const useCreateGenAIAssets = () => {
return generatedAssets;
} catch ( error ) {
+ if ( signal.aborted ) {
+ return;
+ }
+
// Catch unexpected runtime errors
createNotice(
'error',
@@ -170,7 +192,7 @@ const useCreateGenAIAssets = () => {
]
);
- return [ generateAssets, isGeneratingAssets ];
+ return { generateAssets, isGeneratingAssets, abortGenerateAssets };
};
export default useCreateGenAIAssets;
From d8aba267e8ab56de475b86f578a884029d110e37 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 13 Mar 2026 17:19:35 +0400
Subject: [PATCH 117/123] style(gen-ai-progress): Add top margin to actions
---
js/src/components/paid-ads/gen-ai-progress/index.scss | 1 +
1 file changed, 1 insertion(+)
diff --git a/js/src/components/paid-ads/gen-ai-progress/index.scss b/js/src/components/paid-ads/gen-ai-progress/index.scss
index d3ca3757fa..4822ecf9b7 100644
--- a/js/src/components/paid-ads/gen-ai-progress/index.scss
+++ b/js/src/components/paid-ads/gen-ai-progress/index.scss
@@ -23,6 +23,7 @@
}
.gen-ai-progress__actions {
+ margin-block-start: $grid-unit-10;
min-height: $gla-size-control-height;
}
}
From e4e5946187a1d2b1d4e8277d5a06563f31b40720 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 13 Mar 2026 17:51:56 +0400
Subject: [PATCH 118/123] feat: Add analytics events to Gen AI buttons
---
.../gen-ai-image-picker/index.js | 17 +++++++++++++++++
.../asset-group-editor/texts-editor.js | 15 +++++++++++++++
.../paid-ads/gen-ai-progress/skip-button.js | 14 +++++++++++++-
3 files changed, 45 insertions(+), 1 deletion(-)
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js b/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js
index d472121eac..635406df0c 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js
@@ -19,10 +19,21 @@ import AppButton from '~/components/app-button';
import AIIcon from '~/images/ai-icon.svg?inline';
import './index.scss';
+/**
+ * Triggered when the "Add selected images" button is clicked.
+ *
+ * @event gla_gen_ai_image_picker_add_selected_images_click
+ * @property {string} finalUrl The final URL for which the images were generated.
+ * @property {string} assetKey The asset key for which the images were generated.
+ * @property {number} numberOfSelectedImages The number of images that were selected to be added.
+ */
+
/**
* GenAIImagePicker component.
* Allows users to pick AI-generated images based on the final URL and the spec type.
*
+ * @fires gla_gen_ai_image_picker_add_selected_images_click when the "Add selected images" button is clicked.
+ *
* @param {Object} props Component props.
* @param {string} props.assetKey Asset key.
* @param {Function} props.onAddSelectedImages Callback to add selected images.
@@ -124,6 +135,12 @@ export default function GenAIImagePicker( { assetKey, onAddSelectedImages } ) {
) }
onClick={ handleOnAddSelectedImages }
disabled={ selectedImages.length === 0 }
+ eventName="gla_gen_ai_image_picker_add_selected_images_click"
+ eventProps={ {
+ finalUrl,
+ assetKey,
+ numberOfSelectedImages: selectedImages.length,
+ } }
/>
diff --git a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
index 9328007fb8..cf5cb02cc6 100644
--- a/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
+++ b/js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js
@@ -32,9 +32,19 @@ function normalizeNumberOfTexts( texts, minNumberOfTexts, maxNumberOfTexts ) {
return texts.concat( supplement ).slice( ...sliceArgs );
}
+/**
+ * Triggered when the generate texts button is clicked in the TextsEditor component. Event properties include finalUrl and assetKey.
+ *
+ * @event gla_texts_editor_generate_button_click
+ * @property {string} finalUrl The final URL for the ad.
+ * @property {string} assetKey The key of the text asset.
+ */
+
/**
* Renders a list of text inputs for managing the single type of asset texts.
*
+ * @fires gla_texts_editor_generate_button_click when the generate texts button is clicked in the TextsEditor component.
+ *
* @param {Object} props React props.
* @param {string} props.assetKey Key of the text asset.
* @param {string} props.finalUrl The final URL for the ad.
@@ -231,6 +241,11 @@ export default function TextsEditor( {
text={ generateButtonText }
onClick={ handleGenerateClick }
loading={ isGeneratingAssets }
+ eventName="gla_texts_editor_generate_button_click"
+ eventProps={ {
+ finalUrl,
+ assetKey,
+ } }
/>
) }
diff --git a/js/src/components/paid-ads/gen-ai-progress/skip-button.js b/js/src/components/paid-ads/gen-ai-progress/skip-button.js
index 5f2a330600..cd4e079930 100644
--- a/js/src/components/paid-ads/gen-ai-progress/skip-button.js
+++ b/js/src/components/paid-ads/gen-ai-progress/skip-button.js
@@ -9,11 +9,19 @@ import { __ } from '@wordpress/i18n';
import { useAdaptiveFormContext } from '~/components/adaptive-form';
import AppButton from '~/components/app-button';
+/**
+ * Triggered when the skip button is clicked during Gen AI asset generation progress.
+ *
+ * @event gla_gen_ai_progress_skip_button_click
+ */
+
/**
* Component for the skip button displayed during Gen AI asset generation progress.
*
* This button allows users to abort the asset generation process if they choose to skip it.
*
+ * @fires gla_gen_ai_progress_skip_button_click when the skip button is clicked.
+ *
* @return {JSX.Element|null} The SkipButton component, or null if not currently generating assets.
*/
const SkipButton = () => {
@@ -25,7 +33,11 @@ const SkipButton = () => {
}
return (
-
+
{ __( 'Skip', 'google-listings-and-ads' ) }
);
From b165c3fa4114278c54f4fd436a5fda4c9475c011 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 13 Mar 2026 17:52:17 +0400
Subject: [PATCH 119/123] docs: Add Gen AI tracking event descriptions
---
src/Tracking/README.md | 34 ++++++++++++++++++++++++++++++----
1 file changed, 30 insertions(+), 4 deletions(-)
diff --git a/src/Tracking/README.md b/src/Tracking/README.md
index 93c77ba775..f7f0ea073c 100644
--- a/src/Tracking/README.md
+++ b/src/Tracking/README.md
@@ -552,6 +552,22 @@ Saving changes of audience and/or shipping settings to the product feed.
#### Emitters
- [`exports`](../../js/src/pages/shipping/index.js#L46)
+### [`gla_gen_ai_image_picker_add_selected_images_click`](../../js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js#L22)
+Triggered when the "Add selected images" button is clicked.
+#### Properties
+| name | type | description |
+| ---- | ---- | ----------- |
+`finalUrl` | `string` | The final URL for which the images were generated.
+`assetKey` | `string` | The asset key for which the images were generated.
+`numberOfSelectedImages` | `number` | The number of images that were selected to be added.
+#### Emitters
+- [`exports`](../../js/src/components/paid-ads/asset-group/asset-group-editor/gen-ai-image-picker/index.js#L41) when the "Add selected images" button is clicked.
+
+### [`gla_gen_ai_progress_skip_button_click`](../../js/src/components/paid-ads/gen-ai-progress/skip-button.js#L12)
+Triggered when the skip button is clicked during Gen AI asset generation progress.
+#### Emitters
+- [`SkipButton`](../../js/src/components/paid-ads/gen-ai-progress/skip-button.js#L27) when the skip button is clicked.
+
### [`gla_google_account_connect_button_click`](../../js/src/utils/tracks.js#L185)
Clicking on the button to connect Google account.
#### Properties
@@ -592,14 +608,14 @@ Clicking on a Google Merchant Center link.
#### Emitters
- [`HelpIconButton`](../../js/src/components/help-icon-button/index.js#L31)
-### [`gla_import_assets_by_final_url_button_click`](../../js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js#L80)
+### [`gla_import_assets_by_final_url_button_click`](../../js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js#L83)
Clicking on the "Scan for assets" button.
#### Properties
| name | type | description |
| ---- | ---- | ----------- |
`type` | `string` | The type of the selected Final URL suggestion to be imported. Possible values: `post`, `term`, `homepage`.
#### Emitters
-- [`exports`](../../js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js#L96)
+- [`exports`](../../js/src/components/paid-ads/asset-group/asset-group-header/assets-loader.js#L99)
### [`gla_launch_paid_campaign_button_click`](../../js/src/utils/tracks.js#L173)
Triggered when the "Launch paid campaign" button is clicked to add a new paid campaign in the Google Ads setup flow.
@@ -912,10 +928,10 @@ Triggered when the request review is successful
#### Emitters
- [`ReviewRequestModal`](../../js/src/pages/product-feed/review-request/review-request-modal.js#L58)
-### [`gla_reselect_another_final_url_button_click`](../../js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js#L23)
+### [`gla_reselect_another_final_url_button_click`](../../js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js#L24)
Clicking on the "Or, select another page" button.
#### Emitters
-- [`exports`](../../js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js#L39)
+- [`exports`](../../js/src/components/paid-ads/asset-group/asset-group-header/final-url-card.js#L40)
### [`gla_setup_ads`](../../js/src/utils/tracks.js#L203)
Triggered on events during ads onboarding
@@ -1052,6 +1068,16 @@ Sorting table
- [`AppTableCard`](../../js/src/components/app-table-card/index.js#L74) upon sorting table by column
- [`recordTableSortEvent`](../../js/src/components/app-table-card/index.js#L55) with given props.
+### [`gla_texts_editor_generate_button_click`](../../js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js#L35)
+Triggered when the generate texts button is clicked in the TextsEditor component. Event properties include finalUrl and assetKey.
+#### Properties
+| name | type | description |
+| ---- | ---- | ----------- |
+`finalUrl` | `string` | The final URL for the ad.
+`assetKey` | `string` | The key of the text asset.
+#### Emitters
+- [`exports`](../../js/src/components/paid-ads/asset-group/asset-group-editor/texts-editor.js#L62) when the generate texts button is clicked in the TextsEditor component.
+
### [`gla_tooltip_viewed`](../../js/src/components/help-popover/index.js#L16)
Viewing tooltip
#### Properties
From 728841769150ec754a9136951e490457f3b4199a Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 13 Mar 2026 18:12:47 +0400
Subject: [PATCH 120/123] build: Update max size for Google Listings & Ads zip
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 2c8bce56d6..3c1938519c 100644
--- a/package.json
+++ b/package.json
@@ -169,7 +169,7 @@
},
{
"path": "./google-listings-and-ads.zip",
- "maxSize": "8.26 mB",
+ "maxSize": "8.27 mB",
"compression": "none"
}
],
From 2335f11cb1278b0d6eef71f7519cc81085f6f772 Mon Sep 17 00:00:00 2001
From: asvinb
Date: Fri, 13 Mar 2026 18:18:15 +0400
Subject: [PATCH 121/123] build: Update google-listings-and-ads.zip maxSize
---
package.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/package.json b/package.json
index 3c1938519c..89a392cf13 100644
--- a/package.json
+++ b/package.json
@@ -169,7 +169,7 @@
},
{
"path": "./google-listings-and-ads.zip",
- "maxSize": "8.27 mB",
+ "maxSize": "8.28 mB",
"compression": "none"
}
],
From 37d369d1dfbcb31a2ee54c779dd50531e81b9259 Mon Sep 17 00:00:00 2001
From: Joe McGill
Date: Fri, 13 Mar 2026 11:18:11 -0500
Subject: [PATCH 122/123] Disable brand guidelines for non-shopping campaigns.
---
src/API/Google/AdsCampaign.php | 3 +++
1 file changed, 3 insertions(+)
diff --git a/src/API/Google/AdsCampaign.php b/src/API/Google/AdsCampaign.php
index 67fa903d4c..ebd969de67 100644
--- a/src/API/Google/AdsCampaign.php
+++ b/src/API/Google/AdsCampaign.php
@@ -559,6 +559,9 @@ protected function create_operation( string $campaign_name, ?string $country, bo
'feed_label' => $country,
]
);
+ } else {
+ // Turn off brand guidelines for non-shopping campaigns.
+ $campaign_data['brand_guidelines_enabled'] = false;
}
$campaign = new Campaign( $campaign_data );
From c7655af80f53812c12f279991e5318bf1dab61f8 Mon Sep 17 00:00:00 2001
From: Joe McGill
Date: Mon, 16 Mar 2026 15:33:19 -0500
Subject: [PATCH 123/123] Bump bundlewatch
---
package.json | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/package.json b/package.json
index 89a392cf13..fd88af507c 100644
--- a/package.json
+++ b/package.json
@@ -149,11 +149,11 @@
},
{
"path": "./js/build/index.js",
- "maxSize": "19.68 kB"
+ "maxSize": "19.85 kB"
},
{
"path": "./js/build/commons.js",
- "maxSize": "68 kB"
+ "maxSize": "68.1 kB"
},
{
"path": "./js/build/vendors.js",
@@ -169,7 +169,7 @@
},
{
"path": "./google-listings-and-ads.zip",
- "maxSize": "8.28 mB",
+ "maxSize": "8.30 mB",
"compression": "none"
}
],