Angular + Firebase のアプリで Angular Material を含むコンポーネントの Storybook
ここのところ、Stroybook についての大変勉強になる記事を立て続けに発見いたしました。
大変、ありがたいことです。
私の手元にも Angular + Firebase で構築した SPA のプロジェクトがあり、E2E は Protractor でなんとかしていますが、"コンポーネントレベルのUT" についてはちょっと頭を悩ませていました。
コンポーネントの クラス単位、ts・jsのコードレベルでのテストには Karma は使えますが、コンポーネントの UI/UXとしてのパーツ
= 機能
+デザイン
+独立性(他コンポーネントとの分離・非依存)
のテストには Karma は使えないので頑張ってガリガリ書くのもちょっとなぁ、という所で頭の痛いところでありましたし、そもそもページ単位でデザインや要求される機能がコミットされますので、コンポーネントとしての仕様
は開発者の裁量に任されてしまう部分もあって、たとえ git に上がっている全員がコミットできるリポジトリにソースが上がっていたとしても他人の作ったコンポーネントはもはや闇になりつつあったのです。
そんなときに一条の光、闇コンポーネントを切り出し、嗚呼、コンポーネントだけを白日の下に晒し、依存のない状態でメンバー変数のあんなことやこn(ry
Stroybook と Angular アドオンのインストール
下記のブログ記事に Angular + Storybook での非常に分かりやすいガイドがありました。
こちらを参考にさっそく Angular のプロジェクトに Storybook を導入してまいりましょう。
手元にはすでにAnglarはプロジェクトが作成済みであるので Storybookの追加からいきます。
以下のコマンドでインストール完了です。
$ npx -p @storybook/cli sb init
なぜか @storybook/angular
もインストールされています。素晴らしい。
で、以下のコマンドで Storybook の開発サーバー?が立ち上がります。
$ npm run storybook
http://localhost:6006
にアクセスすればOKです。
6006
が 気に入らない という方はpackage.json
で"start-storybook -p 6006"
の箇所を修正していただければOKです!
本題1. Firebase を参照するコンポーネントの Stories を書く時の注意
今回の本題その1ですが、コンポーネントの中で Firestoreからデータの一覧を取ってくる、などのように Firebaseとそのサービスにアクセスするコンポーネントは、以下のように単にコンポーネントを記載しただけではFirebaseにはアクセスできません。
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)を試す)
さっそく、storybook のページのコンソールを覗いてみると・・・
ERROR Error: StaticInjectorError(DynamicModule)[FirestoreListService -> AngularFirestore]:
StaticInjectorError(Platform: core)[FirestoreListService -> AngularFirestore]:
NullInjectorError: No provider for AngularFirestore!
FirestoreListComponentから参照しているFirestoreListService、そしてそこから参照しているAngularFirestore の provider がない!と怒られてしまいます。
そんなわけで上記の providers に AngularFirestore
を追加します。
...
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!
そりゃそうですが、アクセス用のトークンどうやって渡せばいいの?
ということでいろいろ試してみた結果、以下の設定で怒られなくなりました。
import { environment } from "src/environments/environment";
...
moduleMetadata: {
imports: [AngularFireModule.initializeApp(environment.firebase)],
providers: [AngularFirestore]
},
...
Angularの App.module.ts などでやっている AngularFireModule の初期化処理ですね、これを上記のように imports
に渡してやると大人しくなりました。
最終的には以下のようになりました。
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 を追加する場合も上記の指定が必要です。以下のように。
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のコンポーネントを使用しているコンポーネント
続いて本題その2ですが、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です。
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のページの内容に加えて、以下のサンプルを参考に Material 関連の手当てを厚くしてあげる必要があります。
具体的には .storybook/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のコンポーネントがビシッと引き締まるはずです。
以上です!
それでは私も既存のコンポーネントがちゃんと"コンポーネントしているか"確かめてみることにします。。。