组装一个复合组件
上一章我们构建了第一个组件;本章将扩展我们所学的知识来构建 TaskList,一个任务列表。让我们将组件组合在一起,看看引入更多复杂性时会发生什么。
任务列表
Taskbox 通过将固定的任务放置在默认任务之上来突出显示它们。这产生了您需要为其创建故事的 TaskList
的两种变体:默认项以及默认项和固定项。
由于 Task
数据可以异步发送,因此我们还需要一个加载状态,以便在没有连接时呈现。此外,当没有任务时,还需要一个空状态。
准备设置
复合组件与其包含的基本组件没有太大区别。创建一个 TaskList
模板和一个随附的故事文件:app/components/task-list.hbs
和 app/components/task-list.stories.js
。
从 TaskList
的粗略实现开始。您需要从前面导入 Task
组件,并将属性和操作作为输入传入。
{{#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
的测试状态。
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
组件的真正复杂性在边缘情况 withPinnedTasks
、loading
和 empty
中揭示出来。
对于加载边缘情况,我们将创建一个新组件,该组件将显示正确的标记。
创建一个名为 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
更新为以下内容
{{#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
的新文件,内容如下
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 进行单元测试
Storybook 故事与手动可视化测试相结合,在避免 UI 错误方面大有裨益。如果故事涵盖了各种组件用例,并且我们使用工具来确保人工检查故事的任何更改,则错误的可能性会大大降低。
然而,有时细节决定成败。需要一个明确说明这些细节的测试框架。这就引出了单元测试。
在我们的例子中,我们希望我们的 TaskList
在未固定的任务之前呈现它在 tasks
属性中传递的任何固定任务。尽管我们有一个故事 (WithPinnedTasks
) 来测试这个确切的场景,但对于人工审查员来说,如果组件停止像这样对任务进行排序,则可能不明确这是一个错误。对于不经意的眼睛来说,它肯定不会尖叫“错误!”。
因此,为了避免这个问题,我们可以使用 Qunit 来呈现组件并运行一些 DOM 查询代码来验证输出的显着特征。
创建一个名为 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');
});
});
与本教程的其他版本相反,使用 Ember,我们无法导入我们之前创建的故事文件中使用的数据和故事,而不会引入大量复杂性,这超出了本教程的范围。现在,我们将复制故事文件中使用的值,以帮助我们进行测试。
另请注意,此测试非常脆弱。随着项目的成熟,Task
的确切实现可能会发生变化——可能使用不同的类名或 textarea
而不是 input
——测试将失败,并且需要更新。这不一定是一个问题,而是一个迹象,表明要谨慎地对 UI 自由使用单元测试。它们不容易维护。相反,尽可能依赖可视化、快照和视觉回归(请参阅测试章节)测试。