Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
17
Help us understand the problem. What is going on with this article?
@fufujimoto

ReactでStorybook チュートリアルをやってみました。

More than 1 year has passed since last update.

はじめに

k.s.ロジャースの藤本です。

今回は公式のチュートリアルをやってみました。

その前に「そもそもstorybookってなんだろう」という状態からだったので
いろんな方の記事を参考にしてみました。

僕なりにまとめると、
サンドボックス環境を作り、
その中でコンポーネントの動作確認ができ
かつスタイルガイドとしても使える便利なツール

と解釈しました。
(間違えてたらすいません)

色んな記事を見てるとデザイナーさんとの認識合わせができることについて触れられている気がしました。

インストール

npx create-react-app taskbox
cd taskbox
npx -p @storybook/cli sb init

実行

npm run storybook

初期画面

image.png

Simple component

以下のコンパイル済みCSSをsrc/index.cssに格納
https://github.com/chromaui/learnstorybook-code/blob/master/src/index.css

public/fonticonを格納
https://github.com/chromaui/learnstorybook-code/tree/master/public

stories.jsを読み込めるように修正

storybook/config.js
import { configure } from '@storybook/react';
import '../src/index.css';

const req = require.context('../src', true, /\.stories.js$/);

function loadStories() {
  req.keys().forEach(filename => req(filename));
}

configure(loadStories, module);

Task.jsTask.stories.jsを作成

src/components/Task.js
import React from 'react';

export default function Task({ task: { id, title, state }, onArchiveTask, onPinTask }) {
  return (
    <div className="list-item">
      <input type="text" value={title} readOnly={true} />
    </div>
  );
}
src/components/Task.stories.js
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';

import Task from './Task';

export const task = {
  id: '1',
  title: 'Test Task',
  state: 'TASK_INBOX',
  updatedAt: new Date(2018, 0, 1, 9, 0),
};

export const actions = {
  onPinTask: action('onPinTask'),
  onArchiveTask: action('onArchiveTask'),
};

storiesOf('Task', module)
  .add('default', () => <Task task={task} {...actions} />)
  .add('pinned', () => <Task task={{ ...task, state: 'TASK_PINNED' }} {...actions} />)
  .add('archived', () => <Task task={{ ...task, state: 'TASK_ARCHIVED' }} {...actions} />);

.addで追加したタスクが表示されていることを確認
image.png

状態、イベントを設定

src/components/Task.js
import React from 'react';

export default function Task({ task: { id, title, state }, onArchiveTask, onPinTask }) {
  return (
    <div className={`list-item ${state}`}>
      <label className="checkbox">
        <input
          type="checkbox"
          defaultChecked={state === 'TASK_ARCHIVED'}
          disabled={true}
          name="checked"
        />
        <span className="checkbox-custom" onClick={() => onArchiveTask(id)} />
      </label>
      <div className="title">
        <input type="text" value={title} readOnly={true} placeholder="Input title" />
      </div>

      <div className="actions" onClick={event => event.stopPropagation()}>
        {state !== 'TASK_ARCHIVED' && (
          <a onClick={() => onPinTask(id)}>
            <span className={`icon-star`} />
          </a>
        )}
      </div>
    </div>
  );
}

確認してみます。

d.gif

その後、propTypesを設定することで問題を早期発見できるとのことでした。

src/components/Task.js
import React from 'react';
import PropTypes from 'prop-types';

export default function Task({ task: { id, title, state }, onArchiveTask, onPinTask }) {
  // ...
}

Task.propTypes = {
  task: PropTypes.shape({
    id: PropTypes.string.isRequired,
    title: PropTypes.string.isRequired,
    state: PropTypes.string.isRequired,
  }),
  onArchiveTask: PropTypes.func,
  onPinTask: PropTypes.func,
};

自動テスト

アドオンとbabel-macrosを追加

yarn add --dev @storybook/addon-storyshots react-test-renderer require-context.macro
yarn add --dev babel-plugin-macros
src/storybook.test.js
import initStoryshots from '@storybook/addon-storyshots';
initStoryshots();
babelrc/package.json
{
  "plugins": ["macros"]
}

各ファイルの追加とconfig.jsの一部変更
require.contextからrequireContextに変更されています。

.storybook/config.js
import { configure } from '@storybook/react';
import requireContext from 'require-context.macro';

import '../src/index.css';

const req = requireContext('../src/components', true, /\.stories\.js$/);

function loadStories() {
  req.keys().forEach(filename => req(filename));
}

