• How to extend PWA Studio with new components?
  • How to send GraphQL queries?
  • How to add styles for components?
  • How to overwrite Venia UI components?

In this tutorial, I would like to show you how to extend PWA Studio, using a real example. We will add a short description of a product to the product page.

The final result looks like this:

How to extend PWA Studio with new features  - short description of the product

It’s not a big deal, but it’ll be perfect for the first tutorial about PWA Studio on my blog, and a good first issue for people who want to learn PWA Studio 😉

Pre-Requirements

  1. Knowledge of HTML/CSS
  2. Basic knowledge of React
  3. Local instance of PWA Studio
  4. Reading Lars Roettig’s article about the PWA Studio extensibility framework

Let’s do this!

Using the PWA Studio extensibility framework, we are going to create two modules:

  1. short-description-plugin – a module with a ShortDescription component
  2. venia-ui-overwrites – a module for overwrites of Venia UI components, necessary to inject our ShortDescription component into the storefront

Create directories for the project

Create a directory in a place where you want to store your PWA Studio projects on your computer. For me, it’s the Sites/pwa-studio/tutorials directory.

mkdir short-description-tutorial
cd short-description-tutorial
mkdir -p packages/short-description-plugin
touch packages/short-description-plugin/package.json
touch packages/short-description-plugin/intercept.js

Package.json file

{
  "name": "@marcinkwiatkowski/short-description-plugin",
  "version": "1.0.0",
  "description": "PWA Studio Component which fetches short description of a product and displays it on a Storefront",
  "author": "Marcin Kwiatkowski <contact@frodigo.com>",
  "license": "MIT",
  "pwa-studio": {
    "targets": {
      "intercept": "intercept.js"
    }
  },
  "peerDependencies": {
    "@magento/pwa-buildpack": "*",
    "@magento/venia-ui": "*",
    "react": "~16.9.0",
    "react-router-dom": "~5.1.0"
  },
  "main": "./lib/components/ShortDescription/index.js"
}

On line 9, we declare the location for an intercept.js file. In this file, we set special flags that let PWA Studio know that our module needs to be loaded.

Intercept.js file

module.exports = targets => {
    targets.of('@magento/pwa-buildpack').specialFeatures.tap(flags => {
        flags[targets.name] = {esModules: true, cssModules: true};
    });
}

Create ShortDescriptionComponent

mkdir -p packages/short-description-plugin/lib/components/ShortDescription
touch packages/short-description-plugin/lib/components/ShortDescription/short-description.js
touch packages/short-description-plugin/lib/components/ShortDescription/short-description.gql.js
touch packages/short-description-plugin/lib/components/ShortDescription/short-description.css
touch packages/short-description-plugin/lib/components/ShortDescription/index.js

Short-description.js file

We will go through this component step by step to explain what we are doing here.

Declare the component
import React from 'react';

const ShortDescription = props => {
    const { productSku } = props;
};

export default ShortDescription;

On the first line, we import React, because it’s necessary for creating the component.

On the third line, we declare the component, and we pass the props object. This object has attributes, which are passed to the component from a parent component. In our case, this is the product’s SKU. The initialization of this component in the parent component will look like this:

<ShortDescription productSku="<product SKU here>"/>

On the last line of code, we define the default export so that we can use this component elsewhere.

Import CSS styles

Now we will import styles for our component (new lines of code are highlighted).

import React from 'react';
import {mergeClasses} from '@magento/venia-ui/lib/classify';
import defaultClasses from './short-description.css';

const ShortDescription = props => {
    const { productSku } = props;
    const classes = mergeClasses(defaultClasses);
};

export default ShortDescription;

PWA Studio uses CSS modules, and the classify library, which is responsible for applying CSS stylesheets to the object. Thanks to this, we can use these styles in our component.

GraphQL query

We will use a GraphQL query to get a short description of a product from the backend.

import React from 'react';
import {mergeClasses} from '@magento/venia-ui/lib/classify';
import defaultClasses from './short-description.css';
import { useQuery } from '@apollo/react-hooks';
import productOperations from './short-description.gql';

