DEV Community

Chris Brabender
Chris Brabender

Posted on • Updated on

Simplifying styling in PWA Studio

TLDR: It doesn't have to be difficult.

A short history

PWA Studio from Magento has been out and about in the wild for a couple of years now and over time they are slowly adding to the core product.

Originally Venia (the code name for the project) was seen as a reference storefront only but over time has grown in to the base starting point for new projects. This comes with a set of challenges not originally considered by the Magento team.

One of those key challenges is simply modifying or replacing the styling of a component. Until now that has been done in one of 3 ways:

Tree replacement
Taking and replacing the entire tree to get to the component you want to style, and then overriding the entire CSS file. This causes issues because you are taking on ownership of the entire code for all components in the tree, making it more difficult to maintain and upgrade.

Normal replacement module / webpack aliases
This focuses on using webpack to modify the file references in particular components and replacing them with a file in your own project. This approach is relatively solid but can become unwieldly with a long list of aliases to manage when you start
overriding hundreds of CSS files.

The alias approach can also be risky if file names are duplicated elsewhere.

config.resolve.alias = {
    ...config.resolve.alias,
    './productFullDetail.css': path.resolve('./src/components/ProductFullDetail/productFullDetail.css')
}
Enter fullscreen mode Exit fullscreen mode

Fooman VeniaUiOverrideResolver
A great module from Fooman allowing you to overwrite any file in peregrine/venia-ui easily and following a simple pattern. I personally didn't like the folder structure this introduced to projects and was override only, not extend.

Reference - https://github.com/fooman/venia-ui-override-resolver

So what's different now?

Version 9.0 of PWA Studio introduced some new features and enhancements to the extensibility framework. Targetables gives us the opportunity to modify a React component without the need to override the entire component in our app.

The concept

How do I turn the standard Venia storefront into something custom for my clients?

Here's our starting point:

Screenshot 2021-03-03 093255

I wanted to explore how we could use the Targetables, the naming convention for styling attached to components and mergeClasses to simplify the entire process of updating styling.

The naming convention
PWA Studio follows a strict naming convention with regards to CSS files for components. Lets take Button as an example.

The Button component is made up of two files:

  1. button.js
  2. button.css

button.js imports button.css and uses that as the defaultClasses with the mergeClasses function from classify.

So what if we were to mimic that file structure in our local project? Following along with the Button example, if I was to create a file src/components/Button/button.css could I have that picked up automatically?

mergeClasses
mergeClasses, by default, takes the default styling from defaultClasses and merges them with anything passed in via props to the component.
Here we could add an addition set of classes that could be out local styling updates and make it look something like:

const classes = mergeClasses(defaultClasses, localClasses, props.classes);
Enter fullscreen mode Exit fullscreen mode

This would give us the flexibility of local styling on top of the default styling, but also the ability to pass in props styling for specific use cases across the application, which would update our local styling.

Putting it to work

We need two things to make this all work:

  1. A way to identify any local files that extend default styling
  2. A way to add them to our library components without overriding

Identifying local styling
globby is a great tool for recursively scanning directories to find files or folders matching specific criteria, so we need to add that to our project.

yarn add globby
Enter fullscreen mode Exit fullscreen mode

Next, we are going to use our local-intercept.js file as the place we do most of the work here.

This script scans all directories in src/components and finds any CSS files. It then extracts the component from the folder names and tries to match that to a component in venia-ui, if it matches, we know we are trying to extend styling.

function localIntercept(targets) {
    const { Targetables } = require('@magento/pwa-buildpack');
    const targetables = Targetables.using(targets);

    const magentoPath = 'node_modules/@magento';

    const globby = require('globby');
    const fs = require('fs');
    const path = require('path');

    (async () => {
        /** Load all CSS files from src/components */
        const paths = await globby('src/components', {
          expandDirectories: {
            extensions: ['css']
          }
        });

        paths.forEach((myPath) => {
          const relativePath = myPath.replace('src/components', `${magentoPath}/venia-ui/lib/components`);
          const absolutePath = path.resolve(relativePath);

          /** Identify if local component maps to venia-ui component */
          fs.stat(absolutePath, (err, stat) => {
            if (!err && stat && stat.isFile()) {
              /** 
               * This means we have matched a local file to something in venia-ui!
               * Find the JS  component from our CSS file name 
               * */
              const jsComponent = relativePath.replace('node_modules/', '').replace('.css', '.js');
            }
          });
        });
    })();
}
Enter fullscreen mode Exit fullscreen mode

