Back to Intro to Storybook
Chapters
  • はじめに
  • 単純なコンポーネント
  • 複合的なコンポーネント
  • データ
  • 画面
  • デプロイ
  • テスト
  • アドオン
  • まとめ
  • 貢献する

画面を作る

コンポーネントをまとめて画面を作りましょう
このコミュニティの翻訳は、最新の Storybook バージョンに対応していません。英語ガイドの最新の変更を適用して、この日本語ガイドの更新にご協力ください。 Pull requests を大歓迎します。.

今までボトムアップ (小さく始めてから複雑性を追加していく) で UI の作成に集中してきました。ボトムアップで作業することで、Storybook で遊びながら、それぞれのコンポーネントを切り離された環境で、それぞれに必要なデータを考えながら開発することができました。サーバーを立ち上げたり、画面を作ったりする必要は全くありませんでした!

この章では Storybook を使用して、コンポーネントを組み合わせて画面を作り、完成度を高めていきます。

ネストされたコンテナーコンポーネント

このアプリケーションはとても単純なので、作る画面も些細なものです。 ngxs から自分でデータを取得する TaskListComponent を表示して、ストアからの error フィールド (サーバーとの接続に失敗したときに設定される項目だと思ってください) を追加するだけです。

(src/app/state/task.state.tsにある) ストアを更新して、 error フィールドを含めることから始めましょう

Copy
src/app/state/task.state.ts
import { State, Selector, Action, StateContext } from '@ngxs/store';
import { Task } from '../models/task.model';

// defines the actions available to the app
export const actions = {
  ARCHIVE_TASK: 'ARCHIVE_TASK',
  PIN_TASK: 'PIN_TASK',
  // Defines the new error field we need
+ ERROR: 'APP_ERROR',
};

export class ArchiveTask {
  static readonly type = actions.ARCHIVE_TASK;

  constructor(public payload: string) {}
}

export class PinTask {
  static readonly type = actions.PIN_TASK;

  constructor(public payload: string) {}
}
+ // The class definition for our error field
+ export class AppError {
+   static readonly type = actions.ERROR;
+   constructor(public payload: boolean) {}
+ }

// The initial state of our store when the app loads.
// Usually you would fetch this from a server
const defaultTasks = {
  1: { id: '1', title: 'Something', state: 'TASK_INBOX' },
  2: { id: '2', title: 'Something more', state: 'TASK_INBOX' },
  3: { id: '3', title: 'Something else', state: 'TASK_INBOX' },
  4: { id: '4', title: 'Something again', state: 'TASK_INBOX' },
};

export class TaskStateModel {
  entities: { [id: number]: Task };
+ error: boolean;
}

// Sets the default state
@State<TaskStateModel>({
  name: 'tasks',
  defaults: {
    entities: defaultTasks,
+   error: false,
  },
})
export class TasksState {
  @Selector()
  static getAllTasks(state: TaskStateModel) {
    const entities = state.entities;
    return Object.keys(entities).map(id => entities[+id]);
  }

  // Defines a new selector for the error field
  @Selector()
  static getError(state: TaskStateModel) {
    const { error } = state;
    return error;
  }
  //
  // Triggers the PinTask action, similar to redux
  @Action(PinTask)
  pinTask({ patchState, getState }: StateContext<TaskStateModel>, { payload }: PinTask) {
    const state = getState().entities;

    const entities = {
      ...state,
      [payload]: { ...state[payload], state: 'TASK_PINNED' },
    };

    patchState({
      entities,
    });
  }
  // Triggers the PinTask action, similar to redux
  @Action(ArchiveTask)
  archiveTask({ patchState, getState }: StateContext<TaskStateModel>, { payload }: ArchiveTask) {
    const state = getState().entities;

    const entities = {
      ...state,
      [payload]: { ...state[payload], state: 'TASK_ARCHIVED' },
    };

    patchState({
      entities,
    });
  }

+ // Function to handle how the state should be updated when the action is triggered
+ @Action(AppError)
+ setAppError({ patchState, getState }: StateContext<TaskStateModel>, { payload }: AppError) {
+   const state = getState();
+   patchState({
+     error: !state.error,
+   });
+ }
}