const ShortDescription = props => {
    const { productSku } = props;
    const classes = mergeClasses(defaultClasses);
    const { queries } = productOperations;
    const { getShortDescriptionQuery } = queries;

    const { data } = useQuery(getShortDescriptionQuery, {
        fetchPolicy: 'cache-and-network',
        variables: {
            productSku
        }
    });

    const { productDetail } = data;
    const shortDescription = productDetail.items[0].short_description.html
};

export default ShortDescription;


On the fourth line, we import the useQuery hook, which is used for sending GraphQL queries to the backend. On the fifth line, we import our object with queries (we will talk about this later).

On the tenth line, we send the query. The useQuery hook has two arguments. The first one is a query, and the second one is the options used.

On lines 20 and 21, we assign the query values to constants for later use.

Render component on a Storefront
import React from 'react';
import {mergeClasses} from '@magento/venia-ui/lib/classify';
import defaultClasses from './short-description.css';
import { useQuery } from '@apollo/react-hooks';
import productOperations from './short-description.gql';
import RichText from '@magento/venia-ui/lib/components/RichText';

const ShortDescription = props => {
    const { productSku } = props;
    const classes = mergeClasses(defaultClasses);
    const { queries } = productOperations;
    const { getShortDescriptionQuery } = queries;

    const { data } = useQuery(getShortDescriptionQuery, {
        fetchPolicy: 'cache-and-network',
        variables: {
            productSku
        }
    });

    const { productDetail } = data;
    const shortDescription = productDetail.items[0].short_description.html

    if (shortDescription.length) {
        return (
            <div className={classes.root}>
                <div className={classes.section}>
                    <RichText content={shortDescription} />
                </div>
            </div>
        )
    }

    return null;
};

export default ShortDescription;


On the sixth line, we import the RichText component, which lets us render HTML code on Storefront. On lines 23 to 30, there is some JSX code responsible for rendering our component. Note that if the product has no short description, then we don’t want to render anything, so we return null.

Props validation

The last thing we do is the validation of props. It is especially important to check that the productSku attribute is passed to the component.

import React from 'react';
import {mergeClasses} from '@magento/venia-ui/lib/classify';
import defaultClasses from './short-description.css';
import { useQuery } from '@apollo/react-hooks';
import productOperations from './short-description.gql';
import RichText from '@magento/venia-ui/lib/components/RichText';
import {shape, string} from 'prop-types';

const ShortDescription = props => {
    const { productSku } = props;
    const classes = mergeClasses(defaultClasses);
    const { queries } = productOperations;
    const { getShortDescriptionQuery } = queries;

    const { data } = useQuery(getShortDescriptionQuery, {
        fetchPolicy: 'cache-and-network',
        variables: {
            productSku
        }
    });

    const { productDetail } = data;
    const shortDescription = productDetail.items[0].short_description.html

    if (shortDescription.length) {
        return (
            <div className={classes.root}>
                <div className={classes.section}>
                    <RichText content={shortDescription} />
                </div>
            </div>
        )
    }

    return null;
};

export default ShortDescription;

ShortDescription.propTypes = {
    classes: shape({
        root: string,
        section: string,
        loader: string
    }),
    productSku: string.isRequired
};

This is the final version of the working component – I have checked and tested it, trust me 😉

short-description.gql.js file

import gql from 'graphql-tag';

const GET_SHORT_DESCRIPTION_QUERY = gql`
    query shortDescriptionOfProduct($productSku: String!) {
        productDetail: products(filter: { sku: { eq: $productSku } }) {
            items {
                short_description {
                    html
                }
            }
        }
    }
`;

export default {
    queries: {
        getShortDescriptionQuery: GET_SHORT_DESCRIPTION_QUERY
    },
    mutations: {}
};

In files like this one, we declare GraphQL queries and mutations. In our case, it’s only one query: getShortDescriptionQuery

Short-description.css file

This is the file with styles of our components:

.root {
    margin: 15px 0;
}

.section {
    border-color: rgb(var(--venia-border));
    border-style: solid;
    border-width: 1px 0 1px;
    margin: 0 1.5rem;
    padding: 1.5rem 0;
}

Please note that the names of classes are later used in JSX:

// short-description.js

<div className={classes.root}>
    <div className={classes.section}>
        <RichText content={shortDescription} />
    </div>
