JavaScript
GitHub
CircleCI
vue.js
storybook

PRごとにCIでStorybookをビルドしてデザイナーとインタラクションまで作っていく話

Retty Advent Calendar 2018 6日目の記事です。
昨日は @tkngueCircleCI as a Resource でした。
CircleCIをビルド環境以外にも使い倒していこうという内容でしたが、今日はそれをフロントエンドで実践してみた事例紹介になります。

TL;DR

  • PRごとにStorybookビルドするの、CircleCIで完結して簡単だからやるといいよ
  • ほんでデザイナーもPRのフローに乗ってもらうと実装された細かい動作も確認できていいよ

はじめに

Rettyではお店会員様が

  • 店舗情報の編集
  • 検索結果にどう広告を出すかの設定
  • Retty予約にまつわる在庫登録・確認などの設定
  • アクセス解析

など多種多様な設定・確認を行える管理画面があります。
そちらを通常の開発とは別でフロントエンドのリニューアルを小さなプロジェクトチームで目下進行中です。

古き良きサーバサイドでのページレンダリングに高度プロフェッショナルなjQueryを乗せて複雑な管理画面アプリケーションを作っていましたが、より良いユーザ体験を求めて Vue.js を使って全面デザインリニューアルを行っています。

今日はその中でデザイナーとフロントエンドエンジニアがコミュニケーションを行って、コンポーネントの細かいインタラクションやトランジションまで合意をして作っていくためにPRごとにStorybookをビルドして、デザイナーがPRをレビューするフローを構築する取り組みについてご紹介したいと思います。

これまでの状況

現在チームの作業メンバーとしては

  • デザイナー: 1名
  • フロントエンド: 2名
  • フロント〜バックエンド: 1名

の4名で進行しています。
そして、今回の話で主役になるのはデザイナーとフロントエンドエンジニアになります。

デザイナーは要件の整理・プランニングからデザインカンプ作成までを守備範囲として、マークアップ以降のフロントエンド領域は行っていません。デザインカンプの作成はSketchで行い、エンジニアとのコミュニケーションツールにzeplinを利用しています。プロトタイピングにはProttを使っていますが、エンジニアに渡す最終成果物としては静的な一枚絵になります。

対してフロントエンドエンジニアはスプリントプランニングや実装前などのタイミングで「細かいインタラクションの行間の確認」であったり「既存のバックエンドを再利用する上でコスパの良い動作の提案」をzeplin上でコミュニケーションし、マークアップ以降のフロントエンド実装を行います。

問題

こういった役割分担・コミュニケーションのとり方で作業を進行していくと、専門領域であるデザインとフロントエンドをそれぞれ作っていくスピードを上げることができます。

しかし、お互いの領域が交わる 結局マークアップ実装が求める品質を満たしているか という確認ポイントで合意を取るコミュニケーションツールが無く、「フロントエンドエンジニアがデザイナーに動作を見せて確認を取る」や「ちょっとデザインかじってて行間の読めるフロントエンドデザイナーが勝手に実装して、デザイナーがレビューをするフローが抜ける」であったりの属人的な運用が行われていました。

まとめると

  • 作ったコンポーネントをデザイナーに見せるのダルい
  • デザイナーは結局カンプの行間が同実装されたかわからない

などのメインとする問題があり、

  • Atomic Designを採用しているのにデザインとフロントエンド実装で分割粒度が共有されていない
  • カンプの行間をどうするべきかの合意が他のエンジニアに共有されない

などサブの問題があるという状況です。

したいこと

雑に図示すると
expected.png
上図の 「ここ」 をカバーし、フロントエンドエンジニアがデザインカンプでのコミュニケーションにおいてzeplinでエンパワーメントされているように、 マークアップ実装についてのコミュニケーションにおいてデザイナーをエンパワーメントしてあげたい わけです。

具体的にしたいことに言いかえると、

  1. デザイナーがコンポーネントカタログをPRで見て、動作を確認する
  2. デザイナーがPRに意見をコメントする
  3. エンジニアがそれを見てコミュニケーションしたり修正する

のフローをうまく回せる仕組みです。

昨日の記事 でも紹介があった通り、CircleCIには Artifacts という機能があり、ビルド結果をCircleCI単体で配信することが可能です。
これに乗っかるとHerokuなど他サービスと連携する必要なく、PRごとにビルドしたStorybookを配信できそうです。

