iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 22
1
Modern Web

玩轉 Storybook系列 第 22

玩轉 Storybook: Day 22 Construct a screen - Angular

  • 分享至 

  • xImage
  •  

在前面的單元,我們已經完成了開發單一元件Task,也完成了複合元件 TaskList,也把 TaskList 拆分為容器型(TaskListComponent) 及 表現型(PureTaskListComponent),接下來要做的是把元件組合成頁面 - InboxScreen。

元件驅動開發

元件驅動開發 Component-Driven Development(CDD),是指從元件開始向上開發,逐步的擴充及組合元件,到最後完成一個頁面。

Nested container components

在 InboxScreen 除了使用 TaskList,也會在當頁面出問題時呈現錯誤的樣式。InboxScreen 也會拆分成容器型(InboxScreenComponent) 及表現型 (PureInboxScreenComponent)

修改 taske.state.ts

增加 Error 相關的欄位及操作

// 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,
    });
  }
}

表現型 PureInboxScreenComponent

表現型會做DOM markup及樣式呈現,如果有 error 就顯示錯誤區塊,否則就呈現 TaskListComponent,使用@Input()來接收值

// src/app/components/pure-inbox-screen.component.ts

import { Component, OnInit, 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 implements OnInit {
  @Input() error: any;

  constructor() {}

  ngOnInit() {}
}

容器型 InboxScreenComponent

容器型會做資料及狀態管理,把內容利用property binding傳給表現型

// src/app/components/inbox-screen.component.ts

import { Component, OnInit } 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 implements OnInit {
  @Select(TasksState.getError) error$: Observable<any>;

  constructor() {}

  ngOnInit() {}
}

頁面呈現 AppComponent

AppComponent 使用 InboxScreenComponent (容器型)

//src/app/app.component.ts

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  template: `
    <app-inbox-screen></app-inbox-screen>
  `,
})
export class AppComponent {
  title = 'taskbox';
}

調整 AppModule

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

@NgModule({
  declarations: [
    AppComponent,
    InboxScreenComponent, 
    PureInboxScreenComponent
  ],
  ...
})
export class AppModule { }

Stories 使用 表現型元件

pure-inbox-screen.stories.ts

// src/app/components/pure-inbox-screen.stories.ts

import { moduleMetadata } from '@storybook/angular';
import { PureInboxScreenComponent } from './pure-inbox-screen.component';
import { TaskModule } from './task.module';
export default {
  title: 'PureInboxScreen',
  decorators: [
    moduleMetadata({
      imports: [TaskModule],
    }),
  ],
};
// inbox screen default state
export const Default = () => ({
  component: PureInboxScreenComponent,
});

// inbox screen error state
export const error = () => ({
  component: PureInboxScreenComponent,
  props: {
    error: true,
  },
});

現在Storybook有出現PureInboxScreen的元件清單,但Default Story,還需要把內容傳給它做呈現

Supplying context to stories

// src/app/components/pure-inbox-screen.stories.ts

import { moduleMetadata } from '@storybook/angular';
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',
  decorators: [
    moduleMetadata({
      imports: [TaskModule, NgxsModule.forRoot([TasksState])],
      providers: [Store],
    }),
  ],
};
// inbox screen default state
export const Default = () => ({
  component: PureInboxScreenComponent,
});

// inbox screen error state
export const error = () => ({
  component: PureInboxScreenComponent,
  props: {
    error: true,
  },
});

小結

本篇修改的程式碼:https://git.io/JUSl2

Next

接下來要介紹如何使用Storybook做出文件,讓程式碼即是可以使用的說明文檔。

Reference

Construct a screen - Angular


上一篇
玩轉 Storybook: Day 21 Wire in data - Angular & ngxs
下一篇
玩轉 Storybook: Day 23 DocsPage and Doc Blocks
系列文
玩轉 Storybook30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言