Try   HackMD

(AdonisJS + Storybook) - React = Success?

I'll detail what I found out about how to make Storybook work with AdonisJS WITHOUT adding an extra UI layer on the frontend.

Basic pieces

  • Storybook's server adapter. Without this, nothing here would be possible. This allows us to write anything we want on the server, and then for Storybook to dynamically fetch said anything for use in stories.
  • Storybook's {builder,manager}-webpack5 packages. When using Adonis' Encore-based Webpack tooling, it uses Webpack 5. Storybook ships with Webpack 4. You can guess that those different versions don't like each other, so we can tell Storybook to use Webpack 5 instead.
  • Adonis' components. Adonis encourages writing components that can take props and slots, just like in component systems like React.

Putting them together

Install Storybook

Running npx storybook init in an Adonis application will confuse Storybook so much that it'll let you select a type. We want to select server from the dropdown, that installs a bunch of the basic pieces for us. Make sure to enable Webpack 5 support after this:

npm i -D @storybook/manager-webpack5 @storybook/builder-webpack5
npm un -D @storybook/manager-webpack4 @storybook/builder-webpack4

And edit .storybook/init.js:

module.exports = {
  stories: ['../stories/**/*.stories.mdx', '../stories/**/*.stories.@(json)'],
  addons: ['@storybook/addon-links', '@storybook/addon-essentials'],
-  framework: '@storybook/server'
+  framework: '@storybook/server',
+  core: {
+    builder: 'webpack5',
+  },
}

Add a component endpoint

Now, the big question is how to structure our HTTP endpoints that Storybook will query. An early pattern I've gone for here is to have a component route that will relay its parameters to render any component, with any props (as long as they can be serialized into a query string, which is admittedly kind of an annoying limitation).

For this, you'll also want to set the server URL in Storybook's config. That's .storybook/preview.js:

export const parameters = {
  server: {
-    url: 'https://whatever-url-they-have-here.netlify.app',
+    url: 'http://localhost:3333/dev/component',
  },
}

Add a new route:

// We want to make sure this route is ONLY exposed
// in development!
if (!process.env.NODE_ENV || process.env.NODE_ENV === "development") {
  Route.get("/dev/component/*", async ({ view, params, request }) => {
    // Get query string params first...
    const qs = request.qs();
    // And render the proxy component view.
    return view.render("component", {
      // Map to whichever name was provided after `/dev/component/`
      name: params["*"],
      props: qs,
    });
  });
}

We can then define an Edge view that proxies to a component (in this case resources/views/component.edge):

@!component(`components/${name}`, props || {})

This will effectively only render whichever component is given as the name parameter, with props, should there be any.

Now we can define components (resources/views/components/button.edge):

<button>{{ label || "Test" }}</button>

Try loading https://localhost:3333/dev/component/button in your browser, you'll see a Test button. You can also now add query parameters, for example the button label: https://localhost:3333/dev/component/button?label=My+cool+label.

Enabling CORS

Before we try to configure Storybook further, we'll need to enable Adonis' CORS support. We need this because the default browser policy is very restrictive, and we want the storybook page to be able to make HTTP requests to the Adonis page. Modify config/cors.ts and set enabled: false to enabled: true.

Write stories

It's time to add some stories. Change stories/button.stories.json to:

{
  "title": "Buttons",
  "parameters": {
    "options": {
      "component": "button"
    }
  },
  "stories": [
    {
      "name": "No label",
      "parameters": {
        "server": {
          "id": "button"
        }
      }
    },
    {
      "name": "With label",
      "parameters": {
        "server": {
          "id": "button",
          "params": {
            "label": "This is a label"
          }
        }
      }
    },
    {
      "name": "With custom label",
      "parameters": {
        "server": {
          "id": "button"
        }
      },
      "args": {
        "label": "Customize me!"
      }
    }
  ]
}

There's three stories being defined here. I'll go through them:

  • "No label": This is the same as Storybook calling /dev/component/button.
  • "With label": This is the same as calling /dev/component/button?label=This+is+a+label. Notice how the params object will be serialized into query string parameters.
  • "With custom label": This adds a control for the user to change the label. Using args instead of server.params, we can create controls. The contents of args will also get serialized into the query string.

Add some convenience

You may have noticed that you need to run Storybook and Adonis at the same time to make this work, otherwise Storybook will hit you with a big red error message about being unable to reach the remote.

I like to use a package called npm-run-all for this. Install it, and edit your package.json scripts to something like this:

{
  "scripts": {
    [...]
-   "storybook": "start-storybook -p 6006",
+   "storybook": "run-p dev run-storybook",
+   "run-storybook": "start-storybook -p 6006",
    [...]
  }
}

run-p is a script that will run two or more other npm scripts in parallel.

Other considerations

We may want to customize how Storybook fetches from our Adonis endpoint. Thankfully, the @storybook/server adapter provides a hook for this. Say we wanted to use a POST request with the props in the request's body instead:

// in .storybook/preview.js

const fetchStoryHtml = async (url, path, params, context) => {
  const fetchUrl = new URL(`${url}/${path}`);
  const response = await fetch(fetchURL, {
    method: "POST",
    body: params,
  });
  return response.text();
};

export const parameters = {
  server: {
    url: `http://localhost:3333/dev/component`,
    fetchStoryHtml,
  },
};