Closed36

Storybook アドオンを作りたい (Vue 3)

ピン留めされたアイテム
shingo.sasakishingo.sasaki

色々ハマりすぎて先に進めなくなったのと、本来やりたかったことは別にアドオン作らなくてもカスタムデコレータ使うだけで済む気がしてきたのでここで断念。

また機会があれば挑戦したいl。

shingo.sasakishingo.sasaki

スクラップの概要

  • Vue 3 + TypeScript + Storybook 7 の構成で、Storybook のアドオンを作りたい
  • Storybook 自体にはそれなりに慣れ親しんでいるし、アドオンを柔軟に導入することもできるけど、作った経験はない
  • アドオン開発の知識がゼロの状態から、公式ドキュメントを参考に形にしていくスクラップ

作りたいもの

  • storybook-addon-pinia みたいなもの
  • Vue インスタンスに対する pinia の注入を裏でやってくれる
  • pinia の Initial State をアドオン上で定義できる
  • アドオンパネルから Initial State の内容を書き換えられる
  • 各ストーリーの parameters あたりから、ストーリー単位で State を書き換えられる
shingo.sasakishingo.sasaki

ドキュメントのチュートリアルでは自作のアドオンで以下が出来るらしい

  • Storybook にアドオン専用のパネルを追加
  • ストーリーからカスタムパラメータを取り出す
  • パラメータをパネルに表示する

うーん、作りたいものの pinia の Initial State をアドオン上で定義できる が満たせるかまだ怪しいけど、他は行けそうだし、まずはそのへんから理解してこうか。

shingo.sasakishingo.sasaki

新規パッケージを作る。

$ mkdir my-storybook-addon
$ yarn init
yarn init v1.22.19
question name (shingo.sasaki): my-addon
question version (1.0.0): 
question description: 
question entry point (index.js): dist/preset.js
question repository url: 
question author: 
question license (MIT): 
question private: 
success Saved package.json
✨  Done in 35.06s.

ちょっと package.json を調整

package.json
{
  "name": "my-addon",
  "version": "1.0.0",
  "main": "dist/preset.js",
  "files": ["dist/**/*", "README.md", "*.js"],
  "keywords": ["storybook", "addons"],
  "license": "MIT"
}

プロジェクトはだいたいこういうディレクトリ構成が良いらしい。

  • dist: トランスパイル後の成果物
  • src: アドオンのソースコード
  • .babelrc.js: Babel の設定
  • preset.js: アドオンのエントリーポイント
  • package.json
  • README.md
shingo.sasakishingo.sasaki

React と Babel は必須だから入れろとのこと。まぁ TypeScript はいらないかな多分。

$ yarn add -D react react-dom @babel/cli
shingo.sasakishingo.sasaki

テスト用に Storybook 自体も入れる。そりゃ必要だろうけど。普通に入れて良いのかな。まぁチュートリアルに従おう。

$ npx storybook@latest init

バンドラは Vite で良いか別に。

shingo.sasakishingo.sasaki

Babel の設定。普通に React 向けのアドオンを作る流れになりそうだ。

.babelrc.js
export default {
  presets: ['@babel/preset-env', '@babel/preset-react'],
};
shingo.sasakishingo.sasaki

ビルドコマンドの追加。ここでいうビルドはアドオン自体のビルドなので babel を使用する。
もっと良いビルド方法ありそうなもんだけどここも従う。

package.json
{
  "scripts": {
    "build": "babel ./src --out-dir ./dist"
  }
}
shingo.sasakishingo.sasaki

アドオンのエントリーポイントである preset.js を実装。

src/preset.js
function managerEntries(entry = []) {
  return [...entry, require.resolve("./register")];
}

export default { managerEntries };

なにを言ってるのかはまったくわからんが、アドオンなんだから Storybook 側がこれを呼び出して良い感じにアドオンをインストールしてくれるんだろうな。

shingo.sasakishingo.sasaki

カスタムパネルを追加するコードを作成。

src/manager.js
import React from "react";

import { addons, types } from "@storybook/manager-api";

import { AddonPanel } from "@storybook/components";

const ADDON_ID = "myaddon";
const PANEL_ID = `${ADDON_ID}/panel`;

