Skip to content

Commit

Permalink
Update getProductOptions to handle divergent options (#2747)
Browse files Browse the repository at this point in the history
  • Loading branch information
wizardlyhel authored Feb 12, 2025
1 parent 6bff6b6 commit 3af2e45
Show file tree
Hide file tree
Showing 3 changed files with 140 additions and 44 deletions.
6 changes: 6 additions & 0 deletions .changeset/afraid-onions-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@shopify/hydrogen-react': patch
'@shopify/hydrogen': patch
---

Update `getProductOptions` to handle divergent product options.
44 changes: 44 additions & 0 deletions packages/hydrogen-react/src/getProductOptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,50 @@ describe('getProductOptions', () => {
]
`);
});

it("only returns options presented in the selected variant's selectedOptions", () => {
const options = getProductOptions({
...PRODUCT,
selectedOrFirstAvailableVariant: {
availableForSale: true,
id: 'gid://shopify/ProductVariant/41007290613816',
product: {
handle: 'mail-it-in-freestyle-snowboard',
},
selectedOptions: [
{
name: 'Size',
value: '154cm',
},
],
},
} as unknown as RecursivePartial<Product>);
expect(options.length).toBe(1);
});

it("does not error if product option doesn't have an entry for one of the selected option", () => {
const options = getProductOptions({
...PRODUCT,
selectedOrFirstAvailableVariant: {
availableForSale: true,
id: 'gid://shopify/ProductVariant/41007290613816',
product: {
handle: 'mail-it-in-freestyle-snowboard',
},
selectedOptions: [
{
name: 'Size',
value: '154cm',
},
{
name: 'Weight',
value: '5lbs',
},
],
},
} as unknown as RecursivePartial<Product>);
expect(options.length).toBe(1);
});
});

describe('getAdjacentAndFirstAvailableVariants', () => {
Expand Down
134 changes: 90 additions & 44 deletions packages/hydrogen-react/src/getProductOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type {
export type RecursivePartial<T> = {
[P in keyof T]?: RecursivePartial<T[P]>;
};
type ProductOptionsMapping = Record<string, number>;
type ProductOptionsMapping = Record<string, Record<string, number>>;
type ProductOptionValueState = {
variant: ProductVariant;
handle: string;
Expand All @@ -36,22 +36,27 @@ type MappedProductOptionValue = ProductOptionValue & ProductOptionValueState;
* \}
* ]
* Would return
* [
* \{Red: 0, Blue: 1\},
* \{Small: 0, Medium: 1, Large: 2\}
* ]
* \{
* 'Color': \{Red: 0, Blue: 1\},
* 'Size': \{Small: 0, Medium: 1, Large: 2\}
* \}
*/
function mapProductOptions(options: ProductOption[]): ProductOptionsMapping[] {
return options.map((option: ProductOption) => {
return Object.assign(
{},
...(option?.optionValues
? option.optionValues.map((value, index) => {
return {[value.name]: index};
})
: []),
) as ProductOptionsMapping;
});
function mapProductOptions(options: ProductOption[]): ProductOptionsMapping {
return Object.assign(
{},
...options.map((option: ProductOption) => {
return {
[option.name]: Object.assign(
{},
...(option?.optionValues
? option.optionValues.map((value, index) => {
return {[value.name]: index};
})
: []),
),
} as Record<string, number>;
}),
);
}

/**
Expand Down Expand Up @@ -107,41 +112,75 @@ function mapSelectedProductOptionToObjectAsString(
* \}
* ]
* Would return
* [0,1]
*
* Also works with the result of mapSelectedProductOption. For example:
* \{
* JSON.stringify(\{
* Color: 'Red',
* Size: 'Medium',
* \}
* Would return
* [0,1]
*
* @param selectedOption - The selected product option
* @param productOptionMappings - The result of product option mapping from mapProductOptions
* @returns
* \})
*/
function encodeSelectedProductOptionAsKey(
selectedOption:
| Pick<SelectedOption, 'name' | 'value'>[]
| Record<string, string>,
productOptionMappings: ProductOptionsMapping[],
): string {
if (Array.isArray(selectedOption)) {
return JSON.stringify(
selectedOption.map((key, index) => {
return productOptionMappings[index][key.value];
}),
Object.assign(
{},
...selectedOption.map((option) => ({[option.name]: option.value})),
),
);
} else {
return JSON.stringify(
Object.keys(selectedOption).map((key, index) => {
return productOptionMappings[index][selectedOption[key]];
}),
);
return JSON.stringify(selectedOption);
}
}

/**
* Build the encoding array for the given selected options. For example, if we have
* the following productOptionMappings:
*
* \{
* 'Color': \{Red: 0, Blue: 1\},
* 'Size': \{Small: 0, Medium: 1, Large: 2\}
* \}
*
* A selectedOption of
*
* \{
* Color: 'Red',
* Size: 'Medium',
* \}
*
* `buildEncodingArrayFromSelectedOptions` will produce
*
* [0,1]
*
* If in the case where a selected option doesn't exists in the mapping array, for example:
*
* \{
* Color: 'Red',
* Fabric: 'Cotton',
* Size: 'Medium',
* \}
*
* `buildEncodingArrayFromSelectedOptions` will still produce
*
* [0,1]
*
* This can be caused by when we do not have all the product
* option information for the loading optimistic variant
*/
function buildEncodingArrayFromSelectedOptions(
selectedOption: Record<string, string>,
productOptionMappings: ProductOptionsMapping,
): Array<number> {
const encoding = Object.keys(selectedOption).map((key) => {
return productOptionMappings[key]
? productOptionMappings[key][selectedOption[key]]
: null;
});
return encoding.filter((code) => code !== null);
}

/**
* Takes an array of product variants and maps them to an object with the encoded selected option values as the key.
* For example, a product variant of
Expand Down Expand Up @@ -169,14 +208,12 @@ function encodeSelectedProductOptionAsKey(
*/
function mapVariants(
variants: ProductVariant[],
productOptionMappings: ProductOptionsMapping[],
): Record<string, ProductVariant> {
return Object.assign(
{},
...variants.map((variant) => {
const variantKey = encodeSelectedProductOptionAsKey(
variant.selectedOptions || [],
productOptionMappings,
);
return {[variantKey]: variant};
}),
Expand Down Expand Up @@ -371,21 +408,30 @@ export function getProductOptions(
encodedVariantAvailability,
handle: productHandle,
} = checkedProduct;

// The available product options is dictated by the selected options of the current variant:
// Filter out un-used options (Happens on parent combined listing product)
const selectedOptionKeys = selectedVariant?.selectedOptions.map(
(option) => option.name,
);
const filteredOptions = options.filter((option) => {
return selectedOptionKeys && selectedOptionKeys.indexOf(option.name) >= 0;
});

// Get a mapping of product option names to their index for matching encoded values
const productOptionMappings = mapProductOptions(options);

// Get the adjacent variants mapped to the encoded selected option values
const variants = mapVariants(
selectedVariant ? [selectedVariant, ...adjacentVariants] : adjacentVariants,
productOptionMappings,
);

// Get the key:value version of selected options for building url query params
const selectedOptions = mapSelectedProductOptionToObject(
selectedVariant ? selectedVariant.selectedOptions : [],
);

const productOptions = options.map((option, optionIndex) => {
const productOptions = filteredOptions.map((option, optionIndex) => {
return {
...option,
optionValues: option.optionValues.map((value) => {
Expand All @@ -397,14 +443,14 @@ export function getProductOptions(
// Encode the new selected option values as a key for mapping to the product variants
const targetKey = encodeSelectedProductOptionAsKey(
targetOptionParams || [],
);
const encodingKey = buildEncodingArrayFromSelectedOptions(
targetOptionParams || [],
productOptionMappings,
);

// Top-down option check for existence and availability
const topDownKey = (JSON.parse(targetKey) as number[]).slice(
0,
optionIndex + 1,
);
const topDownKey = encodingKey.slice(0, optionIndex + 1);
const exists = isOptionValueCombinationInEncodedVariant(
topDownKey,
encodedVariantExistence || '',
Expand Down

0 comments on commit 3af2e45

Please sign in to comment.