configure(loadStories, module);
結果
$ yarn test
yarn run v1.17.3
$ react-scripts test
PASS src/storybook.test.js
  Storyshots
    Task
      √ default (15ms)
      √ pinned (2ms)
      √ archived (1ms)

 › 3 snapshots written.
Snapshot Summary
 › 3 snapshots written from 1 test suite.

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   3 written, 3 total
Time:        9.264s
Ran all test suites related to changed files.

Composite component

コンポーネントを組み合わせて実際の画面を確認してみます。

TaskList.jsTaskList.stories.jsを作成
addDecorator()で各タスクにスタイルなどを追加できるようです。

src/components/TaskList.js
import React from 'react';

import Task from './Task';

function TaskList({ loading, tasks, onPinTask, onArchiveTask }) {
  const events = {
    onPinTask,
    onArchiveTask,
  };

  if (loading) {
    return <div className="list-items">loading</div>;
  }

  if (tasks.length === 0) {
    return <div className="list-items">empty</div>;
  }

  return (
    <div className="list-items">
      {tasks.map(task => <Task key={task.id} task={task} {...events} />)}
    </div>
  );
}

export default TaskList;
src/components/TaskList.stories.js
import React from 'react';
import { storiesOf } from '@storybook/react';

import TaskList from './TaskList';
import { task, actions } from './Task.stories';

export const defaultTasks = [
  { ...task, id: '1', title: 'Task 1' },
  { ...task, id: '2', title: 'Task 2' },
  { ...task, id: '3', title: 'Task 3' },
  { ...task, id: '4', title: 'Task 4' },
  { ...task, id: '5', title: 'Task 5' },
  { ...task, id: '6', title: 'Task 6' },
];

