1. Creating the Product List
Creating a Product List
A product list is the most common element found on any online shop/marketplace regardless of how it’s built, but it is also the hardest to element to create without some any flaws. There are serious guidelines that a developer must follow or the performance of very common component, that may appear multiple times on one page, could tank your application’s speed and UX.
What Are the Guidelines?
- Never query all your products at once, use pagination instead. Specifically, cursor-based pagination, it’s following the principles of setting some filters on what type products you want and what order they are, and ignoring everything else. We don’t care how many products there are in total because with could take a long time to count them. Just continue to send enough to feel the view as user browses the list. If the user wants a specific product from the list allow the user to update the filters to be more specific and refetch the products with the new filters.
- Only query the data you need when you need it. The means only query the data you need to display on the product list. Typically this is the “title”, “short description”, “price”, and the “thumbnail”. You’ll also need the “id” and “productId” for caching or an “Add to cart” button, but that just about it. No need to get all the product information for the product page when the user might not even view that product.
Before We Begin.
We are going to skip setting up a React/Apollo application because there are a ton of great tutorials on the web as well as the Apollo Docs[1]. Instead, we will do three simple step to prepare.
- Make a list of the NPM dependencies we will require for reference.
- react
- react-dom
- apollo-boost
- @apollo/react-hooks
- graphql
- graphql-anywhere
- styled-components
- lodash
- prop-types
- Make a quick note of the project “src” structure
- assets/ – Will contain non-js project files like fonts or images,
- components/ – Will contain reusable components
- app.js – Application root, we will be staging our product list component for testing with the Webpack “devServer” here.
- grid.js – Simple reusable style-component.
- products/ – Will contain our product list component files
- index.js – Application entry, we will setup our “ApolloClient” and render our “App” wrapped in an “ApolloProvider” here.
- Create our index.js, components/app.jsx, components/grid.js files
// index.jsimport React from 'react';import ReactDOM from 'react-dom';import ApolloClient from 'apollo-boost';import { ApolloProvider } from '@apollo/react-hooks';import App from './components/app';const client = new ApolloClient({ uri: process.env.REACT_APP_ENDPOINT });ReactDOM.render(<ApolloProvider client={client}><App /></ApolloProvider>,document.getElementById('root'));Nothing to complex here if you have basic familiarity with React and Apollo.import styled from 'styled-components';const Grid = styled.div.attrs((props) => ({columns: props.columns || 'auto-fit',inline: props.inline || false,rows: props.rows || '1fr',itemWidth: props.itemWidth || '375px',min: typeof props.columns === 'number' ? `${100 / props.columns}%` : props.itemWidth,}))`padding: 12px 16px;width: ${({ width }) => (width || '100%')};height: ${({ height }) => (height || 'auto')};display: ${({ inline }) => (inline ? 'inline-grid' : 'grid')};grid-template-columns: repeat(${(props) => props.columns}, minmax(${(props) => props.min}, 1fr));grid-template-rows: ${(props) => props.rows};${({ columnGap }) => columnGap && `grid-column-gap: ${columnGap};`}${({ rowGap }) => rowGap && `grid-row-gap: ${rowGap};`}${({ justifyItems }) => justifyItems && `justify-items: ${justifyItems};`}${({ justifyContent }) => justifyContent && `justify-content: ${justifyContent};`}${({ alignItems }) => alignItems && `align-items: ${alignItems};`}${({ alignContent }) => alignContent && `align-content: ${alignContent};`}${({ autoFlow }) => autoFlow && `grid-auto-flow: ${autoFlow};`}${({ maxWidth }) => maxWidth && `max-width: ${maxWidth};`}`;export default Grid;
A quick, versatile, and reusable Grid styled component.
// app.jsimport React from 'react';import ProductsList from './products';const App = () => {return <ProductsList />};export default App;
We are just importing our <ProductsList />
component and rendering it.
Creating the Component.
Now start the development server based upon however you setup your application and React should throw an compile error because ./products/index.js
doesn’t exist yet. So let’s jump right into developing our simple-in-design and reusable product list component. It will be made up of two components, a list component for rendering items and the controls for filtering them, and an item component for defining who the individual product data should be rendered. So let’s start with components/products/index.jsx
.
// products/index.jsximport React from 'react';import { useQuery } from '@apollo/react-hooks';import { gql } from 'apollo-boost';import PropTypes from 'prop-types';import Grid from '../grid';import ProductsItem from './item';export const GET_PRODUCTS = gql`query ($first: Int, $after: String) {products(first: $first, after: $after, where: { supportedTypesOnly: true }) {edges {cursornode {idslugnametypeshortDescriptionimage {idsourceUrlaltText}galleryImages {nodes {idsourceUrlaltText}}... on SimpleProduct {onSalepriceregularPrice}... on VariableProduct {onSalepriceregularPrice}}}}}`;const ProductsList = (props) => {const { columns, itemWidth, ...rest } = props;const { data, loading, error } = useQuery(GET_PRODUCTS);if (loading) {return <div>Fetching products...</div>}if (error) {return <div>{error.message}</div>}const products = data.products.edges || [];return (<Grid maxWidth="100%" columns={columns} itemWidth={itemWidth} {...rest}>{products.map(({ cursor, node, }) => <ProductsItem key={cursor} data={node} width={itemWidth} />)}</Grid>);};ProductsList.propTypes = {products: PropTypes.arrayOf(PropTypes.shape({})),columns: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),itemWidth: PropTypes.string,};ProductsList.defaultProps = {products: [],columns: 'auto-fit',itemWidth: '375px',};export default ProductsList;
This is our list component. It’ll do the job of querying our products and mapping our product data to the list of <ProductsItem>
components, however if you stick this in the application and use it you get an error because components/products/item.jsx
doesn’t exist so let’s make that too.
// products/item.jsximport React from 'react';import parse, { domToReact } from 'html-react-parser';import PropTypes from 'prop-types';import Rail, { ProductRail } from '../rail';import Image from './image';import Price from './price';const ProductsItem = ({ data, ...rest }) => {const {name,onSale,regularPrice,price,image,galleryImages,type,shortDescription: description,link,} = data;return (<Rail justifyContent="center" direction="column" {...rest}><Image data={{ image, galleryImages }} width="175px" squared noUI /><ProductRaildirection="column"height="175px"width="175px"alignItems="center"inlineshrink><a className="product-name" href={link}>{onSale && (<><span className="badge">On Sale</span><br /></>)}{name}{description && (<><br />{parse(description, {replace({ name, children }) {if (name === 'p') {return <small>{domToReact(children)}</small>;}}})}</>)}<Pricetype={type}onSale={onSale}price={price}regularPrice={regularPrice}/></a></ProductRail></Rail>);};ProductsItem.propTypes = {data: PropTypes.shape({name: PropTypes.string,slug: PropTypes.string,onSale: PropTypes.bool,regularPrice: PropTypes.string,price: PropTypes.string,image: PropTypes.shape({}),galleryImages: PropTypes.shape({nodes: PropTypes.arrayOf(PropTypes.shape({}))}),type: PropTypes.string,shortDescription: PropTypes.string,}),};ProductsItem.defaultProps = {data: {},type: 'normal',};export default ProductsItem;
// products/image.jsximport React from 'react';import PropTypes from 'prop-types';import styled from 'styled-components';/*** Returns the proper width for the image.** @param {*} props*/function getWidth(props, raw = false) {const {thumbnail,small,medium,large,width,} = props;let output;switch (true) {case thumbnail:output = '128px';break;case small:output = '240px';break;case medium:output = '512px';break;case large:output = '764px';break;case width && typeof width === 'string':output = width;break;default:return false;}return raw ? parseFloat(output) : output;}const Image = styled.img`max-width: 100%;width: ${(props) => getWidth(props) ? getWidth(props) : '100%'};${({ rounded }) => rounded && `border-radius: 50%;`}`;const ProductImage = ({ data, ...rest }) => {const { image } = data;return image? <Image src={data.image.sourceUrl} alt={data.image.altText} {...rest} />: <Image src="http://place-puppy.com/640x640" alt="no product image" {...rest} />;};const imagePropType = PropTypes.shape({sourceUrl: PropTypes.string,altText: PropTypes.string,});ProductImage.propTypes = {data: PropTypes.shape({image: imagePropType,}),};ProductImage.defaultProps = {data: {image: {sourceUrl: 'http://place-puppy.com/640x640',altText: 'product image',},},};export default ProductImage;
// products/price.jsximport React from 'react';import PropTypes from 'prop-types';const ProductPrice = ({ onSale, regularPrice, price, type }) => {if (onSale) {return type === 'VARIABLE'? (<p className="product-price">{price}</p>): (<p className="product-price"><span className="regular-price">{regularPrice}</span> {price}</p>);}return <p className="product-price">{price}</p>;};ProductPrice.propTypes = {price: PropTypes.string,regularPrice: PropTypes.string,salePrice: PropTypes.string,onSale: PropTypes.bool,};ProductPrice.defaultProps = {price: 'Free',regularPrice: 'Free',salePrice: 'Free',onSale: false,};export default ProductPrice;
This is our item component. Right now, it just displays the most basic product data, simple enough. It’ll do much more later in the tutorial, but this will do for now.
If you run in an application now you should see a list of 10 products. If you don’t see any product check your browser console for error messages, and follow accordingly. If you don’t see any because there are no product in store, I suggest importing the sample product data included in the WooCommerce plugin while developing[2].
Adding Some Pagination
Well, this fine all we ever plan to sell is 10 items, but since that’s probably not the case let’s implement some pagination. First we’ll create a callback for executing “fetchMore” request. This will tell Apollo to grab the next group of products and add them to the “data” object essentially. To do this, we’ll have to make some changes to products/index.jsx
// products/index.jsx...import InfiniteLoader from 'react-infinite-loader';...const ProductsList = (props) => {const containerRef = useRef(null);const {columns,itemWidth,width,...variables} = props;const { data, loading, error, fetchMore } = useQuery(GET_PRODUCTS, { variables });if (loading) {return <div>Fetching products...</div>}if (error) {return <div>{error.message}</div>}const hasMore = () => {if (variables.last) {return data.products.pageInfo.hasPreviousPage;}return data.products.pageInfo.hasNextPage;};const loadMore = () => {// eslint-disable-next-lineconsole.log('fetching more items.');return hasMore() && fetchMore({variables: variables.last? { before: data.products.pageInfo.startCursor }: { after: data.products.pageInfo.endCursor },updateQuery(prev, { fetchMoreResult }) {if (fetchMoreResult) {const next = {...fetchMoreResult,products: {...fetchMoreResult.products,edges: uniqBy([...prev.products.edges, ...fetchMoreResult.products.edges], 'cursor'),},};return next;}return prev;},});};const products = data.products.edges || [];return (<Grid ref={containerRef} maxWidth="100%" columns={columns} itemWidth={itemWidth} width={width}>{products.map(({ cursor, node, }) => (<ProductsItem key={cursor} data={node} width={itemWidth} />))}<InfiniteLoader onVisited={loadMore} containerElement={containerRef && containerRef.current} /></Grid>);};ProductsList.propTypes = {columns: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),itemWidth: PropTypes.string,width: PropTypes.string};ProductsList.defaultProps = {columns: 'auto-fit',itemWidth: '375px',width: undefined,};export default ProductsList;
This implements a loadMore callback that is executed when the <InfinteLoader />
is scrolled passed. This callback queries the next set of products and updates the GET_PRODUCTS
query results that are stored in Apollo's cache. This new products are this added to the data
object in ProductsList
component.
const { data, loading, error, fetchMore } = useQuery(GET_PRODUCTS, { variables });
This implemention of allows for controlling the number of products queried at one time be setting the first
or last
props on the ProductsList
<ProductsList first={10} />
And there you have it. A product list with pagination. Next, we'll be implementing a product page with a heavy focus on what each piece of data provided by the GraphQL Interface type Product
, and how that data should be used.
Quick Tip
You can actually implements some complex filtering capabilities by making some small changes to the
GET_PRODUCTS
query- query ($first: Int, $after: String) {- products(first: $first, after: $after, where: { supportedTypesOnly: true }) {+ query ($first: Int, $after: String, $where: RootQueryToProductConnectionWhereArgs) {+ products(first: $first, after: $after, where: $where) {With these changes it's now possible to filtering using the
where
prop.<ProductsList first={10} where={{ category: "Clothing" }} />To what properties are available on the GraphQL Input type
RootQueryToProductConnectionWhereArgs
. You can search this type and more using the Docs panel on the right side of Playground.