Subscribe to access all episodes. View plans →

#169: Phoenix Storybook

Published August 21, 2023

Episode Sponsored by Perch Admin


PhoenixStorybook

Follow along with the episode starter on GitHub


PhoenixStorybook provides an easy way to add a storybook-like UI for Phoenix components. In this episode let’s see how we can add PhoenixStorybook to a Phoenix application. Then we’ll add some components and create PhoenixStorybook stories for them.

Alright, let’s get started. We’ll head over to Hex and grab the PhoenixStorybook config. Then we’ll open our Mixfile. And add PhoenixStorybook to our list of dependencies.

mix.exs

defps do
  ...
  {:phoenix_storybook, "~> 0.5.6"},
  ...
end

Then let’s go to the command line and download it.

$ mix deps.get
...
New:
  ...
  phoenix_storybook 0.5.6

With PhoenixStorybook installed, we now need to do a little configuration. There are two options - you can set up your application manually by following the guide listed here in the docs. Also, there’s a handy task we can run to set up our application to use PhoenixStorybook. Let’s use that.

We’ll go back to the command line and run mix phx.gen.storybook and when we do it creates some files for us. Let’s go over these more in a minute, but we can see it created some component stories for us based on our existing components.

Below it prompts us with a few setup instructions. Let’s do those.

$ mix phx.gen.storybook
* creating lib/teacher_web/storybook.ex
* creating storybook/_root.index.exs
* creating storybook/welcome.story.exs
* creating storybook/core_components/button.story.exs
* creating storybook/core_components/flash.story.exs
* creating storybook/core_components/header.story.exs
* creating storybook/core_components/input.story.exs
* creating storybook/core_components/list.story.exs
* creating storybook/core_components/modal.story.exs
* creating storybook/core_components/table.story.exs
* creating storybook/examples/core_components.story.exs
* creating assets/css/storybook.css
* creating assets/js/storybook.js
* manual setup instructions:
...

The first instruction is for us to update our Router module. Let’s open that. And then we’ll paste in the routes Storybook gave us. We’ll also want to include the import for the PhoenixStorybook.Router.

...
* manual setup instructions:
Add the following to your router.ex:

    use TeacherWeb, :router
    import PhoenixStorybook.Router

    scope "/" do
      storybook_assets()
    end

    scope "/", TeacherWeb do
      pipe_through(:browser)
      live_storybook "/storybook", backend_module: TeacherWeb.Storybook
    end
...

Then let’s continue to the next step. We now need to add a new entry point for storybook.js to our esbuild in our config.exs. Let’s update that.

...
* manual setup instructions:
  Add js/storybook.js as a new entry point to your esbuild args in config/config.exs:

    config :esbuild,
    default: [
      args:
        ~w(js/app.js js/storybook.js --bundle --target=es2017 --outdir=../priv/static/assets ...),
      ...
    ]

And then we need to add a new Tailwind build profile for Storybook.

...
* manual setup instructions:
  Add a new Tailwind build profile for css/storybook.css in config/config.exs:

    config :tailwind,
      ...
      default: [
        ...
      ],
      storybook: [
        args: ~w(
          --config=tailwind.config.js
          --input=css/storybook.css
          --output=../priv/static/assets/storybook.css
        ),
        cd: Path.expand("../assets", __DIR__)
      ]

Then let’s add a new endpoint watcher for our Tailwind build profile in dev.exs.

...
* manual setup instructions:
  Add a new endpoint watcher for your new Tailwind build profile in config/dev.exs:

    config :teacher_web, TeacherWeb.Endpoint,
      ...
      watchers: [
        ...
        storybook_tailwind: {Tailwind, :install_and_run, [:storybook, ~w(--watch)]}
      ]

And a new live reload pattern to our endpoint in development.

...
* manual setup instructions:
  Add a new live_reload pattern to your endpoint in config/dev.exs:

    config :teacher_web, TeacherWeb.Endpoint,
      live_reload: [
        patterns: [
          ...
          ~r"storybook/.*(exs)$"
        ]
      ]

Then we’ll need to open our formatter and include our Storybook content.

