Skip to content

Commit

Permalink
feat(data): Add live GraphQL data to product detail page
Browse files Browse the repository at this point in the history
Basic implementation of a GraphQL query for product details. Builds on #52 by replicating the inline query declaration. Had to plumb out the child components for the new data shape; in doing so, I made a few reusable functions.

 - Added GraphQL query to
 `packages/venia-concept/src/RootComponents/Product.js`.
     - Resolves from URL by using the `url_key` in a GraphQL query.
     - Can also resolve by SKU.
     - Modified prop types and render method to accommodate live data shape.
 - Added `<Currency />` component whose signature matches the Magento GraphQl `Money` type.
    - Uses the `window.Intl` standard object to format.
 - Modified the `Gallery` and `ProductImageCarousel` components to use new data shape.
 - Moved shared constant data URIs to a single `src/shared` folder, to replicate placeholder logic.
 - Created a shared `propShapes.js` file containing commonly used prop type expressions.
 - Anticipating that `url_key` would be a common way to navigate, I made a `url_key` utility function.
 - Added a `makeProductMediaPath` utility function, for turning product image file paths from API responses into relative URLs.
   - Though [magento/graphql-ce/issues/88](magento/graphql-ce#88) is still a problem for production, I found that **when `magento-sample-data` is installed, it symlinks into the `pub/media` folder so you can use simpler URLs.**
   - You can see this for yourself with `ls -l <magento-root>/pub/media/catalog/product`.
   - So I added a `makePathPrepender` function, which we'll later use often, that can create functions like `makeProductMediaPath`.
   - I hardcoded `/media/catalog/products` in the code, but I also added an environment variable to `.env` and `webpack.config.js` for configuring that URL per instance.

 - Optimize queries with fragments
 - Centralize queries in query file to be preprocessed
 - Make link to product detail on category page
 - Resolve media URL issue
 - Test with image galleries

Closes #87.
  • Loading branch information
zetlen committed Jun 26, 2018
1 parent cd99213 commit 0d31f07
Show file tree
Hide file tree
Showing 13 changed files with 265 additions and 97 deletions.
186 changes: 113 additions & 73 deletions packages/venia-concept/src/RootComponents/Product/Product.js
Original file line number Diff line number Diff line change
@@ -1,97 +1,137 @@
import { Component, createElement } from 'react';
import PropTypes from 'prop-types';
import { shape, number, arrayOf, string } from 'prop-types';

import classify from 'src/classify';
import getUrlKey from 'src/util/getUrlKey';
import Page from 'src/components/Page';
import Carousel from 'src/components/ProductImageCarousel';
import Options from 'src/components/ProductOptions';
import Quantity from 'src/components/ProductQuantity';
import RichText from 'src/components/RichText';
import mockData from './mockData';
import Currency from 'src/components/Currency';
import defaultClasses from './product.css';

import { mediaGalleryEntry, price } from 'src/shared/propShapes'

import { Query } from 'react-apollo';
import gql from 'graphql-tag';

const productDetailQuery = gql`
query productDetail($sku: String, $urlKey: String) {
productDetail: products(filter: { sku: {eq: $sku}, or: {url_key: {eq: $urlKey }}}, pageSize: 1, currentPage: 1) {
total_count
items {
id
sku
name
price {
regularPrice {
amount {
currency
value
}
}
}
image
image_label
description
short_description
media_gallery_entries {
id
media_type
label
position
disabled
file
}
canonical_url
}
}
}`

class Product extends Component {
static propTypes = {
classes: PropTypes.shape({
root: PropTypes.string
classes: shape({
root: string
}),
data: PropTypes.shape({
additionalInfo: PropTypes.string,
description: PropTypes.string,
images: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string
})
),
name: PropTypes.string,
options: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string
})
),
price: PropTypes.string
data: shape({
productDetail: shape({
total_count: number,
items: arrayOf(shape({
id: number,
sku: string.isRequired,
price: shape({
regularPrice: price.isRequired
}).isRequired,
image: string,
image_label: string,
media_gallery_entries: arrayOf(mediaGalleryEntry),
description: string,
short_description: string,
canonical_url: string
})).isRequired
}).isRequired
})
};

static defaultProps = {
data: mockData
};

