Compare commits

...

1 commit

Author SHA1 Message Date
cathryngriffiths 4427c87ee8 feat: add support for product reference metafields 2021-11-10 13:33:29 -05:00
20 changed files with 521 additions and 94 deletions

View file

@ -1,6 +1,6 @@
export default {
locale: 'en-us',
storeDomain: 'hydrogen-preview.myshopify.com',
storefrontToken: '3b580e70970c4528da70c98e097c2fa0',
storeDomain: 'react-advanced-hydrogen.myshopify.com',
storefrontToken: '267baac86464f30e5a6cd11451406690',
graphqlApiVersion: 'unstable',
};

View file

@ -7,7 +7,7 @@ import gql from 'graphql-tag';
import Layout from './Layout.server';
import Button from './Button.client';
import ProductCard from './ProductCard.server';
import ProductCard from './ProductCard';
function NotFoundHero() {
return (
@ -68,6 +68,7 @@ export default function NotFound({country = {isoCode: 'US'}}) {
const QUERY = gql`
query NotFoundProductDetails(
$country: CountryCode
$includeReferenceMetafieldDetails: Boolean = false
$numProductMetafields: Int!
$numProductVariants: Int!
$numProductMedia: Int!

View file

@ -7,6 +7,7 @@ import {
BUTTON_PRIMARY_CLASSES,
BUTTON_SECONDARY_CLASSES,
} from './Button.client';
import ProductCard from './ProductCard';
function ProductPriceMarkup() {
return (
@ -104,9 +105,57 @@ function SizeChart() {
);
}
function RecommendedProducts() {
const {metafields} = useProduct();
const recommendedProducts = metafields.filter((metafield) =>
metafield.key.includes('recommended_product'),
);
if (recommendedProducts.length === 0) {
return null;
}
return (
<div className="p-10 col-span-3">
<h3 className="text-2xl font-bold text-center mb-6">
You might also like...
</h3>
<div className="grid grid-cols-3 gap-6">
<Product.Metafield
keyName="recommended_product_1"
namespace="my_fields"
>
{(metafield) => {
return <ProductCard product={metafield.reference} />;
}}
</Product.Metafield>
<Product.Metafield
keyName="recommended_product_2"
namespace="my_fields"
>
{/* eslint-disable-next-line @shopify/jsx-prefer-fragment-wrappers */}
<div>
<Product.Title />
<Product.SelectedVariant.Image />
<Product.SelectedVariant.Price priceType="compareAt" />
<Product.SelectedVariant.Price />
</div>
</Product.Metafield>
<Product.Metafield
keyName="recommended_product_3"
namespace="my_fields"
>
{(metafield) => {
return <ProductCard product={metafield.reference} />;
}}
</Product.Metafield>
</div>
</div>
);
}
export default function ProductDetails({product}) {
const initialVariant = flattenConnection(product.variants)[0];
return (
<>
<Seo product={product} />
@ -230,6 +279,7 @@ export default function ProductDetails({product}) {
}}
</Product.Metafield>
</div>
<RecommendedProducts />
</div>
</Product>
</>

View file

@ -9,7 +9,7 @@ import gql from 'graphql-tag';
import {Link} from '../components/Link.client';
import Layout from '../components/Layout.server';
import FeaturedCollection from '../components/FeaturedCollection.server';
import ProductCard from '../components/ProductCard.server';
import ProductCard from '../components/ProductCard';
import Welcome from '../components/Welcome.server';
function GradientBackground() {
@ -132,6 +132,7 @@ const QUERY = gql`
$country: CountryCode
$numCollections: Int = 2
$numProducts: Int = 3
$includeReferenceMetafieldDetails: Boolean = false
$numProductMetafields: Int = 0
$numProductVariants: Int = 250
$numProductMedia: Int = 1

View file

@ -10,7 +10,7 @@ import gql from 'graphql-tag';
import LoadMoreProducts from '../../components/LoadMoreProducts.client';
import Layout from '../../components/Layout.server';
import ProductCard from '../../components/ProductCard.server';
import ProductCard from '../../components/ProductCard';
import NotFound from '../../components/NotFound.server';
export default function Collection({
@ -65,6 +65,7 @@ const QUERY = gql`
$handle: String!
$country: CountryCode
$numProducts: Int!
$includeReferenceMetafieldDetails: Boolean = false
$numProductMetafields: Int = 0
$numProductVariants: Int = 250
$numProductMedia: Int = 6

View file

@ -32,6 +32,7 @@ const QUERY = gql`
query product(
$country: CountryCode
$handle: String!
$includeReferenceMetafieldDetails: Boolean = true
$numProductMetafields: Int = 20
$numProductVariants: Int = 250
$numProductMedia: Int = 6

View file

@ -6,12 +6,13 @@ import {StarRating} from './components/StarRating';
import {RawHtml} from '../RawHtml';
import {ParsedMetafield, Measurement, Rating} from '../../types';
import {MetafieldFragment as Fragment} from '../../graphql/graphql-constants';
import {ProductProvider, Product} from '../ProductProvider';
export interface MetafieldProps {
/** A [Metafield object](/api/storefront/reference/common-objects/metafield) from the Storefront API. */
metafield: ParsedMetafield;
/** A render function that takes a `Metafield` object as an argument. Refer to [Render props](#render-props). */
children?: (value: ParsedMetafield) => ReactElement;
/** A React Element, or a render function that takes a `Metafield` object as an argument. Refer to [Render props](#render-props). */
children?: ReactElement | ((value: ParsedMetafield) => ReactElement);
}
/**
@ -103,6 +104,19 @@ export function Metafield<TTag extends ElementType>(
{JSON.stringify(metafield.value)}
</Wrapper>
);
case 'product_reference': {
if (metafield.reference != null) {
const product = metafield.reference as Product;
return (
<ProductProvider
product={product}
initialVariantId={product?.variants?.edges?.[0]?.node.id ?? ''}
>
{children}
</ProductProvider>
);
}
}
default: {
const Wrapper = as ?? 'span';
return (

View file

@ -4,7 +4,50 @@ fragment MetafieldFragment on Metafield {
namespace
key
value
createdAt
updatedAt
description
reference @include(if: $includeReferenceMetafieldDetails){
... on Product {
handle
id
title
compareAtPriceRange {
maxVariantPrice {
...MoneyFragment
}
minVariantPrice {
...MoneyFragment
}
}
priceRange {
maxVariantPrice {
...MoneyFragment
}
minVariantPrice {
...MoneyFragment
}
}
variants (first: 1) {
edges {
node {
id
title
availableForSale
image {
...ImageFragment
}
priceV2 {
...MoneyFragment
}
compareAtPriceV2 {
...MoneyFragment
}
selectedOptions {
name
value
}
}
}
}
}
}
}

View file

@ -1,13 +1,54 @@
import * as Types from '../../graphql/types/types';
import {MoneyFragmentFragment} from '../Money/MoneyFragment';
import {ImageFragmentFragment} from '../Image/ImageFragment';
export type MetafieldFragmentFragment = {__typename?: 'Metafield'} & Pick<
Types.Metafield,
| 'id'
| 'type'
| 'namespace'
| 'key'
| 'value'
| 'createdAt'
| 'updatedAt'
| 'description'
>;
'id' | 'type' | 'namespace' | 'key' | 'value' | 'description'
> & {
reference?: Types.Maybe<
| ({__typename?: 'MediaImage'} & Pick<
Types.MediaImage,
'id' | 'mediaContentType'
>)
| ({__typename?: 'Page'} & Pick<Types.Page, 'id'>)
| ({__typename?: 'Product'} & Pick<
Types.Product,
'handle' | 'id' | 'title'
> & {
compareAtPriceRange: {__typename?: 'ProductPriceRange'} & {
maxVariantPrice: {__typename?: 'MoneyV2'} & MoneyFragmentFragment;
minVariantPrice: {__typename?: 'MoneyV2'} & MoneyFragmentFragment;
};
priceRange: {__typename?: 'ProductPriceRange'} & {
maxVariantPrice: {__typename?: 'MoneyV2'} & MoneyFragmentFragment;
minVariantPrice: {__typename?: 'MoneyV2'} & MoneyFragmentFragment;
};
variants: {__typename?: 'ProductVariantConnection'} & {
edges: Array<
{__typename?: 'ProductVariantEdge'} & {
node: {__typename?: 'ProductVariant'} & Pick<
Types.ProductVariant,
'id' | 'title' | 'availableForSale'
> & {
image?: Types.Maybe<
{__typename?: 'Image'} & ImageFragmentFragment
>;
priceV2: {__typename?: 'MoneyV2'} & MoneyFragmentFragment;
compareAtPriceV2?: Types.Maybe<
{__typename?: 'MoneyV2'} & MoneyFragmentFragment
>;
selectedOptions: Array<
{__typename?: 'SelectedOption'} & Pick<
Types.SelectedOption,
'name' | 'value'
>
>;
};
}
>;
};
})
| ({__typename?: 'ProductVariant'} & Pick<Types.ProductVariant, 'id'>)
>;
};

View file

@ -17,7 +17,7 @@ When no `children` prop is provided, the `Metafield` component renders the follo
| `color` | A `span` containing the color value as a string. |
| `single_line_text_field` | A `RawHtml` component with the text. |
| `multi_line_text_field` | A `RawHtml` component with the text. |
| `product_reference` | A `span` containing the product reference GID. |
| `product_reference` | A `span` containing the product reference GID when the `reference` field is undefined, or a `ProductProvider` component using the `reference` data. details. |
| `file_reference` | A `span` containing the file reference GID. |
| `page_reference` | A `span` containing the page reference GID. |
| `variant_reference` | A `span` containing the variant reference GID. |

View file

@ -9,8 +9,6 @@ fragment MetafieldFragment on Metafield {
namespace
key
value
createdAt
updatedAt
description
}
```

View file

@ -4,6 +4,7 @@ import {getParsedMetafield} from '../../../utilities/tests/metafields';
import {mountWithShopifyProvider} from '../../../utilities/tests/shopify_provider';
import {RawHtml} from '../../RawHtml';
import {StarRating} from '../components';
import {ProductProvider} from '../../ProductProvider';
describe('<Metafield />', () => {
it('renders nothing when the metafield value is undefined', () => {
@ -735,68 +736,119 @@ describe('<Metafield />', () => {
});
describe('with `product_reference` type metafield', () => {
it('renders the product reference as a string in a `span` by default', () => {
const metafield = getParsedMetafield({type: 'product_reference'});
const component = mountWithShopifyProvider(
<Metafield metafield={metafield} />
);
describe('when `reference` is undefined', () => {
it('renders the value as a string in a `span` by default', () => {
const metafield = getParsedMetafield({
type: 'product_reference',
reference: undefined,
});
const component = mountWithShopifyProvider(
<Metafield metafield={metafield} />
);
expect(component).toContainReactComponent('span', {
children: metafield.value,
expect(component).toContainReactComponent('span', {
children: metafield.value,
});
});
it('renders the value as a string in the element specified by the `as` prop', () => {
const metafield = getParsedMetafield({
type: 'product_reference',
reference: undefined,
});
const component = mountWithShopifyProvider(
<Metafield metafield={metafield} as="p" />
);
expect(component).toContainReactComponent('p', {
children: metafield.value,
});
});
it('passes the metafield as a render prop to the children render function', () => {
const children = jest.fn().mockImplementation(() => {
return null;
});
const metafield = getParsedMetafield({
type: 'product_reference',
reference: undefined,
});
mountWithShopifyProvider(
<Metafield metafield={metafield}>{children}</Metafield>
);
expect(children).toHaveBeenCalledWith({
...metafield,
value: metafield.value,
});
});
it('renders its children', () => {
const metafield = getParsedMetafield({
type: 'product_reference',
reference: undefined,
});
const component = mountWithShopifyProvider(
<Metafield metafield={metafield}>
{({value}) => {
return <p>The reference is {value}</p>;
}}
</Metafield>
);
expect(component).toContainReactComponent('p', {
children: [`The reference is `, metafield.value],
});
});
it('allows passthrough props', () => {
const component = mountWithShopifyProvider(
<Metafield
metafield={getParsedMetafield({
type: 'product_reference',
reference: undefined,
})}
className="emphasized"
/>
);
expect(component).toContainReactComponent('span', {
className: 'emphasized',
});
});
});
it('renders the product reference as a string in the element specified by the `as` prop', () => {
const metafield = getParsedMetafield({type: 'product_reference'});
const component = mountWithShopifyProvider(
<Metafield metafield={metafield} as="p" />
);
describe('when `reference` is not undefined', () => {
it('renders a `ProductProvider` with its children by default', () => {
const metafield = getParsedMetafield({type: 'product_reference'});
function Children() {
return null;
}
const component = mountWithShopifyProvider(
<Metafield metafield={metafield}>
<Children />
</Metafield>
);
expect(component).toContainReactComponent('p', {
children: metafield.value,
expect(component).toContainReactComponent(ProductProvider, {
children: <Children />,
});
});
});
it('passes the metafield as a render prop to the children render function', () => {
const children = jest.fn().mockImplementation(() => {
return null;
});
const metafield = getParsedMetafield({type: 'product_reference'});
it('passes the metafield as a render prop to the children render function', () => {
const children = jest.fn().mockImplementation(() => {
return null;
});
const metafield = getParsedMetafield({type: 'product_reference'});
mountWithShopifyProvider(
<Metafield metafield={metafield}>{children}</Metafield>
);
mountWithShopifyProvider(
<Metafield metafield={metafield}>{children}</Metafield>
);
expect(children).toHaveBeenCalledWith({
...metafield,
value: metafield.value,
});
});
it('renders its children', () => {
const metafield = getParsedMetafield({type: 'product_reference'});
const component = mountWithShopifyProvider(
<Metafield metafield={metafield}>
{({value}) => {
return <p>The reference is {value}</p>;
}}
</Metafield>
);
expect(component).toContainReactComponent('p', {
children: [`The reference is `, metafield.value],
});
});
it('allows passthrough props', () => {
const component = mountWithShopifyProvider(
<Metafield
metafield={getParsedMetafield({type: 'product_reference'})}
className="emphasized"
/>
);
expect(component).toContainReactComponent('span', {
className: 'emphasized',
expect(children).toHaveBeenCalledWith({
...metafield,
value: metafield.value,
});
});
});
});

View file

@ -1,6 +1,6 @@
import {createContext} from 'react';
import {ProductOptionsHookValue} from '../../hooks';
import {GraphQLConnection, ParsedMetafield} from '../../types';
import {GraphQLConnection, ParsedMetafield, RawMetafield} from '../../types';
import {ProductProviderFragmentFragment} from './ProductProviderFragment';
import {Product} from './types';
import {Collection, Image} from '../../graphql/types/types';
@ -21,7 +21,7 @@ export type ProductContextType = Omit<
media?: ProductProviderFragmentFragment['media']['edges'][0]['node'][];
mediaConnection?: ProductProviderFragmentFragment['media'];
metafields?: ParsedMetafield[];
metafieldsConnection?: ProductProviderFragmentFragment['metafields'];
metafieldsConnection?: GraphQLConnection<RawMetafield>;
images?: Partial<Image>[];
imagesConnection?: GraphQLConnection<Partial<Image>>;
collections?: Partial<Collection>[];

View file

@ -3,3 +3,4 @@ export {
ProductProviderFragment,
} from './ProductProvider.client';
export {useProduct} from '../../hooks/useProduct/useProduct';
export type {Product} from './types';

View file

@ -1,5 +1,5 @@
import {SellingPlanGroup, Variant} from '../../hooks/useProductOptions';
import {GraphQLConnection} from '../../types';
import {GraphQLConnection, RawMetafield} from '../../types';
import {ProductProviderFragmentFragment} from './ProductProviderFragment';
import {ImageFragmentFragment} from '../Image/ImageFragment';
import {Collection} from '../../graphql/types/types';
@ -12,7 +12,7 @@ export interface Product {
handle?: ProductProviderFragmentFragment['descriptionHtml'];
id?: ProductProviderFragmentFragment['id'];
media?: ProductProviderFragmentFragment['media'];
metafields?: ProductProviderFragmentFragment['metafields'];
metafields?: GraphQLConnection<RawMetafield>;
priceRange?: Partial<ProductProviderFragmentFragment['priceRange']>;
title?: ProductProviderFragmentFragment['title'];
variants?: GraphQLConnection<Variant>;

View file

@ -2194,9 +2194,62 @@ fragment Model3DFragment on Model3d {
* namespace
* key
* value
* createdAt
* updatedAt
* description
* reference @include(if: $includeReferenceMetafieldDetails){
* ... on Product {
* handle
* id
* title
* compareAtPriceRange {
* maxVariantPrice {
* ...MoneyFragment
* }
* minVariantPrice {
* ...MoneyFragment
* }
* }
* priceRange {
* maxVariantPrice {
* ...MoneyFragment
* }
* minVariantPrice {
* ...MoneyFragment
* }
* }
* variants (first: 1) {
* edges {
* node {
* id
* title
* availableForSale
* image {
* ...ImageFragment
* }
* priceV2 {
* ...MoneyFragment
* }
* compareAtPriceV2 {
* ...MoneyFragment
* }
* selectedOptions {
* name
* value
* }
* }
* }
* }
* }
* ... on MediaImage {
* id
* mediaContentType
* }
* ... on ProductVariant {
* id
* }
* ... on Page {
* id
* }
* }
* }
*```
@ -2207,9 +2260,62 @@ export const MetafieldFragment: string = `fragment MetafieldFragment on Metafiel
namespace
key
value
createdAt
updatedAt
description
reference @include(if: $includeReferenceMetafieldDetails){
... on Product {
handle
id
title
compareAtPriceRange {
maxVariantPrice {
...MoneyFragment
}
minVariantPrice {
...MoneyFragment
}
}
priceRange {
maxVariantPrice {
...MoneyFragment
}
minVariantPrice {
...MoneyFragment
}
}
variants (first: 1) {
edges {
node {
id
title
availableForSale
image {
...ImageFragment
}
priceV2 {
...MoneyFragment
}
compareAtPriceV2 {
...MoneyFragment
}
selectedOptions {
name
value
}
}
}
}
}
... on MediaImage {
id
mediaContentType
}
... on ProductVariant {
id
}
... on Page {
id
}
}
}
`;
@ -2341,9 +2447,62 @@ export const MoneyFragment: string = `fragment MoneyFragment on MoneyV2 {
* namespace
* key
* value
* createdAt
* updatedAt
* description
* reference @include(if: $includeReferenceMetafieldDetails){
* ... on Product {
* handle
* id
* title
* compareAtPriceRange {
* maxVariantPrice {
* ...MoneyFragment
* }
* minVariantPrice {
* ...MoneyFragment
* }
* }
* priceRange {
* maxVariantPrice {
* ...MoneyFragment
* }
* minVariantPrice {
* ...MoneyFragment
* }
* }
* variants (first: 1) {
* edges {
* node {
* id
* title
* availableForSale
* image {
* ...ImageFragment
* }
* priceV2 {
* ...MoneyFragment
* }
* compareAtPriceV2 {
* ...MoneyFragment
* }
* selectedOptions {
* name
* value
* }
* }
* }
* }
* }
* ... on MediaImage {
* id
* mediaContentType
* }
* ... on ProductVariant {
* id
* }
* ... on Page {
* id
* }
* }
* }
*
* fragment VariantFragment on ProductVariant {
@ -2630,9 +2789,62 @@ fragment MetafieldFragment on Metafield {
namespace
key
value
createdAt
updatedAt
description
reference @include(if: $includeReferenceMetafieldDetails){
... on Product {
handle
id
title
compareAtPriceRange {
maxVariantPrice {
...MoneyFragment
}
minVariantPrice {
...MoneyFragment
}
}
priceRange {
maxVariantPrice {
...MoneyFragment
}
minVariantPrice {
...MoneyFragment
}
}
variants (first: 1) {
edges {
node {
id
title
availableForSale
image {
...ImageFragment
}
priceV2 {
...MoneyFragment
}
compareAtPriceV2 {
...MoneyFragment
}
selectedOptions {
name
value
}
}
}
}
}
... on MediaImage {
id
mediaContentType
}
... on ProductVariant {
id
}
... on Page {
id
}
}
}
fragment VariantFragment on ProductVariant {

View file

@ -1,6 +1,5 @@
import {useMemo} from 'react';
import {GraphQLConnection, ParsedMetafield} from '../../types';
import {Metafield} from '../../graphql/types/types';
import {GraphQLConnection, ParsedMetafield, RawMetafield} from '../../types';
import {flattenConnection, parseMetafieldValue} from '../../utilities';
/**
@ -8,7 +7,7 @@ import {flattenConnection, parseMetafieldValue} from '../../utilities';
* in an array of metafields whose `values` have been parsed according to the metafield `type`.
*/
export function useParsedMetafields(
metafields: GraphQLConnection<Partial<Metafield>> | undefined
metafields: GraphQLConnection<RawMetafield> | undefined
): ParsedMetafield[] {
return useMemo(() => {
if (metafields == null) {

View file

@ -1,7 +1,7 @@
import type {ServerResponse} from 'http';
import type {ServerComponentResponse} from './framework/Hydration/ServerComponentResponse.server';
import type {ServerComponentRequest} from './framework/Hydration/ServerComponentRequest.server';
import type {Metafield} from './graphql/types/types';
import type {Metafield, Product} from './graphql/types/types';
export type Renderer = (
url: URL,
@ -71,8 +71,14 @@ export interface GraphQLConnection<T> {
edges?: {node: T}[];
}
export type RawMetafield = Partial<Metafield>;
export type ParsedMetafield = Omit<Partial<Metafield>, 'value'> & {
export type RawMetafield = Omit<Partial<Metafield>, 'reference'> & {
reference?: Product;
};
export type ParsedMetafield = Omit<
Partial<Metafield>,
'value' | 'reference'
> & {
value?:
| string
| number
@ -81,6 +87,7 @@ export type ParsedMetafield = Omit<Partial<Metafield>, 'value'> & {
| Date
| Rating
| Measurement;
reference?: Partial<Product>;
};
export interface Rating {

View file

@ -2,6 +2,7 @@
import faker from 'faker';
import {Metafield} from '../../graphql/types/types';
import {ParsedMetafield, Rating, RawMetafield} from '../../types';
import {getProduct} from './product';
export type MetafieldType =
| 'single_line_text_field'
@ -46,7 +47,7 @@ export const METAFIELDS: MetafieldType[] = [
export function getRawMetafield(
metafield: Partial<Metafield> & {type?: MetafieldType} = {}
): Omit<Metafield, 'parentResource' | 'valueType'> {
): RawMetafield {
const type: MetafieldType =
metafield.type == null
? faker.random.arrayElement(METAFIELDS)
@ -62,6 +63,9 @@ export function getRawMetafield(
type,
updatedAt: metafield.updatedAt ?? faker.date.recent(),
value: metafield.value ?? getMetafieldValue(type),
reference: Object.keys(metafield).includes('reference')
? metafield.reference
: (getProduct({metafields: {edges: []}, variants: {edges: []}}) as any),
};
}
@ -167,6 +171,8 @@ export function getParsedMetafield(
case 'single_line_text_field':
case 'multi_line_text_field':
case 'product_reference':
field.value = rawField.value;
break;
case 'page_reference':
case 'variant_reference':
case 'file_reference':