</div>

This is what it looks like in the browser later (take a look at the highlighted node):

CSS modules

Index.js file

The last file that we need to finish our component is the index.js file.

export { default } from './short-description';

Inject component to existing Storefront

Our component is now finished, and it’s time to put it in the right place on the front. For this, we will create a new module and name it venia-ui-overwrites.

mdir -p packages/venia-ui-overwrites
touch packages/venia-ui-overwrites/package.json
touch packages/venia-ui-overwrites/componentOverrideMapping.js
touch packages/venia-ui-overwrites/moduleOveriddeWebpackPlugin.js
touch packages/venia-ui-overwrites/intercept.js

Package.json file

{
  "name": "@marcinkwiatkowski/venia-ui-overwrites",
  "version": "1.0.0",
  "description": "",
  "author": "Marcin Kwiatkowski <contact@frodigo.com>",
  "license": "MIT",
  "pwa-studio": {
    "targets": {
      "intercept": "intercept.js"
    }
  },
  "peerDependencies": {
    "@magento/pwa-buildpack": "*",
    "@magento/venia-ui": "*",
    "react": "~16.9.0",
    "react-router-dom": "~5.1.0"
  },
  "main": "./lib/components/ProductFullDetail/productFullDetail.js"
}

The file looks a lot like the previous package.json file we created.

componentOverrideMapping.js file

module.exports = componentOverride = {
    [`@magento/venia-ui/lib/components/ProductFullDetail/productFullDetail.js`]: './lib/components/ProductFullDetail/productFullDetail.js',
};

In this file, we declare the mapping of the components. As you can see we have to overwrite the component @magento/venia-ui/lib/components/ProductFullDetail/productFullDetail.js

moduleOveriddeWebpackPlugin.js file

const path = require('path');
const glob = require('glob');

module.exports = class NormalModuleOverridePlugin {
    constructor(moduleOverrideMap) {
        this.name = 'NormalModuleOverridePlugin';
        this.moduleOverrideMap = moduleOverrideMap;
    }
    requireResolveIfCan(id, options = undefined) {
        try {
            return require.resolve(id, options);
        } catch (e) {
            return undefined;
        }
    }
    resolveModulePath(context, request) {
        const filePathWithoutExtension = path.resolve(context, request);
        const files = glob.sync(`${filePathWithoutExtension}@(|.*)`);
        if (files.length === 0) {
            throw new Error(`There is no file '${filePathWithoutExtension}'`);
        }
        if (files.length > 1) {
            throw new Error(
                `There is more than one file '${filePathWithoutExtension}'`
            );
        }
        return require.resolve(files[0]);
    }
    resolveModuleOverrideMap(context, map) {
        return Object.keys(map).reduce(
            (result, x) => ({
                ...result,
                [require.resolve(x)]:
                this.requireResolveIfCan(map[x]) ||
                this.resolveModulePath(context, map[x]),
            }),
            {}
        );
    }
    apply(compiler) {
        if (Object.keys(this.moduleOverrideMap).length === 0) {
            return;
        }
        const moduleMap = this.resolveModuleOverrideMap(
            compiler.context,
            this.moduleOverrideMap
        );
        compiler.hooks.normalModuleFactory.tap(this.name, (nmf) => {
            nmf.hooks.beforeResolve.tap(this.name, (resolve) => {
                if (!resolve) {
                    return;
                }
                const moduleToReplace = this.requireResolveIfCan(resolve.request, {
                    paths: [resolve.context],
                });
                if (moduleToReplace &amp;&amp; moduleMap[moduleToReplace]) {
                    resolve.request = moduleMap[moduleToReplace];
                }
                return resolve;
            });
        });
    }
};

The purpose of this file was perfectly described by Lars Roettig in his article. Click here if you want to know more.

intercept.js file

const componentOverrideMapping = require('./componentOverrideMapping');
const moduleOverridePlugin = require('./moduleOverrideWebpackPlugin');

module.exports = targets => {
    targets.of('@magento/pwa-buildpack').specialFeatures.tap(flags => {
        flags[targets.name] = {esModules: true};
    });
    targets.of('@magento/pwa-buildpack').webpackCompiler.tap(compiler => {
       new moduleOverridePlugin(componentOverrideMapping).apply(compiler);
    })
}

