返回到Storybook 简介
章节
  • 开始使用
  • 简单组件
  • 复合组件
  • 数据
  • 屏幕
  • 部署
  • 测试
  • 插件
  • 结论
  • 贡献

组装一个复合组件

用更简单的组件组装一个复合组件
此社区翻译尚未更新至最新版本的 Storybook。请帮助我们更新它,方法是将英语指南中的更改应用于此翻译。 欢迎提交 Pull Request.

上一章我们构建了第一个组件;本章将扩展我们所学的知识来构建 TaskList,一个任务列表。让我们将组件组合在一起,看看引入更多复杂性时会发生什么。

任务列表

Taskbox 通过将固定的任务放置在默认任务之上来突出显示它们。这产生了您需要为其创建故事的 TaskList 的两种变体:默认项以及默认项和固定项。

default and pinned tasks

由于 Task 数据可以异步发送,因此我们需要一个加载状态,以便在没有连接时呈现。此外,当没有任务时,还需要一个空状态。

empty and loading tasks

准备设置

复合组件与其包含的基本组件没有太大区别。创建一个 TaskList 模板和一个随附的故事文件:app/components/task-list.hbsapp/components/task-list.stories.js

TaskList 的粗略实现开始。您需要从前面导入 Task 组件,并将属性和操作作为输入传入。

