Magento Tutorials

Magento PWA Studio Tutorial: The Basics

Built by Magento themselves, Magento PWA Studio is a collection of tools made to help developers build PWAs on top of the Magento backend. As it is an open-source project on GitHub, Magento PWA Studio is constantly being worked on by its community of over 100 contributors worldwide.

Magento PWA Studio uses several external libraries such as:

The Magento PWA Studio project has been in development for quite a while, and it’s been generally stable from version 7.0 onwards. To best help you get to know this tool, in this tutorial we’ll be going through the basics of Magento PWA Studio and help you learn in the process.

After this tutorials, you are expected to build a fully-functional Magento PWA site with PWA Studio sucessfully.

The first thing is to install PWA Studio. You can follow this step-by-step guide to do that: How to Setup Magento 2 PWA Studio

1. Project folder structure

The packages used in the project are located inside node_modules folder.

node_modules folder

We can see there are a lot of packages. We need to take another approach to understand the package structure. Let’s take a look at packages directory of pwa-studio project on GitHub.

PWA Studio packages

Now that’s better.

You don’t need to understand all of the packages in here, only the most important ones:

  • venia-concept: a storefront demo named Venia which is a proof-of-concept Magento PWA. It pulls the sources from other packages to build the Venia storefront demo: https://venia.magento.com
  • peregrine: contains a collection of useful components and custom React hooks for a Magento PWA.
  • venia-ui: a UI library that we can use for our project.

venia-ui will be called from peregrine. It contains components such as buttons, text boxes, text fields, and even more complicated page components like category trees, category list etc.

venia-ui components

On the other hand, peregrine contains logic inside these views:

peregrine lib

2. Create a new router

The most basic thing to do when modifying a project is creating a new page, with pre-configured routers. 

For example, here we’ll create a router which we can use to open a brands list.

Create a file at src/targets/local-intercept.js with the following content:

module.exports = targets => {
     targets.of("@magento/venia-ui").routes.tap(routes => {
         routes.push({
             name: "BrandList",
             pattern: "/brand.html",
             path: require.resolve("../components/brands/index.js")
         });
         return routes;
     });
 };

Modify package.json to use the file we’ve just created:

Modify package.json
  "pwa-studio": {
    "targets": {
      "intercept": "./local-intercept.js"
    }

Create a component corresponding to the router which we just created above, in src/components/brands/index.js:

import React, { useMemo } from "react";
const Brands = props => {
	return <div>a</div>
}

export default Brands;

Stop and restart runtime, then check the URL brand.html if it returns anything.

In the router, if you want to pass more information or if you have conditions for the router, use the following syntax:

targets.of("@magento/venia-ui").routes.tap(routes => {
     routes.push({
         name: "BrandCategory",
         pattern: "/brand/category/:categoryUrl?",
         path: require.resolve("../components/category/index.js")
     });
     routes.push({
         name: "BrandDetails",
         pattern: "/brand/:brandUrl?",
         path: require.resolve("../components/branddetails/index.js")
     });
     routes.push({
         name: "BrandList",
         pattern: "/brand.html",
         path: require.resolve("../components/brands/index.js")
     });
     return routes;
 });

These routers will pass categoryURL and brandURL into the components which we’ll be using, like this:
in src/components/branddetails/index.js

import React from "react";
import { useParams } from "react-router-dom";

const BrandDetails = () => {
	const classes = defaultClasses;
	const { brandUrl = "" } = useParams();
	console.log(brandUrl);
	return <div>yess!</div>;
}

3. GraphQL

GraphQL, in a nutshell, is a query language for APIs.

You can try it out using this Chrome extension: https://chrome.google.com/webstore/detail/altair-graphql-client/flnheeellpciglgpaodhkhmapeljopja

Altair GraphQL interface

Calling GraphQL from React

There’s a library we can use to run GraphQL on React named apollo. You can learn more about how to use it here:

https://www.apollographql.com/docs/react/api/react/hooks/#usequery

(apollo supports both hook and class components, but Magento now goes full hooks so if you have the passion for it, we can learn to use apollo for class component later on) 

There are hooks that you need to understand thoroughly:

  • useQuery
  • useLazyQuery
  • useMutation

And a function to convert strings into apollo query: gql

gql

apollo can either load a .graphql file, or a .js file if the query string is wrapped in gql function)

Create a query file

Let’s try to create this query in a file named Brand.gql.js.

import gql from 'graphql-tag';
 
