iT邦幫忙

第 12 屆 iThome 鐵人賽

DAY 13
1
Modern Web

玩轉 Storybook系列 第 13

玩轉 Storybook: Day 13 Simple and Composite - Angular

  • 分享至 

  • xImage
  •  

在這個單元我們使用官網的教學搭配Angular的框架,STEP BY STEP的撰寫Component及Stories。(如果是使用Vue的讀者,可以跳過這個單元)

Setup Angular Storybook

官網教學參考:Angular

# Create our application, using a preset that contains jest:
$ ng new ng-taskbox --style css

$ cd ng-taskbox

# Add Storybook:
$ npx sb init

依照需要,在開發時期你可以會執行三種模式

# 執行 Unit Test
$ npm run test:unit

# 執行 Storybook
$ npm run storybook

# 執行專案網頁
$ npm run serve

因為這個單元是專注於開發 Single Component,所以目前我們只要執行 Storybook。

Reuse CSS

範例提供的CSS:請按此

把這個CSS檔案下載到src/styles.css

Add Assets

$ npx degit chromaui/learnstorybook-code/public/font src/assets/font
$ npx degit chromaui/learnstorybook-code/public/icon src/assets/icon

Simple component

官網教學參考:Angular

在這個範例中,關鍵的元件是Task,每種狀態的Task都有其代表的意思,用Checkbox來表示是否完成任務,用Pin Button(圖上星星符號)來做重要任務置頂,以及中間要顯示Task的文字。經過分析後,Task元件需要二個props

  • title - 用來描述任務
  • state - 任務的狀態

初步建立 Task 元件

// src/app/components/task.component.ts
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';

@Component({
  selector: 'app-task',
  template: `
    <div class="list-item">
      <input type="text" [value]="task.title" readonly="true" />
    </div>
  `,
})
export class TaskComponent implements OnInit {
  title: string;
  @Input() task: any;

  // tslint:disable-next-line: no-output-on-prefix
  @Output() onPinTask: EventEmitter<any> = new EventEmitter();

  // tslint:disable-next-line: no-output-on-prefix
  @Output() onArchiveTask: EventEmitter<any> = new EventEmitter();

  constructor() {}

  ngOnInit() {}
}

初步建立 Task Stories

// src/app/components/task.stories.ts
import { action } from '@storybook/addon-actions';
import { TaskComponent } from './task.component';
export default {
  title: 'Task',
  excludeStories: /.*Data$/,
};

export const actionsData = {
  onPinTask: action('onPinTask'),
  onArchiveTask: action('onArchiveTask'),
};

export const taskData = {
  id: '1',
  title: 'Test Task',
  state: 'Task_INBOX',
  updated_at: new Date(2019, 0, 1, 9, 0),
};
export const Default = () => ({
  component: TaskComponent,
  props: {
    task: taskData,
    onPinTask: actionsData.onPinTask,
    onArchiveTask: actionsData.onArchiveTask,
  },
});
// pinned task state
export const Pinned = () => ({
  component: TaskComponent,
  props: {
    task: {
      ...taskData,
      state: 'TASK_PINNED',
    },
    onPinTask: actionsData.onPinTask,
    onArchiveTask: actionsData.onArchiveTask,
  },
});
// archived task state
export const Archived = () => ({
  component: TaskComponent,
  props: {
    task: {
      ...taskData,
      state: 'TASK_ARCHIVED',
    },
    onPinTask: actionsData.onPinTask,
    onArchiveTask: actionsData.onArchiveTask,
  },
});

目前Storybook運行畫面

調整 Config

接下來要做一些調整,讓Storybook只顯示components目錄下的stories

因為是修改.storybookconfig設定檔案,調整完成後記得要重啟Storybook

目前運行畫面

已經只有變成我們要看的components目錄下的元件

增加 task 型別

// src/app/models/task.model.ts

export interface Task {
  id: string;
  title: string;
  state: string;
}

完善 Task 元件 加上狀態