export const withPinnedTasks = [
  ...defaultTasks.slice(0, 5),
  { id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' },
];

storiesOf('TaskList', module)
  .addDecorator(story => <div style={{ padding: '3rem' }}>{story()}</div>)
  .add('default', () => <TaskList tasks={defaultTasks} {...actions} />)
  .add('withPinnedTasks', () => <TaskList tasks={withPinnedTasks} {...actions} />)
  .add('loading', () => <TaskList loading tasks={[]} {...actions} />)
  .add('empty', () => <TaskList tasks={[]} {...actions} />);

a.gif

各状態のUIを設定

src/components/TaskList.js
import React from 'react';

import Task from './Task';

function TaskList({ loading, tasks, onPinTask, onArchiveTask }) {
  const events = {
    onPinTask,
    onArchiveTask,
  };

  const LoadingRow = (
    <div className="loading-item">
      <span className="glow-checkbox" />
      <span className="glow-text">
        <span>Loading</span> <span>cool</span> <span>state</span>
      </span>
    </div>
  );

  if (loading) {
    return (
      <div className="list-items">
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
        {LoadingRow}
      </div>
    );
  }

  if (tasks.length === 0) {
    return (
      <div className="list-items">
        <div className="wrapper-message">
          <span className="icon-check" />
          <div className="title-message">You have no tasks</div>
          <div className="subtitle-message">Sit back and relax</div>
        </div>
      </div>
    );
  }

  const tasksInOrder = [
    ...tasks.filter(t => t.state === 'TASK_PINNED'),
    ...tasks.filter(t => t.state !== 'TASK_PINNED'),
  ];

  return (
    <div className="list-items">
      {tasksInOrder.map(task => <Task key={task.id} task={task} {...events} />)}
    </div>
  );
}

export default TaskList;

全体的にチュートリアル通りの表示になっていることを確認
c.gif

propTypesを設定

TaskList.js
import React from 'react';
import PropTypes from 'prop-types';

import Task from './Task';

function TaskList() {
  ...
}

TaskList.propTypes = {
  loading: PropTypes.bool,
  tasks: PropTypes.arrayOf(Task.propTypes.task).isRequired,
  onPinTask: PropTypes.func.isRequired,
  onArchiveTask: PropTypes.func.isRequired,
};

TaskList.defaultProps = {
  loading: false,
};

export default TaskList;

Data

アプリケーション側にデータの配線を行います。

react-redux reduxをインストール

yarn add react-redux redux

単純なStoreを作成

src/lib/redux.js
import { createStore } from 'redux';

export const actions = {
  ARCHIVE_TASK: 'ARCHIVE_TASK',
  PIN_TASK: 'PIN_TASK',
};

export const archiveTask = id => ({ type: actions.ARCHIVE_TASK, id });
export const pinTask = id => ({ type: actions.PIN_TASK, id });

function taskStateReducer(taskState) {
  return (state, action) => {
    return {
      ...state,
      tasks: state.tasks.map(
        task => (task.id === action.id ? { ...task, state: taskState } : task)
      ),
    };
  };
};

export const reducer = (state, action) => {
  switch (action.type) {
    case actions.ARCHIVE_TASK:
      return taskStateReducer('TASK_ARCHIVED')(state, action);
    case actions.PIN_TASK:
      return taskStateReducer('TASK_PINNED')(state, action);
    default:
      return state;
  }
};

const defaultTasks = [
  { id: '1', title: 'Something', state: 'TASK_INBOX' },
  { id: '2', title: 'Something more', state: 'TASK_INBOX' },
  { id: '3', title: 'Something else', state: 'TASK_INBOX' },
  { id: '4', title: 'Something again', state: 'TASK_INBOX' },
];

export default createStore(reducer, { tasks: defaultTasks });

storeに接続し、該当タスクをレンダリングするようTaskList.jsを修正

src/components/TaskList.js
import React from 'react';
import PropTypes from 'prop-types';

import Task from './Task';
import { connect } from 'react-redux';
import { archiveTask, pinTask } from '../lib/redux';

export function PureTaskList({ loading, tasks, onPinTask, onArchiveTask }) {
  /* 以前のTaskList内を張り付ける */
}

PureTaskList.propTypes = {
  loading: PropTypes.bool,
  tasks: PropTypes.arrayOf(Task.propTypes.task).isRequired,
  onPinTask: PropTypes.func.isRequired,
  onArchiveTask: PropTypes.func.isRequired,
};

PureTaskList.defaultProps = {
  loading: false,
};

export default connect(
  ({ tasks }) => ({
    tasks: tasks.filter(t => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED'),
  }),
  dispatch => ({
    onArchiveTask: id => dispatch(archiveTask(id)),
    onPinTask: id => dispatch(pinTask(id)),
  })
)(PureTaskList);

TaskListPureTaskListになっているので合わせて修正

src/components/TaskList.stories.js
import React from 'react';
import { storiesOf } from '@storybook/react';

import { PureTaskList } from './TaskList';
import { task, actions } from './Task.stories';

export const defaultTasks = [
  { ...task, id: '1', title: 'Task 1' },
  { ...task, id: '2', title: 'Task 2' },
  { ...task, id: '3', title: 'Task 3' },
  { ...task, id: '4', title: 'Task 4' },
  { ...task, id: '5', title: 'Task 5' },
  { ...task, id: '6', title: 'Task 6' },
];

export const withPinnedTasks = [
  ...defaultTasks.slice(0, 5),
  { id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' },
];

storiesOf('TaskList', module)
  .addDecorator(story => <div style={{ padding: '3rem' }}>{story()}</div>)
  .add('default', () => <PureTaskList tasks={defaultTasks} {...actions} />)
  .add('withPinnedTasks', () => <PureTaskList tasks={withPinnedTasks} {...actions} />)
  .add('loading', () => <PureTaskList loading tasks={[]} {...actions} />)
  .add('empty', () => <PureTaskList tasks={[]} {...actions} />);

Screens

コンポーネントを組み合わせて
実際の画面を使い開発をしてみます。

InboxScreen.jsを作成

src/components/InboxScreen.js
import React from "react";
import PropTypes from "prop-types";
import { connect } from "react-redux";

import TaskList from "./TaskList";

export function PureInboxScreen({ error }) {
  if (error) {
    return (
      <div className="page lists-show">
        <div className="wrapper-message">
          <span className="icon-face-sad" />
          <div className="title-message">Oh no!</div>
          <div className="subtitle-message">Something went wrong</div>
        </div>
      </div>
    );
  }

  return (
    <div className="page lists-show">
      <nav>
        <h1 className="title-page">
          <span className="title-wrapper">Taskbox</span>
        </h1>
      </nav>
      <TaskList />
    </div>
  );
}

PureInboxScreen.propTypes = {
  error: PropTypes.string
};

PureInboxScreen.defaultProps = {
  error: null
};

export default connect(({ error }) => ({ error }))(PureInboxScreen);

InboxScreenのコンポーネントを使用するようにApp.jsを修正

src/App.js
import React, { Component } from "react";
import { Provider } from "react-redux";
import store from "./lib/redux";

import InboxScreen from "./components/InboxScreen";

class App extends Component {
  render() {
    return (
      <Provider store={store}>
        <InboxScreen />
      </Provider>
    );
  }
}

export default App;

チュートリアルにはありませんが、実際の画面を見てみます。

npm start

f.gif

StorybookでもInboxScreenをレンダリングしてみます。

InboxScreen.stories.jsを作成

src/components/InboxScreen.stories.js
import React from "react";
import { storiesOf } from "@storybook/react";

import { PureInboxScreen } from "./InboxScreen";

storiesOf("InboxScreen", module)
  .add("default", () => <PureInboxScreen />)
  .add("error", () => <PureInboxScreen error="Something" />);

チュートリアル通りですが、エラーが出ます。
image.png
Presentational Component(見た目)をレンダリングするコンポーネントなので
Container Component(ロジック)を単独でレンダリングすることはできないようです。

今回の例では、PureInboxScreen単体はPresentational Componentですが
TaskListがそうではないことが原因のようです。

.addDecoratorを使用し、取り回せるよう
src/components/InboxScreen.stories.jsを修正

InboxScreen.stories.js
import React from "react";
import { storiesOf } from "@storybook/react";
import { action } from "@storybook/addon-actions";
import { Provider } from "react-redux";

import { PureInboxScreen } from "./InboxScreen";
import { defaultTasks } from "./TaskList.stories";

const store = {
  getState: () => {
    return {
      tasks: defaultTasks
    };
  },
  subscribe: () => 0,
  dispatch: action("dispatch")
};

storiesOf("InboxScreen", module)
  .addDecorator(story => <Provider store={store}>{story()}</Provider>)
  .add("default", () => <PureInboxScreen />)
  .add("error", () => <PureInboxScreen error="Something" />);

正しく表示されていることを確認
g.gif

Addons

アドオンを設定することができるようです

Knobsをセットアップ

yarn add @storybook/addon-knobs

.storybook/addons.jsaddon-knobsを追記

.storybook/addons.js
import "@storybook/addon-actions/register";
import "@storybook/addon-knobs/register";
import "@storybook/addon-links/register";

addon-knobsのインポートの追加
.addDecoratorにパラメーターを渡します
defaultのストーリーにobjectを結合

src/components/Task.stories.js
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { withKnobs, object } from "@storybook/addon-knobs/react";

import Task from './Task';

export const task = {
  id: '1',
  title: 'Test Task',
  state: 'TASK_INBOX',
  updatedAt: new Date(2018, 0, 1, 9, 0),
};

export const actions = {
  onPinTask: action('onPinTask'),
  onArchiveTask: action('onArchiveTask'),
};

storiesOf('Task', module)
  .addDecorator(withKnobs)
  .add("default", () => {
    return <Task task={object("task", { ...task })} {...actions} />;
  })
  .add("pinned", () => (
    <Task task={{ ...task, state: "TASK_PINNED" }} {...actions} />
  ))
  .add("archived", () => (
    <Task task={{ ...task, state: "TASK_ARCHIVED" }} {...actions} />
  ));

Knobsのタブが追加されていて、defaultにパラメータ―が表示されていることを確認
image.png

その他(チュートリアル外)

テーマを設定

storybookのテーマが設定できるみたいなので設定してみました

config.js
import { addParameters } from '@storybook/react';
import { themes } from '@storybook/theming';

// Option defaults.
addParameters({
  options: {
    theme: themes.dark,
  },
});

image.png

終わりに

「Creating addons」や「Deploy」などもありましたが
Storybookを実務で利用する方法とは若干逸れていると思い一部割愛させていただきました。

今回、チュートリアルを触ってみた個人的な感想を簡単にまとめると

良いところ
・コンポーネントカタログから直感的に必要なコンポーネントを探せる
・UIコンポーネントの再利用性が高い
・アプリを実行する必要が無く、Storybook単体でデザインの確認や実装ができる
・HMRが標準で設定されているため素早く動作確認ができる

気になるところ
・小規模プロジェクトだとわざわざ導入する必要は無いかも

個人的にはこんなところでした。
少し使い方は複雑かなとも思いましたが
慣れてくるとスムーズに開発できそうです。

弊社のプロジェクトでも導入されているため
実務部分についてはこれから理解を深めていきます。
応用に関しては知見が深まったらまたブログを書きます。

間違いやご指摘がありましたが是非ご教授頂ければと思います。

17
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ks-rogers
エンジニアによるエンジニアのためのエンジニアの会社

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
17
Help us understand the problem. What is going on with this article?