Commit ca1d393393 for woocommerce
commit ca1d3933937d220a93b2312d6797ac956e25a5dd
Author: Albert Juhé Lluveras <contact@albertjuhe.com>
Date: Fri Dec 5 18:45:01 2025 +0100
Limit ProductControl component to loading a maximum of 25 variations at once (#61853)
* Limit ProductControl component to loading a maximum of 100 variations at once
* Add changelog file
* Fix button width on mobile
* Reduce getProductVariationsWithTotal() per_page default value to 25
* Update props name and fix types
* Fix type mismatch
* Add and update tests
* Make sure we don't render the button if the number of total variations is undefined
* Styling updates
* Typo
* Add correct type to button
* Make it so SearchListControl doesn't have specific references to variations in the text
* Fix types
* Add missing types
* Improve prop description
* Make sure product ids in object keys are typed as numbers
* Remove unnecessary 'undefined' type
diff --git a/plugins/woocommerce/changelog/fix-product-control-load-100-variations-at-once b/plugins/woocommerce/changelog/fix-product-control-load-100-variations-at-once
new file mode 100644
index 0000000000..3c23c57ea5
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-product-control-load-100-variations-at-once
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Limit ProductControl component to loading a maximum of 25 variations at once
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-control/index.tsx b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-control/index.tsx
index f86f2878dc..8c5c754088 100644
--- a/plugins/woocommerce/client/blocks/assets/js/editor-components/product-control/index.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/product-control/index.tsx
@@ -45,7 +45,15 @@ interface ProductControlProps {
/**
* The ID of the currently expanded product.
*/
- expandedProduct: number | null;
+ expandedProduct?: number | null;
+ /**
+ * Callback to load more variations.
+ */
+ onLoadMoreVariations?: () => void;
+ /**
+ * The total number of variations.
+ */
+ totalVariations?: Record< number, number | null >;
/**
* Callback to search products by their name.
*/
@@ -94,6 +102,8 @@ const ProductControl = (
isCompact = false,
isLoading,
onChange,
+ onLoadMoreVariations,
+ totalVariations,
onSearch,
products,
renderItem,
@@ -224,6 +234,15 @@ const ProductControl = (
selected.includes( Number( id ) )
) }
onChange={ onChange }
+ loadMoreChildrenText={
+ showVariations
+ ? __( 'Load more variations', 'woocommerce' )
+ : undefined
+ }
+ onLoadMoreChildren={
+ showVariations ? onLoadMoreVariations : undefined
+ }
+ totalChildren={ showVariations ? totalVariations : undefined }
renderItem={ getRenderItemFunc() }
onSearch={ onSearch }
messages={ {
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/search-list-control.tsx b/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/search-list-control.tsx
index ee26e735da..9f8ad36109 100644
--- a/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/search-list-control.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/search-list-control.tsx
@@ -46,6 +46,9 @@ const ListItems = ( props: ListItemsProps ): JSX.Element | null => {
selected,
renderItem,
depth = 0,
+ loadMoreChildrenText,
+ onLoadMoreChildren,
+ totalChildren,
onSelect,
instanceId,
isSingle,
@@ -61,16 +64,20 @@ const ListItems = ( props: ListItemsProps ): JSX.Element | null => {
return (
<>
{ list.map( ( item ) => {
+ const childrenCount = item.children?.length ?? 0;
const isSelected =
- item.children?.length && ! isSingle
- ? item.children.every( ( { id } ) =>
+ childrenCount && ! isSingle
+ ? item.children?.every( ( { id } ) =>
selected.find(
( selectedItem ) => selectedItem.id === id
)
)
: !! selected.find( ( { id } ) => id === item.id );
- const isExpanded =
- item.children?.length && expandedPanelId === item.id;
+ const isExpanded = childrenCount && expandedPanelId === item.id;
+ const totalChildrenForItem = totalChildren?.[ item.id ];
+ const hasMoreChildren =
+ typeof totalChildrenForItem === 'number' &&
+ childrenCount < totalChildrenForItem;
return (
<Fragment key={ item.id }>
@@ -88,11 +95,32 @@ const ListItems = ( props: ListItemsProps ): JSX.Element | null => {
} ) }
</li>
{ isExpanded ? (
- <ListItems
- { ...props }
- list={ item.children as SearchListItemProps[] }
- depth={ depth + 1 }
- />
+ <>
+ <ListItems
+ { ...props }
+ list={
+ item.children as SearchListItemProps[]
+ }
+ depth={ depth + 1 }
+ />
+ { onLoadMoreChildren && hasMoreChildren ? (
+ <li>
+ <button
+ type="button"
+ className="woocommerce-search-list__item woocommerce-search-list__item-load-more"
+ onClick={ () =>
+ onLoadMoreChildren()
+ }
+ >
+ { loadMoreChildrenText ||
+ __(
+ 'Load more',
+ 'woocommerce'
+ ) }
+ </button>
+ </li>
+ ) : null }
+ </>
) : null }
</Fragment>
);
@@ -156,7 +184,15 @@ const ListItemsContainer = < T extends object = object >( {
useExpandedPanelId,
...props
}: SearchListItemsContainerProps< T > ) => {
- const { messages, renderItem, selected, isSingle } = props;
+ const {
+ messages,
+ renderItem,
+ selected,
+ isSingle,
+ loadMoreChildrenText,
+ onLoadMoreChildren,
+ totalChildren,
+ } = props;
const renderItemCallback = renderItem || defaultRenderListItem;
if ( filteredList.length === 0 ) {
@@ -182,6 +218,9 @@ const ListItemsContainer = < T extends object = object >( {
list={ filteredList }
selected={ selected }
renderItem={ renderItemCallback }
+ loadMoreChildrenText={ loadMoreChildrenText }
+ onLoadMoreChildren={ onLoadMoreChildren }
+ totalChildren={ totalChildren }
onSelect={ onSelect }
instanceId={ instanceId }
isSingle={ isSingle }
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/style.scss b/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/style.scss
index a6d27b3fa4..b9476d2543 100644
--- a/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/style.scss
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/style.scss
@@ -306,4 +306,18 @@
li:last-child .woocommerce-search-list__item {
border-bottom: none;
}
+
+ .woocommerce-search-list__item-load-more {
+ border: none;
+ color: var(--wp-components-color-accent, var(--wp-admin-theme-color, #3858e9));
+ cursor: pointer;
+ padding-left: calc($gap * 2 + 24px);
+ width: 100%;
+
+ @media screen and (max-width: 782px) {
+ box-sizing: content-box;
+ height: 25px;
+ padding-left: calc($gap * 2 + 33px);
+ }
+ }
}
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/types.ts b/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/types.ts
index e2048cc008..df6d88410e 100644
--- a/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/types.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/search-list-control/types.ts
@@ -7,6 +7,13 @@ import { Require } from '@woocommerce/types';
interface ItemProps< T extends object = object > {
// Depth, non-zero if the list is hierarchical.
depth?: number;
+ // Text to display for the load more button.
+ loadMoreChildrenText?: string | undefined;
+ // Callback for loading more children.
+ onLoadMoreChildren?: ( () => void ) | undefined;
+ // Map of parent item IDs to their total number of children.
+ // Value is null when total is unknown, and 0 when known to have no children.
+ totalChildren?: { [ key: string ]: number | null } | undefined;
// Callback for selecting the item.
onSelect: (
item: SearchListItem< T > | SearchListItem< T >[]
@@ -119,6 +126,13 @@ export interface SearchListControlProps< T extends object = object > {
list: SearchListItem< T >[];
// Messages displayed or read to the user. Configure these to reflect your object type.
messages?: Partial< SearchListMessages >;
+ // Text to display for the load more button.
+ loadMoreChildrenText?: string | undefined;
+ // Callback for loading more children.
+ onLoadMoreChildren?: ( () => void ) | undefined;
+ // Map of parent item IDs to their total number of children.
+ // Value is null when total is unknown, and 0 when known to have no children.
+ totalChildren?: { [ key: string ]: number | null } | undefined;
// Callback fired when selected items change, whether added, cleared, or removed. Passed an array of item objects (as passed in via props.list).
onChange: ( search: SearchListItem< T >[] ) => void;
// Callback fired when the search field is used.
diff --git a/plugins/woocommerce/client/blocks/assets/js/editor-components/utils/index.js b/plugins/woocommerce/client/blocks/assets/js/editor-components/utils/index.js
index 6a8f79a14a..ba1a4e8470 100644
--- a/plugins/woocommerce/client/blocks/assets/js/editor-components/utils/index.js
+++ b/plugins/woocommerce/client/blocks/assets/js/editor-components/utils/index.js
@@ -191,9 +191,42 @@ export const getCategory = ( categoryId ) => {
} );
};
+/**
+ * Get a promise that resolves to a list of variation objects from the Store API
+ * and the total number of variations.
+ *
+ * @param {number} product Product ID.
+ * @param {Object} args Query args to pass in.
+ */
+export const getProductVariationsWithTotal = ( product, args = {} ) => {
+ return apiFetch( {
+ path: addQueryArgs( `wc/store/v1/products`, {
+ type: 'variation',
+ parent: product,
+ orderby: 'title',
+ per_page: 25,
+ ...args,
+ } ),
+ parse: false,
+ } ).then( ( response ) => {
+ return response.json().then( ( data ) => {
+ const totalHeader = response.headers.get( 'x-wp-total' );
+ return {
+ variations: data,
+ total: totalHeader ? Number( totalHeader ) : null,
+ };
+ } );
+ } );
+};
+
/**
* Get a promise that resolves to a list of variation objects from the Store API.
*
+ * NOTE: If implementing new features, prefer using the
+ * `getProductVariationsWithTotal()` function above, as it doesn't default to
+ * `per_page: 0`.
+ * See: https://github.com/woocommerce/woocommerce/pull/61755#issuecomment-3499859585
+ *
* @param {number} product Product ID.
*/
export const getProductVariations = ( product ) => {
diff --git a/plugins/woocommerce/client/blocks/assets/js/hocs/test/with-product-variations.jsx b/plugins/woocommerce/client/blocks/assets/js/hocs/test/with-product-variations.jsx
index c9494dedc5..0ba3fd4a86 100644
--- a/plugins/woocommerce/client/blocks/assets/js/hocs/test/with-product-variations.jsx
+++ b/plugins/woocommerce/client/blocks/assets/js/hocs/test/with-product-variations.jsx
@@ -14,7 +14,7 @@ import withProductVariations from '../with-product-variations';
import * as mockBaseUtils from '../../base/utils/errors';
jest.mock( '@woocommerce/editor-components/utils', () => ( {
- getProductVariations: jest.fn(),
+ getProductVariationsWithTotal: jest.fn(),
} ) );
jest.mock( '../../base/utils/errors', () => ( {
@@ -37,6 +37,8 @@ const TestComponent = withProductVariations( ( props ) => {
data-isLoading={ props.isLoading }
data-variations={ props.variations }
data-variationsLoading={ props.variationsLoading }
+ data-onLoadMoreVariations={ props.onLoadMoreVariations }
+ data-totalVariations={ props.totalVariations }
/>
);
} );
@@ -55,25 +57,30 @@ const render = () => {
describe( 'withProductVariations Component', () => {
let renderer;
afterEach( () => {
- mockUtils.getProductVariations.mockReset();
+ mockUtils.getProductVariationsWithTotal.mockReset();
} );
describe( 'lifecycle events', () => {
beforeEach( () => {
- mockUtils.getProductVariations.mockImplementation( () =>
- Promise.resolve( mockVariations )
+ mockUtils.getProductVariationsWithTotal.mockImplementation( () =>
+ Promise.resolve( {
+ variations: mockVariations,
+ total: mockVariations.length,
+ } )
);
} );
- it( 'getProductVariations is called on mount', () => {
+ it( 'getProductVariationsWithTotal is called on mount', () => {
renderer = render();
- const { getProductVariations } = mockUtils;
+ const { getProductVariationsWithTotal } = mockUtils;
- expect( getProductVariations ).toHaveBeenCalledWith( 1 );
- expect( getProductVariations ).toHaveBeenCalledTimes( 1 );
+ expect( getProductVariationsWithTotal ).toHaveBeenCalledWith( 1, {
+ offset: 0,
+ } );
+ expect( getProductVariationsWithTotal ).toHaveBeenCalledTimes( 1 );
} );
- it( 'getProductVariations is called on component update', () => {
+ it( 'getProductVariationsWithTotal is called on component update', () => {
renderer = TestRenderer.create(
<TestComponent
error={ null }
@@ -81,9 +88,9 @@ describe( 'withProductVariations Component', () => {
products={ mockProducts }
/>
);
- const { getProductVariations } = mockUtils;
+ const { getProductVariationsWithTotal } = mockUtils;
- expect( getProductVariations ).toHaveBeenCalledTimes( 0 );
+ expect( getProductVariationsWithTotal ).toHaveBeenCalledTimes( 0 );
renderer.update(
<TestComponent
@@ -95,11 +102,13 @@ describe( 'withProductVariations Component', () => {
/>
);
- expect( getProductVariations ).toHaveBeenCalledWith( 1 );
- expect( getProductVariations ).toHaveBeenCalledTimes( 1 );
+ expect( getProductVariationsWithTotal ).toHaveBeenCalledWith( 1, {
+ offset: 0,
+ } );
+ expect( getProductVariationsWithTotal ).toHaveBeenCalledTimes( 1 );
} );
- it( 'getProductVariations is not called if selected product has no variations', () => {
+ it( 'getProductVariationsWithTotal is not called if selected product has no variations', () => {
TestRenderer.create(
<TestComponent
error={ null }
@@ -109,12 +118,12 @@ describe( 'withProductVariations Component', () => {
showVariations={ true }
/>
);
- const { getProductVariations } = mockUtils;
+ const { getProductVariationsWithTotal } = mockUtils;
- expect( getProductVariations ).toHaveBeenCalledTimes( 0 );
+ expect( getProductVariationsWithTotal ).toHaveBeenCalledTimes( 0 );
} );
- it( 'getProductVariations is called if selected product is a variation', () => {
+ it( 'getProductVariationsWithTotal is called if selected product is a variation', () => {
TestRenderer.create(
<TestComponent
error={ null }
@@ -124,22 +133,27 @@ describe( 'withProductVariations Component', () => {
showVariations={ true }
/>
);
- const { getProductVariations } = mockUtils;
+ const { getProductVariationsWithTotal } = mockUtils;
- expect( getProductVariations ).toHaveBeenCalledWith( 1 );
- expect( getProductVariations ).toHaveBeenCalledTimes( 1 );
+ expect( getProductVariationsWithTotal ).toHaveBeenCalledWith( 1, {
+ offset: 0,
+ } );
+ expect( getProductVariationsWithTotal ).toHaveBeenCalledTimes( 1 );
} );
} );
describe( 'when the API returns variations data', () => {
beforeEach( () => {
- mockUtils.getProductVariations.mockImplementation( () =>
- Promise.resolve( mockVariations )
+ mockUtils.getProductVariationsWithTotal.mockImplementation( () =>
+ Promise.resolve( {
+ variations: mockVariations,
+ total: mockVariations.length,
+ } )
);
renderer = render();
} );
- it( 'sets the variations props', () => {
+ it( 'sets the variations props', async () => {
const props = renderer.root.findByType( 'div' ).props;
const expectedVariations = {
1: [
@@ -156,12 +170,12 @@ describe( 'withProductVariations Component', () => {
describe( 'when the API returns an error', () => {
const error = { message: 'There was an error.' };
- const getProductVariationsPromise = Promise.reject( error );
+ const getProductVariationsWithTotalPromise = Promise.reject( error );
const formattedError = { message: 'There was an error.', type: 'api' };
beforeEach( () => {
- mockUtils.getProductVariations.mockImplementation(
- () => getProductVariationsPromise
+ mockUtils.getProductVariationsWithTotal.mockImplementation(
+ () => getProductVariationsWithTotalPromise
);
mockBaseUtils.formatError.mockImplementation(
() => formattedError
@@ -170,7 +184,9 @@ describe( 'withProductVariations Component', () => {
} );
test( 'sets the error prop', async () => {
- await expect( () => getProductVariationsPromise() ).toThrow();
+ await TestRenderer.act( async () => {
+ await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
+ } );
const { formatError } = mockBaseUtils;
const props = renderer.root.findByType( 'div' ).props;
@@ -182,4 +198,190 @@ describe( 'withProductVariations Component', () => {
expect( props[ 'data-variations' ] ).toEqual( { 1: null } );
} );
} );
+
+ describe( 'when a product has more than 25 variations', () => {
+ const totalVariations = 60;
+ const mockManyVariations = Array.from(
+ { length: totalVariations },
+ ( _, i ) => ( {
+ id: i + 1,
+ name: `Variation ${ i + 1 }`,
+ } )
+ );
+
+ const productWithManyVariations = [
+ {
+ id: 1,
+ name: 'Hoodie',
+ variations: mockManyVariations.map( ( v ) => ( { id: v.id } ) ),
+ },
+ ];
+
+ beforeEach( () => {
+ mockUtils.getProductVariationsWithTotal.mockImplementation(
+ ( productId, { offset = 0 } ) => {
+ const start = offset;
+ const end = Math.min( start + 25, totalVariations );
+ const variations = mockManyVariations.slice( start, end );
+
+ return Promise.resolve( {
+ variations,
+ total: totalVariations,
+ } );
+ }
+ );
+ } );
+
+ it( 'loads the first 25 variations by default and provides onLoadMoreVariations', async () => {
+ renderer = TestRenderer.create(
+ <TestComponent
+ error={ null }
+ isLoading={ false }
+ products={ productWithManyVariations }
+ selected={ [ 1 ] }
+ showVariations={ true }
+ />
+ );
+
+ await TestRenderer.act( async () => {
+ await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
+ } );
+
+ const props = renderer.root.findByType( 'div' ).props;
+ const { getProductVariationsWithTotal } = mockUtils;
+
+ // Should have been called once with offset 0
+ expect( getProductVariationsWithTotal ).toHaveBeenCalledWith( 1, {
+ offset: 0,
+ } );
+ expect( getProductVariationsWithTotal ).toHaveBeenCalledTimes( 1 );
+
+ // Should have first 25 variations
+ expect( props[ 'data-variations' ][ 1 ] ).toHaveLength( 25 );
+ expect( props[ 'data-variations' ][ 1 ][ 0 ] ).toEqual( {
+ id: 1,
+ name: 'Variation 1',
+ parent: 1,
+ } );
+ expect( props[ 'data-variations' ][ 1 ][ 24 ] ).toEqual( {
+ id: 25,
+ name: 'Variation 25',
+ parent: 1,
+ } );
+
+ // Should have total variations count
+ expect( props[ 'data-totalVariations' ][ 1 ] ).toBe(
+ totalVariations
+ );
+
+ // Should provide onLoadMoreVariations function
+ expect( typeof props[ 'data-onLoadMoreVariations' ] ).toBe(
+ 'function'
+ );
+ } );
+
+ it( 'loads the next 25 variations when onLoadMoreVariations is called', async () => {
+ renderer = TestRenderer.create(
+ <TestComponent
+ error={ null }
+ isLoading={ false }
+ products={ productWithManyVariations }
+ selected={ [ 1 ] }
+ showVariations={ true }
+ />
+ );
+
+ // Wait for initial load
+ await TestRenderer.act( async () => {
+ await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
+ } );
+
+ let props = renderer.root.findByType( 'div' ).props;
+
+ // Verify initial 25 variations are loaded
+ expect( props[ 'data-variations' ][ 1 ] ).toHaveLength( 25 );
+
+ // Call onLoadMoreVariations to load next batch
+ await TestRenderer.act( async () => {
+ props[ 'data-onLoadMoreVariations' ]();
+ await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
+ } );
+
+ props = renderer.root.findByType( 'div' ).props;
+ const { getProductVariationsWithTotal } = mockUtils;
+
+ // Should have been called again with offset 25
+ expect( getProductVariationsWithTotal ).toHaveBeenCalledWith( 1, {
+ offset: 25,
+ } );
+ expect( getProductVariationsWithTotal ).toHaveBeenCalledTimes( 2 );
+
+ // Should now have 50 variations (25 + 25)
+ expect( props[ 'data-variations' ][ 1 ] ).toHaveLength( 50 );
+ expect( props[ 'data-variations' ][ 1 ][ 25 ] ).toEqual( {
+ id: 26,
+ name: 'Variation 26',
+ parent: 1,
+ } );
+ expect( props[ 'data-variations' ][ 1 ][ 49 ] ).toEqual( {
+ id: 50,
+ name: 'Variation 50',
+ parent: 1,
+ } );
+ } );
+
+ it( 'loads all variations when onLoadMoreVariations is called multiple times', async () => {
+ renderer = TestRenderer.create(
+ <TestComponent
+ error={ null }
+ isLoading={ false }
+ products={ productWithManyVariations }
+ selected={ [ 1 ] }
+ showVariations={ true }
+ />
+ );
+
+ // Wait for initial load
+ await TestRenderer.act( async () => {
+ await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
+ } );
+
+ let props = renderer.root.findByType( 'div' ).props;
+
+ // Load second batch
+ await TestRenderer.act( async () => {
+ props[ 'data-onLoadMoreVariations' ]();
+ await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
+ } );
+
+ props = renderer.root.findByType( 'div' ).props;
+
+ // Load third batch (final 10 variations)
+ await TestRenderer.act( async () => {
+ props[ 'data-onLoadMoreVariations' ]();
+ await new Promise( ( resolve ) => setTimeout( resolve, 0 ) );
+ } );
+
+ props = renderer.root.findByType( 'div' ).props;
+ const { getProductVariationsWithTotal } = mockUtils;
+
+ // Should have been called 3 times total
+ expect( getProductVariationsWithTotal ).toHaveBeenCalledTimes( 3 );
+ expect( getProductVariationsWithTotal ).toHaveBeenNthCalledWith(
+ 3,
+ 1,
+ {
+ offset: 50,
+ }
+ );
+
+ // Should now have all 60 variations
+ expect( props[ 'data-variations' ][ 1 ] ).toHaveLength( 60 );
+ expect( props[ 'data-variations' ][ 1 ][ 59 ] ).toEqual( {
+ id: 60,
+ name: 'Variation 60',
+ parent: 1,
+ } );
+ } );
+ } );
} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/hocs/with-product-variations.tsx b/plugins/woocommerce/client/blocks/assets/js/hocs/with-product-variations.tsx
index 918591eace..22d25f83cb 100644
--- a/plugins/woocommerce/client/blocks/assets/js/hocs/with-product-variations.tsx
+++ b/plugins/woocommerce/client/blocks/assets/js/hocs/with-product-variations.tsx
@@ -4,7 +4,7 @@
import { Component } from '@wordpress/element';
import { createHigherOrderComponent } from '@wordpress/compose';
import isShallowEqual from '@wordpress/is-shallow-equal';
-import { getProductVariations } from '@woocommerce/editor-components/utils';
+import { getProductVariationsWithTotal } from '@woocommerce/editor-components/utils';
import { ErrorObject } from '@woocommerce/editor-components/error-placeholder';
import {
ProductResponseItem,
@@ -27,7 +27,8 @@ interface WithProductVariationsProps {
interface State {
error: ErrorObject | null;
loading: boolean;
- variations: { [ key: string ]: ProductResponseVariationsItem[] | null };
+ variations: { [ key: number ]: ProductResponseVariationsItem[] | null };
+ totalVariations: { [ key: number ]: number | null };
}
/**
@@ -47,6 +48,7 @@ const withProductVariations = createHigherOrderComponent(
error: null,
loading: false,
variations: {},
+ totalVariations: {},
};
private prevSelectedItem?: number;
@@ -71,9 +73,9 @@ const withProductVariations = createHigherOrderComponent(
}
}
- loadVariations = () => {
+ loadVariations = ( { offset = 0 }: { offset?: number } = {} ) => {
const { products } = this.props;
- const { loading, variations } = this.state;
+ const { loading, variations, totalVariations } = this.state;
if ( loading ) {
return;
@@ -81,7 +83,20 @@ const withProductVariations = createHigherOrderComponent(
const expandedProduct = this.getExpandedProduct();
- if ( ! expandedProduct || variations[ expandedProduct ] ) {
+ if ( ! expandedProduct ) {
+ return;
+ }
+
+ if ( ! offset && variations?.[ expandedProduct ] ) {
+ return;
+ }
+
+ if (
+ variations?.[ expandedProduct ] &&
+ totalVariations?.[ expandedProduct ] &&
+ variations[ expandedProduct ].length >=
+ totalVariations[ expandedProduct ]
+ ) {
return;
}
@@ -106,13 +121,19 @@ const withProductVariations = createHigherOrderComponent(
this.setState( { loading: true } );
+ const alreadyLoadedVariations =
+ this.state.variations[ expandedProduct ] || [];
+
(
- getProductVariations( expandedProduct ) as Promise<
- ProductResponseVariationsItem[]
- >
+ getProductVariationsWithTotal( expandedProduct, {
+ offset,
+ } ) as Promise< {
+ variations: ProductResponseVariationsItem[];
+ total: number;
+ } >
)
- .then( ( expandedProductVariations ) => {
- const newVariations = expandedProductVariations.map(
+ .then( ( { variations: variationsData, total } ) => {
+ const newVariations = variationsData.map(
( variation ) => ( {
...variation,
parent: expandedProduct,
@@ -121,7 +142,14 @@ const withProductVariations = createHigherOrderComponent(
this.setState( {
variations: {
...this.state.variations,
- [ expandedProduct ]: newVariations,
+ [ expandedProduct ]: [
+ ...alreadyLoadedVariations,
+ ...newVariations,
+ ],
+ },
+ totalVariations: {
+ ...this.state.totalVariations,
+ [ expandedProduct ]: total,
},
loading: false,
error: null,
@@ -135,6 +163,10 @@ const withProductVariations = createHigherOrderComponent(
...this.state.variations,
[ expandedProduct ]: null,
},
+ totalVariations: {
+ ...this.state.totalVariations,
+ [ expandedProduct ]: null,
+ },
loading: false,
error,
} );
@@ -190,7 +222,12 @@ const withProductVariations = createHigherOrderComponent(
render() {
const { error: propsError, isLoading } = this.props;
- const { error, loading, variations } = this.state;
+ const { error, loading, variations, totalVariations } =
+ this.state;
+ const expandedProduct = this.getExpandedProduct();
+ const offset = expandedProduct
+ ? variations[ expandedProduct ]?.length || 0
+ : 0;
return (
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -198,8 +235,14 @@ const withProductVariations = createHigherOrderComponent(
<OriginalComponent
{ ...this.props }
error={ error || propsError }
+ onLoadMoreVariations={ () =>
+ this.loadVariations( {
+ offset,
+ } )
+ }
expandedProduct={ this.getExpandedProduct() }
isLoading={ isLoading }
+ totalVariations={ totalVariations }
variations={ variations }
variationsLoading={ loading }
/>
diff --git a/plugins/woocommerce/client/blocks/assets/js/types/type-defs/hocs.ts b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/hocs.ts
index 1518bc9b74..56fe7d5065 100644
--- a/plugins/woocommerce/client/blocks/assets/js/types/type-defs/hocs.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/types/type-defs/hocs.ts
@@ -15,6 +15,8 @@ export interface WithInjectedProductVariations {
expandedProduct: number | null;
variations: Record< number, ProductResponseItem[] >;
variationsLoading: boolean;
+ onLoadMoreVariations?: () => void;
+ totalVariations?: Record< number, number | null >;
}
export interface WithInjectedSearchedProducts {