...
* manual setup instructions:
  Add your storybook content to .formatter.exs

    [
      import_deps: [...],
      inputs: [
        ...
        "storybook/**/*.exs"
      ]
    ]

Now we need to add an alias for our asset deployment. So let’s open our Mixfile and add that.

...
* manual setup instructions:
  Add an alias to mix.exs

  defp aliases do
    [
      ...,
      "assets.deploy": [
        ...
        "tailwind storybook --minify",
        "phx.digest"
      ]
    ]
  end

For this step, you’ll want to add a copy directive to your Dockerfile if you’re using Docker. I since I’m not using Docker here, I’ll skip this setup step.

...
* manual setup instructions:
  Add a COPY directive in Dockerfile

  COPY priv priv
  COPY lib lib
  COPY assets assets
  COPY storybook storybook

Great, with that, our application should be configured to use Storybook. Before we start our application, let’s go back to some of the files Storybook created for us. Let’s open the Storybook module.

In this module, only the otp_app and content_path settings are required. content_path is just the path to our storybook stories. This module holds the general config settings for our storybook. We can customize things like the theme for Storybook or add a title. Let’s add a title for our storybook here. Then it includes paths to the storybook CSS and JavaScript files. Let’s take a quick look at those.

We’ll open storybook.css that was created for us. This is our custom storybook stylesheet. We can include any custom styles for our storybook here.

Then storybook.js. If any of your components use something like a LiveView JS Hook, or a custom uploader, or if your pages require connect parameters, you can use this to include them in your storybook. I’ll leave these commented out since we aren’t using them for our components.

Alright, let’s start up our server.

$ mix phx.server
...

And then we can go to localhost:4000/storybook in the browser. And great - we see the storybook welcome page displayed, along with our custom title. The welcome page starts out with some handy links to the documentation. There’s also a great search feature you can use to look up component stories.

Since our example application was created with the default core components, we can see them nested here in the side navigation. For the “Button” component, “Flash” component, “Header”, “Input”, “List”, “Modal”, and “Table” components. Now that everything is set up correctly, let’s create a new component and add a story for it, to our Storybook.

We’ll create a simple album card component. Let’s create a new directory in “components” called “cards” and then a new file inside of that named “summary.ex”. We’ll define the module. This will be a basic Phoenix Component - also known as a function component. So in the module, we’ll use Phoenix Component, and then let’s create a function called summary card that takes our assigns. Inside the function, I’ll paste the code for our card HTML. It will display some information like the title and artist of the album. And then the album’s summary.

Component path: lib/teacher_web/components/cards/summary.ex

defmodule TeacherWeb.Cards.Summary do
  use Phoenix.Component

  def summary_card(assigns) do
    ~H"""
    <div class="block max-w-sm p-6 bg-white border border-gray-200 rounded-lg shadow hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700">
    <h5 class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white">
    <%= @title %> by <%= @artist %>
    </h5>
    <p class="font-normal text-gray-700 dark:text-gray-400">
    <%= @summary %>
    </p>
    </div>
    """
  end
end

Now let’s create one more module in the same directory called “summary_live.ex”. This will be a simple LiveComponent. So we’ll use TeacherWeb, :live_component. And then we’ll define a render function that accepts some assigns.

Inside it I’ll paste in the code for our card. This is the same that we just used for our function component.

Component path: lib/teacher_web/components/cards/summary_live.ex

defmodule TeacherWeb.Cards.SummaryLive do
  use TeacherWeb, :live_component

  def render(assigns) do
    ~H"""
    <div class="block max-w-sm p-6 bg-white border border-gray-200 rounded-lg shadow hover:bg-gray-100 dark:bg-gray-800 dark:border-gray-700 dark:hover:bg-gray-700">
    <h5 class="mb-2 text-2xl font-bold tracking-tight text-gray-900 dark:text-white">
    <%= @title %> by <%= @artist %>
    </h5>
    <p class="font-normal text-gray-700 dark:text-gray-400">
    <%= @summary %>
    </p>
    </div>
    """
  end
end

Now that we have our two components, let’s display them in another dropdown alongside our “Core Components” and “Examples” here in the sidebar. To do that let’s use PhoenixStorybook.Index, which lets us customize the content tree displayed in our sidebar.