ストア が新しいフィールドを含むよう更新されました。src/app/components/pure-inbox-screen.component.tsという名前で表示用コンポーネントを作りましょう:

Copy
src/app/components/pure-inbox-screen.component.ts
import { Component, Input } from '@angular/core';

@Component({
  selector: 'app-pure-inbox-screen',
  template: `
    <div *ngIf="error" class="page lists-show">
      <div class="wrapper-message">
        <span class="icon-face-sad"></span>
        <div class="title-message">Oh no!</div>
        <div class="subtitle-message">Something went wrong</div>
      </div>
    </div>

    <div *ngIf="!error" class="page lists-show">
      <nav>
        <h1 class="title-page">
          <span class="title-wrapper">Taskbox</span>
        </h1>
      </nav>
      <app-task-list></app-task-list>
    </div>
  `,
})
export class PureInboxScreenComponent {
  @Input() error: any;
}

それから前回と同様、PureInboxScreenComponentにデータを取ってくるコンテナーを作ります。inbox-screen.component.tsという名前の新しいファイルに:

Copy
src/app/components/inbox-screen.component.ts
import { Component } from '@angular/core';
import { Select } from '@ngxs/store';
import { TasksState } from '../state/task.state';
import { Observable } from 'rxjs';

@Component({
  selector: 'app-inbox-screen',
  template: `
    <app-pure-inbox-screen [error]="error$ | async"></app-pure-inbox-screen>
  `,
})
export class InboxScreenComponent {
  @Select(TasksState.getError) error$: Observable<any>;
}

さらに、AppComponent コンポーネントを InboxScreenComponent を描画するように変更します (いずれはルーターにどの画面を表示するか決めてもらいますが、今は気にしないでください):

Copy
src/app/app.component.ts
import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
- templateUrl: './app.component.html',
- styleUrls: ['./app.component.css']
+ template: `
+   <app-inbox-screen></app-inbox-screen>
+ `,
})
export class AppComponent {
- title = 'intro-storybook-angular-template';
+ title = 'taskbox';
}

最後に、app.module.ts:

Copy
src/app/app.module.ts
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { TaskModule } from './components/task.module';
import { NgxsModule } from '@ngxs/store';
import { NgxsReduxDevtoolsPluginModule } from '@ngxs/devtools-plugin';
import { NgxsLoggerPluginModule } from '@ngxs/logger-plugin';
import { AppComponent } from './app.component';

+ import { InboxScreenComponent } from './components/inbox-screen.component';
+ import { PureInboxScreenComponent } from './components/pure-inbox-screen.component';

