Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Chore: update angular documentation #228

Merged
merged 15 commits into from
Jan 24, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
233 changes: 120 additions & 113 deletions content/intro-to-storybook/angular/en/composite-component.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,42 +9,49 @@ Last chapter we built our first component; this chapter extends what we learned

## TasklistComponent

Taskbox emphasizes pinned tasks by positioning them above default tasks. This yields two variations of `TaskListComponent` you need to create stories for: default items and default and pinned items.
Taskbox emphasizes pinned tasks by positioning them above default tasks. This yields two variations of `TaskList` you need to create stories for: default items and default and pinned items.

![default and pinned tasks](/intro-to-storybook/tasklist-states-1.png)

Since `TaskComponent` data can be sent asynchronously, we **also** need a loading state to render in the absence of a connection. In addition, an empty state is required when there are no tasks.
Since `Task` data can be sent asynchronously, we **also** need a loading state to render in the absence of a connection. In addition, an empty state is required when there are no tasks.

![empty and loading tasks](/intro-to-storybook/tasklist-states-2.png)

## Get setup

A composite component isn’t much different than the basic components it contains. Create a `TaskListComponent` component and an accompanying story file: `src/tasks/task-list.component.ts` and `src/tasks/task-list.stories.ts`.
A composite component isn’t much different than the basic components it contains. Create a `TaskList` component and an accompanying story file: `src/app/components/task-list.component.ts` and `src/app/components/task-list.stories.ts`.

Start with a rough implementation of the `TaskListComponent`. You’ll need to import the `TaskComponent` component from earlier and pass in the attributes and actions as inputs and events.
Start with a rough implementation of the `TaskList`. You’ll need to import the `Task` component from earlier and pass in the attributes and actions as inputs and events.

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

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