const MyPanel = () => <div>MyAddon</div>;

addons.register(ADDON_ID, (api) => {
  addons.add(PANEL_ID, {
    type: types.PANEL,
    title: "My Addon",
    render: ({ active, key }) => (
      <AddonPanel active={active} key={key}>
        <MyPanel />
      </AddonPanel>
    ),
  });
});

おそらくは src/ 以下にある JS ファイルはすべてインポートされて、トップレベルのコードはアドオンを組み込んた際に実行されるのかな。

コードからある程度判断はできる

  • ADDON_ID みたいな Storybook 全体でユニークになるようなキーが必要
  • addons.register が実際にアドオンを適用するメソッド
  • addons.add が、アドオンで何らかの Storybook 拡張を行う
  • ここでは type: types.PANEL ということでパネルの追加っぽい
  • パネルをどのようにレンダリングするかは普通に React(JSX) で書ける
    • これ Vue 向けのアドオンだろうと、アドオン自体の実装は React でいいのかな
shingo.sasakishingo.sasaki

そしてテスト用の Storybook でアドオンを有効化する。

.storybook/main.js
  addons: [
    "@storybook/addon-links",
    "@storybook/addon-essentials",
    "@storybook/addon-interactions",
    "../src/preset.js",
  ],
  // 以下略
shingo.sasakishingo.sasaki

本題からズレるけど Vite のインストール必要だった。 peerDependencies だったか。

$ yarn add -D vite
shingo.sasakishingo.sasaki

preset.js で読み込むファイルも、 manager.js しか作ってないんだからそっち指定する必要あった。それはそうだよなぁ。

function managerEntries(entry = []) {
  return [...entry, require.resolve("./manager.js")];
}

export default { managerEntries };
shingo.sasakishingo.sasaki

ストーリーからパラメータを受け取って、パネル内に表示するサンプル。

src/manager.js
const PARAM_KEY = "myAddon";

const MyPanel = () => {
  const value = useParameter(PARAM_KEY, null);
  const item = value ? value.data : "No story parameter defined";
  return <div>{item}</div>;
};

useParameter はアドオン用の React Hooks で、レンダーされたコンポーネントのパラメータが変わったときに MyPanel を再レンダリングできるようになるっぽい。

shingo.sasakishingo.sasaki

スキャフォルドされててた適当なストーリーでパラメータを渡してみる。

stories/Button.stories.js
export const Primary = {
  parameters: {
    myAddon: {
      data: "this is Primary",
    },
  },
  args: {
    primary: true,
    label: "Button",
  },
};

export const Secondary = {
  parameters: {
    myAddon: {
      data: "this is Secondary",
    },
  },
  args: {
    label: "Button",
  },
};
shingo.sasakishingo.sasaki

あとはパッケージとして公開する場合の注意点がいくつか。

shingo.sasakishingo.sasaki

Addon Kit の README もざっくり読んでおく

https://github.com/storybookjs/addon-kit

特徴

  • Live-Editing 機能
  • React/JSX サポート
  • Bable トランスパイル
  • TypeScript サポート
  • プラグインメタデータ
  • リリース管理機能
  • ボイラーテンプレートとサンプルコード
  • ESM サポート

なんだかしらんがすごそうだ。

開発用スクリプト

  • yarn start: 監視モードで Babel を動かして Storybook を起動する
  • yarn build: アドオンコードをパッケージビルドする

サンプルコード

  • src には最初から、React/JSX で書かれたサンプルアドオンが実装されている
    • ツールバーの拡張
    • パネルの拡張
    • タブの拡張
  • ほかにも、グローバルステート管理のためのサンプルコードや、パラメータをストーリーから受け取るサンプルコードが含まれている

メタデータ

メタデータは npm で公開したアドオンを、公式のアドオンカタログに掲載する際のメタデータのこと
https://storybook.js.org/integrations

これを良い感じに作成してくれる。

リリース管理

auto を用いたリリース自動化の仕組みが用意されている
https://github.com/intuit/auto

