Ember.jsでstorybookを使う

Ember.jsでもStorybookを利用することが出来ます。

参考用ソースコード:https://github.com/mk2/ember-storybook-demo

# プロジェクトのセットアップ

- ※`ember new ...`で新規プロジェクトは生成済み
- ※ `ember-bootstrap`と`ember-bootstrap-cp-validations`と`ember-cp-validations`はインストール済み（例示のために入れてるだけで、必須ではないです）

## storybookをセットアップ

```sh
npx -p @storybook/cli sb init
ember install @storybook/ember-cli-storybook
```

ここまでやると、package.jsonに`storybook`というタスクが追加されているので、`npm run storybook`で起動するはずです。

```json
...
  "scripts":
    ...
    "storybook": "start-storybook -p 6006 -s dist --no-dll",
    "build-storybook": "build-storybook -s dist --no-dll"
...
```

ただし、2020/11/8時点では`npm run storybook`してもエラーが出て起動しないので、

https://github.com/storybookjs/ember-cli-storybook/issues/48

このissueにあるように、`core-js@3.6.5`を依存に追加してあげます。

```sh
npm i --save-dev core-js@3.6.5
```

これで起動するようになります。

## 開始できるようにする

storybookは起動しているemberからコンポーネントを取得するので、事前にemberを起動しておきます。

```sh
ember s
```

その後、storybookを起動します。

```sh
npm run storybook
```

これで、`localhost:6006`でstorybookが動くようになりました。

# storyを記述していく

## 例: ember-bootstrapのフォーム

ember-bootstrapで作成したフォームをstorybook上に出してみたいと思います。ember-cp-validationsでバリデーションも出来るようにしてちゃんと動くかチェックします。

```sh
# `-cc`でClassic Componentスタイルのjsファイルを、hbsファイルに加えて生成する
ember g component -cc SampleForm
```

```hbs:app/components/sample-form.hbs
<BsForm @formLayout="vertical" @model={{this}} @onSubmit={{action "submit"}} as |form|>
  <form.element
    @controlType="text"
    @label="タイトル"
    @placeholder="タイトル"
    @property="title"
    @required={{true}}
  />
  <form.element @controlType="textarea" @label="説明" @property="description" />
  <form.submitButton>
    作成
  </form.submitButton>
</BsForm>
```

```js:app/components/sample-form.js
import Component from "@ember/component";
import { buildValidations, validator } from "ember-cp-validations";

const Validations = buildValidations({
  title: validator("presence", { presence: true, message: "入力が必要です" }),
});

export default Component.extend(Validations, {
  title: null,
  description: null,
  actions: {
    submit() {
      this.onSubmit(this.title, this.description);
    },
  },
});
```

```js:stories/sample-form.stories.js
import { action } from "@storybook/addon-actions";
import { hbs } from "ember-cli-htmlbars";

export default {
  title: "SampleForm",
  argTypes: {},
};

export const Template = () => ({
  template: hbs`<SampleForm />`,
  context: {
    submit: () => action("onSubmit")(),
  },
});
```

これで画面を見ると、動いているのがわかります。

![スクリーンショット 2020-11-08 16.12.16.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213719/b99e899d-6a0b-8f6c-57df-38263ec28909.png)

ちゃんとバリデーションも動いているようです。

![スクリーンショット 2020-11-08 16.12.21.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213719/66e70df2-ec70-236d-6950-687c88e56602.png)

## 例: 少しだけ複雑な画面

下図のような、ちょっとだけ複雑そうな画面を作ってみます。（月〜金の時間帯を選んで保存できる、という画面です）