render() {
const { classes, data } = this.props;

return (
<Page>
<article className={classes.root}>
<section className={classes.title}>
<h1 className={classes.productName}>
<span>{data.name}</span>
</h1>
<p className={classes.productPrice}>
<span>{data.price}</span>
</p>
</section>
<section className={classes.imageCarousel}>
<Carousel images={data.images} />
</section>
<section className={classes.actions}>
<button className={classes.action}>
<span>Add to Wishlist</span>
</button>
</section>
<section className={classes.options}>
<Options options={data.options} />
</section>
<section className={classes.quantity}>
<h2 className={classes.quantityTitle}>
<span>Quantity</span>
</h2>
<Quantity />
</section>
<section className={classes.cartActions}>
<button className={classes.addToCart}>
<span>Add to Cart</span>
</button>
</section>
<section className={classes.description}>
<h2 className={classes.descriptionTitle}>
<span>Product Description</span>
</h2>
<RichText content={data.description} />
</section>
<section className={classes.details}>
<h2 className={classes.detailsTitle}>
<span>Details</span>
</h2>
<RichText content={data.additionalInfo} />
</section>
<section className={classes.related}>
<h2 className={classes.relatedTitle}>
<span>Similar Products</span>
</h2>
</section>
</article>
</Page>
<Query query={productDetailQuery} variables={{ urlKey: getUrlKey() }}>
{({ loading, error, data }) => {
if (error) return <div>Data Fetch Error</div>;
if (loading) return <div>Fetching Data</div>;

const product = data.productDetail.items[0];
const { regularPrice } = product.price;

return (
<article className={classes.root}>
<section className={classes.title}>
<h1 className={classes.productName}>
<span>{product.name}</span>
</h1>
<p className={classes.productPrice}>
<Currency {...regularPrice.amount} />
</p>
</section>
<section className={classes.imageCarousel}>
<Carousel images={product.media_gallery_entries} />
</section>
<section className={classes.actions}>
<button className={classes.action}>
<span>Add to Wishlist</span>
</button>
</section>
<section className={classes.quantity}>
<h2 className={classes.quantityTitle}>
<span>Quantity</span>
</h2>
<Quantity />
</section>
<section className={classes.cartActions}>
<button className={classes.addToCart}>
<span>Add to Cart</span>
</button>
</section>
<section className={classes.description}>
<h2 className={classes.descriptionTitle}>
<span>Product Description</span>
</h2>
<RichText content={product.description} />
</section>
<section className={classes.details}>
<h2 className={classes.detailsTitle}>
<span>SKU</span>
</h2>
<strong>{product.sku}</strong>
</section>
</article>
)}}
</Query>
</Page>
);
}
}
Expand Down
38 changes: 38 additions & 0 deletions packages/venia-concept/src/components/Currency/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Component, createElement } from 'react';
import PropTypes from 'prop-types';

export default class Currency extends Component {

static propTypes = {
locale: PropTypes.string,
value: PropTypes.number.isRequired,
currency: PropTypes.string.isRequired,
tagName: PropTypes.string
};

static defaultProps = {
tagName: 'span'
};

guessLocalLanguage() {
if (!window.navigator) return;
return window.navigator
&& (navigator.languages && navigator.languages[0]) // HTML5 spec
|| navigator.language // HTML5 spec
|| 'en_US'; // America!!!
}


render() {
const { tagName: Tag, value, locale, currency, ...attrs } = this.props;
const formatter = new Intl.NumberFormat(locale || this.guessLocalLanguage(), {
style: 'currency',
currency
});
return (
<Tag {...attrs}>
{formatter.format(value)}
</Tag>
);
}
}
10 changes: 7 additions & 3 deletions packages/venia-concept/src/components/Gallery/item.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { Component, createElement } from 'react';
import { string, number, shape, func, bool } from 'prop-types';
import { Price } from '@magento/peregrine';
import classify from 'src/classify';
import { imagePlaceholderUri } from 'src/constants';
import { transparentPlaceholder } from 'src/shared/images';
import { makeProductMediaPath } from 'src/util/makeMediaPath';
import defaultClasses from './item.css';