复制
app/components/task-list.hbs
{{#if @loading}}
 <div class="list-items">loading</div>>
{{else if @tasks}}
  {{#each @tasks as |task|}}
    <Task
      @task={{task}}
      @pin={{fn @pinTask task.id}}
      @archive={{fn @archiveTask task.id}}
    />
  {{/each}}
{{else}}
  <div class="list-items">
    empty
  </div>
{{/if}}

接下来在故事文件中创建 Tasklist 的测试状态。

复制
app/components/task-list.stories.js
import { hbs } from 'ember-cli-htmlbars';

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

export default {
  title: 'TaskList',
  component: 'TaskList',
};

const Template = args => ({
  template: hbs`
    <div style="margin: 3em">
      <TaskList
        @tasks={{this.tasks}}
        @pinTask={{fn this.onPinTask}}
        @archiveTask={{fn this.onArchiveTask}}
        @loading={{this.loading}}/>
    </div>`,
  context: args,
});

export const Default = Template.bind({});
Default.args = {
  // Shaping the stories through args composition.
  // The data was inherited from the Default story in task.stories.js.
  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' },
  ],
  ...TaskStories.actionsData,
};

export const WithPinnedTasks = Template.bind({});
WithPinnedTasks.args = {
  // Shaping the stories through args composition.
  // Inherited data coming from the Default story.
  ...Default.args,
  tasks: [
    ...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 Empty = Template.bind({});
Empty.args = {
  ...Loading.args,
  loading: false,
};

通过导入 TaskStories,我们能够以最小的努力组合故事中的参数(简称为 args)。这样,两个组件期望的数据和操作(模拟回调)都得以保留。

现在检查 Storybook 中的新 TaskList 故事。

构建状态

我们的组件仍然很粗糙,但现在我们对要实现的故事有了一个概念。您可能会认为 .list-items 包装器过于简单。您是对的——在大多数情况下,我们不会创建一个新组件只是为了添加一个包装器。但是 TaskList 组件的真正复杂性在边缘情况 withPinnedTasksloadingempty 中揭示出来。

对于加载边缘情况,我们将创建一个新组件,该组件将显示正确的标记。

创建一个名为 loading-row.hbs 的新文件,并在其中添加以下标记

复制
app/components/loading-row.hbs
<div class="loading-item">
  <span class="glow-checkbox" />
  <span class="glow-text">
    <span>Loading</span>
    <span>cool</span>
    <span>state</span>
  </span>
</div>

并将 task-list.hbs 更新为以下内容

复制
app/components/task-list.hbs
{{#if @loading}}
  <LoadingRow />
  <LoadingRow />
  <LoadingRow/>
  <LoadingRow />
  <LoadingRow />
{{else if this.tasksInOrder}}
  {{#each this.tasksInOrder as |task|}}
    <Task
       @task={{task}}
       @pin={{fn @pinTask task.id}}
       @archive={{fn @archiveTask task.id}}
    />
   {{/each}}
{{else}}
  <div class="list-items">
    <div class="wrapper-message">
      <span class="icon-check" />
      <div class="title-message">You have no tasks</div>
      <div class="subtitle-message">Sit back and relax</div>
    </div>
  </div>
{{/if}}

最后创建一个名为 task-list.js 的新文件,内容如下

复制
app/components/task-list.js
import Component from '@glimmer/component';

export default class TaskList extends Component {
  // computed property to arrange the tasks per their state
  get tasksInOrder() {
    return [
      ...this.args.tasks.filter(t => t.state === 'TASK_PINNED'),
      ...this.args.tasks.filter(t => t.state !== 'TASK_PINNED'),
    ];
  }
}

添加的标记会导致以下 UI

注意列表中固定项的位置。我们希望将固定项呈现在列表顶部,使其成为用户的首要任务。

自动化测试

由于 TaskList 添加了另一层复杂性,我们希望验证某些输入是否以适合自动测试的方式产生某些输出。为此,我们将使用 Qunit 和测试渲染器创建单元测试。

Qunit logo

使用 Qunit 进行单元测试

Storybook 故事与手动可视化测试相结合,在避免 UI 错误方面大有裨益。如果故事涵盖了各种组件用例,并且我们使用工具来确保人工检查故事的任何更改,则错误的可能性会大大降低。

然而,有时细节决定成败。需要一个明确说明这些细节的测试框架。这就引出了单元测试。

在我们的例子中,我们希望我们的 TaskList 在未固定的任务之前呈现它在 tasks 属性中传递的任何固定任务。尽管我们有一个故事 (WithPinnedTasks) 来测试这个确切的场景,但对于人工审查员来说,如果组件停止像这样对任务进行排序,则可能不明确这是一个错误。对于不经意的眼睛来说,它肯定不会尖叫“错误!”

因此,为了避免这个问题,我们可以使用 Qunit 来呈现组件并运行一些 DOM 查询代码来验证输出的显着特征。

创建一个名为 tests/integration/task-list-test.js 的测试文件。在这里,我们将构建我们的测试,以对输出做出断言。

复制
tests/integration/task-list-test.js
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import { render } from '@ember/test-helpers';
import { hbs } from 'ember-cli-htmlbars';

module('Integration | Component | TaskList', function(hooks) {
  setupRenderingTest(hooks);
  const taskData = {
    id: '1',
    title: 'Test Task',
    state: 'TASK_INBOX',
    updatedAt: new Date(2018, 0, 1, 9, 0),
  };
  const tasklist = [
    { ...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 (pinned)', state: 'TASK_PINNED' },
  ];

  test('renders pinned tasks at the start of the list', async function(assert) {
    this.tasks = tasklist;
    await render(hbs`<TaskList @tasks={{this.tasks}}/>`);
    assert.dom('[data-test-task]:nth-of-type(1)').hasClass('TASK_PINNED');
  });
});

TaskList test runner

与本教程的其他版本相反,使用 Ember,我们无法导入我们之前创建的故事文件中使用的数据和故事,而不会引入大量复杂性,这超出了本教程的范围。现在,我们将复制故事文件中使用的值,以帮助我们进行测试。

另请注意,此测试非常脆弱。随着项目的成熟,Task 的确切实现可能会发生变化——可能使用不同的类名或 textarea 而不是 input——测试将失败,并且需要更新。这不一定是一个问题,而是一个迹象,表明要谨慎地对 UI 自由使用单元测试。它们不容易维护。相反,尽可能依赖可视化、快照和视觉回归(请参阅测试章节)测试。

💡 别忘了使用 git 提交您的更改!
这个免费指南对您有帮助吗?发推文以表示赞赏并帮助其他开发人员找到它。
下一章
数据
了解如何将数据连接到您的 UI 组件
✍️ 在 GitHub 上编辑 – 欢迎提交 PR!
加入社区
6,721位开发者及更多
为什么为什么选择 Storybook组件驱动的 UI
开源软件
Storybook - Storybook 中文

特别感谢 Netlify CircleCI