组装一个复合组件
在上一章中,我们构建了第一个组件;本章将扩展我们所学的知识来创建 TaskList,一个任务列表。让我们将组件组合在一起,看看当我们引入更多复杂性时会发生什么。
任务列表
Taskbox 通过将置顶任务放在默认任务之上来突出显示它们。它产生了你需要为其创建故事的 TaskList
的两种变体:默认项和置顶项。
由于 Task 数据可以异步发送,我们也需要一个加载状态,以便在没有连接时渲染。此外,当没有任务时,我们需要一个空状态。
设置
复合组件与它包含的基本组件没有太大区别。创建一个 TaskList
组件和一个配套的故事文件:src/app/components/task-list.component.ts
和 src/app/components/task-list.stories.ts
。
从 TaskList
的粗略实现开始。你需要从前面导入 Task
组件,并将属性和操作作为输入传入。
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { Task } from '../models/task.model';
@Component({
selector: 'app-task-list',
standalone: false,
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 default class TaskListComponent {
/** The list of tasks */
@Input() tasks: Task[] = [];
/** Checks if it's in loading state */
@Input() loading = false;
/** Event to change the task to pinned */
// tslint:disable-next-line: no-output-on-prefix
@Output()
onPinTask = new EventEmitter<Event>();
/** Event to change the task to archived */
// tslint:disable-next-line: no-output-on-prefix
@Output()
onArchiveTask = new EventEmitter<Event>();
}
接下来,在故事文件中创建 Tasklist
的测试状态。
import type { Meta, StoryObj } from '@storybook/angular';
import { componentWrapperDecorator, moduleMetadata } from '@storybook/angular';
import { CommonModule } from '@angular/common';
import TaskListComponent from './task-list.component';
import TaskComponent from './task.component';
import * as TaskStories from './task.stories';
const meta: Meta<TaskListComponent> = {
component: TaskListComponent,
title: 'TaskList',
tags: ['autodocs'],
decorators: [
moduleMetadata({
//👇 Imports both components to allow component composition with Storybook
declarations: [TaskListComponent, TaskComponent],
imports: [CommonModule],
}),
//👇 Wraps our stories with a decorator
componentWrapperDecorator(
(story) => `<div style="margin: 3em">${story}</div>`
),
],
args: {
...TaskStories.ActionsData,
},
};
export default meta;
type Story = StoryObj<TaskListComponent>;
export const Default: Story = {
args: {
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' },
],
},
};
export const WithPinnedTasks: Story = {
args: {
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,
},
};
💡装饰器是一种为故事提供任意包装器的方法。在本例中,我们在默认导出上使用装饰器键来在渲染的组件周围添加一些 margin
。它们也可以用于将故事包装在“providers”中——即,设置某些上下文的库组件。
通过导入 TaskStories
,我们能够以最小的努力组合故事中的参数(简称 args)。这样,两个组件期望的数据和操作(模拟回调)都得以保留。
现在检查 Storybook 中新的 TaskList
故事。
构建状态
我们的组件仍然很粗糙,但现在我们对要实现的故事有了一个想法。你可能会认为 .list-items
包装器过于简单。你是对的——在大多数情况下,我们不会仅仅为了添加一个包装器而创建一个新组件。但是 TaskList
组件的真正复杂性在 withPinnedTasks
、loading
和 empty
这些边缘情况下显现出来。
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { Task } from '../models/task.model';
@Component({
selector: 'app-task-list',
standalone: false,
+ 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>
+ <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 default class TaskListComponent {
- /** The list of tasks */
- @Input() tasks: Task[] = [];
+ /**
+ * @ignore
+ * Component property to define ordering of tasks
+ */
+ 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[]) {
+ 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'
+ );
+ }
}
添加的标记会产生以下 UI
请注意列表中置顶项的位置。我们希望置顶项在列表顶部渲染,使其成为我们用户的首要任务。
数据要求
随着组件的增长,输入要求也随之增加。使用 TypeScript 定义 TaskList
组件的数据要求。由于 Task
是一个子组件,请确保以正确的形状提供数据以进行渲染。为了节省时间和避免麻烦,请重用你在前面 task.model.ts
中定义的模型。