返回Storybook 简介
章节
  • 开始上手
  • 简单的组件
  • 复合组件
  • 数据
  • 页面
  • 部署
  • 视觉测试
  • 可访问性测试
  • 结论
  • 贡献

集成数据

了解如何将数据集成到你的 UI 组件中

到目前为止,我们已经创建了独立的无状态组件——非常适合 Storybook,但直到我们为它们提供一些应用程序中的数据,它们最终都没有多大用处。

本次教程不侧重于构建应用程序的细节,所以我们在这里不深入探讨。但我们会花点时间来看一个常见的模式,即如何通过容器组件来连接数据。

容器组件

在本教程中,我们将使用 Angular 的 Signals,这是一个强大的响应式系统,提供了显式、细粒度的响应式原始类型来实现一个简单的 store。我们将使用 signal 来构建应用程序的简单数据模型,并帮助我们管理任务的状态。

首先,我们将构建一个简单的 store,它响应在 src/app/state 目录下的 store.ts 文件中的操作,这些操作会改变任务的状态(故意保持简单)。

复制
src/app/state/store.ts
// A simple Angular state management implementation using signals update methods and initial data.
// A true app would be more complex and separated into different files.
import type { TaskData } from '../types';

import { Injectable, signal, computed } from '@angular/core';

interface TaskBoxState {
  tasks: TaskData[];
  status: 'idle' | 'loading' | 'error' | 'success';
  error: string | null;
}

/*
 * The initial state of our store when the app loads.
 * Usually, you would fetch this from a server. Let's not worry about that now
 */
