Commit a5f7b7f9bc2 for woocommerce
commit a5f7b7f9bc2150dd05fada6b7f64ef6849876cfb
Author: Karol Manijak <20098064+kmanijak@users.noreply.github.com>
Date: Thu Mar 12 10:49:08 2026 +0100
Add to Cart: improve validation of attributes with multi-word names (#63640)
* Improve attributes validation for two words cases
* Add changelog
* Cover one more case missing normalization
* Update chaneglog
* Refactor normalisation so it's more centralised
* Add tests
* Fix lint
diff --git a/plugins/woocommerce/changelog/fix-63635 b/plugins/woocommerce/changelog/fix-63635
new file mode 100644
index 00000000000..70e99d72f72
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-63635
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Add to Cart: fix validation of attributes with multi-word names
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/attribute-matching.ts b/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/attribute-matching.ts
index f824ff9b3f7..66c611c35f9 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/attribute-matching.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/attribute-matching.ts
@@ -9,13 +9,17 @@ import type {
/**
* Normalize attribute name by stripping the 'attribute_' or 'attribute_pa_' prefix
- * that WooCommerce adds for variation attributes.
+ * that WooCommerce adds for variation attributes, and replacing hyphens with spaces
+ * so that slugs (e.g., "some-name") match labels (e.g., "some name").
*
* @param name The attribute name (e.g., 'attribute_color' or 'attribute_pa_color').
* @return The normalized name (e.g., 'color').
*/
export const normalizeAttributeName = ( name: string ): string => {
- return name.replace( /^attribute_(pa_)?/, '' );
+ return name
+ .replace( /^attribute_(pa_)?/, '' )
+ .replace( /-/g, ' ' )
+ .toLowerCase();
};
/**
@@ -32,10 +36,7 @@ export const attributeNamesMatch = (
name1: string,
name2: string
): boolean => {
- return (
- normalizeAttributeName( name1 ).toLowerCase() ===
- normalizeAttributeName( name2 ).toLowerCase()
- );
+ return normalizeAttributeName( name1 ) === normalizeAttributeName( name2 );
};
/**
@@ -53,10 +54,8 @@ export const getVariationAttributeValue = (
variation: ProductResponseVariationsItem,
attributeName: string
): string | undefined => {
- const normalizedName =
- normalizeAttributeName( attributeName ).toLowerCase();
- const attr = variation.attributes.find(
- ( a ) => a.name.toLowerCase() === normalizedName
+ const attr = variation.attributes.find( ( a ) =>
+ attributeNamesMatch( attributeName, a.name )
);
return attr?.value;
};
@@ -82,12 +81,8 @@ export const findMatchingVariation = (
const matchedVariation = product.variations.find(
( variation: ProductResponseVariationsItem ) => {
return variation.attributes.every( ( attr ) => {
- const attrNameLower = attr.name.toLowerCase();
- const selectedAttr = selectedAttributes.find(
- ( selected ) =>
- normalizeAttributeName(
- selected.attribute
- ).toLowerCase() === attrNameLower
+ const selectedAttr = selectedAttributes.find( ( selected ) =>
+ attributeNamesMatch( attr.name, selected.attribute )
);
// If variation attribute is null, it accepts "Any" value.
diff --git a/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/test/attribute-matching.ts b/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/test/attribute-matching.ts
index 763800f188e..6a9a83b91bb 100644
--- a/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/test/attribute-matching.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/base/utils/variations/test/attribute-matching.ts
@@ -19,8 +19,20 @@ describe( 'normalizeAttributeName', () => {
);
} );
- it( 'returns unchanged name without prefix', () => {
- expect( normalizeAttributeName( 'Color' ) ).toBe( 'Color' );
+ it( 'returns lowercased name without prefix', () => {
+ expect( normalizeAttributeName( 'Color' ) ).toBe( 'color' );
+ } );
+
+ it( 'replaces hyphens with spaces for multi-word slugs', () => {
+ expect( normalizeAttributeName( 'attribute_pa_numeric-size' ) ).toBe(
+ 'numeric size'
+ );
+ } );
+
+ it( 'replaces hyphens with spaces without prefix', () => {
+ expect( normalizeAttributeName( 'numeric-size' ) ).toBe(
+ 'numeric size'
+ );
} );
} );
@@ -41,6 +53,24 @@ describe( 'attributeNamesMatch', () => {
).toBe( true );
} );
+ it( 'matches hyphenated slug against spaced label', () => {
+ expect(
+ attributeNamesMatch( 'attribute_pa_numeric-size', 'numeric size' )
+ ).toBe( true );
+ } );
+
+ it( 'matches hyphenated slug against capitalized spaced label', () => {
+ expect(
+ attributeNamesMatch( 'attribute_pa_numeric-size', 'Numeric Size' )
+ ).toBe( true );
+ } );
+
+ it( 'matches two hyphenated names', () => {
+ expect(
+ attributeNamesMatch( 'attribute_pa_numeric-size', 'numeric-size' )
+ ).toBe( true );
+ } );
+
it( 'returns false for different names', () => {
expect( attributeNamesMatch( 'color', 'size' ) ).toBe( false );
} );
@@ -78,6 +108,45 @@ describe( 'getVariationAttributeValue', () => {
getVariationAttributeValue( variation, 'material' )
).toBeUndefined();
} );
+
+ describe( 'multi-word attribute names', () => {
+ const variationWithSpaces = {
+ id: 456,
+ attributes: [ { name: 'numeric size', value: '42' } ],
+ };
+
+ const variationWithHyphens = {
+ id: 789,
+ attributes: [ { name: 'numeric-size', value: '44' } ],
+ };
+
+ it( 'finds value when slug has hyphens and Store API has spaces', () => {
+ expect(
+ getVariationAttributeValue(
+ variationWithSpaces,
+ 'attribute_pa_numeric-size'
+ )
+ ).toBe( '42' );
+ } );
+
+ it( 'finds value when both use hyphens', () => {
+ expect(
+ getVariationAttributeValue(
+ variationWithHyphens,
+ 'attribute_pa_numeric-size'
+ )
+ ).toBe( '44' );
+ } );
+
+ it( 'finds value when both use spaces', () => {
+ expect(
+ getVariationAttributeValue(
+ variationWithSpaces,
+ 'numeric size'
+ )
+ ).toBe( '42' );
+ } );
+ } );
} );
describe( 'findMatchingVariation', () => {
@@ -149,6 +218,54 @@ describe( 'findMatchingVariation', () => {
).toBeNull();
} );
+ describe( 'multi-word attribute names', () => {
+ const productWithMultiWord = {
+ id: 3,
+ type: 'variable',
+ variations: [
+ {
+ id: 301,
+ attributes: [
+ { name: 'Color', value: 'Blue' },
+ { name: 'numeric size', value: '42' },
+ ],
+ },
+ {
+ id: 302,
+ attributes: [
+ { name: 'Color', value: 'Red' },
+ { name: 'numeric size', value: '44' },
+ ],
+ },
+ ],
+ };
+
+ it( 'matches when selected attributes use hyphenated slugs', () => {
+ const result = findMatchingVariation( productWithMultiWord, [
+ { attribute: 'attribute_pa_color', value: 'Blue' },
+ { attribute: 'attribute_pa_numeric-size', value: '42' },
+ ] );
+ expect( result?.id ).toBe( 301 );
+ } );
+
+ it( 'matches when Store API uses hyphens instead of spaces', () => {
+ const productWithHyphens = {
+ id: 4,
+ type: 'variable',
+ variations: [
+ {
+ id: 401,
+ attributes: [ { name: 'numeric-size', value: '42' } ],
+ },
+ ],
+ };
+ const result = findMatchingVariation( productWithHyphens, [
+ { attribute: 'attribute_pa_numeric-size', value: '42' },
+ ] );
+ expect( result?.id ).toBe( 401 );
+ } );
+ } );
+
describe( 'Any attribute handling', () => {
const productWithAny = {
id: 2,
diff --git a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts
index ca6b9c77b23..4e73669d844 100644
--- a/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts
+++ b/plugins/woocommerce/client/blocks/assets/js/blocks/add-to-cart-with-options/variation-selector/frontend.ts
@@ -349,17 +349,18 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
// Normalize included/excluded attributes to lowercase for comparison
// with Store API labels (e.g., "Color" vs "attribute_pa_color" → "color").
const normalizedIncluded = includedAttributes.map( ( attr ) =>
- normalizeAttributeName( attr ).toLowerCase()
+ normalizeAttributeName( attr )
);
const normalizedExcluded = excludedAttributes.map( ( attr ) =>
- normalizeAttributeName( attr ).toLowerCase()
+ normalizeAttributeName( attr )
);
const productAttributesAndOptions: Record< string, string[] > =
getProductAttributesAndOptions( product );
Object.entries( productAttributesAndOptions ).forEach(
( [ attribute, options ] ) => {
- const attributeLower = attribute.toLowerCase();
+ const attributeLower =
+ normalizeAttributeName( attribute );
if (
normalizedIncluded.length !== 0 &&
! normalizedIncluded.includes( attributeLower )
@@ -386,9 +387,8 @@ const { actions, state } = store< VariableProductAddToCartWithOptionsStore >(
const contextName =
includedAttributes.find(
( attr ) =>
- normalizeAttributeName(
- attr
- ).toLowerCase() === attributeLower
+ normalizeAttributeName( attr ) ===
+ attributeLower
) || attribute;
actions.setAttribute( contextName, validOption );
}