// src/app/components/task.component.ts
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
import { Task } from '../models/task.model';
@Component({
  selector: 'app-task',
  template: `
    <div class="list-item {{ task?.state }}">
      <label class="checkbox">
        <input
          type="checkbox"
          [defaultChecked]="task?.state === 'TASK_ARCHIVED'"
          disabled="true"
          name="checked"
        />
        <span class="checkbox-custom" (click)="onArchive(task.id)"></span>
      </label>
      <div class="title">
        <input type="text" [value]="task?.title" readonly="true" placeholder="Input title" />
      </div>
      <div class="actions">
        <a *ngIf="task?.state !== 'TASK_ARCHIVED'" (click)="onPin(task.id)">
          <span class="icon-star"></span>
        </a>
      </div>
    </div>
  `,
})
export class TaskComponent implements OnInit {
  title: string;
  @Input() task: Task;

  // tslint:disable-next-line: no-output-on-prefix
  @Output() onPinTask: EventEmitter<any> = new EventEmitter();

  // tslint:disable-next-line: no-output-on-prefix
  @Output() onArchiveTask: EventEmitter<any> = new EventEmitter();

  constructor() {}

  ngOnInit() {}

  onPin(id: any) {
    this.onPinTask.emit(id);
  }
  onArchive(id: any) {
    this.onArchiveTask.emit(id);
  }
}

小結

目前運行畫面

整個元件建置的過程,並沒有啟動專案本身的運行環境,僅僅只有啟動Storybook!

這也就表示在元件建置階段的時候我們只要關心元件的開發就可以了!

程式碼下載

tag: simple-component https://git.io/JU0Dj

Composite component

官網教學參考:Angular

TaskList

可以把TaskList規劃成四種狀態

  • Default - 當 Task 都是 defualt 狀態的時候
  • Pinned - 當有 default task 也有 Pinned task 時,Pinned Task 要擺在 TaskList 的最上面
  • Empty - 當 Task 全數完成清空的時候
  • Loading - 當 Task 正在被載入的時候

Get Setup

接下來就可以來建立 TaskList.vue 及 TaskList.stories.vue

task-list.component.ts

// src/app/components/task-list.component.ts

import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
import { Task } from '../models/task.model';

@Component({
  selector: 'app-task-list',
  template: `
    <div class="list-items">
      <div *ngIf="loading">loading</div>
      <div *ngIf="!loading && tasks.length === 0">empty</div>
      <app-task
        *ngFor="let task of tasks"
        [task]="task"
        (onArchiveTask)="onArchiveTask.emit($event)"
        (onPinTask)="onPinTask.emit($event)"
      >
      </app-task>
    </div>
  `,
})
export class TaskListComponent implements OnInit {
  @Input() tasks: Task[] = [];
  @Input() loading = false;

  // tslint:disable-next-line: no-output-on-prefix
  @Output() onPinTask: EventEmitter<any> = new EventEmitter();
  // tslint:disable-next-line: no-output-on-prefix
  @Output() onArchiveTask: EventEmitter<any> = new EventEmitter();

  constructor() {}

  ngOnInit() {}
}

components.module.ts

因為TaskList會用到Task,因此我們要把它們都宣告在Module中

TypeScript就不會發生引用的問題

另外因為未來元件可能會在不同的Module使用

所以也一併把它們export出來

import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TaskComponent } from './task.component';
import { TaskListComponent } from './task-list.component';

const components = [
    TaskComponent,
    TaskListComponent
];

@NgModule({
  declarations: [...components],
  imports: [
    CommonModule
  ],
  exports: [...components]
})
export class ComponentsModule { }

task-list.stories.ts

// src/app/components/task-list.stories.ts

import { moduleMetadata } from '@storybook/angular';
import { CommonModule } from '@angular/common';
import { TaskListComponent } from './task-list.component';
import { taskData, actionsData } from './task.stories';
import { ComponentsModule } from './components.module';

export default {
  title: 'TaskList',
  excludeStories: /.*Data$/,
  decorators: [
    moduleMetadata({
      imports: [CommonModule, ComponentsModule],
    }),
  ],
};