const imageWidth = '300';
Expand Down Expand Up @@ -101,7 +102,7 @@ class GalleryItem extends Component {
return (
<img
className={className}
src={imagePlaceholderUri}
src={transparentPlaceholder}
alt=""
width={imageWidth}
height={imageHeight}
Expand All @@ -112,6 +113,9 @@ class GalleryItem extends Component {
/**
* Product images are currently broken and pending a fix from the `graphql-ce` project
* https://github.com/magento/graphql-ce/issues/88
*
* When using sample data, which symlinks to bypass cache,
* you can simple prepend /media/catalog/product/, which we'll do from .env.
*/
renderImage = () => {
const { classes, item, showImage } = this.props;
Expand All @@ -126,7 +130,7 @@ class GalleryItem extends Component {
return (
<img
className={className}
src={small_image}
src={makeProductMediaPath(small_image)}
alt={name}
width={imageWidth}
height={imageHeight}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,32 @@ import PropTypes from 'prop-types';
import classify from 'src/classify';
import ThumbnailList from './thumbnailList';
import defaultClasses from './carousel.css';

const uri =
'iVBORw0KGgoAAAANSUhEUgAAAAQAAAAFCAQAAADIpIVQAAAADklEQVR42mNkgAJGIhgAALQABsHyMOcAAAAASUVORK5CYII=';
import { makeProductMediaPath } from 'src/util/makeMediaPath';
import { mediaGalleryEntry } from 'src/shared/propShapes';
import { grayPlaceholder } from 'src/shared/images';

class Carousel extends Component {
static propTypes = {
classes: PropTypes.shape({
currentImage: PropTypes.string,
currentImageFile: PropTypes.string,
root: PropTypes.string
}),
images: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.string
})
)
mediaGalleryEntry
).isRequired
};

render() {
const { classes, images } = this.props;

const mainImage = images[0] || {};
const src = mainImage.file ? makeProductMediaPath(mainImage.file) : grayPlaceholder;
const alt = mainImage.label || 'product';
return (
<div className={classes.root}>
<img
className={classes.currentImage}
src={`data:image/png;base64,${uri}`}
alt="product"
src={src}
alt={alt}
/>
<ThumbnailList items={images} />
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,8 @@ import PropTypes from 'prop-types';

import classify from 'src/classify';
import defaultClasses from './thumbnail.css';

const uri =
'iVBORw0KGgoAAAANSUhEUgAAAAQAAAAFCAQAAADIpIVQAAAADklEQVR42mNkgAJGIhgAALQABsHyMOcAAAAASUVORK5CYII=';
import { grayPlaceholder } from 'src/shared/images';
import { makeProductMediaPath } from 'src/util/makeMediaPath';

class Thumbnail extends Component {
static propTypes = {
Expand All @@ -15,14 +14,14 @@ class Thumbnail extends Component {
};

render() {
const { classes } = this.props;
const { classes, item } = this.props;

return (
<div className={classes.root}>
<img
className={classes.image}
src={`data:image/png;base64,${uri}`}
alt="thumbnail"
src={item.file ? makeProductMediaPath(item.file) : grayPlaceholder}
alt={item.label}
/>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,25 @@ import { List } from '@magento/peregrine';
import classify from 'src/classify';
import Thumbnail from './thumbnail';
import defaultClasses from './thumbnailList.css';
import { mediaGalleryEntry } from 'src/shared/propShapes';

class ThumbnailList extends Component {
static propTypes = {
classes: PropTypes.shape({
root: PropTypes.string
}),
items: PropTypes.arrayOf(PropTypes.object).isRequired
items: PropTypes.arrayOf(mediaGalleryEntry).isRequired
};

render() {
return <List renderItem={Thumbnail} {...this.props} />;
// linear-time sort is possible when we have a numeric 'position' prop
const validItems = this.props.items
.filter(i => !i.disabled)
.reduce((sorted, item) => {
sorted[item.position - 1] = item;
return sorted;
}, []);
return <List renderItem={Thumbnail} {...this.props} items={validItems} />;
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// corresponds to a 300x372 transparent png
// keep in sync with constants above
// TODO: generate this programmatically?
export const imagePlaceholderUri =
export const transparentPlaceholder =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABkAAAAfCAQAAAC4ua71AAAAGklEQVR42mNkIBkwjmoZ1TKqZVTLqJYRpgUAaP0AIAQAObYAAAAASUVORK5CYII=';

export const grayPlaceholder = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAFCAQAAADIpIVQAAAADklEQVR42mNkgAJGIhgAALQABsHyMOcAAAAASUVORK5CYII=';
Loading

0 comments on commit 0d31f07

Please sign in to comment.