Adding our styling
So now we know what CSS files we are extending, how do we tell our library components to use our styling?

Thats where Targetables come in to play. Taking our script above, we know what the JS Component is so we can just add this after the jsComponent line:

/** Load the relevant venia-ui component */
const eSModule = targetables.reactComponent(jsComponent);
const module = targetables.module(jsComponent);

/** Add import for our custom CSS classes */
eSModule.addImport(`import localClasses from "${myPath}"`);

/** Update the mergeClasses() method to inject our additional custom css */
module.insertAfterSource(
    'const classes = mergeClasses(defaultClasses, ',
    'localClasses, '
);
Enter fullscreen mode Exit fullscreen mode

The script here loads an esModule and injects our localClasses into the top of the file as an import and then modifies the default mergeClasses from:

const mergeClasses(defaultClasses, props.classes);
Enter fullscreen mode Exit fullscreen mode

to

const mergeClasses(defaultClasses, localClasses, props.classes);
Enter fullscreen mode Exit fullscreen mode

Setting up some custom styles

The screenshot above shows the product detail page, so lets change up some of the styling on that page.

To do that we are going to create a new file in our project:

src/components/ProductFullDetail/productFullDetail.css

Now you can do a yarn watch and see the changes we are going to do live. As this customisation is applied at build time if you create a NEW file you will need to stop and start your project but if you modify a file you have already created the hot reload functions will work just fine.

Let's add the following to our css file, which will add a border around our Image Carousel:

.imageCarousel  {
    border: solid 1px black;
}
Enter fullscreen mode Exit fullscreen mode

That's it. That's the blog, thanks for reading. Not really but this should have reloaded and should look a little broken, but this is a good thing.

Screenshot 2021-03-03 095042

What we've done here is modified just the imageCarousel class in our custom file and kept all the rest of the styling for the ProductFullDetail page which is great! Exactly what we wanted, but we've lost all our original styling for the imageCarousel.

This is good in some instances where we want to just replace all the styling for a particular class so having this full replacement as an option is great, but if we want to just modify one thing and inherit the rest, we able to use composes from CSS Modules to achieve this. All we need to do is composes imageCarousel from Venia like this:

.imageCarousel  {
    composes: imageCarousel from '~@magento/venia-ui/lib/components/ProductFullDetail/productFullDetail.css';
    border: solid 1px black;
}
Enter fullscreen mode Exit fullscreen mode

Now our page is looking like it should, with our border.

Screenshot 2021-03-03 095349

That really is it now though. Thanks for reading! If you have any questions let me know on Twitter @brabs13 or via the #pwa slack channel in Magento Community Engineering.

If you put this into practice please share me a link so I can check out the work.

Top comments (5)

Collapse
 
jissereitsma profile image
Jisse Reitsma

Thanks Chris for the great write-up. Already, others have responded to say that your method is quite labor-intensive, when just wanting to replace a few lines. But it does show the power of the target interceptor API, which is awesome. In my personal opinion, a small example like changing the look & feel of a Button is ok for this approach. But with a real-life project, the customers graphical design might be so different from the Venia theme, that I would recommend a custom tree replacement anyway. What is your opinion on that?

Collapse
 
vasiliib profile image
Burlacu Vasilii

Hi Jisse,
I agree - for some projects, overrides are more appropriate than customizations like this.
Anyway, it depends from case to case and from project to project. Just as with everything - we need to use things wisely.

Collapse
 
dndsafran profile image
Dnd-Safran • Edited

Hi ! First great article, very inspiring ;p

@jissereitsma you can replace the component only using webpack hooks (upon import request, you switch the file ;p)

@chrisbrabender Is there's a particular reason why you use double declaration :

const eSModule = targetables.reactComponent(jsComponent);
const module = targetables.module(jsComponent);
Enter fullscreen mode Exit fullscreen mode

Since TargetableReactComponent extends TargetablesModule, you just need the first one right ? And I think, it will return the same instance anyway, so "module = eSModule"

Collapse
 
rafaelcg profile image
Rafael Corrêa Gomes

Excellent article, thanks for sharing it Chris!

Collapse
 
0m3r profile image
Oleksandr Krasko

Chris we have a problem here
github.com/magento/pwa-studio/issu...