やったこと

※ ビルドしたStorybookのurlだけをPRにコメントさせる場合は @yszk0123 さんの コードレビューに役立つ React Storybook の閲覧環境を作る が大変明快でした。(大部分を参考にさせていただきました。ありがとうございます。)
以下は変更のあったコンポーネントだけリストアップして該当するStorybookのurlをコメントした自分の場合を前提にして書きます。

1. Storybook環境を設定する

すでに導入していたり、Vue以外を使っている方は次の項まで読み飛ばしていただくとよいです。

導入する

当プロジェクトでは 手書きwebpack + Vue.js の構成なので、 Storybook for Vue の手順をおよそそのまま実行しました。

vue-cli@3 を使っている場合は vue-cli-plugin-storybook があるのでそちらが一番楽だと思います。(2018/12/06現在ではStorybookがalpha版のためbuild生成物にうまくスタイルが当たらない問題はありますが、 @storybook/vue をアップデートすると解消します)

webpack + vue環境の場合、特にこだわりが無ければ https://github.com/storybooks/storybook/blob/next/app/vue/README.md#getting-started にも書いてあるとおり、初期設定のコマンドを実行すると、必要なファイルを一式scaffoldしてくれるので、これが便利です。

$ npm i --save-dev @storybook/vue
$ npx -p @storybook/cli sb init

おおまかに

  • Storybookの設定を .storybook/config.js に生成
  • Storyファイルを src/stories/index.stories.js として生成
  • npm scriptsの設定

を行ってくれます。

コンポーネントの隣にStoryファイルを置くようにする

自分は後ほどのフローで「差分があったコンポーネントのStorybookへのリンクだけコメントする」機能の実装をする予定なのと、単純に管理が楽という2点で、コンポーネントのvueファイルの隣にStoryファイルを置くように変更しました。

まずはビルド対象のStoryファイルを拾う設定

.storybook/config.js
import { configure } from '@storybook/vue';

// automatically import all files ending in *.stories.js
const req = require.context('src', true, /.story.js$/); // ここを変更
function loadStories() {
  req.keys().forEach(req);
}

configure(loadStories, module);

次に、Storyファイルを下記のように作ります

src/components/atoms/InputText.vue.story.js
import { storiesOf } from '@storybook/vue';
import base from 'paths.macro';
// pathからStorybookの構造をよしなに作るfunctionを用意しておく
// "~~" はproject root へのalias
import generateHierarchyFromFilepath from '~~/scripts/generateHierarchyFromFilepath';

import InputText from './InputText.vue';

// paths.macro から取ったパスは末尾に '/' がつく
const hierarchy = generateHierarchyFromFilepath(base);
storiesOf(`${hierarchy}InputText.vue`, module) // `/atoms/InputText.vue` になる
  .add('default', () => ({
    components: { InputText },
    data() {
      return {
        text: '',
      };
    },
    template: `<input-text v-model="text" placeholder="placeholder"/>`,
  }))

Storyファイルの置き場所から動的にStoryの構造を作るのは Generating nesting path based on __dirname に書いてあるので、その通りに paths.macro を使います。

scripts/generateHierarchyFromFilepath.jssrc/components/atoms/sub-dir/Hoge.vue のような構成であれば

scripts/generateHierarchyFromFilepath.js
module.exports = filepath =>
  filepath
    .match(/.*?components\/(.*?)(\.story\.js)?$/)[1];

などで良いと思います。リポジトリのディレクトリ構成に合わせて適宜変更してください。

アプリケーション環境のwebpackの設定を使えるようにする

アプリケーション環境のwebpackで

  • alias
  • scssを処理するツールチェイン
  • normalize.css

が設定されており、Storybook環境でも利用したかったので下記のように流用しました。
デフォルトでは下記ファイルは存在しませんが、作ってあげると、Storybookがうまく自分の持っているwebpack configにマージしてくれます。

.storybook/webpack.config.js
const path = require('path');
const applicationWebpackConfig = require('../webpack.config');