- 9:00~19:00の時間帯はstorybookの[controls](https://storybook.js.org/docs/react/essentials/controls)機能で画面上からいじれるようにする

![スクリーンショット 2020-11-08 16.20.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213719/34ac9155-8337-005a-a837-642764640c3a.png)

```sh
ember g component day-input
# `-gc`でOctane Componentスタイルのjsファイルを、hbsファイルに加えて生成する
ember g component -gc week-input
```

```hbs:app/components/day-input.hbs
<BsButtonGroup @value={{@selectedHours}} @type="checkbox" @onChange={{@onChange}} as |bg|>
  {{#each @hours as |time|}}
    <bg.button @type="info" @outline={{true}} @value={{time}}>
      {{time}}
    </bg.button>
  {{/each}}
</BsButtonGroup>
```

```hbs:app/components/week-input.hbs
<div class="row">
  <div class="mx-auto col-md-8" style="margin-bottom: 20px">
    <div class="row">
      <div class="col-md-8">
        <BsButton @type="success" @onClick={{@onSave}}>
          保存
        </BsButton>
        <BsButton @type="danger" @onClick={{@onCancel}}>
          キャンセル
        </BsButton>
      </div>
      <div class="col-md-4">
        <div class="input-group flex-nowrap">
          <div class="input-group-prepend">
            <span class="input-group-text" id="addon-wrapping">
              記入者名
            </span>
          </div>
          <input
            type="text"
            class="form-control"
            placeholder=""
            aria-label="表示名"
            aria-describedby="addon-wrapping"
            value={{@scheduleInput.name}}
            {{on "change" this.updateName}}
          />
        </div>
      </div>
    </div>
  </div>
</div>

<div class="row" {{did-insert setupHours}}>
  <div class="mx-auto col-md-8">
    <table>
      <tbody>
        <tr>
          <td>
            月
          </td>
          <td>
            <DayInput
              @hours={{hours}}
              @selectedHours={{@scheduleInput.monHours}}
              @onChange={{action (mut this.args.scheduleInput.monHours)}}
            />
          </td>
        </tr>

        <tr>
          <td>
            火
          </td>
          <td>
            <DayInput
              @hours={{hours}}
              @selectedHours={{@scheduleInput.tueHours}}
              @onChange={{action (mut @scheduleInput.tueHours)}}
            />
          </td>
        </tr>

        <tr>
          <td>
            水
          </td>
          <td>
            <DayInput
              @hours={{hours}}
              @selectedHours={{@scheduleInput.wedHours}}
              @onChange={{action (mut @scheduleInput.wedHours)}}
            />
          </td>
        </tr>

        <tr>
          <td>
            木
          </td>
          <td>
            <DayInput
              @hours={{hours}}
              @selectedHours={{@scheduleInput.thuHours}}
              @onChange={{action (mut @scheduleInput.thuHours)}}
            />
          </td>
        </tr>

        <tr>
          <td>
            金
          </td>
          <td>
            <DayInput
              @hours={{hours}}
              @selectedHours={{@scheduleInput.friHours}}
              @onChange={{action (mut @scheduleInput.friHours)}}
            />
          </td>
        </tr>
      </tbody>
    </table>
  </div>
</div>
```

```js:app/components/week-input.js
import { action } from "@ember/object";
import Component from "@glimmer/component";
import { tracked } from "@glimmer/tracking";

function generateHours(startHour, endHour) {
  return Array.from({ length: endHour - startHour + 1 }, (_, v) => v + startHour).map(
    (v) => `${v}:00`
  );
}

export default class ScheduleWeekInputComponent extends Component {
  @tracked
  hours = [];

  @action
  setupHours() {
    const startHour = this.args.schedule.startHour;
    const endHour = this.args.schedule.endHour;
    this.hours = generateHours(startHour, endHour);
  }

  @action
  updateName(e) {
    this.args.scheduleInput.name = e.target.value;
  }
}
```

これで画面のコードは用意できました。storybookのコードを作成します。

```js:stories/week-input.stories.js
import { action } from "@storybook/addon-actions";
import { hbs } from "ember-cli-htmlbars";

export default {
  title: "WeekInput",
  argTypes: {
    startHour: { control: { type: "range", min: 0, max: 24, step: 1 } },
    endHour: {
      control: { type: "range", min: 0, max: 24, step: 1 },
    },
  },
};

export const Template = ({ startHour, endHour, ...args }) => ({
  template: hbs`
  <WeekInput
    @schedule={{schedule}}
    @scheduleInput={{scheduleInput}}
    @onSave={{onSave}}
    onCancel={{onCancel}} />
  `,
  context: {
    onSave: () => action("onSave")(),
    onCancel: () => action("onCancel")(),
    scheduleInput: {
      monHours: [],
      tueHours: [],
      wedHours: [],
      thuHours: [],
      friHours: [],
      satHours: [],
      sunHours: [],
    },
    schedule: {
      startHour,
      endHour,
    },
    ...args,
  },
});

Template.args = {
  startHour: 9,
  endHour: 19,
};
```

storybookの画面を見ると、ちゃんと動いているのがわかります。

![スクリーンショット 2020-11-08 16.24.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213719/f00d5e9c-659c-0564-8f19-65905951bf86.png)

スライダーをぐりぐりすると良い感じに画面の表示がリアクティブに切り替わるのがわかります。やったね！

![Nov-08-2020 16-30-19.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/213719/a2a7c433-2238-b3a2-6f1d-b4b6279db5fd.gif)