AngularプロジェクトにStorybookを導入するときに行ったこと

[Angular Advent Calendar 2022](https://qiita.com/advent-calendar/2022/angular) 22 日目の記事です。

# 前提

- Angular Materialを使用してプロダクションコードは開発しております
- Storybookでデザインモックとシェアードコンポーネントを表現する
- Storybookの実装工数が十分でない And 人がいない
    - 他の業務がある中、96ファイルを2人で2か月で終わらせる
  
# 行ったこと

## 1. ルールを決める

- 作成対象ファイルについて
    - 画面とSharedコンポーネント(合計96ファイル)

- Storybookファイルの作成先について
    - 作成元と同じディレクトリの中に作成する
    ```sh
    xxxxxxxx
    ├─xxxxxxxx.component.html
    ├─xxxxxxxx.component.ts
    ├─xxxxxxxx.component.spec.ts
    ├─xxxxxxxx.component.scss
    ├─xxxxxxxx.stories.ts
    ```

- 画面のStorybookは `argsType`を使用して、`control`をfalseにする

```typescript: hoge.stories.ts
argTypes: {
 data: { control: false }
}
```

- ファイルの命名規則を決める
    - `Domein名/画面ID_画面名__ファイル名`ということにしました

## 2. MSW(Mock Service Worker)を導入する

- HTTPリクエストをモック化したかったので導入しました
- 使用したStorybook Addonは下記です
    - [Mock Service Worker](https://storybook.js.org/addons/msw-storybook-addon)
- 参考になる記事は下記です
    - [参考](https://zenn.dev/rabbit/articles/dd9b04940b93fe)

## 3. Angular Schematicsを導入する

> Schematicsの作成手順は公式ドキュメントなどを参考にしてください。
[公式ドキュメント](https://angular.jp/guide/schematics)

- 限られた期間での開発だったので作成しました。
    - [Schematics を使用したコード生成](https://angular.jp/guide/schematics)
    - 必要なモジュールをいちいちファイルを作るごとに記載していくのは面倒くさいため

```typescript:schematics/files/__name@dasherize__.stories.ts
import { rest } from 'msw';
import { Story, Meta } from '@storybook/angular/types';
// 各ファイルごとで分ける
import { <%= classify(name) %>Component } from '@/<%= dasherize(path) %>/<%= dasherize(name) %>/<%= dasherize(name) %>.component';

export default {
  title: '<%= dasherize(feature) %>/<%= title %><%= dasherize(name) %>',
  component: <%= classify(name) %>Component,
  decorators: [
    moduleMetadata({
      imports: [
        RouterModule.forRoot([{ path: 'iframe.html', component: <%= classify(name) %>Component }]),
      ],
      providers: [
      ],
      schemas: [NO_ERRORS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA],
    }),
    // Modal時、width, heightの指定
    componentWrapperDecorator(
      (story) => `<div style="margin: 3em;">${story}</div>`
    ),
  ],
  argTypes: {
    // @docs (https://storybook.js.org/docs/react/essentials/controls#dealing-with-complex-values)
      // property名: {
      //     control: false
      // },
  },
} as Meta;

const Template: Story<<%= classify(name) %>Component> = (args: <%= classify(name) %>Component) => ({
  props: { ...args },
});

export const Default = Template.bind({});
Default.args = {
  /**
   * クラス変数の入力
   */
}

Default.parameters = {
  // APIをMock化したい場合
  // msw: {
  //   handlers: [
  //     rest.get(`${apiEndpoint}`, (req, res, ctx) =>
  //       res(
  //         ctx.json({})
  //       )
  //     ),
  //   ]
  // }
}
```

```typescript:schematics/index.ts
import { Path, normalize, strings } from '@angular-devkit/core';
import {
  Rule,
  SchematicContext,
  apply,
  branchAndMerge,
  mergeWith,
  move,
  template,
  url,
} from '@angular-devkit/schematics';

export function storybookSchematics(_options: any): Rule {
  return (_, _context: SchematicContext) => {
    // コードを生成するパスの指定
    const componentId = _options.componentId;
    const nameJP = _options.nameJP;
    const fileName = _options.fileName;
    const SharedReg = /shared/i;
    const featureName = _options.feature;
    const pathComponent =
      _options.path === 'c' || _options.path === 'component' ? 'components' : 'pages';
    const addHyphenBeforeUppercase = fileName
      .replace(/[A-Z]/g, (match: string) => {
        return '-' + match;
      })
      .replace(/^-/, '')
      .toLowerCase();
    let pathToCreate: Path;
    let path: string;
    let title: string = '';
    if (componentId) {
      title = `${componentId}_`;
    }
    if (nameJP) {
      title = `${title}${nameJP}_`;
    }
    if (SharedReg.test(featureName)) {
      path = `${featureName.toLowerCase()}/${pathComponent}`;
      pathToCreate = normalize(
        `src/app/${featureName.toLowerCase()}/${pathComponent}/${addHyphenBeforeUppercase}` as string
      );
    } else {
      path = `features/${featureName.toLowerCase()}/${pathComponent}`;
      pathToCreate = normalize(
        `src/app/features/${featureName.toLowerCase()}/${pathComponent}/${addHyphenBeforeUppercase}` as string
      );
    }
    return branchAndMerge(
      mergeWith(
        apply(url('./files'), [
          template({
            ...strings,
            feature: featureName,
            path: path,
            name: fileName,
            title: title,
          }),
          move(pathToCreate),
        ])
      )
    );
  };
}

```

- Storybookのファイル名規則に従うように作成しました
    - `Domein名/画面ID_画面名__ファイル名` のファイルが生成されるように設計しました

```json: schema.json
{
  "$schema": "http://json-schema.org/schema",
  "$id": "StorybookSchematics",
  "title": "Story Schematics",
  "type": "object",
  "description": "Storybookのファイルを生成します",
  "properties": {
    "feature": {
      "type": "string",
      "description": "Feature名もしくはSharedの入力",
      "$default": {
        "$source": "argv",
        "index": 0
      },
      "x-prompt": "ドメインを入力してください ex: progress, shared..."
    },
    "path": {
      "type": "string",
      "description": "ComponentもしくはPage",
      "$default": {
        "$source": "argv",
        "index": 0
      },
      "x-prompt": "Componentの場合は`c`もしくは`component`と入力 Pageの場合は`p`もしくは`page`と入力してください"
    },
    "componentId": {
      "type": "string",
      "description": "ComponentID",
      "$default": {
        "$source": "argv",
        "index": 0
      },
      "x-prompt": "ComponentIDを入力してください。なければそのままエンターキーを押してください"
    },
    "nameJP": {
      "type": "string",
      "description": "日本語名",
      "$default": {
        "$source": "argv",
        "index": 0
      },
      "x-prompt": "ファイル名の日本語を入力してください。なければそのままエンターキーを押してください"
    },
    "fileName": {
      "type": "string",
      "description": "Component名",
      "$default": {
        "$source": "argv",
        "index": 0
      },
      "x-prompt": "ファイル名をCamel型で入力してください ex: TableAddFolder"
    }
  },
  "required": ["fileName", "path", "feature"]
}

```

- `componentWrapperDecorator`を設定する
    - ２つの理由でいれました
        1. モーダルのHeight, Widthの指定をしたい場合
        1. 見た目がよくなる

## 4. Angular materialの設定をいれる

- これはStorybookに限ったことではないのですが、StorybookでAngular Materialのアイコンを表示できるように

```html:.storybook/preview-head.html
<link rel="preconnect" href="https://fonts.gstatic.com" />
<link
  href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500&display=swap"
  rel="stylesheet"
/>
<link
  href="https://fonts.googleapis.com/icon?family=Material+Icons|Material+Icons+Outlined"
  rel="stylesheet"
/>
<link
  href="https://fonts.googleapis.com/css?family=Noto+Sans+JP:400,500&display=swap"
  rel="stylesheet"
/>
```

## 5. Docsタブのスクロールを可能にする

- 下記のDOCタブに遷移したしたときにスクロールができなかったため下記のような記載をしました

![DOCS_Scroll.PNG](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/176216/bca82cbb-af9e-8722-d096-29586c4e1113.png)

```html:.storybook/preview-head.html
<!-- StorybookのDocsタブのスクロールを可能にするスタイル設定 -->
<style>
  .sbdocs-wrapper {
    height: 90vh;
    overflow: scroll;
  }
</style>
```

## 6. Viewportを設定する

- 画面サイズを固定にする必要があったので記載しました

```ts:.storybook/preview.js
export const parameters = {
  layout: 'fullscreen',
  viewport: {
    kindleFire2: {
        name: 'Kindle Fire 2',
        styles: {
          width: '600px',
          height: '963px',
        },
    },
  },
};
```


## 7. StorybookのSidebarを常時表示設定する

- ホットリロードを行ったりするとSidebarが消えたりしていたので、常時表示するようにしました

```javascript:.storybook/manager.js
import { addons } from '@storybook/addons';

addons.setConfig({
  sidebar: {
    showRoots: true,
  },
});
```

## 8. Markdownを使えるようにする

- このStorybookの作成に伴って、やっぱり注意書きとかを立ち上げ時に表示したい
    - そのためにはマークダウンで記載したいという気持ちが生まれたので設定しました。

1. `appendix.md` にマークダウンで注意書きを記載する
1. 任意の `mdx`ファイルを作成


```md:appendix.md
# HogeHoge

1. aaaaaaaaaaaaaaaaaa
1. bbbbbbbbbbbbbbbbb

```


```mdx:Appendix.mdx
import { Meta, Description } from '@storybook/addon-docs/blocks';
import Appendix from './appendix.md';

<Meta title="Appendix" />

<Description>{Appendix}</Description>
```



# まとめ

- 品質について
    - ２人で作成するとしても、各個人で作成の粒度がまったく違くなってしまうため最初にルールを話して決めて、あとはルール通りに作れるようにSchematicsを作成して統一のとれたファイルの作成を心がけた
- コミュニケーションコストについて
    - 開発をする上でエンジニア同士が会話をしながら実装していくことは重要だと思います。しかしそんなに難易度が高くないものかつ、同じような実装になるものに関してはできる限り、コミュニケーションの数は減らすことも重要だと思っております。なのでルールを最初に話して、ドキュメントにした