module.exports = (baseConfig, env, defaultConfig) => {
  // オレオレnormalize css
  defaultConfig.entry['normalize'] = path.resolve(__dirname, '../src/assets/base/_normalize.scss');

  // alias
  defaultConfig.resolve.alias = {
    ...defaultConfig.resolve.alias,
    ...applicationWebpackConfig.resolve.alias,
  }

  // modules
  defaultConfig.module.rules.push({
    test: /\.scss$/,
    use:[
      'vue-style-loader',
      {
        loader: 'css-loader',
        options: {
          sourceMap: true,
          url: false,
        },
      },
      {
        loader: 'sass-loader',
        options: {
          sourceMap: true,
        },
      },
    ],
  })

  return defaultConfig;
}

2. circleciでビルドさせる

何はともあれ .circleci/config.yml を設定します
わかりやすくするためにcache周りなどは省いたので、適宜設定してください。

.circleci/config.yml
version: 2
jobs:
  build_storybook:
    docker:
      - image: circleci/node:10.13
    working_directory: ~/repo
    steps:
      - checkout
      - run:
          name: Installing node modules
          command: |
            npm i
      # storybookをビルドする
      - run:
          name: Generate Storybook
          command: |
            ./scripts/generateStorybookForPr.sh
      # Artifactsにアップロードする
      - store_artifacts:
          path: ./storybook-static
          destination: ~/storybook
workflows:
  version: 2
  test:
    jobs:
      - build_storybook

上記で触れた Artifacts 機能は

- store_artifacts:
    path: ./storybook-static
    destination: ~/storybook

の設定だけで動きます。簡便!!

./scripts/generateStorybookForPr.sh

./scripts/generateStorybookForPr.sh
#!/bin/bash
set -eu

# PRが作られていない場合はスキップ
export PULL_REQUEST_ID=$(echo $CIRCLE_PULL_REQUEST | awk -F'/' '{print $NF}')
if [ -z "$PULL_REQUEST_ID" ]; then
  echo "Skip building storybook."
  exit 0
fi

npm run build:storybook

を置きます。

するとCircleCIのコンパネのArtifactsから下記のように確認することができます。

screenshot
Storing Build Artifacts より引用

3. 変更のあったコンポーネントをリストアップさせる

PRのbase branchとの差分を確認したいので下記のように scripts/generateStorybookForPr.sh に追記します

scripts/generateStorybookForPr.sh
# 中略

export GITHUB_API_TOKEN="hogehoge" # あらかじめAPI_TOKENを払い出しておくなどする
export BASE_BRANCH=$(eval curl "https://api.github.com/repos/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/pulls/$PULL_REQUEST_ID?access_token=$GITHUB_API_TOKEN" | jq '.base.ref' | tr -d '"')

git fetch
git diff origin/${BASE_BRANCH}...HEAD --name-only | grep 'components.*\(vue\|vue\.story\.js\)$'

これでコンポーネントに関するファイルの差分が取れます。

4. PRにコメントをさせる

コンポーネントに関するファイルの差分が取れればあとはコメントを作ってポストするだけです。

jsが慣れているのでjsでやってしまいます。

scripts/postModifiedStories.js
#!/usr/bin/env node

const readline = require('readline');
const { URL } = require('whatwg-url');
const axios = require('axios');
const generateHierarchyFromFilepath = require('./generateHierarchyFromFilepath');

const {
  CIRCLE_BUILD_NUM,

  // Artifactsのurlのhostnameは
  // "https://4321-12345678-gh.circle-artifacts.com"
  // のフォーマットですが、これの "12345678" を確認し、環境変数に入れておきます。
  CIRCLE_REPO_ID,

  CIRCLE_NODE_INDEX,
  GITHUB_API_TOKEN,
  CIRCLE_PROJECT_USERNAME,
  CIRCLE_PROJECT_REPONAME,
  PULL_REQUEST_ID,
} = process.env;

// Storybookのbase url
const STORYBOOK_BASE_URL = `https://${CIRCLE_BUILD_NUM}-${CIRCLE_REPO_ID}-gh.circle-artifacts.com/${CIRCLE_NODE_INDEX}/~/storybook/index.html`;

process.stdin.resume();
process.stdin.setEncoding('utf-8');

// 標準入力から変更されたファイルの一覧を読む
const getModifiedFilesFromStdin = () =>
  new Promise(resolve => {
    const rl = readline.createInterface({
      input: process.stdin,
    });

    const modifiedFiles = [];
    rl.on('line', line => {
      // 重複したlineを入れない。やらなくてもいい。
      if (line && !modifiedFiles.some(item => item === line)) {
        modifiedFiles.push(line);
      }
    });

    rl.on('close', () => {
      resolve(modifiedFiles);
    });
  });

