【2019年10月版】Angular+Storybook で Material 使ったり Friebase にアクセスするコンポーネントのStoriesはComponent Story Formatでこう書け！

# Angular + Firebase のアプリで Angular Material を含むコンポーネントの Storybook

ここのところ、Stroybook についての大変勉強になる記事を立て続けに発見いたしました。

- [理想的なStorybookのワークフローとは](https://qiita.com/baby-degu/items/7b9ba5615a6810f935d5)
- [VueのUIテストにStorybookを使う](https://qiita.com/sterashima78/items/8db32368289e4859480b)

大変、ありがたいことです。

私の手元にも Angular + Firebase で構築した SPA のプロジェクトがあり、E2E は Protractor でなんとかしていますが、"コンポーネントレベルのUT" についてはちょっと頭を悩ませていました。

コンポーネントの クラス単位、ts・jsのコードレベルでのテストには Karma は使えますが、コンポーネントの `UI/UXとしてのパーツ` = `機能`+`デザイン`+`独立性(他コンポーネントとの分離・非依存)`のテストには Karma は使えないので頑張ってガリガリ書くのもちょっとなぁ、という所で頭の痛いところでありましたし、そもそもページ単位でデザインや要求される機能がコミットされますので、`コンポーネントとしての仕様` は開発者の裁量に任されてしまう部分もあって、たとえ git に上がっている全員がコミットできるリポジトリにソースが上がっていたとしても**他人の作ったコンポーネントはもはや闇**になりつつあったのです。

そんなときに一条の光、**闇コンポーネント**を切り出し、嗚呼、コンポーネントだけを白日の下に晒し、依存のない状態でメンバー変数のあんなことやこｎ(ry

## Stroybook と Angular アドオンのインストール

下記のブログ記事に Angular + Storybook での非常に分かりやすいガイドがありました。

- [Component Driven Development using StoryBook and Angular](https://increate.co/blog/post/SBTutorial/master/tutorial.md)

こちらを参考にさっそく Angular のプロジェクトに Storybook を導入してまいりましょう。
手元にはすでにAnglarはプロジェクトが作成済みであるので Storybookの追加からいきます。

以下のコマンドでインストール完了です。

```console
$ npx -p @storybook/cli sb init
```

なぜか `@storybook/angular` もインストールされています。素晴らしい。

で、以下のコマンドで Storybook の開発サーバー？が立ち上がります。

```console
$ npm run storybook
```

`http://localhost:6006` にアクセスすればOKです。
`6006`が **気に入らない** という方は`package.json`で`"start-storybook -p 6006"` の箇所を修正していただければOKです！



## 本題1. Firebase を参照するコンポーネントの Stories を書く時の注意

今回の**本題その1**ですが、コンポーネントの中で Firestoreからデータの一覧を取ってくる、などのように Firebaseとそのサービスにアクセスするコンポーネントは、以下のように単にコンポーネントを記載しただけではFirebaseにはアクセスできません。

```typescript:firestore-list.stories.ts
import { FirestoreListComponent } from "./firestore-list.component";

export default {
  title: "FirestoreList",
}

// 先頭の5件が表示されるはず・・・
export const Simple = () => ({
  component: FirestoreListComponent ,
  props: {
    selected : ''
  },
  moduleMetadata: {
    imports: [],
    providers: []
  },
});
```
※ ちなみにこちらの形式はComponent Story Format(CSF)というstoriesOf API を利用しないで記述する形式のようです。([Storybookの最新フォーマット・Component Story Format(CSF)を試す](https://suzukalight.com/2019-10-14-storybook-component-story-format/))

さっそく、storybook のページのコンソールを覗いてみると・・・

```
ERROR Error: StaticInjectorError(DynamicModule)[FirestoreListService -> AngularFirestore]: 
  StaticInjectorError(Platform: core)[FirestoreListService -> AngularFirestore]: 
NullInjectorError: No provider for AngularFirestore!
```

FirestoreListComponentから参照しているFirestoreListService、そしてそこから参照しているAngularFirestore の provider がない！と怒られてしまいます。

そんなわけで上記の providers に `AngularFirestore` を追加します。

```typescript:firestore-list.stories.ts
...
  moduleMetadata: {
    imports: [],
    providers: [AngularFirestore]
  },
...
```

そして storybook ページのコンソールを覗いてみると・・・

```
ERROR Error: StaticInjectorError(DynamicModule)[AngularFirestore -> InjectionToken angularfire2.app.options]: 
  StaticInjectorError(Platform: core)[AngularFirestore -> InjectionToken angularfire2.app.options]: 
    NullInjectorError: No provider for InjectionToken angularfire2.app.options!
```

そりゃそうですが、アクセス用のトークンどうやって渡せばいいの？

ということでいろいろ試してみた結果、以下の設定で怒られなくなりました。

```typescript:firestore-list.stories.ts
import { environment } from "src/environments/environment";

...
  moduleMetadata: {
    imports: [AngularFireModule.initializeApp(environment.firebase)],
    providers: [AngularFirestore]
  },
...
```

Angularの App.module.ts などでやっている AngularFireModule の初期化処理ですね、これを上記のように `imports` に渡してやると大人しくなりました。

最終的には以下のようになりました。

```typescript:firestore-list.stories.ts
import { FirestoreListComponent } from "./firestore-list.component";
import { AngularFireModule } from "@angular/fire";
import { AngularFirestore } from "@angular/fire/firestore";
import { environment } from "src/environments/environment";

export default {
  title: "FirestoreList",
}

// 無事に先頭の5件が表示される！
export const Simple = () => ({
  component: FirestoreListComponent ,
  props: {
    selected : ''
  },
  moduleMetadata: {
    imports: [AngularFireModule.initializeApp(environment.firebase)],
    providers: [AngularFirestore]
  },
});
```

これで storybook上でもバッチリ Firestore のテストデータが表示されます！

ちなみに、別の story を追加する場合も上記の指定が必要です。以下のように。

```typescript:firestore-list.stories.ts
import { FirestoreListComponent } from "./firestore-list.component";
import { AngularFireModule } from "@angular/fire";
import { AngularFirestore } from "@angular/fire/firestore";
import { environment } from "src/environments/environment";

export default {
  title: "FirestoreList",
}

// 無事に先頭の5件が表示される！
export const Simple = () => ({
  component: FirestoreListComponent ,
  props: {
    selected : ''
  },
  moduleMetadata: {
    imports: [AngularFireModule.initializeApp(environment.firebase)],
    providers: [AngularFirestore]
  },
});

// `One` が選択されている。
export const WithOneSelected = () => ({
  component: FirestoreListComponent ,
  props: {
    selected : 'One'
  },
  moduleMetadata: {
    imports: [AngularFireModule.initializeApp(environment.firebase)],
    providers: [AngularFirestore]
  },
});

```

うーん、ちょっとウザいかも。。。

## 本題2. AngularMaterialのコンポーネントを使用しているコンポーネント

続いて**本題その２**ですが、Materialのタグも一筋縄ではいきません。

あるコンポーネントが AngularMaterialを含むとしましょう。例えば `<mat-icon>` を含む場合は、以下のように `mat-iconタグなんて知らん`っと怒られてしまいます。

```
Unhandled Promise rejection: Template parse errors:
'mat-icon' is not a known element:
1. If 'mat-icon' is an Angular component, then verify that it is part of this module.
2. If 'mat-icon' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@NgModule.schemas' of this component to suppress this message. 
```

このエラーは `MatIconModule` を宜しく渡してあげればOKです。

```typescript:firestore-list.stories.ts
import { MyIconListComponent } from "./my-icon-list.component";
import { MatIconModule } from "@angular/material";

export default {
  title: "My Icon List",
}

export const StarUserMail = () => ({
  component: MyIconListComponent ,
  props: {
    values : ['star', 'user', 'mail']
  },
  moduleMetadata: {
    imports: [MatIconModule],
    providers: []
  },
});
```

これで上記のエラーは出なくなります。ただし・・・

## Material の CSS が効いていない

このままでは **Materialのテーマ(CSS)が効いていない** ので見た目が大きく損なわれます。 Material のテーマに依存するようなCSS (ハイライトだけ変える、とかMaterialのマージンやパディングからさらに調整をするなど) の場合は見た目が完全に崩れてしまうかもしれません。

そこで上記で紹介した[Component Driven Development using StoryBook and Angular](https://increate.co/blog/post/SBTutorial/master/tutorial.md)のページの内容に加えて、以下のサンプルを参考に Material 関連の手当てを厚くしてあげる必要があります。

- [https://github.com/amcdnl/material-storybook](https://github.com/amcdnl/material-storybook)

具体的には `.storybook/preview-head.html` を以下の内容で作成いたしました。

```html:preview-head.html

<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<style>
@import '@angular/material/prebuilt-themes/deeppurple-amber.css'

html, body {
  margin: 0;
  height: 100%;
}
storybook-dynamic-app-root {
  display: flex;
  flex-direction: column;
  justify-content: center;
  height: 100%;
  padding: 25px;
}
storybook-dynamic-app-root > ng-component {
  margin: 0 auto;
}
```

`preview-head.html`を作成することでコンポーネントのプレビューエリアの先頭にこのHTMLを差し込んでくれるようになるようです。
`@import '@angular/material/prebuilt-themes/deeppurple-amber.css'`の箇所は上記のブログ記事では、

> Edit src/styles.scss and import a theme '@angular/material/prebuilt-themes/deeppurple-amber.css'

とありますが、**styles.scss をいじるのはちょっと・・・** なのでこちらに記載しました。
上記の Styleが効けば、Materialのコンポーネントがビシッと引き締まるはずです。

以上です！

それでは私も既存のコンポーネントがちゃんと"コンポーネントしているか"確かめてみることにします。。。