GitHub 及び npm へのプッシュを自動化するので、NPM_TOKEN GH_TOKEN が必要(.env` で定義)

yarn release で実行できるが、これも自動化する GitHub Actions も用意されている。

リリースに含まれるアクション

  • アドオンのビルド
  • version の更新
  • GitHub / npm にリリースをプッシュ
  • ChangeLog を作成して GitHub にプッシュ
shingo.sasakishingo.sasaki

明日以降やること

  • AddonKit でスキャフォルドされた現状をざっと確認する
  • Storybook 側を React でなく Vue 3 で書き直す
  • Pinia を導入する
  • スキャフォルドされたアドオンのコードをまるっと削除する

そこから本格的にアドオン開発に着手する

shingo.sasakishingo.sasaki

AddonKit で生成されたコードの構成、役割をざーっと確認する。

package.json

諸々の手順を踏んで自動生成された package.json の内容メモ

  • パッケージの公開を前提としたパッケージメタデータが定義されてる
    • name
    • version
    • description
    • keywords
    • repository
    • author
    • licencse
    • publicConfig
  • commonjs/esm どちらでも使えるように exports フィールドが定義されている
    • exports
    • main
    • module
    • types
    • files
  • Storybook のアドオンカタログへの公開を前提とした、アドオンメタデータが定義されている
    • storybook
      • displayName
      • supportedFrameworks
      • icon

dependencies はナシ。代わりに peerDependencies に、アドオンを動かすための各種パッケージが入ってる。利用側で複数のアドオンを使用する場合に余計にパッケージがインストールされないようになってる。

devDependencies では最初に指定した通り、Vite を使って React 用の Storybook を動かすためのパッケージ群が入ってきてる。

おそらく開発に要するユーティリティと思われるパッケージが多数。これは必要に応じて調べたりする。

npm スクリプトが色々定義されてる。定義から内容を予想すると以下の感じ。

  • clean: ビルド成果物を削除
  • prebuild: おそらくビルド前に呼び出されるコマンドで、clean を実行するだけ
  • build: tsup を使ったビルド
    • tsup は TypeScript のバンドラーらしい
  • build:watch: 監視付きビルド
  • test: 定義なし。アドオンのテストって大変そう。
  • start: アドオンの監視ビルドをしながら Storybook を起動する
  • prerelease: リリース前の各種チェックをしてくれるスクリプトの実行?
  • release: auto を用いた自動リリースの実行
  • eject-ts: TSで書かれたコードをJSに変換する
  • storybook: いわずもがな
  • build-storybook: いわずもがな

TS で書くことは間違い無さそうだし、eject-ts は不要そう。

$ rm scripts/eject-ts

npm scripts と、あと README 内の記載も削除

scripts/welcome.js

元々は postinstall で実行された、最初にスキャフォルドするためのスクリプト。スキャフォルド完了後は自動で postinstall からも取り除かれる仕組みがあったみたい。

これも削除で良さそう。ここでしか使ってないパッケージである prompts も削除OK

$ rm scripts/welcome.js
$ yarn remove prompts

.github/workflows/release.yml

GitHub にコードプッシュした際に自動でリリース作業が行われるワークフローがすでにある。

プッシュごとに yarn release が実行されそう。 GITHUB_TOKENNPM_TOKEN が設定されてなきゃ何も起こらないけど、プッシュするたびに無駄にワークフローが走って失敗されるの嫌なので当面は実行されないようにしよう。

全部コメントアウトしてもファイルがある時点でエラーになるっぽい。まぁ一旦削除しちゃうか。必要になったらまたコミットログ見るなり、AddonKid のレポ見れば良いし。

$ rm -rf .github

.prettierrc / .prettierignore

Pretiter の設定はデフォルトじゃなくて自分好みにさせてもらおうかな。個別に設定ファイル作るより package.json に含んじゃいたい。

  "prettier": {
    "semi": false,
    "singleQuote": true,
    "trailingComma": "none",
    "printWidth": 120,
    "arrowParens": "avoid"
  }
$ rm .prettierrc .prettierignore 

一応全ファイルに再実行

$ yarn prettier . --write

README.md

当然 AddonKit 側の README が複製されてるのでガッツリ削除。AddonKit について知りたい時はそっちをみるよ。

tsup.config.ts

tsup 自体はまだ良く知らないけど、アドオンをビルドするために使用してるツール。

  • src/index.ts
  • src/preview.ts
  • src/manager.ts

をエントリーに、cjs/esm 両形式でビルドして、 dist に出力してくれそう。

yarn build で dist に生成される。

index にはあんまり意味なくて、 manager preview それぞれが Storybook に読み込まれるみたい。

.storybook

ローカル開発用の Storybook の設定。

基本は React + Vite の構成でシンプルなやつだけど、dist にあるビルド済みの自身のアドオンを使用するように拡張されてる。

yarn start することで、アドオンのビルドと、それを使った Storybook の起動ができる。

src

ちょっとややこしいが、ここにはアドオン自体の実装と、それを開発環境で動かす Storyファイル及びコンポーネントが一緒になって入ってる。というかターゲットを Vue にしてるんだから、ここで使用されるストーリーも Vue になるべきでは。

shingo.sasakishingo.sasaki

スキャフォルドされたアドオンの内容を確認する

アドオンは大きく managerpreview に分かれてる。

manager

やってること

  • ツールバーにメニューを追加
  • パネルを追加
  • タブを追加

preview

やってること

  • デコレーターの追加
  • グローバルパラメーターの追加

アドオンで出来ることの一通りのミニマムパターンが定義されてるみたい。
これを参考にするとはいえ、基本的には使わないコードが殆どなので削除していこう

shingo.sasakishingo.sasaki

描画するストーリーを React から Vue に変更する

今回作りたいのは pinia 用のアドオンなので、 pinia を動かすための Vue が必須になる。現状 React で実装されているストーリーファイルを、Vue に変える必要が出てくる。

必要そうなパッケージ入れる

$ yarn add -D @storybook/vue3-vite vue vue-tsc @vitejs/plugin-vue

設定ファイルを書き換える

.storybook/main.ts
import type { StorybookConfig } from '@storybook/vue3-vite'
const config: StorybookConfig = {
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
  addons: [
    '@storybook/addon-links',
    '@storybook/addon-essentials',
    '@storybook/addon-interactions',
    './local-preset.js'
  ],
  framework: {
    name: '@storybook/vue3-vite',
    options: {}
  },
  docs: {
    autodocs: 'tag'
  }
}
export default config

vite 設定も変える

vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [vue()]
})

適当にコンポーネント作る。
https://zenn.dev/sa2knight/books/storybook-7-with-vue-3
で作ってるやつを流用しようかな。

とりあえずボタンコンポーネント

<script setup lang="ts">
const props = defineProps<{
  label: string
  variant: 'primary' | 'secondary'
  size: 'small' | 'medium' | 'large'
}>()

const emits = defineEmits<{
  (e: 'click'): void
}>()
</script>

<template>
  <button @click="emits('click')" :class="[props.variant, props.size]">
    {{ props.label }}
  </button>
</template>

<style scoped>
button {
  font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif;
  font-weight: 700;
  border: 0;
  border-radius: 3em;
  cursor: pointer;
  display: inline-block;
  line-height: 1;
  color: white;
  padding: 1rem;
}
button.primary {
  background-color: #1ea7fd;
}
button.secondary {
  background-color: #ff7e00;
}

button.large {
  font-size: 1.25rem;
  padding: 1.25rem 1.5rem;
}

button.medium {
  font-size: 1rem;
  padding: 1rem 1.25rem;
}

button.small {
  font-size: 0.875rem;
  padding: 0.75rem 1rem;
}
</style>

そのストーリー

import MyButton from './MyButton.vue'
import type { Meta, StoryObj } from '@storybook/vue3'

type Story = StoryObj<typeof MyButton>

const meta: Meta<typeof MyButton> = {
  title: 'MyButton',
  component: MyButton,
  render: args => ({
    components: { MyButton },
    setup() {
      return { args }
    },
    template: "<MyButton v-bind='args' />"
  })
}

export const Default: Story = {
  args: {
    label: 'Button',
    variant: 'primary',
    size: 'medium'
  }
}

export default meta
shingo.sasakishingo.sasaki

スキャフォルドされたアドオンの実装を最小限にする

それぞれ、ただテキストをベタ書きするだけの最小構成に。

import { addons, types } from '@storybook/manager-api'
import { ADDON_ID, TOOL_ID, PANEL_ID, TAB_ID } from './constants'

addons.register(ADDON_ID, () => {
  addons.add(TOOL_ID, {
    type: types.TOOL,
    title: 'My addon',
    match: ({ viewMode }) => !!(viewMode && viewMode.match(/^(story|docs)$/)),
    render: () => 'My addon tool'
  })

  addons.add(PANEL_ID, {
    type: types.PANEL,
    title: 'My addon',
    match: ({ viewMode }) => viewMode === 'story',
    render: () => 'My addon panel'
  })

  addons.add(TAB_ID, {
    type: types.TAB,
    title: 'My addon',
    route: ({ storyId }) => `/myaddon/${storyId}`,
    match: ({ viewMode }) => viewMode === 'myaddon',
    render: () => 'My addon tab'
  })
})

デコレーターとかグローバルパラメーターのファイルも削除。

これで devDependencies から React を消せる。 (react / react-dom 自体は Storybook のビルドに必要)

$ yarn remove @storybook/react @storybook/react-vite @types/react @vitejs/plugin-react
$ yarn start

で、Vue コンポーネントのストーリーが描画されつつ、最小限のアドオンが動くことを確認。

ようやくスタート地点って感じ。

shingo.sasakishingo.sasaki

pinia を導入する

pinia 用のアドオンなので、まずは普通に pinia を導入する。

ここでは pinia を使って、ユーザーの認証周りと、TODO管理を扱うステートを作成するってイメージで実装する。

$ yarn add -D pinia

pinia は利用側でも必要なので peerDependencies にも入れておくと良いのかな。

"pinia": "^2.0.0"

開発用のコードは src 直下じゃなくて src/dev にしようかな。

  • src/dev/components
  • src/dev/stores
  • src/dev/stories

みたいな。

ストアは雑にこんな感じ。

useCurrentUserStore.ts
import { defineStore } from 'pinia'

type CurrentUser = {
  id: string
  name: string
}

export const useCurrentUserStore = defineStore('currentUser', {
  state: () => ({
    currentUser: null as CurrentUser | null
  }),
  getters: {
    displayName() {
      return this.currentUser?.name ?? 'Guest'
    }
  },
  actions: {
    async signIn(name: string, password: string) {
      const response = await fetch('/api/sign-in', {
        method: 'POST',
        body: JSON.stringify({ name, password })
      })
      if (response.ok) {
        const user = await response.json()
        this.currentUser = {
          id: user.id,
          name: user.name
        }
      }
    },
    async signOut() {
      await fetch('/api/sign-out', {
        method: 'POST'
      })
      this.currentUser = null
    }
  }
})
useTodoStore.ts
import { defineStore } from 'pinia'

type Todo = {
  id: string
  text: string
  completed: boolean
}

export const useTodoStore = defineStore('todo', {
  state: () => ({
    todoList: [] as Todo[]
  }),
  getters: {
    completedTodoList(state) {
      return state.todoList.filter(todo => todo.completed)
    },
    uncompletedTodoList(state) {
      return state.todoList.filter(todo => !todo.completed)
    }
  },
  actions: {
    async fetchTodoList() {
      const response = await fetch('/api/todo-list')
      if (response.ok) {
        this.todoList = await response.json()
      }
    },
    async syncTodoList() {
      await fetch('/api/todo-list', {
        method: 'POST',
        body: JSON.stringify(this.todoList)
      })
    }
  }
})
shingo.sasakishingo.sasaki

pinia に依存した Vue コンポーネントを作成する

こんな構造かな

  • MyPage.vue
    • TodoListHeader.vue
    • TodoList.vue
      • Todo.vue

TodoListHeader と TodoList が pinia にアクセスする。Todo は props を受け取るだけ。

ここから2時間ぐらい、色々書いては試してを続けて、ようやく満足の行く状態になった。

shingo.sasakishingo.sasaki

pinia のセットアップをアドオン経由で行うようにする

ここからが本番。

現状、 .storybook/preview.ts にて、 pinia ストア及び初期値の設定を行っているが、これをアドオン経由で行うようにしたい。

setup(app => {
  app.use(
    createTestingPinia({
      createSpy: fn => args => {
        fn?.(args)
      },
      stubActions: false,
      initialState: {
        currentUser: {
          currentUser: {
            id: '1',
            name: 'sasaki'
          }
        },
        todo: {
          todoList: [
            { id: '1', text: 'Task 1', completed: false },
            { id: '2', text: 'Task 2', completed: false },
            { id: '3', text: 'Task 3', completed: false },
            { id: '4', text: 'Task 4', completed: true },
            { id: '5', text: 'Task 5', completed: true },
            { id: '6', text: 'Task 6', completed: true }
          ]
        }
      }
    })
  )
})

おそらくはアドオン側の preview.tsx がマージされるように作られているはずなので、そっちに移動するだけで良さそう。

withPiniaDecorator の形で切り出した。

export const withPiniaDecorator = makeDecorator({
  name: 'withPinia',
  parameterName: 'pinia',
  wrapper: (getStory, context, { parameters }) => {
    setup(app => {
      console.log(app)
      app.use(
        createTestingPinia({
          createSpy: fn => args => {
            fn?.(args)
          },
          stubActions: false,
          initialState: {
            currentUser: {
              currentUser: {
                id: '1',
                name: 'sasaki'
              }
            },
            todo: {
              todoList: [
                { id: '1', text: 'Task 1', completed: false },
                { id: '2', text: 'Task 2', completed: false },
                { id: '3', text: 'Task 3', completed: false },
                { id: '4', text: 'Task 4', completed: true },
                { id: '5', text: 'Task 5', completed: true },
                { id: '6', text: 'Task 6', completed: true }
              ]
            }
          }
        })
      )
    })

    return getStory(context)
  }
})
shingo.sasakishingo.sasaki

アドオンパネルに初期ステートを表示する

デコレーターでは testingPinia のオプションを受け取るようにし、その際に state を保持しておく。

export const withPiniaDecorator = (testingOptions: Partial<TestingOptions> = {}) => {
  const state = testingOptions.initialState || {}

  return makeDecorator({
    name: 'withPinia',
    parameterName: 'pinia',
    wrapper: (getStory, context, { parameters }) => {
      setup(app => {
        app.use(
          createTestingPinia({
            createSpy: fn => args => {
              fn?.(args)
            },
            stubActions: false,
            initialState: {},
            ...testingOptions
          })
        )
      })

      return getStory(context)
    }
  })()
}

パネルを作ろう。今回パネル以外 (ツールバー、タブ) も削除

import { addons, types } from '@storybook/manager-api'
import { ADDON_ID, PANEL_ID } from './constants'

addons.register(ADDON_ID, () => {
  addons.add(PANEL_ID, {
    type: types.PANEL,
    title: 'pinia/state',
    match: ({ viewMode }) => viewMode === 'story',
    render: () => 'pinia state panel'
  })
})

定数もちゃんと定義する

export const ADDON_ID = 'storybook/pinia'
export const PANEL_ID = `${ADDON_ID}/state`
export const PARAM_KEY = `piniaStateParameter`
shingo.sasakishingo.sasaki

次回、どうにかしてデコレーターに渡したステートをパネル上に表示する

shingo.sasakishingo.sasaki

useGlobals 使って Globals に State をまるごと突っ込んで Storybook 全体で共有すれば行ける気がする。
https://storybook.js.org/docs/react/addons/addons-api#useglobals

デコレータ側で注入

      const [_, updateGlobals] = useGlobals()
      const initialState = testingOptions.initialState || {}

      useEffect(() => {
        updateGlobals({ [PARAM_KEY]: initialState })
      }, [])

パネル側で取得して描画

addons.register(ADDON_ID, api => {
  addons.add(PANEL_ID, {
    type: types.PANEL,
    title: 'Pinia/state',
    match: ({ viewMode }) => viewMode === 'story',
    render: context => {
      const [globals] = useGlobals()
      return JSON.stringify(globals[PARAM_KEY])
    }
  })
})

イケたにはイケたけど、こんなやり方で良いのかわからない。
あとURLにシリアライズされたステートがまるごと乗っかるから、サイズによっては大変そう。

useAddonState のほうが向いてそう。
https://storybook.js.org/docs/react/addons/addons-api#useaddonstate

このスクラップは2023/06/25にクローズされました