export const GET_BRANDS_LIST = gql`
   query mpbrand (
       $pageSize : Int!,
       $currentPage : Int
   ) {
       mpbrand (
           filter : {}
           pageSize : $pageSize
           currentPage : $currentPage
       ) {
           items {
               brand_id
               attribute_id
               option_id
               value
               default_value
               store_id
               page_title
               url_key
               image
               is_featured
               short_description
               description
               static_block
               meta_title
               meta_keywords
               meta_description
               mpbrandCategories {
                   cat_id
                   name
                   url_key
               }
           }
           total_count
       }
   }
`;

The above query is used to get the brand list, so we’ll import it in the brand list page.

Use the useQuery hook to fetch data

First we import it:

import { useQuery } from '@apollo/client';
import { GET_BRANDS_LIST } from './Brand.gql'

Then we call it:

   const {
       data: brandsData,
       loading: brandsLoading,
       error: brandsError
   } = useQuery(GET_BRANDS_LIST, {
       variables: {
           pageSize: 99999,
           currentPage: 1,
       }
   });

NotespageSize and currentPage will be used in case there are too many brands and our brand list page need to be splitted into multiple pages.

4. Override components

Say we want to add Brands page link into Venia header like this:

Brand header

To add or modify components from core, we need to rewrite those components, since Magento PWA Studio does not allow injecting content into existing components.

Declare override

In the file local-intercept.js which we previously created in the router tutorial, add the following declarations and export:

module.exports = targets => {
   /*...*/
   targets.of('@magento/pwa-buildpack').webpackCompiler.tap(compiler => {
       new moduleOverridePlugin(componentOverrideMapping).apply(compiler);
   })
};

Create override mapping and plugin for webpack

In src/targets/componentOverrideMapping.js:

module.exports = componentOverrideMapping = {
   [`@magento/venia-ui/lib/components/Header/header.js`]: './src/overwrites/components/header.js',
};

In src/targets/moduleOverrideWebpackPlugin.js:

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 && moduleMap[moduleToReplace]) {
         resolve.request = moduleMap[moduleToReplace];
       }

       return resolve;
     });
   });
 }
};

moduleOverrideWebpackPlugin.js file will map components that we want to override in componentOverrideMapping.js. In this tutorial we will try to override Venia header with the component that we’ll create in the next step.

Create the override file

We will create the override file in src/overwrites/components/header.js (with content copied from “@magento/venia-ui/lib/components/Header/header.js“).

import React, { Suspense } from 'react';
import { shape, string } from 'prop-types';

import Logo from '@magento/venia-ui/lib/components/Logo';
import { Link, resourceUrl, Route } from '@magento/venia-drivers';

import AccountTrigger from '@magento/venia-ui/lib/components/Header/accountTrigger';
import CartTrigger from '@magento/venia-ui/lib/components/Header/cartTrigger';
import NavTrigger from '@magento/venia-ui/lib/components/Header/navTrigger';
import SearchTrigger from '@magento/venia-ui/lib/components/Header/searchTrigger';
import OnlineIndicator from '@magento/venia-ui/lib/components/Header/onlineIndicator';
import { useHeader } from '@magento/peregrine/lib/talons/Header/useHeader';

import { mergeClasses } from '@magento/venia-ui/lib/classify';
import defaultClasses from '@magento/venia-ui/lib/components/Header/header.css';
import PageLoadingIndicator from '@magento/venia-ui/lib/components/PageLoadingIndicator';

import BrandIcon from '../../components/header/BrandIcon'; // import Brands icon into header

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

const Header = props => {
    const {
        handleSearchTriggerClick,
        hasBeenOffline,
        isOnline,
        searchOpen,
        isPageLoading
    } = useHeader();

    const classes = mergeClasses(defaultClasses, props.classes);
    const rootClass = searchOpen ? classes.open : classes.closed;
    const searchBarFallback = (
        <div className={classes.searchFallback}>
            <div className={classes.input}>
                <div className={classes.loader} />
            </div>
        </div>
    );
    const searchBar = searchOpen ? (
        <Suspense fallback={searchBarFallback}>
            <Route>
                <SearchBar isOpen={searchOpen} />
            </Route>
        </Suspense>
    ) : null;
    const pageLoadingIndicator = isPageLoading ? (
        <PageLoadingIndicator />
    ) : null;
 
    return (
        <header className={rootClass}>
            <div className={classes.toolbar}>
                <div className={classes.primaryActions}>
                    <NavTrigger />
                </div>
                {pageLoadingIndicator}
                <OnlineIndicator
                    hasBeenOffline={hasBeenOffline}
                    isOnline={isOnline}
                />
                <Link to={resourceUrl('/')}>
                    <Logo classes={{ logo: classes.logo }} />
                </Link>
                <div className={classes.secondaryActions}>
                    <SearchTrigger
                        active={searchOpen}
                        onClick={handleSearchTriggerClick}
                    />
                    <AccountTrigger />
                    <SimiCartIcon />
                    <CartTrigger />
                </div>
            </div>
            {searchBar}
        </header>
    );
};

