diff --git a/content/intro-to-storybook/angular/ja/composite-component.md b/content/intro-to-storybook/angular/ja/composite-component.md index 9dfb876fd..4da7e937f 100644 --- a/content/intro-to-storybook/angular/ja/composite-component.md +++ b/content/intro-to-storybook/angular/ja/composite-component.md @@ -2,12 +2,12 @@ title: '複合的なコンポーネントを組み立てる' tocTitle: '複合的なコンポーネント' description: '単純なコンポーネントから複合的なコンポーネントを組み立てましょう' -commit: '3da0d0a' +commit: 'd6ccebb' --- -前の章では、最初のコンポーネントを作成しました。この章では、学習した内容を基にタスクのリストである `TaskList` を作成します。それではコンポーネントを組み合わせて、複雑になった場合にどうすればよいか見てみましょう。 +前の章では、最初のコンポーネントを作成しました。この章では、学習した内容を基にタスクのリストである TaskList を作成します。それではコンポーネントを組み合わせて、複雑になった場合にどうすればよいか見てみましょう。 -## TaskListComponent +## Tasklist (タスクリスト) Taskbox はピン留めされたタスクを通常のタスクより上部に表示することで強調します。これにより `TaskList` に、タスクのリストが、通常のタスクのみである場合と、ピン留めされたタスクとの組み合わせである場合という、ストーリーを追加するべき 2 つのバリエーションができます。 @@ -21,7 +21,7 @@ Taskbox はピン留めされたタスクを通常のタスクより上部に表 複合的なコンポーネントも基本的なコンポーネントと大きな違いはありません。`TaskList` のコンポーネントとそのストーリーファイル、`src/app/components/task-list-component.ts` と `src/app/components/task-list.stories.ts` を作成しましょう。 -まずは `TaskList` の大まかな実装から始めます。前の章で作成した `Task` コンポーネントをインポートし、属性とアクションを入力とイベントとして渡します。 +まずは `TaskList` の大まかな実装から始めます。前の章で作成した `Task` コンポーネントをインポートし、属性とアクションを入力として渡します。 ```ts:title=src/app/components/task-list.component.ts import { Component, Input, Output, EventEmitter } from '@angular/core'; @@ -44,7 +44,7 @@ import { Task } from '../models/task.model'; `, }) -export class TaskListComponent { +export default class TaskListComponent { /** The list of tasks */ @Input() tasks: Task[] = []; @@ -66,17 +66,21 @@ export class TaskListComponent { 次に `TaskList` のテスト状態をストーリーファイルに記述します。 ```ts:title=src/app/components/task-list.stories.ts -import { moduleMetadata, Story, Meta, componentWrapperDecorator } from '@storybook/angular'; +import type { Meta, StoryObj } from '@storybook/angular'; + +import { argsToTemplate, componentWrapperDecorator, moduleMetadata } from '@storybook/angular'; import { CommonModule } from '@angular/common'; -import { TaskListComponent } from './task-list.component'; -import { TaskComponent } from './task.component'; +import TaskListComponent from './task-list.component'; +import TaskComponent from './task.component'; import * as TaskStories from './task.stories'; -export default { +const meta: Meta = { component: TaskListComponent, + title: 'TaskList', + tags: ['autodocs'], decorators: [ moduleMetadata({ //👇 Imports both components to allow component composition with Storybook @@ -84,58 +88,65 @@ export default { imports: [CommonModule], }), //👇 Wraps our stories with a decorator - componentWrapperDecorator(story => `
${story}
`), + componentWrapperDecorator( + (story) => `
${story}
` + ), ], - title: 'TaskList', -} as Meta; - -const Template: Story = args => ({ - props: { - ...args, - onPinTask: TaskStories.actionsData.onPinTask, - onArchiveTask: TaskStories.actionsData.onArchiveTask, + render: (args: TaskListComponent) => ({ + props: { + ...args, + onPinTask: TaskStories.actionsData.onPinTask, + onArchiveTask: TaskStories.actionsData.onArchiveTask, + }, + template: ``, + }), +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + tasks: [ + { ...TaskStories.Default.args?.task, id: '1', title: 'Task 1' }, + { ...TaskStories.Default.args?.task, id: '2', title: 'Task 2' }, + { ...TaskStories.Default.args?.task, id: '3', title: 'Task 3' }, + { ...TaskStories.Default.args?.task, id: '4', title: 'Task 4' }, + { ...TaskStories.Default.args?.task, id: '5', title: 'Task 5' }, + { ...TaskStories.Default.args?.task, id: '6', title: 'Task 6' }, + ], }, -}); - -export const Default = Template.bind({}); -Default.args = { - tasks: [ - { ...TaskStories.Default.args.task, id: '1', title: 'Task 1' }, - { ...TaskStories.Default.args.task, id: '2', title: 'Task 2' }, - { ...TaskStories.Default.args.task, id: '3', title: 'Task 3' }, - { ...TaskStories.Default.args.task, id: '4', title: 'Task 4' }, - { ...TaskStories.Default.args.task, id: '5', title: 'Task 5' }, - { ...TaskStories.Default.args.task, id: '6', title: 'Task 6' }, - ], }; -export const WithPinnedTasks = Template.bind({}); -WithPinnedTasks.args = { - // Shaping the stories through args composition. - // Inherited data coming from the Default story. - tasks: [ - ...Default.args.tasks.slice(0, 5), - { id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' }, - ], +export const WithPinnedTasks: Story = { + args: { + tasks: [ + // Shaping the stories through args composition. + // Inherited data coming from the Default story. + ...(Default.args?.tasks?.slice(0, 5) || []), + { id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' }, + ], + }, }; -export const Loading = Template.bind({}); -Loading.args = { - tasks: [], - loading: true, +export const Loading: Story = { + args: { + tasks: [], + loading: true, + }, }; -export const Empty = Template.bind({}); -Empty.args = { - // Shaping the stories through args composition. - // Inherited data coming from the Loading story. - ...Loading.args, - loading: false, +export const Empty: Story = { + args: { + // Shaping the stories through args composition. + // Inherited data coming from the Loading story. + ...Loading.args, + loading: false, + }, }; ```
-💡 デコレーターを使ってストーリーに任意のラッパーを設定できます。上記のコードでは、 decorators というキーをデフォルトエクスポートで使用し、必要なモジュールやコンポーネントを設定するために使っています。ストーリーで使用する「プロバイダー」(例えば、 React のコンテキストを設定するライブラリコンポーネントなど) を使うためにも使用します。 +💡 デコレーターを使ってストーリーに任意のラッパーを設定できます。上記のコードでは、decoratorというキーをデフォルトエクスポートで使用し、レンダリングされたコンポーネントの周りに余白を追加するために使っています。ストーリーで使用する「プロバイダー」(例えば、コンテキストを設定するライブラリコンポーネントなど) でストーリーをラップするにも使用します。
`TaskStories` をインポートすることで、ストーリーに必要な引数 (args) を最小限の労力で[組み合わせる](https://storybook.js.org/docs/angular/writing-stories/args#args-composition)ことができます。そうすることで、2 つのコンポーネントが想定するデータとアクション (呼び出しのモック) の一貫性が保たれます。 @@ -144,14 +155,14 @@ Empty.args = { ## 状態を作りこむ -今のコンポーネントはまだ粗削りですが、ストーリーは見えています。単に `.list-items` だけのためにラッパーを作るのは単純すぎると思うかもしれません。実際にその通りです。ほとんどの場合単なるラッパーのためだけに新しいコンポーネントは作りません。 `TaskListComponent` の**本当の複雑さ**は `WithPinnedTasks`、`loading`、`empty` といったエッジケースに現われているのです。 +今のコンポーネントはまだ粗削りですが、ストーリーは見えています。単に `.list-items` だけのためにラッパーを作るのは単純すぎると思うかもしれません。実際にその通りです。ほとんどの場合単なるラッパーのためだけに新しいコンポーネントは作りません。 `TaskList`コンポーネント の**本当の複雑さ**は `WithPinnedTasks`、`loading`、`empty` といったエッジケースに現われているのです。 ```diff:title=src/app/components/task-list.component.ts import { Component, Input, Output, EventEmitter } from '@angular/core'; @@ -159,7 +170,7 @@ import { Task } from '../models/task.model'; @Component({ selector: 'app-task-list', - template: ` ++ template: ` +
+ + -+
++
+ -+
You have no tasks
-+
Sit back and relax
++

You have no tasks

++

Sit back and relax

+
+
+
+ -+ Loading cool state ++ ++ Loading cool state ++ +
+
+
`, }) -export class TaskListComponent { +export default class TaskListComponent { +- /** The list of tasks */ - @Input() tasks: Task[] = []; + /** @@ -201,10 +218,16 @@ export class TaskListComponent { + @Input() + set tasks(arr: Task[]) { -+ this.tasksInOrder = [ ++ const initialTasks = [ + ...arr.filter(t => t.state === 'TASK_PINNED'), + ...arr.filter(t => t.state !== 'TASK_PINNED'), + ]; ++ const filteredTasks = initialTasks.filter( ++ t => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED' ++ ); ++ this.tasksInOrder = filteredTasks.filter( ++ t => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED' ++ ); + } } ``` @@ -222,61 +245,7 @@ export class TaskListComponent { ## データ要件 -コンポーネントが大きくなるにつれ、入力の要件も増えていきます。`TaskListComponent` のプロパティの要件を Typescript で定義しましょう。`TaskComponent` が子供のコンポーネントなので、表示するのに正しいデータ構造が渡されていることを確認しましょう。時間を節約するため、前の章で `task.model.ts` に定義したモデルを再利用しましょう。 - -## 自動テスト - -前の章で Storyshots を使用してストーリーのスナップショットテストを行う方法を学びました。`TaskComponent` では、問題なく描画されるのを確認することは、それほど複雑ではありませんでした。`TaskListComponent` では複雑さが増しているので、ある入力がある出力を生成するかどうかを、自動テスト可能な方法で検証したいと思います。そのためには [Angular Testing Library](https://testing-library.com/docs/angular-testing-library/intro) を使用し、単体テストを作ります。 - -![Testing library logo](/intro-to-storybook/testinglibrary-image.jpeg) - -### Angular Testing Library で単体テストする - -Storybook のストーリーと、手動のテスト、スナップショットテストがあれば UI のバグを防ぐのに十分でしょう。ストーリーでコンポーネントの様々なユースケースをカバーしており、ストーリーへのどんな変更に対しても、人が確実に確認できるツールを使用していれば、エラーはめったに発生しなくなります。 - -けれども、悪魔は細部に宿ります。そういった細部を明確にするテストフレームワークが必要です。単体テストを始めましょう。 - -`TaskListComponent` の `tasks` プロパティで渡されたタスクのリストのうち、ピン留めされたタスクを通常のタスクの**前に**表示させたいと思います。このシナリオをテストするストーリー (`WithPinnedTasks`) は既にありますが、コンポーネントが並び替えを**しなくなった**場合に、それがバグかどうかを人間のレビュアーでは判別しかねます。ストーリーでは誰にでも分かるように、**間違っているよ!**と叫んではくれません。 - -この問題を回避するため、Angular Testing Library を使ってストーリーを DOM に描画し、DOM を検索するコードを実行し、出力から目立った機能を検証します。 - -`task-list.component.spec.ts` にテストファイルを作ります。以下に、出力を検証するテストコードを示します。 - -```ts:title=src/app/components/task-list.component.spec.ts -import { render } from '@testing-library/angular'; - -import { TaskListComponent } from './task-list.component'; -import { TaskComponent } from './task.component'; - -//👇 Our story imported here -import { WithPinnedTasks } from './task-list.stories'; - -describe('TaskList component', () => { - it('renders pinned tasks at the start of the list', async () => { - const mockedActions = jest.fn(); - const tree = await render(TaskListComponent, { - declarations: [TaskComponent], - componentProperties: { - ...WithPinnedTasks.args, - onPinTask: { - emit: mockedActions, - } as any, - onArchiveTask: { - emit: mockedActions, - } as any, - }, - }); - const component = tree.fixture.componentInstance; - expect(component.tasksInOrder[0].id).toBe('6'); - }); -}); -``` - -![TaskList のテストランナー](/intro-to-storybook/tasklist-testrunner.png) - -単体テストで `WithPinnedTasks` ストーリーを再利用出来ていることに注目してください。このように、多様な方法で既存のリソースを活用していくことができます。 - -ただし、このテストは非常に脆いことにも留意してください。プロジェクトが成熟し、`Task` の実装が変わっていく (たとえば、別のクラス名に変更されたり、`input` 要素ではなく `textarea` に変更されたりする) と、テストが失敗し、更新が必要となる可能性があります。これは必ずしも問題とならないかもしれませんが、UI の単体テストを使用する際の注意点です。UI の単体テストはメンテナンスが難しいのです。可能な限り手動のテストや、スナップショットテスト、視覚的なリグレッションテスト ([テストの章](/intro-to-storybook/angular/ja/test/)を参照してください) に頼るようにしてください。 +コンポーネントが大きくなるにつれ、入力の要件も増えていきます。`TaskList`コンポーネントのプロパティの要件を Typescript で定義しましょう。`Task` が子供のコンポーネントなので、表示するのに正しいデータ構造が渡されていることを確認しましょう。時間を節約するため、前の章で `task.model.ts` に定義したモデルを再利用しましょう。
💡 Git へのコミットを忘れずに行ってください! diff --git a/content/intro-to-storybook/angular/ja/conclusion.md b/content/intro-to-storybook/angular/ja/conclusion.md index afcb5da7b..fd54004be 100644 --- a/content/intro-to-storybook/angular/ja/conclusion.md +++ b/content/intro-to-storybook/angular/ja/conclusion.md @@ -16,17 +16,17 @@ Storybook は React、React Native、Vue、Angular、Svelte、その他のフレ もっと深く掘り下げたいですか?役に立つリソースを紹介します。 -- [**Storybook の公式ドキュメント**](https://storybook.js.org/docs/angular/get-started/introduction)には API ドキュメント、コミュニティのリンク、アドオンのギャラリーがあります。 +- [**Storybook の公式ドキュメント**](https://storybook.js.org/docs/get-started/install)には API ドキュメント、コミュニティのリンク、アドオンのギャラリーがあります。 -- [**楽しい Storybook のワークフロー**](https://www.chromatic.com/blog/the-delightful-storybook-workflow)では Squarespace や、メジャーリーグサッカー、ディスカバリーネットワーク、 Apollo GraphQL といった効率の良いチームにおけるワークフローのベストプラクティスを紹介しています。 +- [**UIテストのレシピ集**](https://storybook.js.org/blog/ui-testing-playbook/)では Twilio や、Adobe、Peloton、Shopifyといった効率の良いチームにおけるワークフローのベストプラクティスを紹介しています。 - [**視覚的なテストのハンドブック**](https://storybook.js.org/tutorials/visual-testing-handbook/)では、コンポーネントを Storybook で視覚的にテストする方法を掘り下げています。無料の 31 ページある eBook です。 -- [**Storybook Discord**](https://discord.gg/UUt2PJb) では Storybook のコミュニティに参加できます。他の Storybook ユーザーと協力しましょう。 +- [**Storybook Discord チャット**](https://discord.gg/UUt2PJb) では Storybook のコミュニティに参加できます。他の Storybook ユーザーと協力しましょう。 -- [**Storybook ブログ**](https://storybook.js.org/blog)ではリリース情報や、UI 開発のワークフローを合理的にするための機能を紹介します。 +- [**Storybook ブログ**](https://storybook.js.org/blog/)ではリリース情報や、UI 開発のワークフローを合理的にするための機能を紹介します。 -## Storybook チュートリアルについて +## 誰が Intro to Storybook チュートリアルを作成しているのでしょうか? 文書や、コード、製作は [Chromatic](https://www.chromatic.com/?utm_source=storybook_website&utm_medium=link&utm_campaign=storybook) の貢献です。このチュートリアルは Chromatic の [GraphQL + React tutorial series](https://www.chromatic.com/blog/graphql-react-tutorial-part-1-6) を参考にしています。 diff --git a/content/intro-to-storybook/angular/ja/data.md b/content/intro-to-storybook/angular/ja/data.md index 19cbe78e0..715ddee24 100644 --- a/content/intro-to-storybook/angular/ja/data.md +++ b/content/intro-to-storybook/angular/ja/data.md @@ -2,7 +2,7 @@ title: 'データを繋ぐ' tocTitle: 'データ' description: 'UI コンポーネントとデータを繋ぐ方法を学びましょう' -commit: 'c416f74' +commit: '33e3597' --- これまでに、Storybook の切り離された環境で、状態を持たないコンポーネントを作成してきました。しかし、究極的には、アプリケーションからコンポーネントにデータを渡さなければ役には立ちません。 @@ -11,20 +11,22 @@ commit: 'c416f74' ## コンテナーコンポーネント -`TaskList` コンポーネントは、今のところ、それ自体では外部とのやりとりをしないので「presentational (表示用)」([このブログ記事](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0)を参照) として書かれています。データを取得するためには「container (コンテナー)」が必要です。 +`TaskList` コンポーネントは、今のところ「presentational (表示用)」として書かれており、その実装以外の外部とは何もやりとりをしません。データを中に入れるためには「container (コンテナー)」が必要です。 ここでは Redux/ngrx の原則を取り入れつつボイラープレートを減らし、より Angular らしい状態管理の方法を提供する [ngxs](https://ngxs.gitbook.io/ngxs/) を使用し、アプリケーションにシンプルなデータモデルを作ります。しかし、 [ngrx/store](https://github.com/ngrx/platform) や [Apollo](https://www.apollographql.com/docs/angular/) といった他のデータ管理用のライブラリーでもここでのパターンが使用できます。 -まず、ngxs をインストールします: +以下のコマンドを実行し必要な依存関係を追加しましょう: ```shell npm install @ngxs/store @ngxs/logger-plugin @ngxs/devtools-plugin ``` -それからタスクの状態を変更するアクションを処理する単純なストアを作ります。`src/app/state/task.state.ts` というファイルを作ってください (あえて簡単にしています): +まず、タスクの状態を変更するアクションを処理する単純なストアを作ります。`src/app/state` フォルダの `task.state.ts` というファイルを作ってください (あえて簡単にしています): ```ts:title=src/app/state/task.state.ts +import { Injectable } from '@angular/core'; import { State, Selector, Action, StateContext } from '@ngxs/store'; +import { patch, updateItem } from '@ngxs/store/operators'; import { Task } from '../models/task.model'; // Defines the actions available to the app @@ -47,90 +49,165 @@ export class PinTask { // 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 }; +const defaultTasks = [ + { id: '1', title: 'Something', state: 'TASK_INBOX' }, + { id: '2', title: 'Something more', state: 'TASK_INBOX' }, + { id: '3', title: 'Something else', state: 'TASK_INBOX' }, + { id: '4', title: 'Something again', state: 'TASK_INBOX' }, +]; + +export interface TaskStateModel { + tasks: Task[]; + status: 'idle' | 'loading' | 'success' | 'error'; + error: boolean; } // Sets the default state @State({ - name: 'tasks', + name: 'taskbox', defaults: { - entities: defaultTasks, + tasks: defaultTasks, + status: 'idle', + error: false, }, }) +@Injectable() export class TasksState { + // Defines a new selector for the error field @Selector() - static getAllTasks(state: TaskStateModel) { - const entities = state.entities; - return Object.keys(entities).map(id => entities[+id]); + static getError(state: TaskStateModel): boolean { + return state.error; + } + + @Selector() + static getAllTasks(state: TaskStateModel): Task[] { + return state.tasks; } // Triggers the PinTask action, similar to redux @Action(PinTask) - pinTask({ patchState, getState }: StateContext, { payload }: PinTask) { - const state = getState().entities; - - const entities = { - ...state, - [payload]: { ...state[payload], state: 'TASK_PINNED' }, - }; - - patchState({ - entities, - }); + pinTask( + { getState, setState }: StateContext, + { payload }: PinTask + ) { + const task = getState().tasks.find((task) => task.id === payload); + + if (task) { + const updatedTask: Task = { + ...task, + state: 'TASK_PINNED', + }; + setState( + patch({ + tasks: updateItem( + (pinnedTask) => pinnedTask?.id === payload, + updatedTask + ), + }) + ); + } } // Triggers the archiveTask action, similar to redux @Action(ArchiveTask) - archiveTask({ patchState, getState }: StateContext, { payload }: ArchiveTask) { - const state = getState().entities; - - const entities = { - ...state, - [payload]: { ...state[payload], state: 'TASK_ARCHIVED' }, - }; - - patchState({ - entities, - }); + archiveTask( + { getState, setState }: StateContext, + { payload }: ArchiveTask + ) { + const task = getState().tasks.find((task) => task.id === payload); + if (task) { + const updatedTask: Task = { + ...task, + state: 'TASK_ARCHIVED', + }; + setState( + patch({ + tasks: updateItem( + (archivedTask) => archivedTask?.id === payload, + updatedTask + ), + }) + ); + } } } ``` -ストアを実装しましたが、アプリにつなげる前にいくつかのステップを踏む必要があります。 - -ストアからデータを読むように`TaskListComponent`を更新しますが、まず表示用のバージョンを`pure-task-list.component.ts`という新しいファイルへ移動し(`selector`は`app-pure-task-list`に変更します)、コンテナーでラップします。 +そしてストアからデータを読み込むように`TaskList`コンポーネントを更新します。まず、表示用のバージョンを`src/app/components/pure-task-list.component.ts`という新しいファイルへ移動しコンテナーでラップします。 -以下、`src/app/components/pure-task-list.component.ts`の内容です: +`src/app/components/pure-task-list.component.ts`: ```diff:title=src/app/components/pure-task-list.component.ts - import { Component, Input, Output, EventEmitter } from '@angular/core'; import { Task } from '../models/task.model'; @Component({ - selector:'app-task-list', + selector: 'app-pure-task-list', - // same content as before with the task-list.component.ts + template: ` +
+ + +
+ +

You have no tasks

+

Sit back and relax

+
+
+
+ + + Loading cool state + +
+
+
+ `, }) -- export class TaskListComponent { -+ export class PureTaskListComponent { - // same content as before with the task-list.component.ts +- export default class TaskListComponent { ++ export default class PureTaskListComponent { + /** + * @ignore + * Component property to define ordering of tasks + */ + tasksInOrder: Task[] = []; + + @Input() loading = false; + + // tslint:disable-next-line: no-output-on-prefix + @Output() onPinTask: EventEmitter = new EventEmitter(); + + // tslint:disable-next-line: no-output-on-prefix + @Output() onArchiveTask: EventEmitter = new EventEmitter(); + + @Input() + set tasks(arr: Task[]) { + const initialTasks = [ + ...arr.filter((t) => t.state === 'TASK_PINNED'), + ...arr.filter((t) => t.state !== 'TASK_PINNED'), + ]; + const filteredTasks = initialTasks.filter( + (t) => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED' + ); + this.tasksInOrder = filteredTasks.filter( + (t) => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED' + ); + } } ``` -その後、`src/app/components/task-list.component.ts`を以下のように変更します: +`src/app/components/task-list.component.ts`: ```ts:title=src/app/components/task-list.component.ts import { Component } from '@angular/core'; -import { Select, Store } from '@ngxs/store'; -import { TasksState, ArchiveTask, PinTask } from '../state/task.state'; -import { Task } from '../models/task.model'; +import { Store } from '@ngxs/store'; +import { ArchiveTask, PinTask } from '../state/task.state'; import { Observable } from 'rxjs'; @Component({ @@ -143,10 +220,12 @@ import { Observable } from 'rxjs'; > `, }) -export class TaskListComponent { - @Select(TasksState.getAllTasks) tasks$: Observable; +export default class TaskListComponent { + tasks$?: Observable; - constructor(private store: Store) {} + constructor(private store: Store) { + this.tasks$ = store.select((state) => state.taskbox.tasks); + } /** * Component method to trigger the archiveTask event @@ -164,19 +243,19 @@ export class TaskListComponent { } ``` -コンポーネントとストアの橋渡しをする Angular モジュールを作ります。 +続いてコンポーネントとストアの橋渡しをする Angular モジュールを作ります。 -`components`フォルダ内に`task.module.ts`というファイルを作成し、以下の内容を追加します: +`src/app/components`フォルダ内に`task.module.ts`というファイルを作成し、以下の内容を追加します: ```ts:title=src/app/components/task.module.ts import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { NgxsModule } from '@ngxs/store'; -import { TaskComponent } from './task.component'; -import { TaskListComponent } from './task-list.component'; +import TaskComponent from './task.component'; +import TaskListComponent from './task-list.component'; import { TasksState } from '../state/task.state'; -import { PureTaskListComponent } from './pure-task-list.component'; +import PureTaskListComponent from './pure-task-list.component'; @NgModule({ imports: [CommonModule, NgxsModule.forFeature([TasksState])], @@ -187,7 +266,7 @@ import { PureTaskListComponent } from './pure-task-list.component'; export class TaskModule {} ``` -全てのピースが揃ったので、後はストアをアプリケーションに繋げるだけです。トップレベルモジュール(`src/app/app.module.ts`)に以下の内容を記載します: +必要なものが揃いました。後はストアをアプリケーションに繋げるだけです。トップレベルモジュール(`src/app/app.module.ts`)を更新します: ```diff:title=src/app/app.module.ts import { BrowserModule } from '@angular/platform-browser'; @@ -198,6 +277,7 @@ import { NgModule } from '@angular/core'; + import { NgxsReduxDevtoolsPluginModule } from '@ngxs/devtools-plugin'; + import { NgxsLoggerPluginModule } from '@ngxs/logger-plugin'; ++ import { environment } from '../environments/environment'; import { AppComponent } from './app.component'; @NgModule({ @@ -205,9 +285,13 @@ import { AppComponent } from './app.component'; imports: [ BrowserModule, + TaskModule, -+ NgxsModule.forRoot([]), ++ NgxsModule.forRoot([], { ++ developmentMode: !environment.production, ++ }), + NgxsReduxDevtoolsPluginModule.forRoot(), -+ NgxsLoggerPluginModule.forRoot(), ++ NgxsLoggerPluginModule.forRoot({ ++ disabled: environment.production, ++ }), ], providers: [], bootstrap: [AppComponent], @@ -215,120 +299,98 @@ import { AppComponent } from './app.component'; export class AppModule {} ``` -表示用の TaskList をそのままにしておくのは、テストと分離が容易になるからです。ストアの存在に依存しないので、テストの観点から見ると取り扱いがより簡単になります。さらに `src/app/components/task-list.stories.ts` も `src/app/components/pure-task-list.stories.ts` に変更し、ストーリーが表示用のバージョンを使っていることを確実にしましょう: +表示用の TaskList をそのままにしておくのは、テストと分離が容易になるからです。ストアの存在に依存しないので、テストの観点から見ると取り扱いがより簡単になります。`src/app/components/task-list.stories.ts` を `src/app/components/pure-task-list.stories.ts` に変更し、ストーリーが表示用のバージョンを使っていることを確認しましょう: ```ts:title=src/app/components/pure-task-list.stories.ts -import { moduleMetadata, Story, Meta, componentWrapperDecorator } from '@storybook/angular'; +import type { Meta, StoryObj } from '@storybook/angular'; + +import { argsToTemplate, componentWrapperDecorator, moduleMetadata } from '@storybook/angular'; import { CommonModule } from '@angular/common'; -import { PureTaskListComponent } from './pure-task-list.component'; -import { TaskComponent } from './task.component'; +import PureTaskListComponent from './pure-task-list.component'; + +import TaskComponent from './task.component'; import * as TaskStories from './task.stories'; -export default { +const meta: Meta = { component: PureTaskListComponent, + title: 'PureTaskList', + tags: ['autodocs'], decorators: [ moduleMetadata({ - //👇 Imports both components to allow component composition with storybook + //👇 Imports both components to allow component composition with Storybook declarations: [PureTaskListComponent, TaskComponent], imports: [CommonModule], }), //👇 Wraps our stories with a decorator - componentWrapperDecorator(story => `
${story}
`), + componentWrapperDecorator( + (story) => `
${story}
` + ), ], - title: 'PureTaskListComponent', -} as Meta; - -const Template: Story = args => ({ - props: { - ...args, - onPinTask: TaskStories.actionsData.onPinTask, - onArchiveTask: TaskStories.actionsData.onArchiveTask, + render: (args: PureTaskListComponent) => ({ + props: { + ...args, + onPinTask: TaskStories.actionsData.onPinTask, + onArchiveTask: TaskStories.actionsData.onArchiveTask, + }, + template: ``, + }), +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + tasks: [ + { ...TaskStories.Default.args?.task, id: '1', title: 'Task 1' }, + { ...TaskStories.Default.args?.task, id: '2', title: 'Task 2' }, + { ...TaskStories.Default.args?.task, id: '3', title: 'Task 3' }, + { ...TaskStories.Default.args?.task, id: '4', title: 'Task 4' }, + { ...TaskStories.Default.args?.task, id: '5', title: 'Task 5' }, + { ...TaskStories.Default.args?.task, id: '6', title: 'Task 6' }, + ], }, -}); - -export const Default = Template.bind({}); -Default.args = { - tasks: [ - { ...TaskStories.Default.args.task, id: '1', title: 'Task 1' }, - { ...TaskStories.Default.args.task, id: '2', title: 'Task 2' }, - { ...TaskStories.Default.args.task, id: '3', title: 'Task 3' }, - { ...TaskStories.Default.args.task, id: '4', title: 'Task 4' }, - { ...TaskStories.Default.args.task, id: '5', title: 'Task 5' }, - { ...TaskStories.Default.args.task, id: '6', title: 'Task 6' }, - ], }; -export const WithPinnedTasks = Template.bind({}); -WithPinnedTasks.args = { - // Shaping the stories through args composition. - // Inherited data coming from the Default story. - tasks: [ - ...Default.args.tasks.slice(0, 5), - { id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' }, - ], +export const WithPinnedTasks: Story = { + args: { + tasks: [ + // Shaping the stories through args composition. + // Inherited data coming from the Default story. + ...(Default.args?.tasks?.slice(0, 5) || []), + { id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' }, + ], + }, }; -export const Loading = Template.bind({}); -Loading.args = { - tasks: [], - loading: true, +export const Loading: Story = { + args: { + tasks: [], + loading: true, + }, }; -export const Empty = Template.bind({}); -Empty.args = { - // Shaping the stories through args composition. - // Inherited data coming from the Loading story. - ...Loading.args, - loading: false, +export const Empty: Story = { + args: { + // Shaping the stories through args composition. + // Inherited data coming from the Loading story. + ...Loading.args, + loading: false, + }, }; ``` -同様に、 `PureTaskListComponent` を Jest のテストで使用する必要があります: - -```diff:title= src/app/components/task-list.component.spec.ts -import { render } from '@testing-library/angular'; - -- import { TaskListComponent } from './task-list.component.ts'; -+ import { PureTaskListComponent } from './pure-task-list.component'; - -import { TaskComponent } from './task.component'; - -//👇 Our story imported here -- import { WithPinnedTasks } from './task-list.stories'; - -+ import { WithPinnedTasks } from './pure-task-list.stories'; - -describe('TaskList component', () => { - it('renders pinned tasks at the start of the list', async () => { - const mockedActions = jest.fn(); - const tree = await render(PureTaskListComponent, { - declarations: [TaskComponent], - componentProperties: { - ...WithPinnedTasks.args, - onPinTask: { - emit: mockedActions, - } as any, - onArchiveTask: { - emit: mockedActions, - } as any, - }, - }); - const component = tree.fixture.componentInstance; - expect(component.tasksInOrder[0].id).toBe('6'); - }); -}); -``` -
-💡 さらにスナップショットテストも失敗しているはずなので、既存のスナップショットテストを -u フラグを付けて実行しなければなりません。Git へのコミットを忘れずに行ってください! +💡 Git へのコミットを忘れずに行ってください!
+ +今はストアから取得した実際のデータがありそれをコンポーネントに入れているので、 `src/app/app.component.ts` に繋げてそこでレンダリングすることもできました。安心してください。次の章でそれを行います。 diff --git a/content/intro-to-storybook/angular/ja/deploy.md b/content/intro-to-storybook/angular/ja/deploy.md index 2d1a2ccb5..e33181546 100644 --- a/content/intro-to-storybook/angular/ja/deploy.md +++ b/content/intro-to-storybook/angular/ja/deploy.md @@ -2,7 +2,7 @@ title: 'Storybook をデプロイする' tocTitle: 'デプロイ' description: 'Storybook をインターネット上にデプロイする方法を学びましょう' -commit: '1586781' +commit: '3f59dc0' --- ここまで、ローカルの開発マシンでコンポーネントを作成してきました。しかし、ある時点で、フィードバックを得るためにチームに作業を共有しなければならないこともあるでしょう。チームメートに UI の実装をレビューしてもらうため、Storybook をインターネット上にデプロイしてみましょう。 @@ -42,12 +42,12 @@ git push -u origin main パッケージを開発時の依存関係に追加します。 ```shell -npm install -D chromatic +npm install chromatic --save-dev ``` パッケージをインストールしたら、GitHub のアカウントを使用して [Chromatic にログイン](https://www.chromatic.com/start/?utm_source=storybook_website&utm_medium=link&utm_campaign=storybook)します。(Chromatic は一部のアクセス許可を要求します。) 「taskbox」という名前でプロジェクトを作成し、GitHub のリポジトリーと同期させます。 -「colaborators」の下にある `Choose from GitHub` をクリックし、リポジトリーを選択します。 +「colaborators」の下にある `Choose GitHub repo` をクリックし、リポジトリーを選択します。
-## デコレーターを使用してコンテキストを渡す +## デコレーターを使用してコンテキストを提供する -ストーリーの中で `PureInboxScreenComponent` に `Store` を渡すのは簡単です!モック化した `Store` をデコレーター内部で使用します: +良いニュースは、ストーリーで `PureInboxScreen` コンポーネントに `Store` を簡単に提供できることです! 私たちは `Store` をデコレーターにインポートし、`applicationConfig` API を通じて有効にし、それを `PureInboxScreen` コンポーネントに渡すことができます。 ```diff:title=src/app/components/pure-inbox-screen.stories.ts -import { moduleMetadata } from '@storybook/angular'; -import { Story, Meta } from '@storybook/angular/types-6-0'; +import type { Meta, StoryObj } from '@storybook/angular'; -import { PureInboxScreenComponent } from './pure-inbox-screen.component'; -import { TaskModule } from './task.module'; ++ import { importProvidersFrom } from '@angular/core'; + import { Store, NgxsModule } from '@ngxs/store'; + import { TasksState } from '../state/task.state'; -export default { ++ import { moduleMetadata, applicationConfig } from '@storybook/angular'; + +import { CommonModule } from '@angular/common'; + +import PureInboxScreenComponent from './pure-inbox-screen.component'; + +import { TaskModule } from './task.module'; + +const meta: Meta = { + component: PureInboxScreenComponent, title: 'PureInboxScreen', - component:PureInboxScreenComponent, + tags: ['autodocs'], decorators: [ moduleMetadata({ -- imports: [CommonModule,TaskModule], -+ imports: [CommonModule,TaskModule,NgxsModule.forRoot([TasksState])], -+ providers: [Store], + imports: [CommonModule, TaskModule], + }), ++ applicationConfig({ ++ providers: [Store, importProvidersFrom(NgxsModule.forRoot([TasksState]))], }), ], -} as Meta; +}; -const Template: Story = (args) => ({ - component: PureInboxScreenComponent, - props: args, -}); +export default meta; +type Story = StoryObj; -export const Default = Template.bind({}); +export const Default: Story = {}; -export const Error = Template.bind({}); -Error.args = { - error: true, +export const Error: Story = { + args: { + error: true, + }, }; ``` @@ -316,14 +347,139 @@ Storybook で状態を選択していくことで、問題なく出来ている + +## インタラクションテスト + +これまでで、シンプルなコンポーネントから画面まで、完全に機能するアプリケーションを作り上げ、ストーリーを用いてそれぞれの変更を継続的にテストすることができるようになりました。しかし、新しいストーリーを作るたびに、UI が壊れていないかどうか、他のすべてのストーリーを手作業でチェックする必要もあります。これは、とても大変な作業です。 + +この作業や操作を自動化することはできないのでしょうか? + +### play 関数を使ったインタラクションテスト + +Storybook の [`play`](https://storybook.js.org/docs/angular/writing-stories/play-function) 関数と [`@storybook/addon-interactions`](https://storybook.js.org/docs/angular/writing-tests/interaction-testing) が役立ちます。play 関数はストーリーのレンダリング後に実行される小さなコードスニペットを含んでいます。 + +play 関数はタスクが更新されたときに UI に何が起こるかを検証するのに役立ちます。フレームワークに依存しない DOM API を使用しています。つまり、 play 関数を使って UI を操作し、人間の行動をシミュレートするストーリーを、フロントエンドのフレームワークに関係なく書くことができるのです。 + +`@storybook/addon-interactions`は、一つ一つのステップごとに Storybook のテストを可視化するのに役立ちます。さらに、各インタラクションの一時停止、再開、巻き戻し、ステップ実行といった便利な UI の制御機能が備わっています。 + +実際に動かしてみましょう!以下のようにして新しく作成された `pure-inbox-screen` ストーリーを更新し、コンポーネント操作を追加してみましょう: + +```diff:title=src/app/components/pure-inbox-screen.stories.ts +import type { Meta, StoryObj } from '@storybook/angular'; + +import { importProvidersFrom } from '@angular/core'; + +import { Store, NgxsModule } from '@ngxs/store'; +import { TasksState } from '../state/task.state'; + +import { moduleMetadata, applicationConfig } from '@storybook/angular'; + ++ import { fireEvent, within } from '@storybook/test'; + +import { CommonModule } from '@angular/common'; + +import PureInboxScreenComponent from './pure-inbox-screen.component'; + +import { TaskModule } from './task.module'; + +const meta: Meta = { + component: PureInboxScreenComponent, + title: 'PureInboxScreen', + tags: ['autodocs'], + decorators: [ + moduleMetadata({ + imports: [CommonModule, TaskModule], + }), + applicationConfig({ + providers: [Store, importProvidersFrom(NgxsModule.forRoot([TasksState]))], + }), + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Error: Story = { + args: { + error: true, + }, +}; + ++ export const WithInteractions: Story = { ++ play: async ({ canvasElement }) => { ++ const canvas = within(canvasElement); ++ // Simulates pinning the first task ++ await fireEvent.click(canvas.getByLabelText('pinTask-1')); ++ // Simulates pinning the third task ++ await fireEvent.click(canvas.getByLabelText('pinTask-3')); ++ }, ++ }; +``` + +
+ +💡 `@storybook/test`が `@storybook/jest`と`@storybook/testing-library`を置き換え、 [Vitest](https://vitest.dev/) に基づいたより小さいバンドルサイズと直感的なAPIを提供します。 + +
+ +新しく作成したストーリーを確認します。`Interactions` パネルをクリックすると、ストーリーの play 関数内のインタラクションのリストが表示されます。 + + +### テスト自動化 + +play 関数を利用して問題を回避し、UI を操作したりタスクを更新した場合の反応を素早く確認することができます。これによって、手作業の手間をかけずに UI の一貫性を保つことができます。 + +しかし、Storybook をよく見ると、ストーリーを見るときだけインタラクションテストが実行されることがわかります。そのため、変更時に各ストーリーを全てチェックしなければなりません。これは自動化できないのでしょうか? + +可能です!Storybook の[テストランナー](https://storybook.js.org/docs/angular/writing-tests/test-runner)は可能にしてくれます。それは [Playwright](https://playwright.dev/) によって実現されたスタンドアロンなパッケージで、全てのインタラクションテストを実行し、壊れたストーリーを検知してくれます。 + +それではどのように動くのかみてみましょう!次のコマンドでインストールして走らせます: + +```shell +npm install @storybook/test-runner --save-dev +``` + +次に、 `package.json` の `scripts` をアップデートし、新しいテストタスクを追加してください: + +```json:clipboard=false +{ + "scripts": { + "test-storybook": "test-storybook" + } +} +``` + +最後に、Storybook を起動し、新しいターミナルで以下のコマンドを実行してください: + +```shell +npm run test-storybook -- --watch +``` + +
+💡 play 関数でのインタラクションテストはUIコンポーネントをテストするための素晴らしい手法です。ここで紹介したもの以外にも、さまざまなことができます。もっと深く学ぶには公式ドキュメントを読むことをお勧めします。 +
+テストをさらにもっと深く知るためには、Testing Handbook をチェックしてみてください。これは開発ワークフローを加速させるために、スケーラブルなフロントエンドチームが採用しているテスト戦略について解説しています。 +
+ +![Storybook test runner successfully runs all tests](/intro-to-storybook/storybook-test-runner-execution.png) + +成功です!これで、全てのストーリーがエラーなくレンダリングされ、全てのテストが自動的に通過するかどうか検証するためのツールができました。さらに、テストが失敗した場合、失敗したストーリーをブラウザで開くリンクを提供してくれます。 + ## コンポーネント駆動開発 -まず、一番下の `TaskComponent` から始めて、`TaskListComponent` を作り、画面全体の UI が出来ました。`InboxScreenComponent` ではネストしたコンテナーコンポーネントを含み、一緒にストーリーも作成しました。 +まず、一番下の `Task` から始めて、`TaskList` を作り、画面全体の UI が出来ました。`InboxScreen` ではネストしたコンテナーコンポーネントを含み、一緒にストーリーも作成しました。
`, }) -export class TaskComponent { +export default class TaskComponent { + /** + * The shape of the task object + */ @Input() task: any; // tslint:disable-next-line: no-output-on-prefix @@ -50,71 +59,75 @@ export class TaskComponent { } ``` -上のコードは Todo アプリケーションの HTML を基にした `TaskComponent` の簡単なマークアップです。 +上のコードは Todo アプリケーションの HTML を基にした `Task` component の簡単なマークアップです。 -下のコードは `TaskComponent` に対する 3 つのテスト用の状態をストーリーファイルに書いています: +下のコードは `Task` に対する 3 つのテスト用の状態をストーリーファイルに書いています: ```ts:title=src/app/components/task.stories.ts -import { moduleMetadata, Story, Meta } from '@storybook/angular'; +import type { Meta, StoryObj } from '@storybook/angular'; -import { CommonModule } from '@angular/common'; +import { argsToTemplate } from '@storybook/angular'; import { action } from '@storybook/addon-actions'; -import { TaskComponent } from './task.component'; - -export default { - component: TaskComponent, - decorators: [ - moduleMetadata({ - declarations: [TaskComponent], - imports: [CommonModule], - }), - ], - excludeStories: /.*Data$/, - title: 'Task', -} as Meta; +import TaskComponent from './task.component'; export const actionsData = { onPinTask: action('onPinTask'), onArchiveTask: action('onArchiveTask'), }; -const Template: Story = args => ({ - props: { - ...args, - onPinTask: actionsData.onPinTask, - onArchiveTask: actionsData.onArchiveTask, - }, -}); - -export const Default = Template.bind({}); -Default.args = { - task: { - id: '1', - title: 'Test Task', - state: 'TASK_INBOX', - updatedAt: new Date(2021, 0, 1, 9, 0), +const meta: Meta = { + title: 'Task', + component: TaskComponent, + excludeStories: /.*Data$/, + tags: ['autodocs'], + render: (args: TaskComponent) => ({ + props: { + ...args, + onPinTask: actionsData.onPinTask, + onArchiveTask: actionsData.onArchiveTask, + }, + template: ``, + }), +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + task: { + id: '1', + title: 'Test Task', + state: 'TASK_INBOX', + }, }, }; -export const Pinned = Template.bind({}); -Pinned.args = { - task: { - ...Default.args.task, - state: 'TASK_PINNED', +export const Pinned: Story = { + args: { + task: { + ...Default.args?.task, + state: 'TASK_PINNED', + }, }, }; -export const Archived = Template.bind({}); -Archived.args = { - task: { - ...Default.args.task, - state: 'TASK_ARCHIVED', +export const Archived: Story = { + args: { + task: { + ...Default.args?.task, + state: 'TASK_ARCHIVED', + }, }, }; ``` +
+💡 アクションは、UIコンポーネントを単独で構築する際にインタラクションを検証するのに役立ちます。多くの場合、アプリのコンテキストで使用している関数や状態にアクセスできないことがあります。action()を使用して、それらをスタブとして用います。 +
+ Storybook には基本となる 2 つの階層があります。コンポーネントとその子供となるストーリーです。各ストーリーはコンポーネントに連なるものだと考えてください。コンポーネントには必要なだけストーリーを記述することができます。 - **コンポーネント** @@ -122,56 +135,57 @@ Storybook には基本となる 2 つの階層があります。コンポーネ - ストーリー - ストーリー -Storybook にコンポーネントを認識させるには、以下の内容を含む `default export` を記述します: +テストやドキュメントを書いているのはどのコンポーネントなのかを Storybook に認識させるには、以下の内容を含む `default` export を記述します: - `component` -- コンポーネント自体 -- `title` -- Storybook のサイドバーにあるコンポーネントを参照する方法 -- `excludeStories` -- ストーリーファイルのエクスポートのうち、Storybook にストーリーとして表示させたくないもの - -ストーリーを定義するには、テスト用の状態ごとにストーリーを生成する関数をエクスポートします。ストーリーとは、特定の状態で描画された要素 (例えば、プロパティを指定したコンポーネントなど) を返す関数で、[関数コンポーネント](https://angular.jp/guide/component-interaction)のようなものです。 +- `title` -- Storybook のサイドバーにあるコンポーネントをグループ化またはカテゴライズする方法 +- `tags` -- コンポーネントのドキュメントを生成するために使用 +- `excludeStories` -- ストーリーに必要だが、Storybook でレンダリングされるべきでない追加情報 +- `render` -- コンポーネントが Storybook でどのようにレンダリングされるかを指定するカスタム[render 関数](https://storybook.js.org/docs/angular/api/csf#custom-render-functions) +- `argsToTemplate` -- args をコンポーネントのプロパティとイベントバインディングに変換するヘルパー関数で、Storybook のコントロールとコンポーネントの入出力に対する堅牢なワークフローのサポートを提供 -コンポーネントにストーリーが複数連なっているので、各ストーリーを単一の `Template` 変数に割り当てるのが便利です。このパターンを導入することで、書くべきコードの量が減り、保守性も上がります。 +ストーリーを定義するために、Component Story Format 3([CSF3](https://storybook.js.org/docs/angular/api/csf) とも呼ばれます)を使用し各テストケースを構築します。このフォーマットは、各テストケースを簡潔に構築するために設計されています。各コンポーネントの状態を含むオブジェクトをエクスポートすることで、私たちはテストをより直感的に定義し、ストーリーをより効率的に作成および再利用することができます。 -
- -💡 `Template.bind({})` は関数のコピーを作成する [JavaScript の標準的な](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Function/bind) テクニックで、同じ実装を使いながら、エクスポートされたそれぞれのストーリーに独自のプロパティを設定することができます。 - -
+ストーリーを定義するには、テスト用の状態ごとにストーリーを生成する関数をエクスポートします。ストーリーとは、特定の状態で描画された要素 (例えば、プロパティを指定したコンポーネントなど) を返す関数で、[関数コンポーネント](https://angular.jp/guide/component-interaction)のようなものです。 Arguments (略して [`args`](https://storybook.js.org/docs/angular/writing-stories/args)) を使用することで、コントロールアドオンを通して、Storybook を再起動することなく、コンポーネントを動的に編集することができるようになります。 [`args`](https://storybook.js.org/docs/angular/writing-stories/args) の値が変わるとコンポーネントもそれに合わせて変わります。 -ストーリーを作る際には素となるタスク (`task`) を使用してコンポーネントが想定するタスクの状態を作成します。想定されるデータは実際のデータと同じように作ります。さらに、このデータをエクスポートすることで、今後作成するストーリーで再利用することが可能となります。 - `action()` を使うと、クリックされたときに Storybook の画面の **actions** パネルに表示されるコールバックを作ることができます。なのでピン留めボタンを作るときに、クリックがうまくいっているかテスト UI 上で分かります。 -コンポーネントの全てのストーリーに同じアクションを渡す必要があるので、 `actionsData` という 1 つの変数にまとめて各ストーリーの定義に渡すと便利です。 +コンポーネントの全てのストーリーに同じアクションを渡す必要があるので、 `actionsData` という 1 つの変数にまとめて各ストーリーの定義に渡すと便利です。コンポーネントに必要な `actionsData` を作るもう一つの利点は、後ほど見るように、 `export` してこのコンポーネントを再利用するコンポーネントのストーリーで使える点です。 -コンポーネントに必要な `actionsData` を作るもう一つの利点は、後ほど見るように、 `export` してこのコンポーネントを再利用するコンポーネントのストーリーで使える点です。 - -
-💡 アクションアドオンは切り離された環境で UI コンポーネントを開発する際の動作確認に役立ちます。アプリケーションの実行中には状態や関数を参照出来ないことがよくあります。 action() はそのスタブとして使用できます。 -
+ストーリーを作る際には素となるタスク引数を使用してコンポーネントが想定するタスクの状態を作成します。想定されるデータは実際のデータと同じように作ります。さらに、このデータをエクスポートすることで、今後作成するストーリーで再利用することが可能となります。 ## 設定する -作成したストーリーを認識させるため、若干の変更を加える必要があります。Storybook 用設定ファイル (`.storybook/main.js`) を以下のように変更してください: +作成したストーリーを認識させるため、1 つ小さな変更を加える必要があります。Storybook 用設定ファイル (`.storybook/main.s`) を以下のように変更してください: -```diff:title=.storybook/main.js -module.exports = { -- stories: [ -- '../src/**/*.stories.mdx', -- '../src/**/*.stories.@(js|jsx|ts|tsx)' -- ], +```diff:title=.storybook/main.ts +import type { StorybookConfig } from '@storybook/angular'; +const config: StorybookConfig = { +- stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'], + stories: ['../src/app/components/**/*.stories.ts'], - addons: ['@storybook/addon-links', '@storybook/addon-essentials'], + addons: [ + '@storybook/addon-links', + '@storybook/addon-essentials', + '@storybook/addon-interactions', + ], + framework: { + name: '@storybook/angular', + options: {}, + }, + docs: { + autodocs: 'tag', + }, }; +export default config; ``` Storybook のサーバーを再起動すると、タスクの 3 つの状態のテストケースが生成されているはずです: -