const defaultTasks: TaskData[] = [
  { 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' },
];

const initialState: TaskBoxState = {
  tasks: defaultTasks,
  status: 'idle',
  error: null,
};

@Injectable({
  providedIn: 'root',
})
export class Store {
  private state = signal<TaskBoxState>(initialState);

  // Public readonly signal for components to subscribe to
  readonly tasks = computed(() => this.state().tasks);
  readonly status = computed(() => this.state().status);
  readonly error = computed(() => this.state().error);

  readonly getFilteredTasks = computed(() => {
    const filteredTasks = this.state().tasks.filter(
      (t) => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED'
    );
    return filteredTasks;
  });

  archiveTask(id: string): void {
    this.state.update((currentState) => {
      const filteredTasks = currentState.tasks
        .map(
          (task): TaskData =>
            task.id === id ? { ...task, state: 'TASK_ARCHIVED' as TaskData['state'] } : task
        )
        .filter((t) => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED');

      return {
        ...currentState,
        tasks: filteredTasks,
      };
    });
  }

  pinTask(id: string): void {
    this.state.update((currentState) => ({
      ...currentState,
      tasks: currentState.tasks.map((task) =>
        task.id === id ? { ...task, state: 'TASK_PINNED' } : task
      ),
    }));
  }
}

然后,我们将更新我们的 TaskList 来从 store 中读取数据。首先,让我们将现有的展示型版本移到 src/app/components/pure-task-list.component.ts 文件中,并用一个容器包裹它。

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

复制
src/app/components/pure-task-list.component.ts
/* This file was moved from task-list.component.ts */
import type { TaskData } from '../types';

import { CommonModule } from '@angular/common';
import { Component, Input, Output, EventEmitter } from '@angular/core';

import { TaskComponent } from './task.component';

@Component({
  selector: 'app-pure-task-list',
  standalone: true,
  imports: [CommonModule, TaskComponent],
    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"
        data-testid="empty"
      >
        <span class="icon-check"></span>
        <p class="title-message">You have no tasks</p>
        <p class="subtitle-message">Sit back and relax</p>
      </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 PureTaskListComponent {
  /**
   * @ignore
   * Component property to define ordering of tasks
   */
  tasksInOrder: TaskData[] = [];

  /**
   * Checks if it's in loading state
   */
  @Input() loading = false;

  /**
   * Event to change the task to pinned
   */
  @Output()
  onPinTask = new EventEmitter<Event>();

  /**
   * Event to change the task to archived
   */
  @Output()
  onArchiveTask = new EventEmitter<Event>();

  /**
   * The list of tasks
   */
  @Input()
  set tasks(arr: TaskData[]) {
    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
import { CommonModule } from '@angular/common';
import { Component, inject } from '@angular/core';

import { Store } from '../state/store';

import { PureTaskListComponent } from './pure-task-list.component';

@Component({
  selector: 'app-task-list',
  standalone: true,
  imports: [CommonModule, PureTaskListComponent],
  template: `
    <app-pure-task-list
      [tasks]="store.getFilteredTasks()"
      (onArchiveTask)="store.archiveTask($event)"
      (onPinTask)="store.pinTask($event)"
    ></app-pure-task-list>
  `,
})
export class TaskListComponent {
  store = inject(Store);
}

TaskList 的展示型版本分开的原因是它更容易测试和隔离。由于它不依赖于 store 的存在,因此从测试的角度来看,它更容易处理。让我们将 src/app/components/task-list.stories.ts 重命名为 src/app/components/pure-task-list.stories.ts,并确保我们的 stories 使用展示型版本。

复制
src/app/components/pure-task-list.stories.ts
import type { Meta, StoryObj } from '@storybook/angular';

import { componentWrapperDecorator } from '@storybook/angular';

import { PureTaskListComponent } from './pure-task-list.component';

import * as TaskStories from './task.stories';

export const TaskListData = [
  { ...TaskStories.TaskData, id: '1', title: 'Task 1' },
  { ...TaskStories.TaskData, id: '2', title: 'Task 2' },
  { ...TaskStories.TaskData, id: '3', title: 'Task 3' },
  { ...TaskStories.TaskData, id: '4', title: 'Task 4' },
  { ...TaskStories.TaskData, id: '5', title: 'Task 5' },
  { ...TaskStories.TaskData, id: '6', title: 'Task 6' },
];

const meta: Meta<PureTaskListComponent> = {
  component: PureTaskListComponent,
  title: 'PureTaskList',
  tags: ['autodocs'],
  excludeStories: /.*Data$/,
  decorators: [
    //👇 Wraps our stories with a decorator
    componentWrapperDecorator((story) => `<div style="margin: 3em">${story}</div>`),
  ],
  args: {
    ...TaskStories.TaskData.events,
  },
};

export default meta;
type Story = StoryObj<PureTaskListComponent>;

export const Default: Story = {
  args: {
    // Shaping the stories through args composition.
    // Inherited data coming from the Default story.
    tasks: TaskListData,
  },
};

export const WithPinnedTasks: Story = {
  args: {
    // Shaping the stories through args composition.
    // Inherited data coming from the Default story.
    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: Story = {
  args: {
    tasks: [],
    loading: true,
  },
};

export const Empty: Story = {
  args: {
    // Shaping the stories through args composition.
    // Inherited data coming from the Loading story.
    ...Loading.args,
    loading: false,
  },
};
💡 别忘了使用 git 提交你的更改!

既然我们已经有一些实际数据从 store 中填充到我们的组件中,我们可以将其连接到 src/app.ts 并在那里渲染组件。不用担心,我们将在下一章处理这个问题。

使您的代码与本章保持同步。在 GitHub 上查看 db7a1a2。
这个免费指南对您有帮助吗?请发推文表示赞赏,帮助其他开发者找到它。
下一章
页面
用组件构建一个页面
✍️ 在 GitHub 上编辑 – PR 欢迎!
加入社区
7,424开发者及更多
原因为什么选择 Storybook组件驱动的 UI
文档指南教程更新日志遥测
社区插件参与进来博客
展示探索关于
开源软件
Storybook - Storybook 中文

特别感谢 Netlify CircleCI