Header.propTypes = {
    classes: shape({
        closed: string,
        logo: string,
        open: string,
        primaryActions: string,
        secondaryActions: string,
        toolbar: string
    })
};

export default Header;

Create icon for Brands link

Create a file under src/components/header/BrandIcon.js. Note that we imported this file in header.js above.

import React from 'react';
import Icon from '@magento/venia-ui/lib/components/Icon';
import { Tag as TagIcon } from 'react-feather';
import { resourceUrl, useHistory } from '@magento/venia-drivers';
import classes from './brandicon.css';
import { FormattedMessage, useIntl } from 'react-intl';
 
const BrandIcon = () => {
   const history = useHistory();
   const { formatMessage } = useIntl();
 
   return (
       <button
           aria-label={formatMessage({
               id: 'brand.brandLabel',
               defaultMessage: 'Brands'
           })}
           className={classes.root}
           onClick={() => history.push(resourceUrl('/brand.html'))}
       >
           <Icon src={TagIcon} />
           <span className={classes.label}>
               <FormattedMessage id={'Brands'} />
           </span>
       </button>
   )
}
export default BrandIcon

5. Talons

Essentially this is a type of React Hook created by Magento, under the format of ‘useAbc’. It only processes logic and has nothing to do with the UI.

Pre-built talons are located in the peregrine package. If you want to add your custom code, you can add it into src/talons.

*In MAMP/LAMP stack, we use the MVC model. Separating the view and talons in React serves the same purpose as MVC.

*You don’t need to import or inherit anything in talons. Just keep in mind that talons only process logic.

For example, we use talons to build the Brand Details page

First, let’s create hooks view for this page:

import React from "react";
import { FormattedMessage } from 'react-intl';
import { useParams } from "react-router-dom";
import { useBrandDetails } from '../../talons/useBrandDetails'; //đây talons đây
import defaultClasses from './branddetails.css';
import { fullPageLoadingIndicator } from '@magento/venia-ui/lib/components/LoadingIndicator';
import { Link } from '@magento/venia-drivers';
import { Title, Meta } from '@magento/venia-ui/lib/components/Head';

const BrandDetails = () => {
   const classes = defaultClasses
   return (
       <div className={classes.rootDetails}>
       </div>
   );
}

export default BrandDetails;

Create a separate folder for talons in src/talons, then create a .js file called useBrandDetails with the following content:

import { useQuery } from '@apollo/client';
import { GET_BRANDS_BY_URL } from './Brand.gql';

const GET_BRANDS_BY_URL = gql`
   query mpbrand (
       $url_key : String!
   ) {
       mpbrand (
           filter : {url_key : {eq: $url_key}}
       ) {
           items {
               brand_id
       attribute_id
       option_id
       value
       default_value
       store_id
       page_title
       url_key
       image
       is_featured
       short_description
       description
       static_block
       meta_title
       meta_keywords
       meta_description
               mpbrandCategories {
                   cat_id
                   name
                   url_key
               }
           }
           total_count
       }
   }
`;


export const useBrandDetails = props => {
   const { url_key } = props
   //get Brand Details useQuery
   const {
       data: brandData,
       loading: brandLoading,
       error: brandError
   } = useQuery(GET_BRANDS_BY_URL, {
       variables: {
           url_key: url_key
       }
   });

   let derivedErrorMessage;
   if (brandError) {
       const errorTarget = brandError;
       if (errorTarget.graphQLErrors) {
           // Apollo prepends "GraphQL Error:" onto the message,
           // which we don't want to show to an end user.
           // Build up the error message manually without the prepended text.
           derivedErrorMessage = errorTarget.graphQLErrors
               .map(({ message }) => message)
               .join(', ');
       } else {
           // A non-GraphQL error occurred.
           derivedErrorMessage = errorTarget.message;
       }
   }

   return {
       brandData,
       brandLoading,
       derivedErrorMessage
   }
}

Inject hooks into brand component:

   const { brandUrl = "" } = useParams();
   const { brandData, brandLoading, derivedErrorMessage } = useBrandDetails({ url_key: brandUrl.replace('.html', '') });

And that’s it. Now we have data, error, and loading to be used in view without implementing the logic.

Conclusion

With all this basic knowledge, you can create for yourself a basic PWA with Magento PWA Studio. If it ever gets too tough, don’t hesitate to shoot us up a question in the comment section below!


Further reading

Magento PWA Studio: Useful Links and Resources

Luke Vu

A content writer with a passion for the English language.