diff --git a/.changeset/strange-zebras-travel.md b/.changeset/strange-zebras-travel.md new file mode 100644 index 0000000000..fdb4d97587 --- /dev/null +++ b/.changeset/strange-zebras-travel.md @@ -0,0 +1,35 @@ +--- +'skeleton': patch +'@shopify/hydrogen': patch +'@shopify/cli-hydrogen': patch +'@shopify/create-hydrogen': patch +--- + +SFAPI update - Deprecate usages of `product.options.values` and use `product.options.optionValues` instead. + +1. Update your product graphql query to use the new `optionValues` field. + +```diff + const PRODUCT_FRAGMENT = `#graphql + fragment Product on Product { + id + title + options { + name +- values ++ optionValues { ++ name ++ } + } +``` + +2. Update your `` to use the new `optionValues` field. + +```diff + option.values.length > 1)} ++ options={product.options.filter((option) => option.optionValues.length > 1)} + variants={variants} + > +``` diff --git a/examples/b2b/app/components/ProductForm.tsx b/examples/b2b/app/components/ProductForm.tsx index cdebd962d4..e72710a971 100644 --- a/examples/b2b/app/components/ProductForm.tsx +++ b/examples/b2b/app/components/ProductForm.tsx @@ -27,7 +27,7 @@ export function ProductForm({
option.values.length > 1)} + options={product.options.filter((option) => option.optionValues.length > 1)} variants={variants} > {({option}) => } diff --git a/examples/b2b/app/routes/products.$handle.tsx b/examples/b2b/app/routes/products.$handle.tsx index 3c0a8aadfb..586955217d 100644 --- a/examples/b2b/app/routes/products.$handle.tsx +++ b/examples/b2b/app/routes/products.$handle.tsx @@ -338,7 +338,9 @@ const PRODUCT_FRAGMENT = `#graphql description options { name - values + optionValues { + name + } } selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) { ...ProductVariant diff --git a/examples/b2b/storefrontapi.generated.d.ts b/examples/b2b/storefrontapi.generated.d.ts index 28d66809e1..1b245ca8a7 100644 --- a/examples/b2b/storefrontapi.generated.d.ts +++ b/examples/b2b/storefrontapi.generated.d.ts @@ -750,7 +750,11 @@ export type ProductFragment = Pick< StorefrontAPI.Product, 'id' | 'title' | 'vendor' | 'handle' | 'descriptionHtml' | 'description' > & { - options: Array>; + options: Array< + Pick & { + optionValues: Array>; + } + >; selectedVariant?: StorefrontAPI.Maybe< Pick< StorefrontAPI.ProductVariant, @@ -842,7 +846,11 @@ export type ProductQuery = { StorefrontAPI.Product, 'id' | 'title' | 'vendor' | 'handle' | 'descriptionHtml' | 'description' > & { - options: Array>; + options: Array< + Pick & { + optionValues: Array>; + } + >; selectedVariant?: StorefrontAPI.Maybe< Pick< StorefrontAPI.ProductVariant, @@ -1296,7 +1304,7 @@ interface GeneratedQueryTypes { return: PoliciesQuery; variables: PoliciesQueryVariables; }; - '#graphql\n query Product(\n $country: CountryCode\n $buyer: BuyerInput\n $handle: String!\n $language: LanguageCode\n $selectedOptions: [SelectedOptionInput!]!\n ) @inContext(country: $country, language: $language, buyer: $buyer) {\n product(handle: $handle) {\n ...Product\n }\n }\n #graphql\n fragment Product on Product {\n id\n title\n vendor\n handle\n descriptionHtml\n description\n options {\n name\n values\n }\n selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) {\n ...ProductVariant\n }\n variants(first: 1) {\n nodes {\n ...ProductVariant\n }\n }\n seo {\n description\n title\n }\n }\n #graphql\n fragment ProductVariant on ProductVariant {\n availableForSale\n compareAtPrice {\n amount\n currencyCode\n }\n id\n image {\n __typename\n id\n url\n altText\n width\n height\n }\n price {\n amount\n currencyCode\n }\n product {\n title\n handle\n }\n selectedOptions {\n name\n value\n }\n quantityRule {\n maximum\n minimum\n increment\n }\n quantityPriceBreaks(first: 5) {\n nodes {\n minimumQuantity\n price {\n amount\n currencyCode\n }\n }\n }\n sku\n title\n unitPrice {\n amount\n currencyCode\n }\n }\n\n\n': { + '#graphql\n query Product(\n $country: CountryCode\n $buyer: BuyerInput\n $handle: String!\n $language: LanguageCode\n $selectedOptions: [SelectedOptionInput!]!\n ) @inContext(country: $country, language: $language, buyer: $buyer) {\n product(handle: $handle) {\n ...Product\n }\n }\n #graphql\n fragment Product on Product {\n id\n title\n vendor\n handle\n descriptionHtml\n description\n options {\n name\n optionValues {\n name\n }\n }\n selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) {\n ...ProductVariant\n }\n variants(first: 1) {\n nodes {\n ...ProductVariant\n }\n }\n seo {\n description\n title\n }\n }\n #graphql\n fragment ProductVariant on ProductVariant {\n availableForSale\n compareAtPrice {\n amount\n currencyCode\n }\n id\n image {\n __typename\n id\n url\n altText\n width\n height\n }\n price {\n amount\n currencyCode\n }\n product {\n title\n handle\n }\n selectedOptions {\n name\n value\n }\n quantityRule {\n maximum\n minimum\n increment\n }\n quantityPriceBreaks(first: 5) {\n nodes {\n minimumQuantity\n price {\n amount\n currencyCode\n }\n }\n }\n sku\n title\n unitPrice {\n amount\n currencyCode\n }\n }\n\n\n': { return: ProductQuery; variables: ProductQueryVariables; }; diff --git a/packages/hydrogen/src/product/VariantSelector.test.ts b/packages/hydrogen/src/product/VariantSelector.test.ts index adf7a20a21..e3d6983283 100644 --- a/packages/hydrogen/src/product/VariantSelector.test.ts +++ b/packages/hydrogen/src/product/VariantSelector.test.ts @@ -41,7 +41,10 @@ describe('getSelectedProductOptions', () => { }); describe('', () => { + const consoleWarnSpy = vi.spyOn(console, 'warn'); + afterEach(() => { + consoleWarnSpy.mockClear(); cleanup(); }); @@ -50,6 +53,55 @@ describe('', () => { }); it('passes value and path for each variant permutation', () => { + const {asFragment} = render( + createElement(VariantSelector, { + handle: 'snowboard', + options: [ + {name: 'Color', optionValues: [{name: 'Red'}, {name: 'Blue'}]}, + {name: 'Size', optionValues: [{name: 'S'}, {name: 'M'}]}, + ], + children: ({option}) => + createElement( + 'div', + null, + option.values.map(({value, to}) => + createElement('a', {key: option.name + value, href: to}, value), + ), + ), + }), + ); + + expect(asFragment()).toMatchInlineSnapshot(` + + + + + `); + }); + + it('accepts deprecated product.options.values and logs a warning', () => { const {asFragment} = render( createElement(VariantSelector, { handle: 'snowboard', @@ -68,6 +120,10 @@ describe('', () => { }), ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + '[h2:warn:VariantSelector] product.options.values is deprecated. Use product.options.optionValues instead.', + ); + expect(asFragment()).toMatchInlineSnapshot(`
@@ -98,14 +154,85 @@ describe('', () => { `); }); + it('accepts deprecated product.options.values, the new product.options.optionValues and does not override the new optionValues', () => { + const {asFragment} = render( + createElement(VariantSelector, { + handle: 'snowboard', + options: [ + { + name: 'Color', + values: ['Red', 'Blue'], + optionValues: [ + {name: 'Red', id: '1'}, + {name: 'Blue', id: '2'}, + ], + }, + { + name: 'Size', + values: ['S', 'M'], + optionValues: [ + {name: 'S', id: '3'}, + {name: 'M', id: '4'}, + ], + }, + ], + children: ({option}) => + createElement( + 'div', + null, + option.values.map(({value, to, optionValue}) => + createElement( + 'a', + {key: option.name + value, href: to, 'data-id': optionValue.id}, + value, + ), + ), + ), + }), + ); + + expect(asFragment()).toMatchInlineSnapshot(` + + + + + `); + }); + it('uses a custom product path', () => { const {asFragment} = render( createElement(VariantSelector, { handle: 'snowboard', productPath: 'shop', options: [ - {name: 'Color', values: ['Red', 'Blue']}, - {name: 'Size', values: ['S', 'M']}, + {name: 'Color', optionValues: [{name: 'Red'}, {name: 'Blue'}]}, + {name: 'Size', optionValues: [{name: 'S'}, {name: 'M'}]}, ], children: ({option}) => createElement( @@ -154,8 +281,8 @@ describe('', () => { handle: 'snowboard', productPath: '/shop', options: [ - {name: 'Color', values: ['Red', 'Blue']}, - {name: 'Size', values: ['S', 'M']}, + {name: 'Color', optionValues: [{name: 'Red'}, {name: 'Blue'}]}, + {name: 'Size', optionValues: [{name: 'S'}, {name: 'M'}]}, ], children: ({option}) => createElement( @@ -207,8 +334,8 @@ describe('', () => { createElement(VariantSelector, { handle: 'snowboard', options: [ - {name: 'Color', values: ['Red']}, - {name: 'Size', values: ['S', 'M']}, + {name: 'Color', optionValues: [{name: 'Red'}]}, + {name: 'Size', optionValues: [{name: 'S'}, {name: 'M'}]}, ], children: ({option}) => createElement( @@ -264,8 +391,8 @@ describe('', () => { createElement(VariantSelector, { handle: 'snowboard', options: [ - {name: 'Color', values: ['Red']}, - {name: 'Size', values: ['S', 'M']}, + {name: 'Color', optionValues: [{name: 'Red'}]}, + {name: 'Size', optionValues: [{name: 'S'}, {name: 'M'}]}, ], children: ({option}) => createElement( @@ -316,7 +443,7 @@ describe('', () => { const {asFragment} = render( createElement(VariantSelector, { handle: 'snowboard', - options: [{name: 'Size', values: ['S', 'M']}], + options: [{name: 'Size', optionValues: [{name: 'S'}, {name: 'M'}]}], children: ({option}) => createElement( 'div', @@ -360,7 +487,7 @@ describe('', () => { const {asFragment} = render( createElement(VariantSelector, { handle: 'snowboard', - options: [{name: 'Size', values: ['S', 'M']}], + options: [{name: 'Size', optionValues: [{name: 'S'}, {name: 'M'}]}], variants: [ { availableForSale: true, @@ -414,7 +541,7 @@ describe('', () => { const {asFragment} = render( createElement(VariantSelector, { handle: 'snowboard', - options: [{name: 'Size', values: ['S', 'M']}], + options: [{name: 'Size', optionValues: [{name: 'S'}, {name: 'M'}]}], variants: { nodes: [ { @@ -470,7 +597,7 @@ describe('', () => { const {asFragment} = render( createElement(VariantSelector, { handle: 'snowboard', - options: [{name: 'Size', values: ['S', 'M']}], + options: [{name: 'Size', optionValues: [{name: 'S'}, {name: 'M'}]}], variants: { nodes: [ { diff --git a/packages/hydrogen/src/product/VariantSelector.ts b/packages/hydrogen/src/product/VariantSelector.ts index 98ca03fc21..1ad45603fb 100644 --- a/packages/hydrogen/src/product/VariantSelector.ts +++ b/packages/hydrogen/src/product/VariantSelector.ts @@ -2,12 +2,14 @@ import {useLocation, useNavigation} from '@remix-run/react'; import {flattenConnection} from '@shopify/hydrogen-react'; import type { ProductOption, + ProductOptionValue, ProductVariant, ProductVariantConnection, SelectedOptionInput, } from '@shopify/hydrogen-react/storefront-api-types'; import {type ReactNode, useMemo, createElement, Fragment} from 'react'; import type {PartialDeep} from 'type-fest'; +import {warnOnce} from '../utils/warning'; export type VariantOption = { name: string; @@ -15,6 +17,13 @@ export type VariantOption = { values: Array; }; +type PartialProductOptionValues = PartialDeep; +type PartialProductOption = PartialDeep< + Omit & { + optionValues: Array; + } +>; + export type VariantOptionValue = { value: string; isAvailable: boolean; @@ -22,13 +31,14 @@ export type VariantOptionValue = { search: string; isActive: boolean; variant?: PartialDeep; + optionValue: PartialProductOptionValues; }; type VariantSelectorProps = { /** The product handle for all of the variants */ handle: string; /** Product options from the [Storefront API](/docs/api/storefront/2024-07/objects/ProductOption). Make sure both `name` and `values` are a part of your query. */ - options: Array> | undefined; + options: Array | undefined; /** Product variants from the [Storefront API](/docs/api/storefront/2024-07/objects/ProductVariant). You only need to pass this prop if you want to show product availability. If a product option combination is not found within `variants`, it is assumed to be available. Make sure to include `availableForSale` and `selectedOptions.name` and `selectedOptions.value`. */ variants?: | PartialDeep @@ -42,12 +52,29 @@ type VariantSelectorProps = { export function VariantSelector({ handle, - options = [], + options: _options = [], variants: _variants = [], productPath = 'products', waitForNavigation = false, children, }: VariantSelectorProps) { + // Deprecation notice for product.options.values + // TODO: Remove this after product.options.values is removed from the Storefront API + let options = _options; + if (options[0]?.values) { + warnOnce( + '[h2:warn:VariantSelector] product.options.values is deprecated. Use product.options.optionValues instead.', + ); + + if (!!options[0] && !options[0].optionValues) { + // Convert the old values format to the new optionValues format + options = _options.map((option) => ({ + ...option, + optionValues: option.values?.map((value) => ({name: value})) || [], + })); + } + } + const variants = _variants instanceof Array ? _variants : flattenConnection(_variants); @@ -61,7 +88,7 @@ export function VariantSelector({ // But instead it always needs to be added to the product options so // the SFAPI properly finds the variant const optionsWithOnlyOneValue = options.filter( - (option) => option?.values?.length === 1, + (option) => option?.optionValues?.length === 1, ); return createElement( @@ -72,18 +99,22 @@ export function VariantSelector({ let activeValue; let availableValues: VariantOptionValue[] = []; - for (let value of option.values!) { + for (let value of option.optionValues!) { // The clone the search params for each value, so we can calculate // a new URL for each option value pair const clonedSearchParams = new URLSearchParams( alreadyOnProductPage ? searchParams : undefined, ); - clonedSearchParams.set(option.name!, value!); + clonedSearchParams.set(option.name!, value.name!); // Because we hide options with only one value, they aren't selectable, // but they still need to get into the URL optionsWithOnlyOneValue.forEach((option) => { - clonedSearchParams.set(option.name!, option.values![0]!); + if (option.optionValues![0]!.name) + clonedSearchParams.set( + option.name!, + option.optionValues![0]!.name, + ); }); // Find a variant that matches all selected options. @@ -99,19 +130,20 @@ export function VariantSelector({ const calculatedActiveValue = currentParam ? // If a URL parameter exists for the current option, check if it equals the current value - currentParam === value! + currentParam === value.name! : false; if (calculatedActiveValue) { // Save out the current value if it's active. This should only ever happen once. // Should we throw if it happens a second time? - activeValue = value; + activeValue = value.name!; } const searchString = '?' + clonedSearchParams.toString(); availableValues.push({ - value: value!, + value: value.name!, + optionValue: value, isAvailable: variant ? variant.availableForSale! : true, to: path + searchString, search: searchString, diff --git a/templates/skeleton/app/components/ProductForm.tsx b/templates/skeleton/app/components/ProductForm.tsx index 673fcf2916..2357bb5c99 100644 --- a/templates/skeleton/app/components/ProductForm.tsx +++ b/templates/skeleton/app/components/ProductForm.tsx @@ -21,7 +21,7 @@ export function ProductForm({
option.values.length > 1)} + options={product.options.filter((option) => option.optionValues.length > 1)} variants={variants} > {({option}) => } diff --git a/templates/skeleton/app/routes/products.$handle.tsx b/templates/skeleton/app/routes/products.$handle.tsx index ef6e474f40..1ed33cbd5f 100644 --- a/templates/skeleton/app/routes/products.$handle.tsx +++ b/templates/skeleton/app/routes/products.$handle.tsx @@ -242,7 +242,9 @@ const PRODUCT_FRAGMENT = `#graphql description options { name - values + optionValues { + name + } } selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) { ...ProductVariant diff --git a/templates/skeleton/storefrontapi.generated.d.ts b/templates/skeleton/storefrontapi.generated.d.ts index 5e0257f15a..746e47773f 100644 --- a/templates/skeleton/storefrontapi.generated.d.ts +++ b/templates/skeleton/storefrontapi.generated.d.ts @@ -782,7 +782,11 @@ export type ProductFragment = Pick< StorefrontAPI.Product, 'id' | 'title' | 'vendor' | 'handle' | 'descriptionHtml' | 'description' > & { - options: Array>; + options: Array< + Pick & { + optionValues: Array>; + } + >; selectedVariant?: StorefrontAPI.Maybe< Pick< StorefrontAPI.ProductVariant, @@ -851,7 +855,11 @@ export type ProductQuery = { StorefrontAPI.Product, 'id' | 'title' | 'vendor' | 'handle' | 'descriptionHtml' | 'description' > & { - options: Array>; + options: Array< + Pick & { + optionValues: Array>; + } + >; selectedVariant?: StorefrontAPI.Maybe< Pick< StorefrontAPI.ProductVariant, @@ -1260,7 +1268,7 @@ interface GeneratedQueryTypes { return: PoliciesQuery; variables: PoliciesQueryVariables; }; - '#graphql\n query Product(\n $country: CountryCode\n $handle: String!\n $language: LanguageCode\n $selectedOptions: [SelectedOptionInput!]!\n ) @inContext(country: $country, language: $language) {\n product(handle: $handle) {\n ...Product\n }\n }\n #graphql\n fragment Product on Product {\n id\n title\n vendor\n handle\n descriptionHtml\n description\n options {\n name\n values\n }\n selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) {\n ...ProductVariant\n }\n variants(first: 1) {\n nodes {\n ...ProductVariant\n }\n }\n seo {\n description\n title\n }\n }\n #graphql\n fragment ProductVariant on ProductVariant {\n availableForSale\n compareAtPrice {\n amount\n currencyCode\n }\n id\n image {\n __typename\n id\n url\n altText\n width\n height\n }\n price {\n amount\n currencyCode\n }\n product {\n title\n handle\n }\n selectedOptions {\n name\n value\n }\n sku\n title\n unitPrice {\n amount\n currencyCode\n }\n }\n\n\n': { + '#graphql\n query Product(\n $country: CountryCode\n $handle: String!\n $language: LanguageCode\n $selectedOptions: [SelectedOptionInput!]!\n ) @inContext(country: $country, language: $language) {\n product(handle: $handle) {\n ...Product\n }\n }\n #graphql\n fragment Product on Product {\n id\n title\n vendor\n handle\n descriptionHtml\n description\n options {\n name\n optionValues {\n name\n }\n }\n selectedVariant: variantBySelectedOptions(selectedOptions: $selectedOptions, ignoreUnknownOptions: true, caseInsensitiveMatch: true) {\n ...ProductVariant\n }\n variants(first: 1) {\n nodes {\n ...ProductVariant\n }\n }\n seo {\n description\n title\n }\n }\n #graphql\n fragment ProductVariant on ProductVariant {\n availableForSale\n compareAtPrice {\n amount\n currencyCode\n }\n id\n image {\n __typename\n id\n url\n altText\n width\n height\n }\n price {\n amount\n currencyCode\n }\n product {\n title\n handle\n }\n selectedOptions {\n name\n value\n }\n sku\n title\n unitPrice {\n amount\n currencyCode\n }\n }\n\n\n': { return: ProductQuery; variables: ProductQueryVariables; };