Skip to content

Commit

Permalink
Product Form Abstraction (#1027)
Browse files Browse the repository at this point in the history
* Product form implementation

Shopify/hydrogen-internal#23
  • Loading branch information
blittle authored and juanpprieto committed Jul 10, 2023
1 parent a9cebd9 commit 61d2d98
Show file tree
Hide file tree
Showing 19 changed files with 1,467 additions and 241 deletions.
6 changes: 6 additions & 0 deletions .changeset/twenty-flies-exist.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'demo-store': patch
'@shopify/hydrogen': patch
---

Add a `<VariantSelector>` component to make building product forms easier. Also added `getFirstAvailableVariant` and `getSelectedProductOptions` helper functions. See the [proposal](https://gist.github.com/blittle/d9205d4ac72528005dc6f3104c328ecd) for examples.
2 changes: 1 addition & 1 deletion examples/express/app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
ScrollRestoration,
useLoaderData,
} from '@remix-run/react';
import {Cart, Shop} from '@shopify/hydrogen-react/storefront-api-types';
import type {Cart, Shop} from '@shopify/hydrogen/storefront-api-types';
import {Layout} from '~/components/Layout';
import styles from './styles/app.css';
import favicon from '../public/favicon.svg';
Expand Down
24 changes: 0 additions & 24 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

259 changes: 259 additions & 0 deletions packages/hydrogen/docs/generated/generated_docs_data.json

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions packages/hydrogen/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ export type {
CartQueryReturn,
} from './cart/queries/cart-types';

export {
VariantSelector as VariantSelector__unstable,
getSelectedProductOptions as getSelectedProductOptions__unstable,
getFirstAvailableVariant as getFirstAvailableVariant__unstable,
} from './product/VariantSelector';

export {
AnalyticsEventName,
AnalyticsPageType,
Expand Down
51 changes: 51 additions & 0 deletions packages/hydrogen/src/product/VariantSelector.doc.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import {ReferenceEntityTemplateSchema} from '@shopify/generate-docs';

const data: ReferenceEntityTemplateSchema = {
name: 'VariantSelector',
category: 'components',
isVisualComponent: true,
related: [
{
name: 'getSelectedProductOptions',
type: 'utilities',
url: '/docs/api/hydrogen/2023-04/utilities/getselectedproductoptions',
},
{
name: 'getFirstAvailableVariant',
type: 'utilities',
url: '/docs/api/hydrogen/2023-04/utilities/getfirstavailablevariant',
},
],
description: `> Caution:
> This component is in an unstable pre-release state and may have breaking changes in a future release.
The \`VariantSelector\` component helps you build a form for selecting available variants of a product. It is important for variant selection state to be maintained in the URL, so that the user can navigate to a product and return back to the same variant selection. It is also important that the variant selection state is shareable via URL. The \`VariantSelector\` component provides a render prop that renders for each product option.`,
type: 'component',
defaultExample: {
description: 'I am the default example',
codeblock: {
tabs: [
{
title: 'JavaScript',
code: './VariantSelector.example.jsx',
language: 'jsx',
},
{
title: 'TypeScript',
code: './VariantSelector.example.tsx',
language: 'tsx',
},
],
title: 'Example code',
},
},
definitions: [
{
title: 'Props',
type: 'VariantSelectorProps',
description: '',
},
],
};

export default data;
27 changes: 27 additions & 0 deletions packages/hydrogen/src/product/VariantSelector.example.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import {VariantSelector__unstable as VariantSelector} from '@shopify/hydrogen';
import {Link} from '@remix-run/react';

const ProductForm = ({product}) => {
return (
<VariantSelector options={product.options} variants={product.variants}>
{({option}) => (
<>
<div>{option.name}</div>
<div>
{option.values.map(({value, isAvailable, path, isActive}) => (
<Link
to={path}
prefetch="intent"
className={
isActive ? 'active' : isAvailable ? '' : 'opacity-80'
}
>
{value}
</Link>
))}
</div>
</>
)}
</VariantSelector>
);
};
28 changes: 28 additions & 0 deletions packages/hydrogen/src/product/VariantSelector.example.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {VariantSelector__unstable as VariantSelector} from '@shopify/hydrogen';
import type {Product} from '@shopify/hydrogen/storefront-api-types';
import {Link} from '@remix-run/react';

const ProductForm = ({product}: {product: Product}) => {
return (
<VariantSelector options={product.options} variants={product.variants}>
{({option}) => (
<>
<div>{option.name}</div>
<div>
{option.values.map(({value, isAvailable, path, isActive}) => (
<Link
to={path}
prefetch="intent"
className={
isActive ? 'active' : isAvailable ? '' : 'opacity-80'
}
>
{value}
</Link>
))}
</div>
</>
)}
</VariantSelector>
);
};
109 changes: 109 additions & 0 deletions packages/hydrogen/src/product/VariantSelector.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import {useLocation} from '@remix-run/react';
import {flattenConnection} from '@shopify/hydrogen-react';
import {useMemo, createElement, Fragment} from 'react';
export function VariantSelector({
options = [],
variants: _variants = [],
children,
defaultVariant,
}) {
const variants =
_variants instanceof Array ? _variants : flattenConnection(_variants);
const {pathname, search} = useLocation();
const {searchParams, path} = useMemo(() => {
const isLocalePathname = /\/[a-zA-Z]{2}-[a-zA-Z]{2}\//g.test(pathname);
const path = isLocalePathname
? `/${pathname.split('/').slice(2).join('/')}`
: pathname;
const searchParams = new URLSearchParams(search);
return {
searchParams: searchParams,
path,
};
}, [pathname, search]);
// If an option only has one value, it doesn't need a UI to select it
// 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,
);
return createElement(
Fragment,
null,
...useMemo(() => {
return (
options
// Only show options with more than one value
.filter((option) => option?.values?.length > 1)
.map((option) => {
let activeValue;
let availableValues = [];
for (let value of option.values) {
// The clone the search params for each value, so we can calculate
// a new URL for each option value pair
const clonedSearchParams = new URLSearchParams(searchParams);
clonedSearchParams.set(option.name, value);
// 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]);
});
// Find a variant that matches all selected options.
const variant = variants.find((variant) =>
variant?.selectedOptions?.every(
(selectedOption) =>
clonedSearchParams.get(selectedOption?.name) ===
selectedOption?.value,
),
);
const currentParam = searchParams.get(option.name);
const calculatedActiveValue = currentParam
? // If a URL parameter exists for the current option, check if it equals the current value
currentParam === value
: defaultVariant
? // Else check if the default variant has the current option value
defaultVariant.selectedOptions?.some(
(selectedOption) =>
selectedOption?.name === option.name &&
selectedOption?.value === value,
)
: 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;
}
availableValues.push({
value: value,
isAvailable: variant ? variant.availableForSale : true,
path: path + '?' + clonedSearchParams.toString(),
isActive: Boolean(calculatedActiveValue),
});
}
return children({
option: {
name: option.name,
value: activeValue,
values: availableValues,
},
});
})
);
}, [options, variants, children]),
);
}
export const getSelectedProductOptions = (request) => {
if (!(request instanceof Request))
throw new TypeError(`Expected a Request instance, got ${typeof request}`);
const searchParams = new URL(request.url).searchParams;
const selectedOptions = [];
searchParams.forEach((value, name) => {
selectedOptions.push({name, value});
});
return selectedOptions;
};
export function getFirstAvailableVariant(variants = []) {
return (
variants instanceof Array ? variants : flattenConnection(variants)
).find((variant) => variant?.availableForSale);
}

0 comments on commit 61d2d98

Please sign in to comment.