Merge pull request #32944 from woocommerce/add/tracks-experimental-products

Add tracks for experimental products page
This commit is contained in:
RJ 2022-05-13 12:41:48 +08:00 committed by GitHub
commit 2c5f47a91d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 284 additions and 18 deletions

View File

@ -0,0 +1,4 @@
Significance: minor
Type: Add
Changed task_view experimental_product key to variant (technically a breaking change but since it was introduced in the same version it is fine) #32944

View File

@ -8,7 +8,7 @@ import { Slot, Fill } from '@wordpress/components';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { isProductTaskExperimentTreatment } from './use-product-layout-experiment'; import { getProductLayoutExperiment } from './use-product-layout-experiment';
export const trackView = async ( taskId ) => { export const trackView = async ( taskId ) => {
const activePlugins = wp.data const activePlugins = wp.data
@ -25,9 +25,7 @@ export const trackView = async ( taskId ) => {
recordEvent( 'task_view', { recordEvent( 'task_view', {
task_name: taskId, task_name: taskId,
experimental_products: variant: await getProductLayoutExperiment(),
window.wcAdminFeatures[ 'experimental-products-task' ] &&
( await isProductTaskExperimentTreatment() ),
wcs_installed: installedPlugins.includes( 'woocommerce-services' ), wcs_installed: installedPlugins.includes( 'woocommerce-services' ),
wcs_active: activePlugins.includes( 'woocommerce-services' ), wcs_active: activePlugins.includes( 'woocommerce-services' ),
jetpack_installed: installedPlugins.includes( 'jetpack' ), jetpack_installed: installedPlugins.includes( 'jetpack' ),

View File

@ -7,6 +7,12 @@ import apiFetch from '@wordpress/api-fetch';
import { WC_ADMIN_NAMESPACE } from '@woocommerce/data'; import { WC_ADMIN_NAMESPACE } from '@woocommerce/data';
import { useDispatch } from '@wordpress/data'; import { useDispatch } from '@wordpress/data';
import { useState } from '@wordpress/element'; import { useState } from '@wordpress/element';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import useRecordCompletionTime from '../use-record-completion-time';
type UseLoadSampleProductsProps = { type UseLoadSampleProductsProps = {
redirectUrlAfterSuccess: string; redirectUrlAfterSuccess: string;
@ -17,8 +23,13 @@ const useLoadSampleProducts = ( {
}: UseLoadSampleProductsProps ) => { }: UseLoadSampleProductsProps ) => {
const [ isRequesting, setIsRequesting ] = useState< boolean >( false ); const [ isRequesting, setIsRequesting ] = useState< boolean >( false );
const { createNotice } = useDispatch( 'core/notices' ); const { createNotice } = useDispatch( 'core/notices' );
const { recordCompletionTime } = useRecordCompletionTime( 'products' );
const loadSampleProduct = async () => { const loadSampleProduct = async () => {
recordEvent( 'tasklist_add_product', {
method: 'sample_product',
} );
recordCompletionTime();
setIsRequesting( true ); setIsRequesting( true );
try { try {
await apiFetch( { await apiFetch( {

View File

@ -50,6 +50,7 @@ export const Products = () => {
const productTypeListItems = useProductTypeListItems( const productTypeListItems = useProductTypeListItems(
getProductTypes( [ 'subscription' ] ), getProductTypes( [ 'subscription' ] ),
[],
{ {
onClick: recordCompletionTime, onClick: recordCompletionTime,
} }

View File

@ -73,6 +73,8 @@ describe( 'Products', () => {
now: jest now: jest
.fn() .fn()
.mockReturnValueOnce( 0 ) .mockReturnValueOnce( 0 )
.mockReturnValueOnce( 1000 )
.mockReturnValueOnce( 0 )
.mockReturnValueOnce( 1000 ), .mockReturnValueOnce( 1000 ),
}, },
} ); } );
@ -84,14 +86,21 @@ describe( 'Products', () => {
'FROM A CSV FILE Import all products at once by uploading a CSV file.', 'FROM A CSV FILE Import all products at once by uploading a CSV file.',
} ) } )
); );
await waitFor( () => await waitFor( () => {
expect( recordEvent ).toHaveBeenCalledWith( expect( recordEvent ).toHaveBeenNthCalledWith(
1,
'tasklist_add_product',
{ method: 'import' }
);
expect( recordEvent ).toHaveBeenNthCalledWith(
2,
'task_completion_time', 'task_completion_time',
{ {
task_name: 'products', task_name: 'products',
time: '0-2s', time: '0-2s',
} }
)
); );
} ); } );
} );
} ); } );

View File

@ -6,6 +6,7 @@ import { Text } from '@woocommerce/experimental';
import { Link } from '@woocommerce/components'; import { Link } from '@woocommerce/components';
import interpolateComponents from '@automattic/interpolate-components'; import interpolateComponents from '@automattic/interpolate-components';
import { getAdminLink } from '@woocommerce/settings'; import { getAdminLink } from '@woocommerce/settings';
import { recordEvent } from '@woocommerce/tracks';
/** /**
* Internal dependencies * Internal dependencies
@ -13,6 +14,7 @@ import { getAdminLink } from '@woocommerce/settings';
import { ProductType } from './constants'; import { ProductType } from './constants';
import CardList from '../experimental-import-products/CardList'; import CardList from '../experimental-import-products/CardList';
import './card-layout.scss'; import './card-layout.scss';
import useRecordCompletionTime from '../use-record-completion-time';
type CardProps = { type CardProps = {
items: ( ProductType & { items: ( ProductType & {
@ -21,6 +23,8 @@ type CardProps = {
}; };
const CardLayout: React.FC< CardProps > = ( { items } ) => { const CardLayout: React.FC< CardProps > = ( { items } ) => {
const { recordCompletionTime } = useRecordCompletionTime( 'products' );
return ( return (
<div className="woocommerce-products-card-layout"> <div className="woocommerce-products-card-layout">
<Text className="woocommerce-products-card-layout__description"> <Text className="woocommerce-products-card-layout__description">
@ -32,6 +36,10 @@ const CardLayout: React.FC< CardProps > = ( { items } ) => {
sbLink: ( sbLink: (
<Link <Link
onClick={ () => { onClick={ () => {
recordEvent( 'tasklist_add_product', {
method: 'manually',
} );
recordCompletionTime();
window.location = getAdminLink( window.location = getAdminLink(
'post-new.php?post_type=product&wc_onboarding_active_task=products&tutorial=true' 'post-new.php?post_type=product&wc_onboarding_active_task=products&tutorial=true'
); );

View File

@ -7,8 +7,16 @@ import { Text } from '@woocommerce/experimental';
import { ExternalLink } from '@wordpress/components'; import { ExternalLink } from '@wordpress/components';
import { Link } from '@woocommerce/components'; import { Link } from '@woocommerce/components';
import { getAdminLink } from '@woocommerce/settings'; import { getAdminLink } from '@woocommerce/settings';
import { recordEvent } from '@woocommerce/tracks';
/**
* Internal dependencies
*/
import useRecordCompletionTime from '../use-record-completion-time';
const Footer: React.FC = () => { const Footer: React.FC = () => {
const { recordCompletionTime } = useRecordCompletionTime( 'products' );
return ( return (
<div className="woocommerce-products-footer"> <div className="woocommerce-products-footer">
<Text className="woocommerce-products-footer__selling-somewhere-else"> <Text className="woocommerce-products-footer__selling-somewhere-else">
@ -23,6 +31,10 @@ const Footer: React.FC = () => {
importCSVLink: ( importCSVLink: (
<Link <Link
onClick={ () => { onClick={ () => {
recordEvent( 'tasklist_add_product', {
method: 'import',
} );
recordCompletionTime();
window.location = getAdminLink( window.location = getAdminLink(
'edit.php?post_type=product&page=product_importer&wc_onboarding_active_task=products' 'edit.php?post_type=product&page=product_importer&wc_onboarding_active_task=products'
); );
@ -36,6 +48,12 @@ const Footer: React.FC = () => {
), ),
_3rdLink: ( _3rdLink: (
<ExternalLink <ExternalLink
onClick={ () => {
recordEvent( 'tasklist_add_product', {
method: 'migrate',
} );
recordCompletionTime();
} }
href="https://woocommerce.com/products/cart2cart/?utm_medium=product" href="https://woocommerce.com/products/cart2cart/?utm_medium=product"
type="external" type="external"
> >

View File

@ -26,6 +26,7 @@ import CardLayout from './card-layout';
import { LoadSampleProductType } from './constants'; import { LoadSampleProductType } from './constants';
import LoadSampleProductModal from '../components/load-sample-product-modal'; import LoadSampleProductModal from '../components/load-sample-product-modal';
import useLoadSampleProducts from '../components/use-load-sample-products'; import useLoadSampleProducts from '../components/use-load-sample-products';
import useRecordCompletionTime from '../use-record-completion-time';
const getOnboardingProductType = (): string[] => { const getOnboardingProductType = (): string[] => {
const onboardingData = getAdminSetting( 'onboarding' ); const onboardingData = getAdminSetting( 'onboarding' );
@ -57,11 +58,28 @@ export const Products = () => {
experimentLayout, experimentLayout,
] = useProductTaskExperiment(); ] = useProductTaskExperiment();
const productTypes = useProductTypeListItems( getProductTypes() );
const surfacedProductTypeKeys = getSurfacedProductTypeKeys( const surfacedProductTypeKeys = getSurfacedProductTypeKeys(
getOnboardingProductType() getOnboardingProductType()
); );
const productTypes = useProductTypeListItems(
getProductTypes(),
surfacedProductTypeKeys
);
const { recordCompletionTime } = useRecordCompletionTime( 'products' );
const productTypesWithTimeRecord = useMemo(
() =>
productTypes.map( ( productType ) => ( {
...productType,
onClick: () => {
productType.onClick();
recordCompletionTime();
},
} ) ),
[ recordCompletionTime ]
);
const { const {
loadSampleProduct, loadSampleProduct,
isLoadingSampleProducts, isLoadingSampleProducts,
@ -72,12 +90,13 @@ export const Products = () => {
} ); } );
const visibleProductTypes = useMemo( () => { const visibleProductTypes = useMemo( () => {
const surfacedProductTypes = productTypes.filter( ( productType ) => const surfacedProductTypes = productTypesWithTimeRecord.filter(
( productType ) =>
surfacedProductTypeKeys.includes( productType.key ) surfacedProductTypeKeys.includes( productType.key )
); );
if ( isExpanded ) { if ( isExpanded ) {
// To show product types in same order, we need to push the other product types to the end. // To show product types in same order, we need to push the other product types to the end.
productTypes.forEach( productTypesWithTimeRecord.forEach(
( productType ) => ( productType ) =>
! surfacedProductTypes.includes( productType ) && ! surfacedProductTypes.includes( productType ) &&
surfacedProductTypes.push( productType ) surfacedProductTypes.push( productType )

View File

@ -6,12 +6,14 @@ import { List, Link } from '@woocommerce/components';
import { Text } from '@woocommerce/experimental'; import { Text } from '@woocommerce/experimental';
import interpolateComponents from '@automattic/interpolate-components'; import interpolateComponents from '@automattic/interpolate-components';
import { getAdminLink } from '@woocommerce/settings'; import { getAdminLink } from '@woocommerce/settings';
import { recordEvent } from '@woocommerce/tracks';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import { ProductType } from './constants'; import { ProductType } from './constants';
import './stack.scss'; import './stack.scss';
import useRecordCompletionTime from '../use-record-completion-time';
type StackProps = { type StackProps = {
items: ( ProductType & { items: ( ProductType & {
@ -26,6 +28,8 @@ const Stack: React.FC< StackProps > = ( {
onClickLoadSampleProduct, onClickLoadSampleProduct,
showOtherOptions = true, showOtherOptions = true,
} ) => { } ) => {
const { recordCompletionTime } = useRecordCompletionTime( 'products' );
return ( return (
<div className="woocommerce-products-stack"> <div className="woocommerce-products-stack">
<List items={ items } /> <List items={ items } />
@ -40,6 +44,10 @@ const Stack: React.FC< StackProps > = ( {
sbLink: ( sbLink: (
<Link <Link
onClick={ () => { onClick={ () => {
recordEvent( 'tasklist_add_product', {
method: 'manually',
} );
recordCompletionTime();
window.location = getAdminLink( window.location = getAdminLink(
'post-new.php?post_type=product&wc_onboarding_active_task=products&tutorial=true' 'post-new.php?post_type=product&wc_onboarding_active_task=products&tutorial=true'
); );
@ -56,6 +64,7 @@ const Stack: React.FC< StackProps > = ( {
href="" href=""
type="wc-admin" type="wc-admin"
onClick={ () => { onClick={ () => {
recordCompletionTime();
onClickLoadSampleProduct(); onClickLoadSampleProduct();
return false; return false;
} } } }

View File

@ -1,7 +1,8 @@
/** /**
* External dependencies * External dependencies
*/ */
import { render } from '@testing-library/react'; import { fireEvent, render } from '@testing-library/react';
import { recordEvent } from '@woocommerce/tracks';
/** /**
* Internal dependencies * Internal dependencies
@ -9,7 +10,11 @@ import { render } from '@testing-library/react';
import CardLayout from '../card-layout'; import CardLayout from '../card-layout';
import { productTypes } from '../constants'; import { productTypes } from '../constants';
jest.mock( '@woocommerce/tracks', () => ( { recordEvent: jest.fn() } ) );
describe( 'CardLayout', () => { describe( 'CardLayout', () => {
beforeEach( () => {
( recordEvent as jest.Mock ).mockClear();
} );
it( 'should render all products types in CardLayout', () => { it( 'should render all products types in CardLayout', () => {
const { queryByText, queryAllByRole } = render( const { queryByText, queryAllByRole } = render(
<CardLayout <CardLayout
@ -26,4 +31,28 @@ describe( 'CardLayout', () => {
expect( queryAllByRole( 'link' ) ).toHaveLength( 1 ); expect( queryAllByRole( 'link' ) ).toHaveLength( 1 );
} ); } );
it( 'start blank link should fire the tasklist_add_product and completion events', () => {
const { getByText } = render(
<CardLayout
items={ [
{
...productTypes[ 0 ],
onClick: () => {},
},
] }
/>
);
fireEvent.click( getByText( 'Start blank' ) );
expect( recordEvent ).toHaveBeenNthCalledWith(
1,
'tasklist_add_product',
{ method: 'manually' }
);
expect( recordEvent ).toHaveBeenNthCalledWith(
2,
'task_completion_time',
{ task_name: 'products', time: '0-2s' }
);
} );
} ); } );

View File

@ -2,6 +2,10 @@
* External dependencies * External dependencies
*/ */
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { recordEvent } from '@woocommerce/tracks';
jest.mock( '@woocommerce/tracks', () => ( { recordEvent: jest.fn() } ) );
/** /**
* Internal dependencies * Internal dependencies
@ -9,8 +13,41 @@ import { render } from '@testing-library/react';
import Footer from '../footer'; import Footer from '../footer';
describe( 'Footer', () => { describe( 'Footer', () => {
beforeEach( () => {
( recordEvent as jest.Mock ).mockClear();
} );
it( 'should render footer with two links', () => { it( 'should render footer with two links', () => {
const { queryAllByRole } = render( <Footer /> ); const { queryAllByRole } = render( <Footer /> );
expect( queryAllByRole( 'link' ) ).toHaveLength( 2 ); expect( queryAllByRole( 'link' ) ).toHaveLength( 2 );
} ); } );
it( 'clicking on import CSV should fire event tasklist_add_product with method:import and task_completion_time', () => {
const { getByText } = render( <Footer /> );
userEvent.click( getByText( 'Import your products from a CSV file' ) );
expect( recordEvent ).toHaveBeenNthCalledWith(
1,
'tasklist_add_product',
{ method: 'import' }
);
expect( recordEvent ).toHaveBeenNthCalledWith(
2,
'task_completion_time',
{ task_name: 'products', time: '0-2s' }
);
} );
it( 'clicking on start blank should fire event tasklist_add_product with method:migrate and task_completion_time', () => {
const { getByText } = render( <Footer /> );
userEvent.click( getByText( 'use a 3rd party migration plugin' ) );
expect( recordEvent ).toHaveBeenNthCalledWith(
1,
'tasklist_add_product',
{ method: 'migrate' }
);
expect( recordEvent ).toHaveBeenNthCalledWith(
2,
'task_completion_time',
{ task_name: 'products', time: '0-2s' }
);
} );
} ); } );

View File

@ -4,6 +4,7 @@
import { render, waitFor } from '@testing-library/react'; import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { useProductTaskExperiment } from '@woocommerce/onboarding'; import { useProductTaskExperiment } from '@woocommerce/onboarding';
import { recordEvent } from '@woocommerce/tracks';
/** /**
* Internal dependencies * Internal dependencies
@ -25,6 +26,12 @@ jest.mock( '@woocommerce/onboarding', () => ( {
useProductTaskExperiment: jest.fn().mockReturnValue( [ false, 'stacked' ] ), useProductTaskExperiment: jest.fn().mockReturnValue( [ false, 'stacked' ] ),
} ) ); } ) );
jest.mock( '../use-create-product-by-type', () => ( {
useCreateProductByType: jest
.fn()
.mockReturnValue( { createProductByType: jest.fn() } ),
} ) );
global.fetch = jest.fn().mockImplementation( () => global.fetch = jest.fn().mockImplementation( () =>
Promise.resolve( { Promise.resolve( {
json: () => Promise.resolve( {} ), json: () => Promise.resolve( {} ),
@ -32,6 +39,8 @@ global.fetch = jest.fn().mockImplementation( () =>
} ) } )
); );
jest.mock( '@woocommerce/tracks', () => ( { recordEvent: jest.fn() } ) );
describe( 'Products', () => { describe( 'Products', () => {
beforeEach( () => { beforeEach( () => {
jest.clearAllMocks(); jest.clearAllMocks();
@ -65,6 +74,70 @@ describe( 'Products', () => {
expect( queryByText( 'View more product types' ) ).toBeInTheDocument(); expect( queryByText( 'View more product types' ) ).toBeInTheDocument();
} ); } );
it( 'clicking on suggested product should fire event tasklist_product_template_selection with is_suggested:true and task_completion_time', () => {
( getAdminSetting as jest.Mock ).mockImplementation( () => ( {
profile: {
product_types: [ 'downloads' ],
},
} ) );
const { getByRole } = render( <Products /> );
userEvent.click(
getByRole( 'menuitem', {
name:
'Digital product A digital product like service, downloadable book, music or video.',
} )
);
expect( recordEvent ).toHaveBeenNthCalledWith(
1,
'tasklist_product_template_selection',
{ is_suggested: true, product_type: 'digital' }
);
expect( recordEvent ).toHaveBeenNthCalledWith(
2,
'task_completion_time',
{ task_name: 'products', time: '0-2s' }
);
} );
it( 'clicking on not-suggested product should fire event tasklist_product_template_selection with is_suggested:false and task_completion_time', async () => {
( getAdminSetting as jest.Mock ).mockImplementation( () => ( {
profile: {
product_types: [ 'downloads' ],
},
} ) );
const { queryByText, getByRole, queryByRole } = render( <Products /> );
expect( queryByText( 'View more product types' ) ).toBeInTheDocument();
userEvent.click(
getByRole( 'button', { name: 'View more product types' } )
);
await waitFor( () =>
expect( queryByRole( 'menu' )?.childElementCount ).toBe(
productTypes.length
)
);
userEvent.click(
getByRole( 'menuitem', {
name: 'Grouped product A collection of related products.',
} )
);
expect( recordEvent ).toHaveBeenNthCalledWith(
1,
'tasklist_product_template_selection',
{ is_suggested: false, product_type: 'grouped' }
);
expect( recordEvent ).toHaveBeenNthCalledWith(
2,
'task_completion_time',
{ task_name: 'products', time: '0-2s' }
);
} );
it( 'should render all products type when clicking view more button', async () => { it( 'should render all products type when clicking view more button', async () => {
( getAdminSetting as jest.Mock ).mockImplementation( () => ( { ( getAdminSetting as jest.Mock ).mockImplementation( () => ( {
profile: { profile: {

View File

@ -3,6 +3,7 @@
*/ */
import { render, waitFor } from '@testing-library/react'; import { render, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { recordEvent } from '@woocommerce/tracks';
/** /**
* Internal dependencies * Internal dependencies
@ -10,7 +11,13 @@ import userEvent from '@testing-library/user-event';
import Stack from '../stack'; import Stack from '../stack';
import { productTypes } from '../constants'; import { productTypes } from '../constants';
jest.mock( '@woocommerce/tracks', () => ( { recordEvent: jest.fn() } ) );
describe( 'Stack', () => { describe( 'Stack', () => {
beforeEach( () => {
( recordEvent as jest.Mock ).mockClear();
} );
it( 'should render stack with given product type and two links', () => { it( 'should render stack with given product type and two links', () => {
const { queryByText, queryAllByRole } = render( const { queryByText, queryAllByRole } = render(
<Stack <Stack
@ -63,5 +70,38 @@ describe( 'Stack', () => {
getByRole( 'link', { name: 'Load Sample Products' } ) getByRole( 'link', { name: 'Load Sample Products' } )
); );
await waitFor( () => expect( onClickLoadSampleProduct ).toBeCalled() ); await waitFor( () => expect( onClickLoadSampleProduct ).toBeCalled() );
expect( recordEvent ).toHaveBeenNthCalledWith(
1,
'task_completion_time',
{ task_name: 'products', time: '0-2s' }
);
} );
it( 'should fire the tasklist_add_product and task_completion_time events when the "Start Blank" link is clicked', async () => {
const onClickLoadSampleProduct = jest.fn();
const { getByRole } = render(
<Stack
onClickLoadSampleProduct={ onClickLoadSampleProduct }
items={ [
{
...productTypes[ 0 ],
onClick: () => {},
},
] }
/>
);
userEvent.click( getByRole( 'link', { name: 'Start Blank' } ) );
expect( recordEvent ).toHaveBeenNthCalledWith(
1,
'tasklist_add_product',
{ method: 'manually' }
);
expect( recordEvent ).toHaveBeenNthCalledWith(
2,
'task_completion_time',
{ task_name: 'products', time: '0-2s' }
);
} ); } );
} ); } );

View File

@ -12,7 +12,7 @@ import { useState } from '@wordpress/element';
import { ProductTypeKey } from './constants'; import { ProductTypeKey } from './constants';
import { createNoticesFromResponse } from '../../../lib/notices'; import { createNoticesFromResponse } from '../../../lib/notices';
const useCreateProductByType = () => { export const useCreateProductByType = () => {
const { createProductFromTemplate } = useDispatch( ITEMS_STORE_NAME ); const { createProductFromTemplate } = useDispatch( ITEMS_STORE_NAME );
const [ isRequesting, setIsRequesting ] = useState< boolean >( false ); const [ isRequesting, setIsRequesting ] = useState< boolean >( false );
@ -54,5 +54,3 @@ const useCreateProductByType = () => {
isRequesting, isRequesting,
}; };
}; };
export default useCreateProductByType;

View File

@ -2,15 +2,17 @@
* External dependencies * External dependencies
*/ */
import { useMemo } from '@wordpress/element'; import { useMemo } from '@wordpress/element';
import { recordEvent } from '@woocommerce/tracks';
/** /**
* Internal dependencies * Internal dependencies
*/ */
import useCreateProductByType from './use-create-product-by-type'; import { useCreateProductByType } from './use-create-product-by-type';
import { ProductType } from './constants'; import { ProductType, ProductTypeKey } from './constants';
const useProductTypeListItems = ( const useProductTypeListItems = (
_productTypes: ProductType[], _productTypes: ProductType[],
suggestedProductTypes: ProductTypeKey[] = [],
{ {
onClick, onClick,
}: { }: {
@ -25,6 +27,12 @@ const useProductTypeListItems = (
...productType, ...productType,
onClick: () => { onClick: () => {
createProductByType( productType.key ); createProductByType( productType.key );
recordEvent( 'tasklist_product_template_selection', {
product_type: productType.key,
is_suggested: suggestedProductTypes.includes(
productType.key
),
} );
if ( typeof onClick === 'function' ) { if ( typeof onClick === 'function' ) {
onClick(); onClick();
} }

View File

@ -0,0 +1,4 @@
Significance: patch
Type: Add
Added events for experimental products page #32944