@NgModule({
+ declarations: [AppComponent, InboxScreenComponent, PureInboxScreenComponent],
  imports: [
    BrowserModule,
    TaskModule,
    NgxsModule.forRoot([]),
    NgxsReduxDevtoolsPluginModule.forRoot(),
    NgxsLoggerPluginModule.forRoot(),
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

テストファイルを更新するのを忘れないでください src/app/app.component.spec.ts. 次に実行したときにテストが失敗してしまいます。

面白いのは Storybook でのレンダリングです。

前に示したように TaskListComponent コンポーネントは、表示用のコンポーネントである PureTaskListComponent を描画するコンテナーです。定義上コンテナーコンポーネントはコンテキストが渡されたり、サービスに接続したりすることを想定するため、切り離された環境においてはそのままでは描画できません。つまりコンテナーを Storybook で描画するには、コンポーネントに必要なコンテキストやサービスをモック化 (例えば、振る舞いを模倣させるなど) しなければならないということです。

TaskListComponent を Storybook に置いたときには、コンテナーではなく、PureTaskListComponent を描画することにより、この問題を回避しました。同じように PureInboxScreenComponent を Storybook に描画してみます。

しかし、 PureInboxScreenComponent には問題があります。PureInboxScreenComponent が表示用コンポーネントであっても、その子供である TaskListComponent は表示用ではないのです。つまり、PureInboxScreenComponentが「コンテナー性」により汚染されたと言えます。なので、pure-inbox-screen.stories.ts を以下のようセットアップすると:

Copy
src/app/components/pure-inbox-screen.stories.ts
import { moduleMetadata, Story, Meta } from '@storybook/angular';

import { CommonModule } from '@angular/common';

import { PureInboxScreenComponent } from './pure-inbox-screen.component';

import { TaskModule } from './task.module';

export default {
  component: PureInboxScreenComponent,
  decorators: [
    moduleMetadata({
      declarations: [PureInboxScreenComponent],
      imports: [CommonModule, TaskModule],
    }),
  ],
  title: 'PureInboxScreen',
} as Meta;

const Template: Story<PureInboxScreenComponent> = args => ({
  props: args,
});

export const Default = Template.bind({});

export const Error = Template.bind({});
Error.args = {
  error: true,
};

ストーリーが壊れてしまっていることが分かります。これは、「ピュア」なコンポーネントをエラーに使っていますが、ストーリーが両方ストアに依存していてそのコンテキストが必要なためです。

この問題を回避する方法の 1 つは、コンテナーコンポーネントをアプリケーションの最上位にのみ描画し、代わりにコンポーネント階層の下層に必要なデータをすべて上位のコンポーネントから渡すことです。

ですが、開発ではきっとコンポーネント階層の下位の層でコンテナーを描画する必要が出てくるでしょう。アプリケーション全体、もしくは大部分を Storyook で描画したいなら、解決策が必要です。

💡 補足として、データを下の階層に渡していくことは正当な手法です。GraphQL を使う場合は特に。Chromatic を作る際にはこの手法で 800 以上のストーリーを作成しました。

デコレーターを使用してコンテキストを渡す

ストーリーの中で PureInboxScreenComponentStore を渡すのは簡単です!モック化した Store をデコレーター内部で使用します:

Copy
src/app/components/pure-inbox-screen.stories.ts
import { moduleMetadata } from '@storybook/angular';
import { Story, Meta } from '@storybook/angular/types-6-0';

import { PureInboxScreenComponent } from './pure-inbox-screen.component';
import { TaskModule } from './task.module';

+ import { Store, NgxsModule } from '@ngxs/store';
+ import { TasksState } from '../state/task.state';

export default {
  title: 'PureInboxScreen',
  component:PureInboxScreenComponent,
  decorators: [
    moduleMetadata({
-     imports: [CommonModule,TaskModule],
+     imports: [CommonModule,TaskModule,NgxsModule.forRoot([TasksState])],
+     providers: [Store],
    }),
  ],
} as Meta;

const Template: Story<PureInboxScreenComponent> = (args) => ({
  component: PureInboxScreenComponent,
  props: args,
});

export const Default = Template.bind({});

export const Error = Template.bind({});
Error.args = {
  error: true,
};

同様に @ngrxApollo など、他のデータライブラリー向けのモックコンテキストも存在します。

Storybook で状態を選択していくことで、問題なく出来ているか簡単にテストできます

コンポーネント駆動開発

まず、一番下の TaskComponent から始めて、TaskListComponent を作り、画面全体の UI が出来ました。InboxScreenComponent ではネストしたコンテナーコンポーネントを含み、一緒にストーリーも作成しました。

コンポーネント駆動開発 (CDD) はコンポーネント階層を上がるごとに少しずつ複雑性を拡張していきます。利点としては、開発プロセスに集中できること、UI の組み合わせの網羅性を向上できること、が挙げられます。要するに、CDD によって、高品質で複雑な UI を作ることができます。

まだ終わりではありません。UI を作成しても仕事は終わりません。長期間にわたり耐久性を維持できるようにしなければなりません。

💡 Git へのコミットを忘れずに行ってください!
Keep your code in sync with this chapter. View f8d168d on GitHub.
Is this free guide helping you? Tweet to give kudos and help other devs find it.
Next Chapter
デプロイ
Storybook をインターネット上にデプロイする方法を学びましょう
✍️ Edit on GitHub – PRs welcome!
Join the community
6,536 developers and counting
WhyWhy StorybookComponent-driven UI
Open source software
Storybook

Maintained by
Chromatic
Special thanks to Netlify and CircleCI