Commit b53bea4fd7e for woocommerce
commit b53bea4fd7ed21784e9b47ac230c6ba536c91c36
Author: Luigi Teschio <gigitux@gmail.com>
Date: Fri May 8 11:54:36 2026 +0200
Fix variation edits in products app (#64688)
* Update variation
* lint code
* improve variation save logic
* improve logic
* simplify logic
diff --git a/packages/js/experimental-products-app/changelog/fix-edit-variations b/packages/js/experimental-products-app/changelog/fix-edit-variations
new file mode 100644
index 00000000000..f57e5b7c679
--- /dev/null
+++ b/packages/js/experimental-products-app/changelog/fix-edit-variations
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Update variations through the variation endpoint in the experimental products app
diff --git a/packages/js/experimental-products-app/src/product-edit/index.tsx b/packages/js/experimental-products-app/src/product-edit/index.tsx
index b4289708952..a534d05e5d7 100644
--- a/packages/js/experimental-products-app/src/product-edit/index.tsx
+++ b/packages/js/experimental-products-app/src/product-edit/index.tsx
@@ -3,7 +3,7 @@
*/
import { Button, Spinner } from '@wordpress/components';
import { store as coreStore } from '@wordpress/core-data';
-import { useDispatch, useSelect } from '@wordpress/data';
+import { select as wpSelect, useDispatch, useSelect } from '@wordpress/data';
import { DataForm } from '@wordpress/dataviews';
import { useCallback, useEffect, useState } from '@wordpress/element';
import { __, sprintf } from '@wordpress/i18n';
@@ -23,9 +23,13 @@ import type { ProductEntityRecord } from '../fields/types';
import { unlock } from '../lock-unlock';
import {
buildMergedProductEditData,
+ findProductInList,
+ getProductWithUpdatedVariation,
getProductEditFields,
getVisibleProductEditFields,
+ isProductVariation,
} from './utils';
+import { saveSelectedProducts } from './save';
const { useHistory, useLocation } = unlock( routerPrivateApis );
@@ -35,6 +39,10 @@ type ProductEditFormProps = {
selectedProducts: ProductEntityRecord[];
};
+type ProductEditProps = {
+ products: ProductEntityRecord[];
+};
+
function getSaveNoticeMessage( successCount: number, failedCount: number ) {
if ( failedCount === 0 ) {
if ( successCount === 1 ) {
@@ -100,7 +108,7 @@ function ProductEditForm( {
);
}
-export default function ProductEdit() {
+export default function ProductEdit( { products }: ProductEditProps ) {
const { navigate } = useHistory();
const { path, query = {} } = useLocation();
const requestedProductIdsFromRoute = getSelectionFromPostId( query.postId )
@@ -115,7 +123,6 @@ export default function ProductEdit() {
const editableFields = getProductEditFields( productFields );
const {
selectedProducts,
- selectedProductIds,
isResolving,
hasResolved,
hasMissingProducts,
@@ -125,7 +132,6 @@ export default function ProductEdit() {
if ( requestedProductIds.length === 0 ) {
return {
selectedProducts: [],
- selectedProductIds: [],
isResolving: false,
hasResolved: true,
hasMissingProducts: false,
@@ -136,37 +142,73 @@ export default function ProductEdit() {
const coreSelect = select( coreStore );
const productResults = requestedProductIds.map( ( productId ) => {
const resolutionArgs = [ 'root', 'product', productId ];
+ const rootRecord = coreSelect.getEditedEntityRecord(
+ 'root',
+ 'product',
+ productId
+ ) as unknown as ProductEntityRecord | false | undefined;
+ const listedProduct = findProductInList( products, productId );
+ const product =
+ listedProduct ??
+ ( rootRecord !== false ? rootRecord : undefined );
+ let record: ProductEntityRecord | false | undefined =
+ product ?? rootRecord;
+
+ if (
+ product &&
+ isProductVariation( product ) &&
+ product.parent_id
+ ) {
+ const parentProduct = coreSelect.getEditedEntityRecord(
+ 'root',
+ 'product',
+ product.parent_id
+ ) as unknown as ProductEntityRecord | false | undefined;
+ const editedParentProduct =
+ parentProduct !== false ? parentProduct : undefined;
+ const editedVariation =
+ editedParentProduct?._embedded?.variations?.find(
+ ( variation ) => variation.id === product.id
+ );
+
+ record = editedVariation || product;
+ }
return {
productId,
- record: coreSelect.getEditedEntityRecord(
- 'root',
- 'product',
- productId
- ) as unknown as ProductEntityRecord | false | undefined,
- isResolving: coreSelect.isResolving(
- 'getEditedEntityRecord',
- resolutionArgs
- ),
- hasFinishedResolution: coreSelect.hasFinishedResolution(
- 'getEditedEntityRecord',
- resolutionArgs
- ),
+ record,
+ isResolving: listedProduct
+ ? false
+ : coreSelect.isResolving(
+ 'getEditedEntityRecord',
+ resolutionArgs
+ ),
+ hasFinishedResolution: listedProduct
+ ? true
+ : coreSelect.hasFinishedResolution(
+ 'getEditedEntityRecord',
+ resolutionArgs
+ ),
};
} );
- const products = productResults
+ const resolvedProducts = productResults
.map( ( { record } ) => record )
.filter(
( product ): product is ProductEntityRecord =>
product !== undefined && product !== false
);
- const validSelectedProductIds = products.map(
- ( product ) => product.id
+ const editedProductIds = Array.from(
+ new Set(
+ resolvedProducts.map( ( product ) =>
+ isProductVariation( product ) && product.parent_id
+ ? product.parent_id
+ : product.id
+ )
+ )
);
return {
- selectedProducts: products,
- selectedProductIds: validSelectedProductIds,
+ selectedProducts: resolvedProducts,
isResolving: productResults.some(
( result ) =>
result.isResolving || ! result.hasFinishedResolution
@@ -178,7 +220,7 @@ export default function ProductEdit() {
( result ) =>
result.hasFinishedResolution && result.record === false
),
- hasEdits: validSelectedProductIds.some( ( productId ) =>
+ hasEdits: editedProductIds.some( ( productId ) =>
coreSelect.hasEditsForEntityRecord(
'root',
'product',
@@ -187,10 +229,10 @@ export default function ProductEdit() {
),
};
},
- [ requestedProductIds ]
+ [ products, requestedProductIds ]
);
- const { editEntityRecord, saveEditedEntityRecord } =
+ const { clearEntityRecordEdits, editEntityRecord, saveEditedEntityRecord } =
useDispatch( coreStore );
const { createSuccessNotice, createErrorNotice } =
@@ -220,38 +262,86 @@ export default function ProductEdit() {
const onChange = useCallback(
( changes: Partial< ProductEntityRecord > ) => {
- selectedProductIds.forEach( ( productId ) => {
- editEntityRecord( 'root', 'product', productId, changes );
+ const updatedParentProductsById = new Map<
+ number,
+ ProductEntityRecord
+ >();
+
+ selectedProducts.forEach( ( product ) => {
+ if ( ! isProductVariation( product ) ) {
+ editEntityRecord( 'root', 'product', product.id, changes );
+ return;
+ }
+
+ if ( ! product.parent_id ) {
+ return;
+ }
+
+ const parentProduct =
+ updatedParentProductsById.get( product.parent_id ) ??
+ ( wpSelect( coreStore ).getEditedEntityRecord(
+ 'root',
+ 'product',
+ product.parent_id
+ ) as ProductEntityRecord | false | undefined );
+
+ if ( ! parentProduct ) {
+ return;
+ }
+
+ updatedParentProductsById.set(
+ product.parent_id,
+ getProductWithUpdatedVariation( parentProduct, {
+ ...product,
+ ...changes,
+ } )
+ );
+ } );
+
+ updatedParentProductsById.forEach( ( parentProduct ) => {
+ editEntityRecord( 'root', 'product', parentProduct.id, {
+ _embedded: parentProduct._embedded,
+ } );
} );
},
- [ editEntityRecord, selectedProductIds ]
+ [ editEntityRecord, selectedProducts ]
);
const closeDrawer = useCallback( () => {
+ const editedProductIds = new Set(
+ selectedProducts.map( ( product ) =>
+ isProductVariation( product ) && product.parent_id
+ ? product.parent_id
+ : product.id
+ )
+ );
const nextQuery = {
...query,
} as Record< string, string >;
+ editedProductIds.forEach( ( productId ) => {
+ clearEntityRecordEdits( 'root', 'product', productId );
+ } );
+
delete nextQuery.quickEdit;
navigate( getProductListNavigationPath( path, nextQuery ) );
- }, [ navigate, path, query ] );
+ }, [ clearEntityRecordEdits, navigate, path, query, selectedProducts ] );
const onSave = useCallback( async () => {
- if ( selectedProductIds.length === 0 || isSaving ) {
+ if ( selectedProducts.length === 0 || isSaving ) {
return;
}
setIsSaving( true );
try {
- const results = await Promise.allSettled(
- selectedProductIds.map( ( productId ) =>
- saveEditedEntityRecord( 'root', 'product', productId, {
- throwOnError: true,
- } )
- )
- );
+ const results = await saveSelectedProducts( {
+ selectedProducts,
+ editEntityRecord,
+ saveEditedEntityRecord,
+ } );
+
const successfulCount = results.filter(
( result ) => result.status === 'fulfilled'
).length;
@@ -279,9 +369,10 @@ export default function ProductEdit() {
}, [
createErrorNotice,
createSuccessNotice,
+ editEntityRecord,
isSaving,
saveEditedEntityRecord,
- selectedProductIds,
+ selectedProducts,
] );
useEffect( () => {
diff --git a/packages/js/experimental-products-app/src/product-edit/save.test.ts b/packages/js/experimental-products-app/src/product-edit/save.test.ts
new file mode 100644
index 00000000000..085e99782c8
--- /dev/null
+++ b/packages/js/experimental-products-app/src/product-edit/save.test.ts
@@ -0,0 +1,157 @@
+/**
+ * External dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../fields/types';
+import { saveSelectedProducts } from './save';
+
+const mockGetEditedEntityRecord = jest.fn();
+const mockGetEntityRecord = jest.fn();
+
+jest.mock( '@wordpress/api-fetch', () => jest.fn() );
+
+jest.mock( '@wordpress/core-data', () => ( {
+ store: {},
+} ) );
+
+jest.mock( '@wordpress/data', () => ( {
+ select: jest.fn( () => ( {
+ getEditedEntityRecord: mockGetEditedEntityRecord,
+ getEntityRecord: mockGetEntityRecord,
+ } ) ),
+} ) );
+
+describe( 'saveSelectedProducts', () => {
+ const buildProduct = (
+ overrides: Partial< ProductEntityRecord > = {}
+ ): ProductEntityRecord =>
+ ( {
+ id: 10,
+ name: 'Hoodie',
+ status: 'draft',
+ type: 'simple',
+ virtual: false,
+ downloadable: false,
+ on_sale: false,
+ categories: [],
+ tags: [],
+ images: [],
+ ...overrides,
+ } as unknown as ProductEntityRecord );
+
+ const buildVariation = (
+ overrides: Partial< ProductEntityRecord > = {}
+ ): ProductEntityRecord =>
+ buildProduct( {
+ id: 100,
+ parent_id: 10,
+ name: 'Blue',
+ type: 'variation',
+ ...overrides,
+ } );
+
+ beforeEach( () => {
+ jest.clearAllMocks();
+ } );
+
+ it( 'keeps edits for selected variations that failed after another variation saved', async () => {
+ const originalSavedVariation = buildVariation( {
+ id: 101,
+ name: 'Blue original',
+ } );
+ const originalFailedVariation = buildVariation( {
+ id: 102,
+ name: 'Green original',
+ } );
+ const originalUnselectedVariation = buildVariation( {
+ id: 103,
+ name: 'Red original',
+ } );
+ const editedSavedVariation = {
+ ...originalSavedVariation,
+ name: 'Blue edited',
+ };
+ const editedFailedVariation = {
+ ...originalFailedVariation,
+ name: 'Green edited',
+ };
+ const editedParent = buildProduct( {
+ id: 10,
+ type: 'variable',
+ _embedded: {
+ variations: [
+ editedSavedVariation,
+ editedFailedVariation,
+ originalUnselectedVariation,
+ ],
+ },
+ } );
+ const saveError = new Error( 'Variation save failed.' );
+ const editEntityRecord = jest.fn(
+ (
+ _kind,
+ _name,
+ _recordId,
+ edits: Partial< ProductEntityRecord >
+ ) => {
+ Object.assign( editedParent, edits );
+ }
+ );
+ const saveEditedEntityRecord = jest.fn( async () => editedParent );
+
+ mockGetEditedEntityRecord.mockImplementation( ( _kind, _name, id ) =>
+ id === editedParent.id ? editedParent : undefined
+ );
+ mockGetEntityRecord.mockReturnValue( undefined );
+ ( apiFetch as unknown as jest.Mock )
+ .mockResolvedValueOnce( {
+ id: 101,
+ parent_id: 10,
+ name: 'Blue saved',
+ manage_stock: false,
+ } )
+ .mockRejectedValueOnce( saveError );
+
+ const results = await saveSelectedProducts( {
+ selectedProducts: [ editedSavedVariation, editedFailedVariation ],
+ editEntityRecord,
+ saveEditedEntityRecord,
+ } );
+
+ expect( saveEditedEntityRecord ).toHaveBeenCalledWith(
+ 'root',
+ 'product',
+ 10,
+ {
+ throwOnError: true,
+ }
+ );
+ expect( editedParent._embedded?.variations ).toEqual( [
+ expect.objectContaining( {
+ id: 101,
+ name: 'Blue saved',
+ } ),
+ expect.objectContaining( {
+ id: 102,
+ name: 'Green edited',
+ } ),
+ expect.objectContaining( {
+ id: 103,
+ name: 'Red original',
+ } ),
+ ] );
+ expect( results ).toEqual( [
+ expect.objectContaining( {
+ status: 'fulfilled',
+ } ),
+ {
+ status: 'rejected',
+ reason: saveError,
+ },
+ ] );
+ } );
+} );
diff --git a/packages/js/experimental-products-app/src/product-edit/save.ts b/packages/js/experimental-products-app/src/product-edit/save.ts
new file mode 100644
index 00000000000..68053e9c1c6
--- /dev/null
+++ b/packages/js/experimental-products-app/src/product-edit/save.ts
@@ -0,0 +1,204 @@
+/**
+ * External dependencies
+ */
+import apiFetch from '@wordpress/api-fetch';
+import { store as coreStore } from '@wordpress/core-data';
+import { select } from '@wordpress/data';
+import type { ProductVariation } from '@woocommerce/data';
+
+/**
+ * Internal dependencies
+ */
+import type { ProductEntityRecord } from '../fields/types';
+import { normalizeVariation } from '../variation-view/normalization';
+import {
+ getProductWithUpdatedVariation,
+ getProductVariationUpdatePath,
+ isProductVariation,
+} from './utils';
+
+type ProductVariationEntityRecord = ProductEntityRecord & {
+ parent_id: number;
+};
+
+type ProductSaveResult = PromiseSettledResult<
+ ProductEntityRecord | ProductVariation
+>;
+
+type EditProductRecord = (
+ kind: 'root',
+ name: 'product',
+ recordId: number,
+ edits: Partial< ProductEntityRecord >,
+ options?: { undoIgnore?: boolean }
+) => void;
+
+type SaveEditedProductRecord = (
+ kind: 'root',
+ name: 'product',
+ recordId: number,
+ options: { throwOnError: true }
+) => Promise< ProductEntityRecord >;
+
+function getEditedProduct( productId: number ) {
+ const product = select( coreStore ).getEditedEntityRecord(
+ 'root',
+ 'product',
+ productId
+ ) as ProductEntityRecord | false | undefined;
+
+ return product !== false ? product : undefined;
+}
+
+async function saveVariation(
+ product: ProductVariationEntityRecord,
+ editEntityRecord: EditProductRecord
+) {
+ const parentProduct = getEditedProduct( product.parent_id );
+ const editedVariation =
+ parentProduct?._embedded?.variations?.find(
+ ( variation ) => variation.id === product.id
+ ) ?? product;
+ const savedVariation = await apiFetch< ProductVariation >( {
+ path: getProductVariationUpdatePath( product ),
+ method: 'PUT',
+ data: editedVariation,
+ } );
+
+ if ( parentProduct ) {
+ const updatedParentProduct = getProductWithUpdatedVariation(
+ parentProduct,
+ normalizeVariation(
+ savedVariation
+ ) as unknown as ProductEntityRecord
+ );
+
+ editEntityRecord(
+ 'root',
+ 'product',
+ parentProduct.id,
+ {
+ _embedded: updatedParentProduct._embedded,
+ },
+ {
+ undoIgnore: true,
+ }
+ );
+ }
+
+ return savedVariation;
+}
+
+async function saveVariationsSequentially(
+ selectedVariations: ProductVariationEntityRecord[],
+ editEntityRecord: EditProductRecord
+) {
+ const variationResults: ProductSaveResult[] = [];
+
+ for ( const product of selectedVariations ) {
+ try {
+ // Save variations one at a time because saveVariation merges each
+ // saved variation into the parent product's current embedded
+ // variations. Concurrent saves can merge against stale parent
+ // snapshots and overwrite another variation's just-saved update.
+ variationResults.push( {
+ status: 'fulfilled',
+ value: await saveVariation( product, editEntityRecord ),
+ } );
+ } catch ( error ) {
+ variationResults.push( {
+ status: 'rejected',
+ reason: error,
+ } );
+ }
+ }
+
+ return variationResults;
+}
+
+function getSelectedProductSaveResults(
+ selectedProducts: ProductEntityRecord[],
+ selectedVariations: ProductVariationEntityRecord[],
+ variationResults: ProductSaveResult[],
+ productSaveIds: number[],
+ productSaveResults: ProductSaveResult[]
+) {
+ const missingSaveResult: PromiseRejectedResult = {
+ status: 'rejected',
+ reason: new Error( 'Product save result is missing.' ),
+ };
+ const productSaveResultsById = new Map(
+ productSaveIds.map( ( productId, index ) => [
+ productId,
+ productSaveResults[ index ] ?? missingSaveResult,
+ ] )
+ );
+ const variationResultsById = new Map(
+ selectedVariations.map( ( product, index ) => [
+ product.id,
+ variationResults[ index ] ?? missingSaveResult,
+ ] )
+ );
+
+ return selectedProducts.map( ( product ) => {
+ if ( ! isProductVariation( product ) ) {
+ return (
+ productSaveResultsById.get( product.id ) ?? missingSaveResult
+ );
+ }
+
+ const variationResult = variationResultsById.get( product.id );
+
+ if ( variationResult?.status === 'rejected' ) {
+ return variationResult;
+ }
+
+ return (
+ productSaveResultsById.get( product.parent_id ) ?? missingSaveResult
+ );
+ } );
+}
+
+export async function saveSelectedProducts( {
+ selectedProducts,
+ editEntityRecord,
+ saveEditedEntityRecord,
+}: {
+ selectedProducts: ProductEntityRecord[];
+ editEntityRecord: EditProductRecord;
+ saveEditedEntityRecord: SaveEditedProductRecord;
+} ) {
+ const selectedVariations = selectedProducts.filter( isProductVariation );
+ const productIdsToSave = new Set(
+ selectedProducts
+ .filter( ( product ) => ! isProductVariation( product ) )
+ .map( ( product ) => product.id )
+ );
+ const variationResults = await saveVariationsSequentially(
+ selectedVariations,
+ editEntityRecord
+ );
+
+ variationResults.forEach( ( result, index ) => {
+ if ( result.status === 'fulfilled' ) {
+ productIdsToSave.add( selectedVariations[ index ].parent_id );
+ }
+ } );
+
+ const productSaveIds = Array.from( productIdsToSave );
+ const productSaveResults = await Promise.allSettled(
+ productSaveIds.map( ( productId ) =>
+ saveEditedEntityRecord( 'root', 'product', productId, {
+ throwOnError: true,
+ } )
+ )
+ );
+
+ return getSelectedProductSaveResults(
+ selectedProducts,
+ selectedVariations,
+ variationResults,
+ productSaveIds,
+ productSaveResults
+ );
+}
diff --git a/packages/js/experimental-products-app/src/product-edit/utils.test.ts b/packages/js/experimental-products-app/src/product-edit/utils.test.ts
index d3233b03270..dbc66826061 100644
--- a/packages/js/experimental-products-app/src/product-edit/utils.test.ts
+++ b/packages/js/experimental-products-app/src/product-edit/utils.test.ts
@@ -11,8 +11,11 @@ import { productFields } from '../product-list/fields';
import {
buildMergedProductEditData,
EXCLUDED_PRODUCT_EDIT_FIELD_IDS,
+ getProductWithUpdatedVariation,
getProductEditFields,
+ getProductVariationUpdatePath,
getVisibleProductEditFields,
+ isProductVariation,
} from './utils';
jest.mock( '@woocommerce/settings', () => ( {
@@ -123,6 +126,76 @@ describe( 'product edit utils', () => {
);
} );
+ it( 'identifies variations and builds their update endpoint path', () => {
+ const variation = buildProduct( {
+ id: 34,
+ parent_id: 12,
+ type: 'variation',
+ } );
+
+ expect( isProductVariation( variation ) ).toBe( true );
+
+ if ( isProductVariation( variation ) ) {
+ expect( getProductVariationUpdatePath( variation ) ).toBe(
+ '/wc/v3/products/12/variations/34'
+ );
+ }
+
+ expect(
+ isProductVariation( buildProduct( { id: 12, parent_id: 0 } ) )
+ ).toBe( false );
+ expect(
+ isProductVariation(
+ buildProduct( {
+ id: 34,
+ parent_id: 0,
+ type: 'variation',
+ } )
+ )
+ ).toBe( true );
+ const orphanVariation = buildProduct( {
+ id: 34,
+ parent_id: 0,
+ type: 'variation',
+ } );
+
+ if ( isProductVariation( orphanVariation ) ) {
+ expect( () =>
+ getProductVariationUpdatePath( orphanVariation )
+ ).toThrow( 'Variation parent ID is required' );
+ }
+ } );
+
+ it( 'updates an embedded variation in a product record', () => {
+ const variation = buildProduct( {
+ id: 34,
+ parent_id: 12,
+ name: 'Blue',
+ type: 'variation',
+ } );
+ const updatedVariation = {
+ ...variation,
+ name: 'Green',
+ };
+ const parent = buildProduct( {
+ id: 12,
+ _embedded: {
+ variations: [ variation ],
+ },
+ } );
+
+ expect(
+ getProductWithUpdatedVariation( parent, updatedVariation )
+ ).toEqual(
+ expect.objectContaining( {
+ id: 12,
+ _embedded: {
+ variations: [ updatedVariation ],
+ },
+ } )
+ );
+ } );
+
describe( 'getVisibleProductEditFields', () => {
const getVisibleFieldIds = ( products: ProductEntityRecord[] ) =>
getVisibleProductEditFields(
diff --git a/packages/js/experimental-products-app/src/product-edit/utils.ts b/packages/js/experimental-products-app/src/product-edit/utils.ts
index 19f58f3f2f1..dc1a4daffd7 100644
--- a/packages/js/experimental-products-app/src/product-edit/utils.ts
+++ b/packages/js/experimental-products-app/src/product-edit/utils.ts
@@ -25,6 +25,9 @@ const EXCLUDED_PRODUCT_EDIT_FIELD_ID_SET = new Set(
type ProductField = Field< ProductEntityRecord >;
type ProductEditFieldId = ( typeof PRODUCT_EDIT_FIELD_IDS )[ number ];
+type ProductVariationEntityRecord = ProductEntityRecord & {
+ parent_id: number;
+};
const PRODUCT_EDIT_FIELD_IDS = [
'name',
@@ -185,6 +188,67 @@ function getProductTypeCompatibleFieldIds( product: ProductEntityRecord ) {
return COMMON_PRODUCT_EDIT_FIELD_IDS;
}
+export function isProductVariation(
+ product: ProductEntityRecord
+): product is ProductVariationEntityRecord {
+ return product.type === 'variation' || Boolean( product.parent_id );
+}
+
+export function getProductVariationUpdatePath(
+ product: ProductVariationEntityRecord
+) {
+ if ( ! product.parent_id ) {
+ throw new Error(
+ 'Variation parent ID is required to update a variation.'
+ );
+ }
+
+ return `/wc/v3/products/${ product.parent_id }/variations/${ product.id }`;
+}
+
+export function getProductWithUpdatedVariation(
+ product: ProductEntityRecord,
+ variation: ProductEntityRecord
+): ProductEntityRecord {
+ const embeddedVariations = product._embedded?.variations ?? [];
+ const hasEmbeddedVariation = embeddedVariations.some(
+ ( embeddedVariation ) => embeddedVariation.id === variation.id
+ );
+
+ return {
+ ...product,
+ _embedded: {
+ ...product._embedded,
+ variations: hasEmbeddedVariation
+ ? embeddedVariations.map( ( embeddedVariation ) =>
+ embeddedVariation.id === variation.id
+ ? variation
+ : embeddedVariation
+ )
+ : [ ...embeddedVariations, variation ],
+ },
+ };
+}
+
+export function findProductInList(
+ products: ProductEntityRecord[],
+ productId: number
+) {
+ for ( const product of products ) {
+ if ( product.id === productId ) {
+ return product;
+ }
+
+ const variation = product._embedded?.variations?.find(
+ ( embeddedVariation ) => embeddedVariation.id === productId
+ );
+
+ if ( variation ) {
+ return variation;
+ }
+ }
+}
+
function getCommonProductTypeCompatibleFieldIds(
products: ProductEntityRecord[]
) {
diff --git a/packages/js/experimental-products-app/src/product-list/index.tsx b/packages/js/experimental-products-app/src/product-list/index.tsx
index 92ad56435ac..1b94e33492f 100644
--- a/packages/js/experimental-products-app/src/product-list/index.tsx
+++ b/packages/js/experimental-products-app/src/product-list/index.tsx
@@ -21,20 +21,18 @@ import { unlock } from '../lock-unlock';
import type { ProductEntityRecord } from '../fields/types';
import {
DEFAULT_LAYOUTS,
- DEFAULT_VIEW,
EMPTY_ARRAY,
PAGE_SIZE,
PRODUCT_LIST_TABS,
+ type StatusTab,
} from './constants';
import { productFields } from './fields';
-import { buildProductListQuery } from './query';
import {
getItemId,
getProductListNavigationPath,
getProductListTab,
getProductsWithEmbeddedVariations,
getSelectionFromPostId,
- getStatusForProductListTab,
isProductEditorAccessible,
} from './utils';
import { useProductActions } from '../dataviews-actions';
@@ -47,30 +45,28 @@ export type ProductListProps = {
className?: string;
hideTitleFromUI?: boolean;
postType?: string;
+ hasResolved: boolean;
+ isLoading: boolean;
+ records?: ProductEntityRecord[] | null;
+ selectedTab: StatusTab;
+ setSelectedTab: ( selectedTab: StatusTab ) => void;
+ setView: ( view: View ) => void;
+ totalCount?: number | null;
+ view: View;
};
-/**
- * This function abstracts working with default & custom views by
- * providing a [ state, setState ] tuple based on the URL parameters.
- *
- * Consumers use the provided tuple to work with state
- * and don't have to deal with the specifics of default & custom views.
- *
- * @return {Array} The [ state, setState ] tuple.
- */
-function useView(): [ View, ( view: View ) => void ] {
- const { query: { activeView = 'all' } = {} } = useLocation();
- const [ view, setView ] = useState< View >( DEFAULT_VIEW );
-
- // When activeView URL parameter changes, reset the view.
- useEffect( () => {
- setView( DEFAULT_VIEW );
- }, [ activeView ] );
-
- return [ view, setView ];
-}
-
-export default function ProductList( { className }: ProductListProps ) {
+export default function ProductList( {
+ className,
+ hasResolved,
+ isLoading,
+ records,
+ selectedTab,
+ setSelectedTab,
+ setView,
+ totalCount,
+ view,
+ postType = 'product',
+}: ProductListProps ) {
const { navigate } = useHistory();
const location = useLocation();
const currentQuery = useMemo(
@@ -82,33 +78,15 @@ export default function ProductList( { className }: ProductListProps ) {
},
[ location.query ]
);
- const { postId, postType = 'product', activeView = 'all' } = currentQuery;
- const selectedTabFromLocation = getProductListTab( activeView );
- const [ selectedTab, setSelectedTab ] = useState( selectedTabFromLocation );
+ const { postId, activeView = 'all' } = currentQuery;
const [ selection, setSelection ] = useState( () =>
getSelectionFromPostId( postId )
);
- const [ view, setView ] = useView();
-
- useEffect( () => {
- setSelectedTab( selectedTabFromLocation );
- }, [ selectedTabFromLocation ] );
useEffect( () => {
setSelection( getSelectionFromPostId( postId ) );
}, [ postId ] );
- const queryParams = useMemo( () => {
- const query = buildProductListQuery( view );
- const productStatus = getStatusForProductListTab( selectedTab );
-
- if ( productStatus ) {
- query.status = productStatus;
- }
-
- return query;
- }, [ selectedTab, view ] );
-
const onChangeSelection = useCallback(
( items: string[] ) => {
setSelection( items );
@@ -154,44 +132,7 @@ export default function ProductList( { className }: ProductListProps ) {
getProductListNavigationPath( location.path, nextParams )
);
},
- [ currentQuery, navigate, location.path, selectedTab ]
- );
-
- const {
- records,
- totalItems: totalCount,
- isResolving: isLoading,
- hasResolved,
- } = useSelect(
- ( select ) => {
- const {
- getEntityRecords,
- isResolving,
- hasFinishedResolution,
- getEntityRecordsTotalItems,
- } = select( coreStore );
- return {
- records: getEntityRecords< ProductEntityRecord >(
- 'root',
- 'product',
- queryParams
- ),
- totalItems: getEntityRecordsTotalItems( 'root', 'product', {
- ...queryParams,
- } ),
- isResolving: isResolving( 'getEntityRecords', [
- 'root',
- 'product',
- queryParams,
- ] ),
- hasResolved: hasFinishedResolution( 'getEntityRecords', [
- 'root',
- 'product',
- queryParams,
- ] ),
- };
- },
- [ queryParams ]
+ [ currentQuery, navigate, location.path, selectedTab, setSelectedTab ]
);
const paginationInfo = useMemo(
diff --git a/packages/js/experimental-products-app/src/router.tsx b/packages/js/experimental-products-app/src/router.tsx
index 2b5dd9fb273..c4d472dcc51 100644
--- a/packages/js/experimental-products-app/src/router.tsx
+++ b/packages/js/experimental-products-app/src/router.tsx
@@ -2,6 +2,10 @@
* External dependencies
*/
import { privateApis as routerPrivateApis } from '@wordpress/router';
+import { store as coreStore } from '@wordpress/core-data';
+import { useSelect } from '@wordpress/data';
+import { useEffect, useMemo, useState } from '@wordpress/element';
+import type { View } from '@wordpress/dataviews';
/**
* Internal dependencies
@@ -9,6 +13,13 @@ import { privateApis as routerPrivateApis } from '@wordpress/router';
import { unlock } from './lock-unlock';
import ProductList from './product-list';
import ProductEdit from './product-edit';
+import type { ProductEntityRecord } from './fields/types';
+import { DEFAULT_VIEW } from './product-list/constants';
+import { buildProductListQuery } from './product-list/query';
+import {
+ getProductListTab,
+ getStatusForProductListTab,
+} from './product-list/utils';
const { useLocation } = unlock( routerPrivateApis );
@@ -31,31 +42,98 @@ export type Route = {
export default function useLayoutAreas() {
const { params = {}, query = {} } = useLocation();
const postType = params.postType ?? query.postType ?? 'product';
- const canvas = params.canvas ?? query.canvas;
+ const activeView = query.activeView as string | undefined;
+ const selectedTabFromLocation = getProductListTab( activeView );
+ const [ selectedTab, setSelectedTab ] = useState( selectedTabFromLocation );
+ const [ view, setView ] = useState< View >( DEFAULT_VIEW );
const showQuickEdit =
params.quickEdit === 'true' ||
query.quickEdit === 'true' ||
params.quickEdit === true ||
query.quickEdit === true;
- // Products list.
- if ( [ 'product' ].includes( postType ) ) {
- return {
- key: 'products-list',
- areas: {
- content: <ProductList />,
- edit: showQuickEdit ? <ProductEdit /> : undefined,
- preview: false,
- mobile: <ProductList postType={ postType } />,
- },
- };
- }
-
- // Fallback shows the home page preview
+
+ useEffect( () => {
+ setSelectedTab( selectedTabFromLocation );
+ }, [ selectedTabFromLocation ] );
+
+ useEffect( () => {
+ setView( DEFAULT_VIEW );
+ }, [ activeView ] );
+
+ const queryParams = useMemo( () => {
+ const productListQuery = buildProductListQuery( view );
+ const productStatus = getStatusForProductListTab( selectedTab );
+
+ if ( productStatus ) {
+ productListQuery.status = productStatus;
+ }
+
+ return productListQuery;
+ }, [ selectedTab, view ] );
+
+ const {
+ records,
+ totalItems: totalCount,
+ isResolving: isLoading,
+ hasResolved,
+ } = useSelect(
+ ( select ) => {
+ const {
+ getEntityRecords,
+ isResolving,
+ hasFinishedResolution,
+ getEntityRecordsTotalItems,
+ } = select( coreStore );
+
+ return {
+ records: getEntityRecords< ProductEntityRecord >(
+ 'root',
+ 'product',
+ queryParams
+ ),
+ totalItems: getEntityRecordsTotalItems( 'root', 'product', {
+ ...queryParams,
+ } ),
+ isResolving: isResolving( 'getEntityRecords', [
+ 'root',
+ 'product',
+ queryParams,
+ ] ),
+ hasResolved: hasFinishedResolution( 'getEntityRecords', [
+ 'root',
+ 'product',
+ queryParams,
+ ] ),
+ };
+ },
+ [ queryParams ]
+ );
+
+ const productListProps = {
+ hasResolved,
+ isLoading,
+ records,
+ selectedTab,
+ setSelectedTab,
+ setView,
+ totalCount,
+ view,
+ };
+
return {
- key: 'default',
+ key: 'products-list',
areas: {
+ content: <ProductList { ...productListProps } />,
+ edit: showQuickEdit ? (
+ <ProductEdit products={ records ?? [] } />
+ ) : undefined,
preview: false,
- mobile: canvas === 'edit',
+ mobile: (
+ <ProductList postType={ postType } { ...productListProps } />
+ ),
+ },
+ widths: {
+ edit: showQuickEdit ? 380 : undefined,
},
};
}