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); +};