// 特定のコンポーネントへのStorybookUrlを返す
const generateStorybookUrlFromHierarchy = hierarchy => {
  const storybookUrl = new URL(STORYBOOK_BASE_URL);
  storybookUrl.searchParams.append('selectedKind', hierarchy);

  return storybookUrl.href;
};

// PRへのコメントをつくる
const generateComment = modifiedStories => {
  let comment = '## :flight_departure: Storybook launched\n\n';
  comment += '| modified component | link |\n|--|--|\n';
  comment += modifiedStories
    .map(({ hierarchy, url }) => `| ${hierarchy.replace(/\//g, ' / ')} | [open](${url}) |`)
    .join('\n');

  return comment;
};

// Github APIを叩いてコメントする
const postCommentToPr = async comment => {
  const endpoint = `https://api.github.com/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}/issues/${PULL_REQUEST_ID}/comments`;
  try {
    await axios.post(
      endpoint,
      {
        body: comment,
      },
      {
        headers: {
          Authorization: `Bearer ${GITHUB_API_TOKEN}`,
          Accept: 'application/vnd.github.v3.html+json',
        },
      }
    );
  } catch (error) {
    console.error(error);
  }
};

// main処理はこちらから
(async () => {
  // 標準入力から変更されたファイルを受け取る
  const modifiedFiles = await getModifiedFilesFromStdin();
  // 無ければ何もしないで処理を終わる
  if ( modifiedFiles.length === 0) {
    process.exit();
  }

  const modifiedStories = modifiedFiles
    // ファイルパスからStorybookのhierarchyを作る
    .map(generateHierarchyFromFilepath)
    // 同一コンポーネントのvueファイルとStoryファイルに変更があった場合など
    // 重複するhierarchyを排除する
    .filter((item, index, array) => array.indexOf(item) === index)
    .map(hierarchy => ({
      hierarchy,
      url: generateStorybookUrlFromHierarchy(hierarchy),
    }));
  // コメントをつくる
  const comment = generateComment(modifiedStories);
  // 投げる
  postCommentToPr(comment);
})();

scripts/generateStorybookForPr.sh は最終的にこんな感じにします。

scripts/generateStorybookForPr.sh
#!/bin/bash
set -eu

export PULL_REQUEST_ID=$(echo $CIRCLE_PULL_REQUEST | awk -F'/' '{print $NF}')
if [ -z "$PULL_REQUEST_ID" ]; then
  echo "Skip building storybook."
  exit 0
fi

npm run build:storybook

export CIRCLE_REPO_ID=22704738
export GITHUB_API_TOKEN="hogehoge"
export TARGET_BRANCH=$(eval curl "https://api.github.com/repos/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME/pulls/$PULL_REQUEST_ID?access_token=$GITHUB_API_TOKEN" | jq '.base.ref' | tr -d '"')

git fetch
git diff origin/${TARGET_BRANCH}...HEAD --name-only | grep 'components.*\(vue\|vue\.story\.js\)$' | ./scripts/postModifiedStories.js

最終的にこんな感じのコメントにしました。 :tada:

image.png

まとめ

デザイナーがマークアップまでやらないチームでも、こうしてコンポーネントカタログをデザイナーに見せてPRで意見をもらうことで、最終的な成果物の品質確認をデザイナーが確認できるようになりました。

デザインカンプを見ただけで動作やエッジケースの実装方針がわかるような行間の無いデザインファイルを作ることが可能であれば、こういったコミュニケーションに時間を割くことは無いかもしれないですが、 デザインファイルはリリース物ではないので、作り込んでもユーザさんに価値を届けられません
また、エッジケースをデザインカンプで表現するには「エッジケースの仕様をどうするかの決定」と「デザイナーの具体的な実装の理解」が必要不可欠になります。そういった作業は、実装してわかる問題の発生やエッジケースの見落としなどがあり、多くの場合コストパフォーマンスが悪いです。

なのでデザインカンプを無理して作り込むことはせず、デザイナーとフロントエンドエンジニアで適切なタイミングで適切なコミュニケーションをして、どんどんリリースをしていけるといいと思います。

こちらからは以上です。