@Component({
selector: 'task-list',
selector: 'app-task-list',
template: `
<div class="list-items">
<task-item
*ngFor="let task of tasksInOrder"
<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)"
>
</task-item>
</app-task>
</div>
`,
})
export class TaskListComponent implements OnInit {
tasks: Task[] = [];
@Input() loading: boolean = false;
@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() {}
Expand All @@ -56,92 +63,89 @@ export class TaskListComponent implements OnInit {
Next create `Tasklist`’s test states in the story file.

```typescript
import { storiesOf, moduleMetadata } from '@storybook/angular';
import { CommonModule } from '@angular/common';
// src/app/components/task-list.stories.ts

import { TaskComponent } from './task.component';
import { moduleMetadata } from '@storybook/angular';
import { CommonModule } from '@angular/common';
import { TaskListComponent } from './task-list.component';
import { task, actions } from './task.stories';

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

export const withPinnedTasks = [
...defaultTasks.slice(0, 5),
{ id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' },
];

const props = {
tasks: defaultTasks,
onPinTask: actions.onPinTask,
onArchiveTask: actions.onArchiveTask,
};
import { TaskComponent } from './task.component';
import { taskData, actionsData } from './task.stories';

storiesOf('TaskList', module)
.addDecorator(
export default {
title: 'TaskList',
excludeStories: /.*Data$/,
decorators: [
moduleMetadata({
// imports both components to allow component composition with storybook
declarations: [TaskListComponent, TaskComponent],
imports: [CommonModule],
})
)
.add('default', () => {
return {
template: `
<div style="padding: 3rem">
<task-list [tasks]="tasks" (onPinTask)="onPinTask($event)" (onArchiveTask)="onArchiveTask($event)"></task-list>
</div>
`,
props,
};
})
.add('withPinnedTasks', () => {
return {
template: `
<div style="padding: 3rem">
<task-list [tasks]="tasks" (onPinTask)="onPinTask($event)" (onArchiveTask)="onArchiveTask($event)"></task-list>
</div>
`,
props: {
...props,
tasks: withPinnedTasks,
},
};
})
.add('loading', () => {
return {
template: `
}),
],
};

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">
<task-list [tasks]="[]" loading="true" (onPinTask)="onPinTask($event)" (onArchiveTask)="onArchiveTask($event)"></task-list>
<app-task-list [tasks]="[]" loading="true" (onPinTask)="onPinTask($event)" (onArchiveTask)="onArchiveTask($event)"></app-task-list>
</div>
`,
props,
};
})
.add('empty', () => {
return {
template: `
});
// tasklist no tasks
export const Empty = () => ({
template: `
<div style="padding: 3rem">
<task-list [tasks]="[]" (onPinTask)="onPinTask($event)" (onArchiveTask)="onArchiveTask($event)"></task-list>
<app-task-list [tasks]="[]" (onPinTask)="onPinTask($event)" (onArchiveTask)="onArchiveTask($event)"></app-task-list>
</div>
`,
props,
};
});
});
```

`addDecorator()` allows us to add some “context” to the rendering of each task. In this case we add the module metadata so we can use all the Angular components inside out stories.

<div class="aside">
<a href="https://storybook.js.org/addons/introduction/#1-decorators"><b>Decorators</b></a> are a way to provide arbitrary wrappers to stories. In this case were using a decorator to add metadata.
<a href="https://storybook.js.org/addons/introduction/#1-decorators"><b>Decorators</b></a> are a way to provide arbitrary wrappers to stories. In this case we're using a decorator key in the default export to add some metadata that is required. But they can also be used to add other context to components, as we'll see later.
</div>

`task` supplies the shape of a `Task` that we created and exported from the `task.stories.ts` file. Similarly, `actions` defines the actions (mocked callbacks) that a `TaskComponent` expects, which the `TaskListComponent` also needs.
`taskData` supplies the shape of a `Task` that we created and exported from the `task.stories.ts` file. Similarly, `actionsData` defines the actions (mocked callbacks) that a `TaskComponent` expects, which the `TaskListComponent` also needs.

Now check Storybook for the new `TaskList` stories.

Expand All @@ -154,23 +158,25 @@ Now check Storybook for the new `TaskList` stories.

## Build out the states

Our component is still rough but now we have an idea of the stories to work toward. You might be thinking that the `.list-items` wrapper is overly simplistic. You're right – in most cases we wouldn’t create a new component just to add a wrapper. But the **real complexity** of `TaskListComponent` is revealed in the edge cases `withPinnedTasks`, `loading`, and `empty`.
Our component is still rough but now we have an idea of the stories to work toward. You might be thinking that the `.list-items` wrapper is overly simplistic. You're right – in most cases we wouldn’t create a new component just to add a wrapper. But the **real complexity** of `TaskListComponent` is revealed in the edge cases `WithPinnedTasks`, `loading`, and `empty`.

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

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

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

<div *ngIf="tasksInOrder.length === 0 && !loading" class="wrapper-message">
<span class="icon-check"></span>
Expand All @@ -189,8 +195,12 @@ import { Task } from './task.model';
})
export class TaskListComponent implements OnInit {
tasksInOrder: Task[] = [];
@Input() loading: boolean = false;
@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()
Expand Down Expand Up @@ -234,46 +244,43 @@ Storybook stories paired with manual visual tests and snapshot tests (see above)

However, sometimes the devil is in the details. A test framework that is explicit about those details is needed. Which brings us to unit tests.

In our case, we want our `TaskListComponent` to render any pinned tasks **before** unpinned tasks that it is passed in the `tasks` prop. Although we have a story (`withPinnedTasks`) to test this exact scenario; it can be ambiguous to a human reviewer that if the component **stops** ordering the tasks like this, it is a bug. It certainly won’t scream **“Wrong!”** to the casual eye.
In our case, we want our `TaskListComponent` to render any pinned tasks **before** unpinned tasks that it is passed in the `tasks` prop. Although we have a story (`WithPinnedTasks`) to test this exact scenario; it can be ambiguous to a human reviewer that if the component **stops** ordering the tasks like this, it is a bug. It certainly won’t scream **“Wrong!”** to the casual eye.

So, to avoid this problem, we can use Jest to render the story to the DOM and run some DOM querying code to verify salient features of the output.

Create a test file called `task-list.component.spec.ts`. Here we’ll build out our tests that make assertions about the output.

```typescript
import { TestBed, async, ComponentFixture } from '@angular/core/testing';
// src/app/components/task-list.component.spec.ts