Thanks to this file, PWA Studio knows that it has to load our module. In addition, we pass the pluginOveriddeWebpackPlugin plugin to Webpack.

Extend PWA Studio ProductFullDetail component

We need to extend the ProductFullDetail component and put our ShortDescription component there.

mkdir -p packages/venia-ui-overwrites/lib/components/ProductFullDetail/productFullDetail.js

We then copy into this file the contents of the @ magento/venia-ui/lib/components /ProductFullDetail /productFullDetail.js file and add our ShortDescription component. In addition to that, we must remember to change the import paths.

imports

Take a look below at the file with the necessary changes:

import React, { Fragment, Suspense } from 'react';
import { arrayOf, bool, number, shape, string } from 'prop-types';
import { Form } from 'informed';

import { Price } from '@magento/peregrine';
import { useProductFullDetail } from '@magento/peregrine/lib/talons/ProductFullDetail/useProductFullDetail';
import { isProductConfigurable } from '@magento/peregrine/lib/util/isProductConfigurable';

import { mergeClasses } from '@magento/venia-ui/lib/classify';
import Breadcrumbs from '@magento/venia-ui/lib/components/Breadcrumbs';
import Button from '@magento/venia-ui/lib/components/Button';
import Carousel from '@magento/venia-ui/lib/components/ProductImageCarousel';
import { fullPageLoadingIndicator } from '@magento/venia-ui/lib/components/LoadingIndicator';
import Quantity from '@magento/venia-ui/lib/components/ProductQuantity';
import RichText from '@magento/venia-ui/lib/components/RichText';
import CREATE_CART_MUTATION from '@magento/venia-ui/lib/queries/createCart.graphql';
import GET_CART_DETAILS_QUERY from '@magento/venia-ui/lib/queries/getCartDetails.graphql';
import defaultClasses from '@magento/venia-ui/lib/components/ProductFullDetail/productFullDetail.css';
import {
    ADD_CONFIGURABLE_MUTATION,
    ADD_SIMPLE_MUTATION
} from '@magento/venia-ui/lib/components/ProductFullDetail/productFullDetail.gql';
import ShortDescription from "../../../../short-description-plugin/lib/components/ShortDescription";

const Options = React.lazy(() => import('@magento/venia-ui/lib/components/ProductOptions'));

const ProductFullDetail = props => {
    const { product } = props;

    const talonProps = useProductFullDetail({
        addConfigurableProductToCartMutation: ADD_CONFIGURABLE_MUTATION,
        addSimpleProductToCartMutation: ADD_SIMPLE_MUTATION,
        createCartMutation: CREATE_CART_MUTATION,
        getCartDetailsQuery: GET_CART_DETAILS_QUERY,
        product
    });

    const {
        breadcrumbCategoryId,
        handleAddToCart,
        handleSelectionChange,
        handleSetQuantity,
        isAddToCartDisabled,
        mediaGalleryEntries,
        productDetails,
        quantity
    } = talonProps;

    const classes = mergeClasses(defaultClasses, props.classes);

    const options = isProductConfigurable(product) ? (
        <Suspense fallback={fullPageLoadingIndicator}>
            <Options
                onSelectionChange={handleSelectionChange}
                options={product.configurable_options}
            />
        </Suspense>
    ) : null;

    const breadcrumbs = breadcrumbCategoryId ? (
        <Breadcrumbs
            categoryId={breadcrumbCategoryId}
            currentProduct={productDetails.name}
        />
    ) : null;

    return (
        <Fragment>
            {breadcrumbs}
            <Form className={classes.root}>
                <section className={classes.title}>
                    <h1 className={classes.productName}>
                        {productDetails.name}
                    </h1>
                    <p className={classes.productPrice}>
                        <Price
                            currencyCode={productDetails.price.currency}
                            value={productDetails.price.value}
                        />
                    </p>
                </section>
                <section className={classes.imageCarousel}>
                    <Carousel images={mediaGalleryEntries} />
                </section>
                <section className={classes.options}>{options}</section>
                <section className={classes.quantity}>
                    <h2 className={classes.quantityTitle}>Quantity</h2>
                    <Quantity
                        initialValue={quantity}
                        onValueChange={handleSetQuantity}
                    />
                </section>
                <ShortDescription productSku={productDetails.sku}/>
                <section className={classes.cartActions}>
                    <Button
                        priority="high"
                        onClick={handleAddToCart}
                        disabled={isAddToCartDisabled}
                    >
                        Add to Cart
                    </Button>
                </section>
                <section className={classes.description}>
                    <h2 className={classes.descriptionTitle}>
                        Product Description
                    </h2>
                    <RichText content={productDetails.description} />
                </section>
                <section className={classes.details}>
                    <h2 className={classes.detailsTitle}>SKU</h2>
                    <strong>{productDetails.sku}</strong>
                </section>
            </Form>
        </Fragment>
    );
};

