diff --git a/packages/peregrine/lib/talons/ProductFullDetail/__tests__/useProductFullDetail.spec.js b/packages/peregrine/lib/talons/ProductFullDetail/__tests__/useProductFullDetail.spec.js
index 03759e9117..450e7da108 100644
--- a/packages/peregrine/lib/talons/ProductFullDetail/__tests__/useProductFullDetail.spec.js
+++ b/packages/peregrine/lib/talons/ProductFullDetail/__tests__/useProductFullDetail.spec.js
@@ -667,6 +667,123 @@ describe('shouldShowSimpleProductOutOfStockButton', () => {
});
});
+const configurableSparseVariantMissingChildStockStatusProps = {
+ ...defaultProps,
+ product: {
+ ...defaultProps.product,
+ __typename: 'ConfigurableProduct',
+ stock_status: 'IN_STOCK',
+ media_gallery_entries: [],
+ price_range: {
+ maximum_price: {
+ final_price: {
+ value: 10,
+ currency: 'USD'
+ },
+ discount: {
+ amount_off: 0
+ }
+ }
+ },
+ configurable_options: [
+ {
+ attribute_code: 'color',
+ attribute_id: '179',
+ id: 1,
+ label: 'Color',
+ values: [
+ {
+ __typename: 'ConfigurableProductOptionsValues',
+ uid: 'c1',
+ default_label: 'A',
+ label: 'A',
+ store_label: 'A',
+ use_default_value: true,
+ value_index: 10,
+ swatch_data: null,
+ media_gallery_entries: []
+ },
+ {
+ __typename: 'ConfigurableProductOptionsValues',
+ uid: 'c2',
+ default_label: 'B',
+ label: 'B',
+ store_label: 'B',
+ use_default_value: true,
+ value_index: 11,
+ swatch_data: null,
+ media_gallery_entries: []
+ }
+ ]
+ },
+ {
+ attribute_code: 'size',
+ attribute_id: '190',
+ id: 2,
+ label: 'Size',
+ values: [
+ {
+ __typename: 'ConfigurableProductOptionsValues',
+ uid: 's1',
+ default_label: 'S',
+ label: 'S',
+ store_label: 'S',
+ use_default_value: true,
+ value_index: 100,
+ swatch_data: null,
+ media_gallery_entries: []
+ },
+ {
+ __typename: 'ConfigurableProductOptionsValues',
+ uid: 's2',
+ default_label: 'M',
+ label: 'M',
+ store_label: 'M',
+ use_default_value: true,
+ value_index: 101,
+ swatch_data: null,
+ media_gallery_entries: []
+ }
+ ]
+ }
+ ],
+ variants: [
+ {
+ __typename: 'ConfigurableVariant',
+ attributes: [
+ {
+ code: 'color',
+ value_index: 10,
+ __typename: 'ConfigurableAttributeOption'
+ },
+ {
+ code: 'size',
+ value_index: 100,
+ __typename: 'ConfigurableAttributeOption'
+ }
+ ],
+ product: {
+ __typename: 'SimpleProduct',
+ sku: 'child-1',
+ media_gallery_entries: [],
+ price_range: {
+ maximum_price: {
+ final_price: {
+ value: 10,
+ currency: 'USD'
+ },
+ discount: {
+ amount_off: 0
+ }
+ }
+ },
+ custom_attributes: []
+ }
+ }
+ ]
+ }
+};
+
describe('shouldShowConfigurableProductOutOfStockButton', () => {
test('is false if product is in stock and no option is selected but disabled', () => {
const tree = createTestInstance(
@@ -739,6 +856,34 @@ describe('shouldShowConfigurableProductOutOfStockButton', () => {
expect(talonProps.isOutOfStock).toBeTruthy();
expect(talonProps.isAddToCartDisabled).toBeTruthy();
});
+
+ test('is false when sparse catalog omits child stock_status but selection is salable', () => {
+ const tree = createTestInstance(
+
+ );
+
+ const { root } = tree;
+
+ act(() => {
+ root.findByType('i').props.talonProps.handleSelectionChange(
+ '179',
+ 10
+ );
+ });
+ act(() => {
+ root.findByType('i').props.talonProps.handleSelectionChange(
+ '190',
+ 100
+ );
+ });
+
+ const { talonProps } = root.findByType('i').props;
+
+ expect(talonProps.isOutOfStock).toBeFalsy();
+ expect(talonProps.isAddToCartDisabled).toBeFalsy();
+ });
});
describe('shouldShowWishlistButton', () => {
@@ -1040,13 +1185,13 @@ test('calls generic mutation when no deprecated operation props are passed', asy
Object {
"variables": Object {
"cartId": "ThisIsMyCart",
- "entered_options": Array [
- Object {
- "uid": "NDA=",
- "value": "Strive Shoulder Pac",
- },
- ],
"product": Object {
+ "entered_options": Array [
+ Object {
+ "uid": "NDA=",
+ "value": "Strive Shoulder Pac",
+ },
+ ],
"quantity": 2,
"sku": "MySimpleProductSku",
},
@@ -1055,6 +1200,65 @@ test('calls generic mutation when no deprecated operation props are passed', asy
`);
});
+test('sends selected_options inside product for configurable add to cart (no entered_options)', async () => {
+ const mockAddProductToCart = jest.fn().mockResolvedValue({});
+ let useMutationCall = 0;
+ // useProductFullDetail registers four useMutation hooks per render (configurable,
+ // simple, addProductToCart, createEmptyCart). Slot 2 is addProductsToCart.
+ useMutation.mockImplementation(() => {
+ const slot = useMutationCall % 4;
+ useMutationCall += 1;
+ if (slot === 2) {
+ return [mockAddProductToCart, { error: null, loading: false }];
+ }
+ return [jest.fn(), { error: null, loading: false }];
+ });
+
+ try {
+ const tree = createTestInstance(
+
+ );
+ const { root } = tree;
+
+ act(() => {
+ root.findByType('i').props.talonProps.handleSelectionChange(
+ '179',
+ 14
+ );
+ });
+ act(() => {
+ root.findByType('i').props.talonProps.handleSelectionChange(
+ '190',
+ 45
+ );
+ });
+
+ await act(async () => {
+ await root.findByType('i').props.talonProps.handleAddToCart({
+ quantity: 1
+ });
+ });
+
+ expect(mockAddProductToCart).toHaveBeenCalledTimes(1);
+ const { variables } = mockAddProductToCart.mock.calls[0][0];
+ expect(variables.product).toMatchObject({
+ sku: configurableProductWithTwoOptionGroupProps.product.sku,
+ quantity: 1,
+ selected_options: ['20', '80']
+ });
+ expect(variables.product.entered_options).toBeUndefined();
+ } finally {
+ useMutation.mockImplementation(() => [
+ jest.fn(),
+ {
+ error: null
+ }
+ ]);
+ }
+});
+
test('it returns text when render prop is executed', () => {
const tree = createTestInstance();
const { root } = tree;
diff --git a/packages/peregrine/lib/talons/ProductFullDetail/useProductFullDetail.js b/packages/peregrine/lib/talons/ProductFullDetail/useProductFullDetail.js
index 4e991e9618..7c7a187913 100644
--- a/packages/peregrine/lib/talons/ProductFullDetail/useProductFullDetail.js
+++ b/packages/peregrine/lib/talons/ProductFullDetail/useProductFullDetail.js
@@ -13,13 +13,13 @@ import mergeOperations from '../../util/shallowMerge';
import defaultOperations from './productFullDetail.gql';
import { useEventingContext } from '../../context/eventing';
import { getOutOfStockVariants } from '@magento/peregrine/lib/util/getOutOfStockVariants';
+import { createProductVariants } from '@magento/peregrine/lib/util/createProductVariants';
import { useAwaitQuery } from '@magento/peregrine/lib/hooks/useAwaitQuery';
import BrowserPersistence from '../../util/simplePersistence';
const INITIAL_OPTION_CODES = new Map();
const INITIAL_OPTION_SELECTIONS = new Map();
const OUT_OF_STOCK_CODE = 'OUT_OF_STOCK';
-const IN_STOCK_CODE = 'IN_STOCK';
const deriveOptionCodesFromProduct = product => {
// If this is a simple product it has no option codes.
@@ -33,7 +33,7 @@ const deriveOptionCodesFromProduct = product => {
attribute_id,
attribute_code
} of product.configurable_options) {
- initialOptionCodes.set(attribute_id, attribute_code);
+ initialOptionCodes.set(String(attribute_id), attribute_code);
}
return initialOptionCodes;
@@ -47,7 +47,7 @@ const deriveOptionSelectionsFromProduct = product => {
const initialOptionSelections = new Map();
for (const { attribute_id } of product.configurable_options) {
- initialOptionSelections.set(attribute_id, undefined);
+ initialOptionSelections.set(String(attribute_id), undefined);
}
return initialOptionSelections;
@@ -70,7 +70,12 @@ const getIsMissingOptions = (product, optionSelections) => {
return numProductSelections < numProductOptions;
};
-const getIsOutOfStock = (product, optionCodes, optionSelections) => {
+const getIsOutOfStock = (
+ product,
+ optionCodes,
+ optionSelections,
+ isOutOfStockProductDisplayed
+) => {
const { stock_status, variants } = product;
const isConfigurable = isProductConfigurable(product);
const optionsSelected =
@@ -78,11 +83,33 @@ const getIsOutOfStock = (product, optionCodes, optionSelections) => {
0;
if (isConfigurable && optionsSelected) {
- const item = findMatchingVariant({
+ let item = findMatchingVariant({
optionCodes,
optionSelections,
variants
});
+
+ const allOptionsSelected = !getIsMissingOptions(
+ product,
+ optionSelections
+ );
+
+ if (
+ allOptionsSelected &&
+ isOutOfStockProductDisplayed === false &&
+ (!item || !item.product?.stock_status)
+ ) {
+ const syntheticVariants = createProductVariants(product);
+ const syntheticItem = findMatchingVariant({
+ optionCodes,
+ optionSelections,
+ variants: syntheticVariants
+ });
+ if (syntheticItem) {
+ item = syntheticItem;
+ }
+ }
+
const stockStatus = item?.product?.stock_status;
return stockStatus === OUT_OF_STOCK_CODE || !stockStatus;
@@ -94,10 +121,13 @@ const getIsAllOutOfStock = product => {
const isConfigurable = isProductConfigurable(product);
if (isConfigurable) {
- const inStockItem = variants.find(item => {
- return item.product.stock_status === IN_STOCK_CODE;
- });
- return !inStockItem;
+ if (!variants || !variants.length) {
+ return true;
+ }
+
+ return variants.every(
+ item => item.product?.stock_status === OUT_OF_STOCK_CODE
+ );
}
return stock_status === OUT_OF_STOCK_CODE;
@@ -327,11 +357,6 @@ export const useProductFullDetail = props => {
[product, optionSelections]
);
- const isOutOfStock = useMemo(
- () => getIsOutOfStock(product, optionCodes, optionSelections),
- [product, optionCodes, optionSelections]
- );
-
// Check if display out of stock products option is selected in the Admin Dashboard
const isOutOfStockProductDisplayed = useMemo(() => {
let totalVariants = 1;
@@ -345,6 +370,17 @@ export const useProductFullDetail = props => {
}
}, [product]);
+ const isOutOfStock = useMemo(
+ () =>
+ getIsOutOfStock(
+ product,
+ optionCodes,
+ optionSelections,
+ isOutOfStockProductDisplayed
+ ),
+ [product, optionCodes, optionSelections, isOutOfStockProductDisplayed]
+ );
+
const isEverythingOutOfStock = useMemo(() => getIsAllOutOfStock(product), [
product
]);
@@ -390,7 +426,7 @@ export const useProductFullDetail = props => {
// For simple items, this will be an empty map.
const options = product.configurable_options || [];
for (const { attribute_id, values } of options) {
- map.set(attribute_id, values);
+ map.set(String(attribute_id), values);
}
return map;
}, [product.configurable_options]);
@@ -400,20 +436,28 @@ export const useProductFullDetail = props => {
// ["abc", "def"]
const selectedOptionsArray = useMemo(() => {
const selectedOptions = [];
+ const options = product.configurable_options || [];
- optionSelections.forEach((value, key) => {
+ for (const { attribute_id } of options) {
+ const key = String(attribute_id);
+ const value = optionSelections.get(key);
+ if (value === undefined || value === null) {
+ continue;
+ }
const values = attributeIdToValuesMap.get(key);
-
const selectedValue = values?.find(
- item => item.value_index === value
+ item => String(item.value_index) === String(value)
);
-
if (selectedValue) {
selectedOptions.push(selectedValue.uid);
}
- });
+ }
return selectedOptions;
- }, [attributeIdToValuesMap, optionSelections]);
+ }, [
+ attributeIdToValuesMap,
+ optionSelections,
+ product.configurable_options
+ ]);
// Cart creation wiring (same approach as useAddToCartButton.js)
const CREATE_CART_MUTATION = gql`
@@ -510,17 +554,20 @@ export const useProductFullDetail = props => {
product: {
sku: product.sku,
quantity
- },
- entered_options: [
+ }
+ };
+
+ if (isProductConfigurable(product)) {
+ if (selectedOptionsArray.length) {
+ variables.product.selected_options = selectedOptionsArray;
+ }
+ } else if (product.uid) {
+ variables.product.entered_options = [
{
uid: product.uid,
value: product.name
}
- ]
- };
-
- if (selectedOptionsArray.length) {
- variables.product.selected_options = selectedOptionsArray;
+ ];
}
try {
@@ -581,11 +628,11 @@ export const useProductFullDetail = props => {
// We must create a new Map here so that React knows that the value
// of optionSelections has changed.
const nextOptionSelections = new Map([...optionSelections]);
- nextOptionSelections.set(optionId, selection);
+ nextOptionSelections.set(String(optionId), selection);
setOptionSelections(nextOptionSelections);
// Create a new Map to keep track of single selections with key as String
const nextSingleOptionSelection = new Map();
- nextSingleOptionSelection.set(optionId, selection);
+ nextSingleOptionSelection.set(String(optionId), selection);
setSingleOptionSelection(nextSingleOptionSelection);
},
[optionSelections]
diff --git a/packages/peregrine/lib/util/__tests__/getOutOfStockVariants.spec.js b/packages/peregrine/lib/util/__tests__/getOutOfStockVariants.spec.js
index acf6d42347..b687edc84f 100644
--- a/packages/peregrine/lib/util/__tests__/getOutOfStockVariants.spec.js
+++ b/packages/peregrine/lib/util/__tests__/getOutOfStockVariants.spec.js
@@ -1464,10 +1464,12 @@ describe('with configurable Product With Two Option Group', () => {
expect(result).toMatchInlineSnapshot(`
Array [
Array [
- 46,
- ],
- Array [
+ 31,
+ 35,
+ 36,
+ 43,
44,
+ 46,
],
]
`);
@@ -1488,12 +1490,12 @@ describe('with configurable Product With Two Option Group', () => {
);
expect(result).toMatchInlineSnapshot(`
Array [
- Array [
- 46,
- 44,
- ],
Array [
31,
+ 35,
+ 36,
+ 44,
+ 46,
],
]
`);
@@ -1539,9 +1541,11 @@ describe('with configurable Product With Three Option Group', () => {
expect(result).toMatchInlineSnapshot(`
Array [
Array [
+ 93,
+ 94,
+ 31,
+ 32,
42,
- ],
- Array [
44,
],
]
@@ -1565,13 +1569,13 @@ describe('with configurable Product With Three Option Group', () => {
expect(result).toMatchInlineSnapshot(`
Array [
Array [
+ 93,
+ 94,
+ 31,
+ 32,
42,
44,
],
- Array [
- 31,
- ],
- Array [],
]
`);
});
@@ -1618,9 +1622,12 @@ describe('with configurable Product With Four Option Group', () => {
expect(result).toMatchInlineSnapshot(`
Array [
Array [
+ 93,
+ 95,
+ 35,
+ 38,
+ 43,
44,
- ],
- Array [
45,
],
]
@@ -1645,12 +1652,13 @@ describe('with configurable Product With Four Option Group', () => {
expect(result).toMatchInlineSnapshot(`
Array [
Array [
+ 93,
+ 95,
+ 35,
+ 38,
44,
45,
],
- Array [],
- Array [],
- Array [],
]
`);
});
@@ -1699,10 +1707,15 @@ describe('with configurable Product With Five Option Group', () => {
expect(result).toMatchInlineSnapshot(`
Array [
Array [
- 95,
- ],
- Array [
+ 92,
93,
+ 95,
+ 34,
+ 36,
+ 22,
+ 28,
+ 47,
+ 48,
],
]
`);
@@ -1727,13 +1740,15 @@ describe('with configurable Product With Five Option Group', () => {
expect(result).toMatchInlineSnapshot(`
Array [
Array [
- 95,
93,
+ 95,
+ 34,
+ 36,
+ 22,
+ 28,
+ 47,
+ 48,
],
- Array [],
- Array [],
- Array [],
- Array [],
]
`);
});
diff --git a/packages/peregrine/lib/util/__tests__/getOutOfStockVariantsWithInitialSelection.spec.js b/packages/peregrine/lib/util/__tests__/getOutOfStockVariantsWithInitialSelection.spec.js
index 3968e58dfd..a58c177b9c 100644
--- a/packages/peregrine/lib/util/__tests__/getOutOfStockVariantsWithInitialSelection.spec.js
+++ b/packages/peregrine/lib/util/__tests__/getOutOfStockVariantsWithInitialSelection.spec.js
@@ -1446,12 +1446,12 @@ describe('with configurable Product With Two Option Group', () => {
);
expect(result).toMatchInlineSnapshot(`
Array [
- Array [
- 46,
- 44,
- ],
Array [
31,
+ 35,
+ 36,
+ 44,
+ 46,
],
]
`);
@@ -1469,10 +1469,12 @@ describe('with configurable Product With Two Option Group', () => {
expect(result).toMatchInlineSnapshot(`
Array [
Array [
- 44,
- ],
- Array [
+ 14,
31,
+ 36,
+ 44,
+ 45,
+ 46,
],
]
`);
@@ -1491,9 +1493,12 @@ describe('with configurable Product With Two Option Group', () => {
expect(result).toMatchInlineSnapshot(`
Array [
Array [
+ 31,
+ 36,
+ 43,
44,
+ 46,
],
- Array [],
]
`);
});
@@ -1520,13 +1525,13 @@ describe('with configurable Product With Three Option Group', () => {
expect(result).toMatchInlineSnapshot(`
Array [
Array [
+ 93,
+ 94,
+ 31,
+ 32,
42,
44,
],
- Array [
- 31,
- ],
- Array [],
]
`);
});
@@ -1541,9 +1546,13 @@ describe('with configurable Product With Three Option Group', () => {
);
expect(result).toMatchInlineSnapshot(`
Array [
- Array [],
- Array [],
- Array [],
+ Array [
+ 94,
+ 31,
+ 32,
+ 42,
+ 44,
+ ],
]
`);
});
@@ -1560,10 +1569,13 @@ describe('with configurable Product With Three Option Group', () => {
expect(result).toMatchInlineSnapshot(`
Array [
Array [
+ 92,
+ 94,
+ 14,
+ 31,
+ 42,
44,
],
- Array [],
- Array [],
]
`);
});
@@ -1581,10 +1593,13 @@ describe('with configurable Product With Three Option Group', () => {
expect(result).toMatchInlineSnapshot(`
Array [
Array [
+ 92,
+ 94,
+ 14,
+ 31,
+ 43,
44,
],
- Array [],
- Array [],
]
`);
});
@@ -1612,12 +1627,13 @@ describe('with configurable Product With Four Option Group', () => {
expect(result).toMatchInlineSnapshot(`
Array [
Array [
+ 93,
+ 95,
+ 35,
+ 38,
44,
45,
],
- Array [],
- Array [],
- Array [],
]
`);
});
@@ -1633,11 +1649,14 @@ describe('with configurable Product With Four Option Group', () => {
expect(result).toMatchInlineSnapshot(`
Array [
Array [
+ 93,
+ 95,
+ 35,
+ 38,
+ 23,
44,
+ 45,
],
- Array [],
- Array [],
- Array [],
]
`);
});
@@ -1654,11 +1673,13 @@ describe('with configurable Product With Four Option Group', () => {
expect(result).toMatchInlineSnapshot(`
Array [
Array [
+ 93,
+ 95,
+ 38,
+ 23,
+ 44,
45,
],
- Array [],
- Array [],
- Array [],
]
`);
});
@@ -1675,10 +1696,15 @@ describe('with configurable Product With Four Option Group', () => {
);
expect(result).toMatchInlineSnapshot(`
Array [
- Array [],
- Array [],
- Array [],
- Array [],
+ Array [
+ 92,
+ 95,
+ 14,
+ 38,
+ 23,
+ 44,
+ 45,
+ ],
]
`);
});
@@ -1696,11 +1722,14 @@ describe('with configurable Product With Four Option Group', () => {
);
expect(result).toMatchInlineSnapshot(`
Array [
- Array [],
- Array [],
- Array [],
Array [
92,
+ 95,
+ 14,
+ 38,
+ 23,
+ 43,
+ 44,
],
]
`);
@@ -1730,13 +1759,15 @@ describe('with configurable Product With Five Option Group', () => {
expect(result).toMatchInlineSnapshot(`
Array [
Array [
- 95,
93,
+ 95,
+ 34,
+ 36,
+ 22,
+ 28,
+ 47,
+ 48,
],
- Array [],
- Array [],
- Array [],
- Array [],
]
`);
});
@@ -1751,11 +1782,16 @@ describe('with configurable Product With Five Option Group', () => {
);
expect(result).toMatchInlineSnapshot(`
Array [
- Array [],
- Array [],
- Array [],
- Array [],
- Array [],
+ Array [
+ 93,
+ 95,
+ 36,
+ 37,
+ 22,
+ 28,
+ 47,
+ 48,
+ ],
]
`);
});
@@ -1771,13 +1807,16 @@ describe('with configurable Product With Five Option Group', () => {
);
expect(result).toMatchInlineSnapshot(`
Array [
- Array [],
- Array [],
- Array [],
Array [
+ 93,
+ 95,
+ 36,
+ 37,
+ 23,
28,
+ 47,
+ 48,
],
- Array [],
]
`);
});
@@ -1794,11 +1833,16 @@ describe('with configurable Product With Five Option Group', () => {
);
expect(result).toMatchInlineSnapshot(`
Array [
- Array [],
- Array [],
- Array [],
- Array [],
- Array [],
+ Array [
+ 93,
+ 95,
+ 36,
+ 37,
+ 23,
+ 28,
+ 47,
+ 49,
+ ],
]
`);
});
@@ -1816,11 +1860,13 @@ describe('with configurable Product With Five Option Group', () => {
);
expect(result).toMatchInlineSnapshot(`
Array [
- Array [],
- Array [],
- Array [],
- Array [],
Array [
+ 93,
+ 95,
+ 36,
+ 37,
+ 23,
+ 29,
47,
49,
],
@@ -1842,11 +1888,16 @@ describe('with configurable Product With Five Option Group', () => {
);
expect(result).toMatchInlineSnapshot(`
Array [
- Array [],
- Array [],
- Array [],
- Array [],
- Array [],
+ Array [
+ 92,
+ 95,
+ 36,
+ 37,
+ 23,
+ 29,
+ 47,
+ 49,
+ ],
]
`);
});
diff --git a/packages/peregrine/lib/util/__tests__/getUnavailableConfigurableValues.spec.js b/packages/peregrine/lib/util/__tests__/getUnavailableConfigurableValues.spec.js
new file mode 100644
index 0000000000..93bd59bda9
--- /dev/null
+++ b/packages/peregrine/lib/util/__tests__/getUnavailableConfigurableValues.spec.js
@@ -0,0 +1,48 @@
+import { getUnavailableConfigurableValues } from '../getUnavailableConfigurableValues';
+
+describe('getUnavailableConfigurableValues', () => {
+ test('keeps a value enabled when it can complete to an in-stock variant, even if another variant shares the same partial selection and is OOS', () => {
+ const product = {
+ configurable_options: [
+ {
+ attribute_id: '1',
+ attribute_code: 'color',
+ values: [{ value_index: 10 }, { value_index: 11 }]
+ },
+ {
+ attribute_id: '2',
+ attribute_code: 'size',
+ values: [{ value_index: 20 }, { value_index: 21 }]
+ }
+ ]
+ };
+ const optionCodes = new Map([['1', 'color'], ['2', 'size']]);
+ const optionSelections = new Map([['1', 10], ['2', undefined]]);
+ const variants = [
+ {
+ attributes: [
+ { code: 'color', value_index: 10 },
+ { code: 'size', value_index: 20 }
+ ],
+ product: { stock_status: 'IN_STOCK' }
+ },
+ {
+ attributes: [
+ { code: 'color', value_index: 10 },
+ { code: 'size', value_index: 21 }
+ ],
+ product: { stock_status: 'OUT_OF_STOCK' }
+ }
+ ];
+
+ const result = getUnavailableConfigurableValues(
+ product,
+ optionCodes,
+ optionSelections,
+ variants
+ );
+
+ expect(result).not.toContain(20);
+ expect(result).toContain(21);
+ });
+});
diff --git a/packages/peregrine/lib/util/createProductVariants.js b/packages/peregrine/lib/util/createProductVariants.js
index 7b9286e971..5ebeee9593 100644
--- a/packages/peregrine/lib/util/createProductVariants.js
+++ b/packages/peregrine/lib/util/createProductVariants.js
@@ -6,6 +6,12 @@
* This returns an array of objects
*/
+const sortedIndexTupleKey = indexes =>
+ [...indexes]
+ .map(Number)
+ .sort((a, b) => a - b)
+ .join('|');
+
export const createProductVariants = product => {
const OUT_OF_STOCK_CODE = 'OUT_OF_STOCK';
const IN_STOCK_CODE = 'IN_STOCK';
@@ -43,10 +49,9 @@ export const createProductVariants = product => {
// with the not to display out of stock products selected in Admin dashboard
foundMatch =
option.length > 1
- ? Array.from(currentValueIndex)
- .sort()
- .toString() === option.sort().toString()
- : currentValueIndex.toString() === option.toString();
+ ? sortedIndexTupleKey(currentValueIndex) ===
+ sortedIndexTupleKey(option)
+ : String(currentValueIndex) === String(option[0]);
if (foundMatch) {
break;
}
diff --git a/packages/peregrine/lib/util/findAllMatchingVariants.js b/packages/peregrine/lib/util/findAllMatchingVariants.js
index 5f581a86a5..e3c65d8f3c 100644
--- a/packages/peregrine/lib/util/findAllMatchingVariants.js
+++ b/packages/peregrine/lib/util/findAllMatchingVariants.js
@@ -2,6 +2,13 @@
* Find all the products/variants contains current option selections
* @return {Array} variants
*/
+const isSameOptionValue = (left, right) =>
+ left !== undefined &&
+ left !== null &&
+ right !== undefined &&
+ right !== null &&
+ String(left) === String(right);
+
export const findAllMatchingVariants = ({
variants,
optionCodes,
@@ -13,11 +20,24 @@ export const findAllMatchingVariants = ({
new Map()
);
for (const [id, value] of singleOptionSelection) {
+ if (value === undefined || value === null) {
+ continue;
+ }
+
const code = optionCodes.get(id);
+ if (!code) {
+ return false;
+ }
- const matchesStandardAttribute = product[code] === value;
+ const matchesStandardAttribute = isSameOptionValue(
+ product ? product[code] : undefined,
+ value
+ );
- const matchesCustomAttribute = customAttributes.get(code) === value;
+ const matchesCustomAttribute = isSameOptionValue(
+ customAttributes.get(code),
+ value
+ );
// if any option selection fails to match any standard attribute
// and also fails to match any custom attribute
diff --git a/packages/peregrine/lib/util/findMatchingProductVariant.js b/packages/peregrine/lib/util/findMatchingProductVariant.js
index e60c435e02..407d195bf4 100644
--- a/packages/peregrine/lib/util/findMatchingProductVariant.js
+++ b/packages/peregrine/lib/util/findMatchingProductVariant.js
@@ -1,6 +1,13 @@
/**
* TODO Document
*/
+const isSameOptionValue = (left, right) =>
+ left !== undefined &&
+ left !== null &&
+ right !== undefined &&
+ right !== null &&
+ String(left) === String(right);
+
export const findMatchingVariant = ({
variants,
optionCodes,
@@ -13,9 +20,23 @@ export const findMatchingVariant = ({
);
for (const [id, value] of optionSelections) {
+ if (value === undefined || value === null) {
+ continue;
+ }
+
const code = optionCodes.get(id);
- const matchesStandardAttribute = product[code] === value;
- const matchesCustomAttribute = customAttributes.get(code) === value;
+ if (!code) {
+ return false;
+ }
+
+ const matchesStandardAttribute = isSameOptionValue(
+ product ? product[code] : undefined,
+ value
+ );
+ const matchesCustomAttribute = isSameOptionValue(
+ customAttributes.get(code),
+ value
+ );
// if any option selection fails to match any standard attribute
// and also fails to match any custom attribute
diff --git a/packages/peregrine/lib/util/getOutOfStockVariants.js b/packages/peregrine/lib/util/getOutOfStockVariants.js
index f596e555bc..cca77c3f29 100644
--- a/packages/peregrine/lib/util/getOutOfStockVariants.js
+++ b/packages/peregrine/lib/util/getOutOfStockVariants.js
@@ -3,10 +3,8 @@
* @return {Array} variants
*/
import { isProductConfigurable } from '@magento/peregrine/lib/util/isProductConfigurable';
-import { findAllMatchingVariants } from '@magento/peregrine/lib/util/findAllMatchingVariants';
-import { getOutOfStockIndexes } from '@magento/peregrine/lib/util/getOutOfStockIndexes';
import { createProductVariants } from '@magento/peregrine/lib/util/createProductVariants';
-import { getCombinations } from '@magento/peregrine/lib/util/getCombinations';
+import { getUnavailableConfigurableValues } from '@magento/peregrine/lib/util/getUnavailableConfigurableValues';
const OUT_OF_STOCK_CODE = 'OUT_OF_STOCK';
@@ -18,7 +16,6 @@ export const getOutOfStockVariants = (
isOutOfStockProductDisplayed
) => {
const isConfigurable = isProductConfigurable(product);
- const outOfStockIndexes = [];
if (isConfigurable) {
let variants = product.variants;
@@ -55,64 +52,18 @@ export const getOutOfStockVariants = (
optionSelections.values()
).filter(value => !!value);
- if (selectedIndexes.length > 0) {
- const items = findAllMatchingVariants({
- optionCodes,
- singleOptionSelection: optionSelections,
- variants
- });
- const outOfStockItemsIndexes = getOutOfStockIndexes(items);
-
- // For all the out of stock options associated with current selection, display out of stock swatches
- // when the number of matching indexes of selected indexes and out of stock indexes are not smaller than the total groups of swatches minus 1
- for (const indexes of outOfStockItemsIndexes) {
- const sameIndexes = indexes.filter(num =>
- selectedIndexes.includes(num)
- );
- const differentIndexes = indexes.filter(
- num => !selectedIndexes.includes(num)
- );
- if (sameIndexes.length > 0) {
- outOfStockIndexes.push(differentIndexes);
- }
- }
- // Display all possible out of stock swatches with current selections, when all groups of swatches are selected
- if (
- selectedIndexes.length === optionCodes.size &&
- !selectedIndexes.includes(undefined)
- ) {
- const selectedIndexesCombinations = getCombinations(
- selectedIndexes,
- selectedIndexes.length - 1
- );
- // Find out of stock items and indexes for each combination
- const oosIndexes = [];
- for (const option of selectedIndexesCombinations) {
- // Map the option indexes to their optionCodes
- const curOption = new Map(
- [...optionSelections].filter(
- ([key, val]) => (
- option.includes(key), option.includes(val)
- )
- )
- );
- const curItems = findAllMatchingVariants({
- optionCodes,
- singleOptionSelection: curOption,
- variants: variants
- });
- const outOfStockIndex = getOutOfStockIndexes(curItems)
- ?.flat()
- .filter(idx => !selectedIndexes.includes(idx));
- oosIndexes.push(outOfStockIndex);
- }
- return oosIndexes;
- }
- } else {
+ if (selectedIndexes.length === 0) {
return [];
}
- return outOfStockIndexes;
+ const unavailable = getUnavailableConfigurableValues(
+ product,
+ optionCodes,
+ optionSelections,
+ variants
+ );
+
+ return unavailable.length ? [unavailable] : [];
}
}
return [];
diff --git a/packages/peregrine/lib/util/getOutOfStockVariantsWithInitialSelection.js b/packages/peregrine/lib/util/getOutOfStockVariantsWithInitialSelection.js
index 8b70525bec..62bd8c37b4 100644
--- a/packages/peregrine/lib/util/getOutOfStockVariantsWithInitialSelection.js
+++ b/packages/peregrine/lib/util/getOutOfStockVariantsWithInitialSelection.js
@@ -2,10 +2,8 @@
* Find out of stock variants/options of current option selections with initial selctions
* @return {Array} variants
*/
-import { findAllMatchingVariants } from '@magento/peregrine/lib/util/findAllMatchingVariants';
-import { getOutOfStockIndexes } from '@magento/peregrine/lib/util/getOutOfStockIndexes';
import { createProductVariants } from '@magento/peregrine/lib/util/createProductVariants';
-import { getCombinations } from '@magento/peregrine/lib/util/getCombinations';
+import { getUnavailableConfigurableValues } from '@magento/peregrine/lib/util/getUnavailableConfigurableValues';
const OUT_OF_STOCK_CODE = 'OUT_OF_STOCK';
@@ -49,67 +47,20 @@ export const getOutOfStockVariantsWithInitialSelection = (
option.attributes.map(attribute => attribute.value_index)
);
return outOfStockIndex;
- } else {
- const outOfStockIndexes = [];
-
- if (selectedIndexes.length > 0) {
- const items = findAllMatchingVariants({
- optionCodes: configurableOptionCodes,
- singleOptionSelection: multipleOptionSelections,
- variants
- });
-
- const outOfStockItemsIndexes = getOutOfStockIndexes(items);
-
- for (const indexes of outOfStockItemsIndexes) {
- const sameIndexes = indexes.filter(num =>
- selectedIndexes.includes(num)
- );
- const differentIndexes = indexes.filter(
- num => !selectedIndexes.includes(num)
- );
-
- if (sameIndexes.length > 0) {
- outOfStockIndexes.push(differentIndexes);
- }
- }
+ }
- if (
- selectedIndexes.length === configurableOptionCodes.size &&
- !selectedIndexes.includes(undefined)
- ) {
- const selectedIndexesCombinations = getCombinations(
- selectedIndexes,
- selectedIndexes.length - 1
- );
+ if (selectedIndexes.length === 0) {
+ return [];
+ }
- const oosIndexes = [];
- for (const option of selectedIndexesCombinations) {
- const curOption = new Map(
- [...multipleOptionSelections].filter(
- ([key, val]) => (
- option.includes(key), option.includes(val)
- )
- )
- );
- const curItems = findAllMatchingVariants({
- optionCodes: configurableOptionCodes,
- singleOptionSelection: curOption,
- variants: variants
- });
- const outOfStockIndex = getOutOfStockIndexes(curItems)
- ?.flat()
- .filter(idx => !selectedIndexes.includes(idx));
- oosIndexes.push(outOfStockIndex);
- }
- return oosIndexes;
- }
- } else {
- return [];
- }
+ const unavailable = getUnavailableConfigurableValues(
+ product,
+ configurableOptionCodes,
+ multipleOptionSelections,
+ variants
+ );
- return outOfStockIndexes;
- }
+ return unavailable.length ? [unavailable] : [];
}
return [];
};
diff --git a/packages/peregrine/lib/util/getUnavailableConfigurableValues.js b/packages/peregrine/lib/util/getUnavailableConfigurableValues.js
new file mode 100644
index 0000000000..2e2c22e5ce
--- /dev/null
+++ b/packages/peregrine/lib/util/getUnavailableConfigurableValues.js
@@ -0,0 +1,71 @@
+import { findAllMatchingVariants } from '@magento/peregrine/lib/util/findAllMatchingVariants';
+
+const IN_STOCK_CODE = 'IN_STOCK';
+
+/**
+ * Returns configurable `value_index` values that cannot extend the current
+ * partial selection into any salable (in stock) variant.
+ *
+ * This replaces the older heuristic that disabled any value appearing on an
+ * OOS variant that partially overlapped the selection — which incorrectly greyed
+ * out values that still participate in a different in-stock combination.
+ *
+ * @param {object} product
+ * @param {Map} optionCodes attribute_id -> attribute_code
+ * @param {Map} optionSelections attribute_id -> selected value_index | undefined
+ * @param {Array} variants variants array already resolved for stock-display mode
+ * @returns {number[]|string[]} distinct unavailable value_index values
+ */
+export const getUnavailableConfigurableValues = (
+ product,
+ optionCodes,
+ optionSelections,
+ variants
+) => {
+ const inStockVariants = variants.filter(
+ v => v.product?.stock_status === IN_STOCK_CODE
+ );
+
+ if (!inStockVariants.length) {
+ return [];
+ }
+
+ const unavailable = new Set();
+ const options = product.configurable_options || [];
+
+ for (const { attribute_id, values } of options) {
+ const attrKey = String(attribute_id);
+
+ for (const { value_index: candidate } of values) {
+ const currentForAttr = optionSelections.get(attrKey);
+ if (
+ currentForAttr !== undefined &&
+ currentForAttr !== null &&
+ String(currentForAttr) === String(candidate)
+ ) {
+ continue;
+ }
+
+ const testSelections = new Map();
+
+ for (const [id, val] of optionSelections) {
+ if (val !== undefined && val !== null) {
+ testSelections.set(String(id), val);
+ }
+ }
+ testSelections.set(attrKey, candidate);
+
+ const matches = findAllMatchingVariants({
+ optionCodes,
+ singleOptionSelection: testSelections,
+ variants: inStockVariants
+ });
+
+ if (!matches.length) {
+ unavailable.add(candidate);
+ }
+ }
+ }
+
+ return Array.from(unavailable);
+};