Iremos construir o interface de utilizador de acordo com a metodologia de Desenvolvimento orientada a componentes, ou nativamente por (CDD, Component-Driven Development). É um processo que cria interfaces de utilizador a partir da base para o topo, iniciando com componentes e terminando com ecrãs. O DOC(CDD nativamente) ajuda no escalonamento da complexidade á qual o programador é sujeito á medida que constrói o interface de utilizador.
TaskComponent
é o componente nuclear da nossa aplicação. Cada tarefa é apresentada de forma diferente dependendo do estado em que se encontra.
O que vai ser apresentado é uma caixa de confirmação, selecionada (ou não), alguma informação adicional acerca da tarefa e um botão "fixador", que permite a movimentação para cima e para baixo das tarefas ao longo da lista.
Para que seja possível implementar isto serão necessárias os seguintes adereços (props):
title
- uma cadeia de caracteres que descreve a tarefastate
- qual a lista em que a tarefa se encontra e se está confirmada?Á medida que construimos a TaskComponent
, é necessário definir os três estados que correspondem os três tipos de tarefa delineados acima.
Em seguida usa-se o Storybook para construir este componente isolado, usando dados predefinidos. Irá "testar-se visualmente" a aparência do componente para cada estado á medida que prosseguimos.
Este processo é algo similar ao Desenvolvimento orientado a testes, ou como é conhecido nativamente (TDD), o que neste caso denominamos de "DOT Visual”, nativamente (Visual TDD).
Primeiro irá ser criado o componente tarefa e o ficheiro de estórias que o acompanha:
src/tasks/task.component.ts
e src/tasks/task.stories.ts
respetivamente.
Iremos iniciar por uma implementação básica da TaskComponent
, que recebe os valores de entrada conhecidos até agora, assim como as duas ações que podem ser desencadeadas (a movimentação entre listas):
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
@Component({
selector: 'task-item',
template: `
<div class="list-item">
<input type="text" [value]="task.title" readonly="true" />
</div>
`,
})
export class TaskComponent implements OnInit {
title: string;
@Input() task: any;
@Output() onPinTask: EventEmitter<any> = new EventEmitter();
@Output() onArchiveTask: EventEmitter<any> = new EventEmitter();
constructor() {}
ngOnInit() {}
}
O bloco de código acima, quando renderizado, não é nada mais nada menos que a estrutura HTML da TaskComponent
na aplicação Todos.
Em seguida irão ser criados os três testes ao estado da tarefa no ficheiro de estórias correspondente:
import { storiesOf, moduleMetadata } from '@storybook/angular';
import { action } from '@storybook/addon-actions';
import { TaskComponent } from './task.component';
export const task = {
id: '1',
title: 'Test Task',
state: 'TASK_INBOX',
updatedAt: new Date(2018, 0, 1, 9, 0),
};
export const actions = {
onPinTask: action('onPinTask'),
onArchiveTask: action('onArchiveTask'),
};
storiesOf('Task', module)
.addDecorator(
moduleMetadata({
declarations: [TaskComponent],
}),
)
.add('default', () => {
return {
template: `<task-item [task]="task" (onPinTask)="onPinTask($event)" (onArchiveTask)="onArchiveTask($event)" ></task-item>`,
props: {
task,
onPinTask: actions.onPinTask,
onArchiveTask: actions.onArchiveTask,
},
};
})
.add('pinned', () => {
return {
template: `<task-item [task]="task" (onPinTask)="onPinTask($event)" (onArchiveTask)="onArchiveTask($event)" ></task-item>`,
props: {
task: { ...task, state: 'TASK_PINNED' },
onPinTask: actions.onPinTask,
onArchiveTask: actions.onArchiveTask,
},
};
})
.add('archived', () => {
return {
template: `<task-item [task]="task" (onPinTask)="onPinTask($event)" (onArchiveTask)="onArchiveTask($event)" ></task-item>`,
props: {
task: { ...task, state: 'TASK_ARCHIVED' },
onPinTask: actions.onPinTask,
onArchiveTask: actions.onArchiveTask,
},
};
});
Existem dois tipos de organização com Storybook. O componente em si e as estórias associadas. É preferível pensar em cada estória como uma permutação de um componente. Como tal podem existir tantas estórias, tantas as que forem necessárias.
Component
Ao ser invocada a função storiesOf()
, está a registar-se o componente, e com isto o processo de arranque do Storybook. É adicionado um nome, nome esse que será usado na barra lateral da aplicação Storybook para identificar o componente.
A função action()
permite a criação de um callback, que irá surgir no painel adequado, ou seja o painel actions do interface de utilizador Storybook quando for feito o click. Como tal assim que for criado o botão para afixar tarefas, irá ser possível determinar o sucesso ou não do click no interface de utilizador de testes.
É extremamente conveniente agrupar todas as ações numa única variável denominada actions
, dessa forma, esta variável pode ser exportada através da cláusula export
e ser usada nas estórias que para um qualquer componente que vá reutilizar este, tal como irá ser visto posteriormente.
Para definir as estórias, invoca-se o metodo add()
, uma única vez para cada um dos estados de teste, de forma a gerar uma estória. A estória ação é uma função que irá retornar um elemento renderizado( ou seja, uma classe de componente com um conjunto de adereços (props)) para um determinado estado.
Ao ser criada uma estória, é usada uma tarefa base (task
) para definir a forma da tarefa em questão que é necessária ao componente. Geralmente modelada a partir de dados concretos. Mais uma vez o uso da cláusula export
, neste caso para a estrutura dos dados irá permitir a sua reutilização em estórias futuras, tal como veremos.
action()
para esta situação.
Será necessária uma alteração minúscula ao ficheiro de configuração do Storybook (storybook/config.js
) de forma que este reconheça os ficheiros com extensão .stories.ts
, mas também utilize o ficheiro LESS.
Por norma o Storybook pesquisa numa pasta denominada /stories
para conter as estórias; este tutorial usa uma nomenclatura similar a .type.extension
, cuja qual favorecida quando se está a desenvolver uma aplicação Angular.
import {
configure
} from '@storybook/angular';
import '../src/styles.less';
// automatically import all files ending in *.stories.ts
const req = require.context('../src/', true, /.stories.ts$/);
function loadStories() {
req.keys().forEach(filename => req(filename));
}
configure(loadStories, module);
De forma a que seja possível o suporte ao conteúdo do ficheiro LESS definido acima, será necessário uma pequena modificação no webpack. Para tal será necessário criar um ficheiro denominado webpack.config.js
dentro da pasta .storybook
com o seguinte conteúdo:
const path = require("path");
module.exports = {
module: {
rules: [{
test: /\.less$/,
loaders: ["style-loader", "css-loader", "less-loader"],
include: path.resolve(__dirname, "../")
}]
}
};
Assim como os loaders necessários terão que ser adicionados, através do seguinte comando:
yarn add -D less-loader css-loader style-loader
Assim que esta operação for concluída, ao reiniciar o servidor Storybook, deverá produzir os casos de teste que foram definidos para o componente TaskComponent:
Neste momento já possuímos o Storybook configurado, os elementos de estilo importados, assim como os casos de teste, podemos agora iniciar a implementação HTML do componente de forma a igualar o design.
O componente neste momento ainda é bastante básico. Primeiro irá ser definido o código necessário para atingir o design definido, sem que se entre em grande detalhe:
import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core';
import { Task } from './task.model';
@Component({
selector: 'task-item',
template: `
<div class="list-item {{ task?.state }}">
<label class="checkbox">
<input
type="checkbox"
[defaultChecked]="task?.state === 'TASK_ARCHIVED'"
disabled="true"
name="checked"
/>
<span class="checkbox-custom" (click)="onArchive(task.id)"></span>
</label>
<div class="title">
<input type="text" [value]="task?.title" readonly="true" placeholder="Input title" />
</div>
<div class="actions">
<a *ngIf="task?.state !== 'TASK_ARCHIVED'" (click)="onPin(task.id)">
<span class="icon-star"></span>
</a>
</div>
</div>
`,
})
export class TaskComponent implements OnInit {
title: string;
@Input() task: Task;
@Output() onPinTask: EventEmitter<any> = new EventEmitter();
@Output() onArchiveTask: EventEmitter<any> = new EventEmitter();
constructor() {}
ngOnInit() {}
onPin(id) {
this.onPinTask.emit(id);
}
onArchive(id) {
this.onArchiveTask.emit(id);
}
}
O markup adicional descrito acima, combinado com o CSS que foi importado anteriormente irá originar o seguinte interface de utilizador:
É boa prática a especificação de qual a forma que os dados tomam para um componente. Não somente é auto documentável, mas ajuda a detectar problemas cedo. Neste caso através do uso de TypeScript, mas também através do uso de um interface para o modelo Task
:
export interface Task {
id: string;
title: string;
state: string;
}
Foi construído com sucesso, sem ser necessário qualquer tipo de servidor, ou que seja necessário executar a aplicação frontend. O próximo passo é construir os restantes componentes da Taskbox um por um de forma similar.
Como se pode ver, começar a construir componentes isoladamente é fácil e rápido. Com isto espera-se que seja possível construir um interface de utilizador de qualidade superior com um número de problemas menor e mais polido. Isto devido ao facto que é possível aprofundar e testar qualquer estado possível.
O Storybook oferece uma forma fantástica de testar visualmente a aplicação durante o desenvolvimento. As "estórias" irão garantir que a tarefa não seja visualmente destruída á medida que a aplicação continua a ser desenvolvida. Mas no entanto continua a ser um processo manual neste momento e alguém terá que fazer o esforço de clickar em cada estado de teste de forma a garantir que irá renderizar sem qualquer tipo de problemas. Não poderíamos automatizar isto?
Este tipo de testes refere-se á pratica de guardar o output considerado "bom" de um determinado componente com base num input e marcar o componente caso o output seja alterado. Isto complementa o Storybook, visto que é uma forma rápida de se visualizar a nova versão de um componente e verificar as alterações.
Com o extra Storyshots é criado um teste de snapshot para cada uma das estórias. Para que este possa ser usado, adiciona-se a dependência de desenvolvimento:
yarn add -D @storybook/addon-storyshots identity-object-proxy jest jest-preset-angular
Em seguida é criado o ficheiro src/storybook.test.ts
com o conteúdo:
import * as path from 'path';
import initStoryshots, {
multiSnapshotWithOptions,
} from '@storybook/addon-storyshots';
initStoryshots({
framework: 'angular',
configPath: path.join(__dirname, '../.storybook'),
test: multiSnapshotWithOptions(),
});
Quando este processo estiver concluído, será necessário criar uma pasta denominada jest-config
dentro da pasta src
, com dois ficheiros dentro desta, globalMocks.ts
com o conteúdo:
const mock = () => {
let storage = {};
return {
getItem: key => (key in storage ? storage[key] : null),
setItem: (key, value) => (storage[key] = value || ''),
removeItem: key => delete storage[key],
clear: () => (storage = {}),
};
};
Object.defineProperty(window, 'localStorage', { value: mock() });
Object.defineProperty(window, 'sessionStorage', { value: mock() });
Object.defineProperty(window, 'getComputedStyle', {
value: () => ['-webkit-appearance'],
});
e o ficheiro setup.ts
, com o seguinte conteúdo:
import 'jest-preset-angular';
import './globalMocks';
Em seguida será necessário adicionar um novo campo ao ficheiropackage.json
,
"jest": {
"coveragePathIgnorePatterns": [
"/jest-config/",
"/node_modules/"
],
"preset": "jest-preset-angular",
"setupTestFrameworkScriptFile": "<rootDir>/src/jest-config/setup.ts",
"snapshotSerializers": [
"<rootDir>/node_modules/jest-preset-angular/AngularSnapshotSerializer.js",
"<rootDir>/node_modules/jest-preset-angular/HTMLCommentSerializer.js"
],
"testPathIgnorePatterns": [
"/node_modules/",
"/build/",
"/storybook-static/"
],
"transform": {
"^.+\\.(ts|js|html)$": "<rootDir>/node_modules/jest-preset-angular/preprocessor.js"
},
"moduleNameMapper": {
"\\.(css|less)$": "identity-obj-proxy"
}
},
Além disto, serão necessários alguns scripts novos para que o jest
possa ser executado:
"scripts": {
...
"jest": "jest",
"jest:watch": "jest --watch"
}
E finalmente, atualizar os ficheiro src/tsconfig.app.json
, de forma que este exclua os ficheiros com extensão .test.ts
"exclude": [
"src/test.ts",
"**/*.stories.ts",
"**/*.spec.ts",
"**/*.test.ts"
]
Assim que os passos descritos acima estiverem concluídos, poderá ser executado yarn test
e constatar o seguinte output:
Com isto encontra-se agora á disposição um teste snapshot para cada uma das estórias TaskComponent
. Se a implementação da TaskComponent
for alterada, será apresentada uma notificação.
Adicionalmente, o jest
irá executar também testes para app.component.ts