ProductFullDetail.propTypes = {
    classes: shape({
        cartActions: string,
        description: string,
        descriptionTitle: string,
        details: string,
        detailsTitle: string,
        imageCarousel: string,
        options: string,
        productName: string,
        productPrice: string,
        quantity: string,
        quantityTitle: string,
        root: string,
        title: string
    }),
    product: shape({
        __typename: string,
        id: number,
        sku: string.isRequired,
        price: shape({
            regularPrice: shape({
                amount: shape({
                    currency: string.isRequired,
                    value: number.isRequired
                })
            }).isRequired
        }).isRequired,
        media_gallery_entries: arrayOf(
            shape({
                label: string,
                position: number,
                disabled: bool,
                file: string.isRequired
            })
        ),
        description: string
    }).isRequired
};

export default ProductFullDetail;

On line 23, we import our ShortDescription component, and on line 93, we initiate it by passing a product SKU as an attribute.

Danger zone!

Keep in mind that if you have two modules that extend one file, you will be in trouble.

Add our modules to existing PWA Studio instance

Our modules are ready, and the last thing we have to do is add them as dependencies to our local PWA Studio installation. If you do not have PWA Studio installed, please do so now. Here you will find a tutorial on how to do it.

Open a <place_where_you_have_installed_pwa_studio>/package.json file and add these two entries to devDependencies:

"@marcinkwiatkowski/venia-ui-overwrites": "file:/<twoja_ścieżka>/short-description-tutorial/packages/venia-ui-overwrites",
    "@marcinkwiatkowski/short-description-plugin": "file:/<twoja_ścieżka>/short-description-tutorial/packages/short-description-plugin"

Now run the following commands in your terminal:

yarn install -f
yarn run watch

If everything went well, you should see something like this:

yarn run watch

Entering the PWADevServer URL on the product card with a defined short Description, you will see that it is displayed on the website.

Note: I recommend creating your own local Magento 2 instance, thanks to which you will have access to the administration panel and the ability to add a short_description for the product of your choice.

How to extend PWA Studio: summary

We have just done our first module, extending PWA Studio. We have added a short description on the product page.

Now you have a general understanding of how to:

  • add your own components to PWA Studio storefront
  • send GraphQL queries
  • works with CSS modules

Can it be done differently?

If you don't want to make a separate module, you can use the Fooman VeniaUiOverrideResolver plugin for PWA Studio. An alternative option is also to overwrite files in the src directory, but I do not recommend this solution. In any case, it all depends on you and what you need to achieve. Remember that there is a risk of errors arising when two modules try to overwrite one component.

The final code on Github for you

You can find the final code on my Github. Here is the link.

Sources

https://larsroettig.dev/getting-started-with-pwa-studio-extensibility/

https://github.com/larsroettig/pwa-studio-component-override

https://magento.github.io/pwa-studio/tutorials/pwa-studio-fundamentals/work-with-graphql/

https://magento.github.io/pwa-studio/pwa-buildpack/reference/configure-webpack/#special-flags

https://magento.github.io/pwa-studio/tutorials/pwa-studio-fundamentals/project-setup/

WRITTEN BY

Marcin Kwiatkowski

Certified Magento Full Stack Developer who is passionate about Magento. He has 7 years of professional Software engineering experience.  Privately, husband, father, and DIY enthusiast.