import { render } from '@testing-library/angular';
import { TaskListComponent } from './task-list.component';
import { TaskComponent } from './task.component';

import { withPinnedTasks } from './task-list.stories';
import { By } from '@angular/platform-browser';

import { withPinnedTasksData } from './task-list.stories';
describe('TaskList component', () => {
let component: TaskListComponent;
let fixture: ComponentFixture<TaskListComponent>;

beforeEach(async(() => {
TestBed.configureTestingModule({
declarations: [TaskComponent, TaskListComponent],
}).compileComponents();
}));

it('renders pinned tasks at the start of the list', () => {
fixture = TestBed.createComponent(TaskListComponent);
component = fixture.componentInstance;
component.tasks = withPinnedTasks;

fixture.detectChanges();
const lastTaskInput = fixture.debugElement.query(By.css('.list-item:nth-child(1)'));

// We expect the task titled "Task 6 (pinned)" to be rendered first, not at the end
expect(lastTaskInput.nativeElement.id).toEqual('6');
it('renders pinned tasks at the start of the list', async () => {
const mockedActions = jest.fn();
const tree = await render(TaskListComponent, {
declarations: [TaskComponent],
componentProperties: {
tasks: withPinnedTasksData,
loading: false,
onPinTask: {
emit: mockedActions,
} as any,
onArchiveTask: {
emit: mockedActions,
} as any,
},
});
const component = tree.fixture.componentInstance;
expect(component.tasksInOrder[0].id).toBe('6');
});
});
```

![TaskList test runner](/intro-to-storybook/tasklist-testrunner.png)

Note that we’ve been able to reuse the `withPinnedTasks` list of tasks in both story and unit test; in this way we can continue to leverage an existing resource (the examples that represent interesting configurations of a component) in more and more ways.
Note that we’ve been able to reuse the `withPinnedTasksData` list of tasks in both story and unit test; in this way we can continue to leverage an existing resource (the examples that represent interesting configurations of a component) in more and more ways.

Notice as well that this test is quite brittle. It's possible that as the project matures, and the exact implementation of the `TaskComponent` changes --perhaps using a different classname or a `textarea` rather than an `input`--the test will fail, and need to be updated. This is not necessarily a problem, but rather an indication to be careful liberally using unit tests for UI. They're not easy to maintain. Instead rely on visual, snapshot, and visual regression (see [testing chapter](/test/)) tests where possible.
Notice as well that this test is quite brittle. It's possible that as the project matures, and the exact implementation of the `TaskComponent` changes --perhaps using a different class name or a `textarea` rather than an `input`--the test will fail, and need to be updated. This is not necessarily a problem, but rather an indication to be careful liberally using unit tests for UI. They're not easy to maintain. Instead rely on visual, snapshot, and visual regression (see [testing chapter](/angular/en/test/)) tests where possible.
2 changes: 1 addition & 1 deletion content/intro-to-storybook/angular/en/conclusion.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Congratulations! You created your first UI in Storybook. Along the way you learn
<br/>
[🌎 **Deployed Storybook**](https://clever-banach-415c03.netlify.com/)

Storybook is a powerful tool for React, Vue, and Angular. It has a thriving developer community and a wealth of addons. This introduction scratches the surface of what’s possible. We’re confident that once you adopt Storybook, you’ll be impressed by how productive it is to build durable UIs.
Storybook is a powerful tool for React, React Native, Vue, Angular, Svelte and many others frameworks. It has a thriving developer community and a wealth of addons. This introduction scratches the surface of what’s possible. We’re confident that once you adopt Storybook, you’ll be impressed by how productive it is to build durable UIs.

## Learn more

Expand Down
Loading