Since this will be for our card components let’s first create a new directory in “storybook” called “cards”. And then a new file inside that directory called _cards.index.exs. Because this is a storybook index - it needs to have the index.exs. Then we’ll define our module. And use PhoenixStorybook.Index.

There are a few different options Storybook gives you to customize the content tree, which you can find the documentation. For our example, we’ll use the folder_name function to customize the name of our folder. Then let’s set an icon to be displayed in our folder. To do that we can use the folder_icon function.

And Storybook comes with some Font Awesome icons, but if you want to use the full selection of icons, you’ll need to add your Font Awesome credentials to the Storybook module. We’ll return true from folder_open? - this will have our new folder open by default. Now we’ll have two entries under our “Album cards” folder. Let’s also add an icon for each of our entries. To customize our entries we can implement the “entry” function, pattern matching on the story.

Let’s create one entry for our “summary” component story. And then from the function, we’ll use the “icon” key to specify the font awesome icon we want to use. We’ll use the file icon here. In the documentation, there’s also an option to customize the name. But we won’t use that for our example. Let’s do the same for the “summary_live” component story.

storybook/cards/_cards.index.exs

defmodule Storybook.Cards do
  use PhoenixStorybook.Index

  def folder_name, do: "Album cards"
  def folder_icon, do: {:fa, "image", :light}
  def folder_open?, do: true

  def entry("summary"), do: [icon: {:fa, "file", :thin}]
  def entry("summary_live"), do: [icon: {:fa, "bolt", :thin}]

end

With that, our card’s storybook index is done. Now let’s create the stories for our components. If we look at the story documentation, we see that there are three different story types supported: component, live_component, and page.

Since we have a component and a live component we’ll create one of each. We need to create our stories with story.exs files. Let’s create the first one for our summary function component. We’ll create a new file named summary.story.exs and define the module. Then we’ll use PhoenixStorybook.Story, :component and let’s add an alias for TeacherWeb.Cards.Summary so we can call it without the prefix.

Because this is a function component we’ll need to add the function function and then return our component, as a function. Now we’ll need to define the variations function. This will return a list of different variations of our component to be displayed on the story page. All variations are populated with the %Variation{} struct. We’ll use that here with some mock data for our story. It has an id, and a description of the variant. Then some attributes for our component to use.

storybook/cards/summary.story.exs

defmodule Storybook.Cards.Summary do
  use PhoenixStorybook.Story, :component

  alias TeacherWeb.Cards.Summary

  def function, do: &Summary.summary_card/1

  def variations do
    [
      %Variation{
        id: :default,
        description: "Default summary card function component",
        attributes: %{
          artist: "Miles Davis",
          title: "Kind of Blue",
          summary: "Lorem ipsum dolar set amit"
        }
      }
    ]
  end
end

Now let’s create another story for our live component. We’ll create a file named summary_live.story.exs. Inside it, I’ll paste the full story module.

This is mostly the same as our function component story but with a couple differences. First, we’re using PhoenixStorybook.Story, :live_component. We’re also aliasing the Summary Live module instead. And instead of using the function function, we’re using the component function and returning the component module.

storybook/cards/summary_live.story.exs

defmodule Storybook.Cards.SummaryLive do
  use PhoenixStorybook.Story, :live_component

  alias TeacherWeb.Cards.SummaryLive

  def component, do: SummaryLive

  def variations do
  [
    %Variation{
      id: :default,
      description: "Default summary card live component",
      attributes: %{
        artist: "Miles Davis",
        title: "Kind of Blue",
        summary: "Lorem ipsum dolar set amit"
      }
    }
  ]
end
end

Alright, let’s see how our new component stories look. We’ll go to the command line and start our server.

$ mix phx.server
...

Then when we go back to our Storybook in the browser. We see our new album cards, just like we expected! And when we click on each story, we can see what our component would look like. And we can see the code to create each component from our application.

Storybook also has some other great features like a playground for more interactive components. As well as a tab displaying the source for the component.

© 2024 HEXMONSTER LLC