export const defaultTasksData = [
  { ...taskData, id: '1', title: 'Task 1' },
  { ...taskData, id: '2', title: 'Task 2' },
  { ...taskData, id: '3', title: 'Task 3' },
  { ...taskData, id: '4', title: 'Task 4' },
  { ...taskData, id: '5', title: 'Task 5' },
  { ...taskData, id: '6', title: 'Task 6' },
];
export const withPinnedTasksData = [
  ...defaultTasksData.slice(0, 5),
  { id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' },
];
// default TaskList state
export const Default = () => ({
  component: TaskListComponent,
  template: `
  <div style="padding: 3rem">
    <app-task-list [tasks]="tasks" (onPinTask)="onPinTask($event)" (onArchiveTask)="onArchiveTask($event)"></app-task-list>
  </div>
`,
  props: {
    tasks: defaultTasksData,
    onPinTask: actionsData.onPinTask,
    onArchiveTask: actionsData.onArchiveTask,
  },
});
// tasklist with pinned tasks
export const WithPinnedTasks = () => ({
  component: TaskListComponent,
  template: `
    <div style="padding: 3rem">
      <app-task-list [tasks]="tasks" (onPinTask)="onPinTask($event)" (onArchiveTask)="onArchiveTask($event)"></app-task-list>
    </div>
  `,
  props: {
    tasks: withPinnedTasksData,
    onPinTask: actionsData.onPinTask,
    onArchiveTask: actionsData.onArchiveTask,
  },
});
// tasklist in loading state
export const Loading = () => ({
  template: `
        <div style="padding: 3rem">
          <app-task-list [tasks]="[]" loading="true" (onPinTask)="onPinTask($event)" (onArchiveTask)="onArchiveTask($event)"></app-task-list>
        </div>
      `,
});
// tasklist no tasks
export const Empty = () => ({
  template: `
        <div style="padding: 3rem">
          <app-task-list [tasks]="[]" (onPinTask)="onPinTask($event)" (onArchiveTask)="onArchiveTask($event)"></app-task-list>
        </div>
      `,
});

在以上範例有一個decorators的設定,在Vue的建立元件的單元我們是用來做增加額外的版型設定,在Angular這裡,則是用來增加Context的設定,使用moduleMetadata,讓Stories檔案知道要import哪些module,因為我們有設定好ComponentsModule,所以只要import它即可。

程式碼設置完成後,目前運行的畫面如下圖

Default Task 的部分是完整呈現了

但 WithPinnedTasks 的 Pinned Task 沒有出現在最上面

而 Loading 及 Empty 則還需要加上樣式調整

完善 TaskList

// src/app/components/task-list.component.ts

import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
import { Task } from '../models/task.model';

@Component({
  selector: 'app-task-list',
  template: `
    <div class="list-items">
      <app-task
        *ngFor="let task of tasksInOrder"
        [task]="task"
        (onArchiveTask)="onArchiveTask.emit($event)"
        (onPinTask)="onPinTask.emit($event)"
      >
      </app-task>

      <div *ngIf="tasksInOrder.length === 0 && !loading" class="wrapper-message">
        <span class="icon-check"></span>
        <div class="title-message">You have no tasks</div>
        <div class="subtitle-message">Sit back and relax</div>
      </div>

      <div *ngIf="loading">
        <div *ngFor="let i of [1, 2, 3, 4, 5, 6]" class="loading-item">
          <span class="glow-checkbox"></span>
          <span class="glow-text"> <span>Loading</span> <span>cool</span> <span>state</span> </span>
        </div>
      </div>
    </div>
  `,
})
export class TaskListComponent implements OnInit {
  tasksInOrder: Task[] = [];
  @Input() loading = false;

  // tslint:disable-next-line: no-output-on-prefix
  @Output() onPinTask: EventEmitter<any> = new EventEmitter();

  // tslint:disable-next-line: no-output-on-prefix
  @Output() onArchiveTask: EventEmitter<any> = new EventEmitter();

  @Input()
  set tasks(arr: Task[]) {
    this.tasksInOrder = [
      ...arr.filter(t => t.state === 'TASK_PINNED'),
      ...arr.filter(t => t.state !== 'TASK_PINNED'),
    ];
  }

  constructor() {}

  ngOnInit() {}
}

從差異修改的地方可以看到 Loading 及 Empty 已加上樣式調整

當設定 tasks 時也會把 tasksInOrder 做出來

把 Pinned Task 放在資料的最前頭

小結

目前運行畫面:

整個元件建置的過程,也沒有啟動專案本身的運行環境,僅僅只有啟動Storybook!

程式碼下載

tag: composite-component https://git.io/JU0yf

Reference

Get started:Angular

Simple Component:Angular

Composite Component:Angular


上一篇
玩轉 Storybook: Day 12 Composite component - Vue
下一篇
玩轉 Storybook: Day 14 Addons - Essential / Actions
系列文
玩轉 Storybook30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言