Adding Components
Now that we have a working build and a working boilerplate app, let's focus on building components and how our app will be architected.
The basis of a TodoMVC application is very simple. You have a single input box for which you can add new todos. Then, you have a list of todos displayed, both completed, un-completed. Finally, you have a footer section which you can toggle viewing only completed, un-completed, all.
If we are thinking about our application in terms of components, we could think of it in three-four main pieces.
- The top level component/controller for which the main application data layer will reside. This layer will initiate the application lifecycle and it will be resposible for co-ordinating data transfer between the child components through callback functions. It will pass the data down to children.
- The TodoHeader. This component will be responsible for handling the user input to the input box. The data entered can be passed back up to the parent controller which can then pass it down to the TodoList.
- The TodoList. This component will be responsible for displaying all current Todos in their current state (completed/un-completed). It will be passed data from the parent which can pass it new todos that were entered in the TodoHeader component.
- The TodoFooter. This component will be responsible for handling the toggling of viewing the TodoList for completed/un-completed lists.
So now that we have an idea of how our application will be structured, let's start building it!
Let's start with the top level "App Component" which will act as our data layer.
We're going to need to create a new folder and a few new files in components/
.
$ mkdir components/App
$ touch components/index.ts
We created the folder which will hold all of the App files, and we also created an index file from which we will export all of components from in one easy place.
Before we go any further, let's define how a typical component folder might look like. Since we are dealing with Angular 1.x, we are dealing with directives which will be our component container. We'll most typically have four files for a given component. If we were creating a Slider component, our folder might look like this:
Slider.controller.ts
Slider.directive.ts
Slider.module.ts
Slider.template.html
So, we've split up our controller and our directive to be separate. Also, we will be hooking in the classes to the angular framework only in the .module.ts
file. This is a nice thing about using ES6 modules is we can a nice separation. Finally, the template will be standalone which is typical for an angular component.
Alright, now that we have an understanding of how we should structure a component, let's build the App Controller. The App Controller will not be a component in the truest sense, it will be more of a controller. I know it seems I am breaking the rule right off the bat, that's why I said "typically" :)
App
$ touch App/App.controller.ts
$ touch App/App.module.ts
App.controller.ts
export class App {
todos: Array<TodoItem>;
doneCount: number;
remainingCount: number;
allChecked: boolean;
statusFilter: any;
location: any;
static $inject = ['$scope', '$location'];
constructor(private $scope: ng.IScope, private $location: ng.ILocationService) {
this.todos = [
{ title: 'complete todomvc app', completed: true },
{ title: 'write blog post', completed: false }
];
$scope.$watch('app.todos', () => this.onTodos(), true);
$scope.$watch('app.location.path()', path => this.onPath(path));
if ($location.path() === '') {
$location.path('/');
}
this.location = $location;
}
onPath(path: any) {
this.statusFilter = (path === '/active') ?
{ completed: false } : (path === '/completed') ?
{ completed: true } : {};
}
onTodos() {
this.remainingCount =
this.todos.filter(todo => !todo.completed).length;
this.doneCount = this.todos.length - this.remainingCount;
this.allChecked = !this.remainingCount;
}
onSubmit(newTodo) {
this.todos.push({ title: newTodo, completed: false });
}
}
App.module.ts
import * as angular from 'angular';
import {App} from './App.controller';
const ngModule = angular.module('todomvc', [])
.controller('App', App);
export default ngModule;
Awesome, I know there is a lot going on above so let's take a minute to understand what is going on. The controller file is a standard ES6 class. The only thing that is special about it is that we are using the static $inject = [];
notation above our constructor. What this special syntax is giving us is it will be used by the angular framework to inject the dependencies into our constructor. Notice how we match the strings in the array to the params passed to our constructor. This is not a coincidence and very much done on purpose. Secondly, notices how we are defining our methods and defining properties on the class instance. When we use this controller in the template, we are going to be leveraging the controller-as syntax from angular which will allow us to write our controllers like this.
The other thing I want you to notice is that we are not hooking into angular straight from the controller file. We are exporting the class which is then hooked into angular separately. We then store the reference to our instantiated controller which we can then export back out to our index.ts
file for consumption in our top level app.ts
file.
Ok, now that we have added our App controller, we're going to need to do a few more things to get this to work.
In the index.ts
file under our components/
folder, we're going to need to export everything from the App.module.ts
file above. Let's do that.
index.ts
export * from './App/App.module';
Now, go back out to the main app.ts
in our root folder. We're going to import the index.ts
file into there.
app.ts
import 'angular';
import './components/index';
Finally, let's add the controller to our template...
index.html
<section class="todoapp" ng-controller="App as app"></section>
Ok, now that we've added our top-level controller we should be go to go to run the app again. Let's try starting it up again, or if you've had the watch task on just go back to http://localhost:9999
grunt build
If it's running and you don't see any errors in the console, we're good to go. Let's take a moment to add and commit the changes we made so far.
$ git add components/
$ git add app.ts
$ git add index.html
$ git commit -m 'added app component'
TodoHeader
Great! We're ready to move on and add our next component. Let's add the Header component.
$ mkdir components/TodoHeader
$ touch components/TodoHeader/Header.controller.ts
$ touch components/TodoHeader/Header.directive.ts
$ touch components/TodoHeader/Header.module.ts
$ touch components/TodoHeader/Header.template.html
Header.controller.ts
export class HeaderCtrl {
newTodo: string = '';
onSubmit: Function;
submit() {
this.onSubmit({ newTodo: this.newTodo });
this.newTodo = '';
}
}
Header.directive.ts
import {HeaderCtrl} from './Header.controller';
export class Header implements ng.IDirective {
scope = {
onSubmit: '&'
};
templateUrl: string = 'components/TodoHeader/Header.template.html';
controllerAs: string = 'header';
bindToController: boolean = true;
controller: Function = HeaderCtrl;
}
Header.module.ts
import * as angular from 'angular';
import {Header} from './Header.directive';
const ngModule = angular.module('todomvc').directive('todoHeader', [() => new Header]);
export default ngModule;
Header.template.html
<header class="header">
<h1>todos</h1>
<form class="todo-form" ng-submit="header.submit()">
<input class="new-todo" placeholder="What needs to be done?" name="newtodo" ng-model="header.newTodo" autofocus>
</form>
</header>
Let's then export it from out index.ts
file.
index.ts
export * from './App/App.module';
export * from './TodoHeader/Header.module';
Then, let's add this new component to our index.html
file.
index.html
<section class="todoapp" ng-controller="App as app">
<todo-header on-submit="app.onSubmit(newTodo)"></todo-header>
</section>
Let's add these changes to git and commit...
$ git add components/TodoHeader/
$ git add components/index.ts
$ git add index.html
$ git commit -m 'added TodoHeader component'
TodoList
$ mkdir components/TodoList
$ touch components/TodoList/TodoList.controller.ts
$ touch components/TodoList/TodoList.directive.ts
$ touch components/TodoList/TodoList.module.ts
$ touch components/TodoList/TodoList.template.html
TodoList.controller.ts
export class TodoListCtrl {
editedTodo: TodoItem;
todos: Array<TodoItem>;
editTodo(todoItem: TodoItem) {
this.editedTodo = todoItem;
}
removeTodo(todo: TodoItem) {
this.todos.splice(this.todos.indexOf(todo), 1);
}
doneEditing(todoItem: TodoItem) {
this.editedTodo = null;
todoItem.title = todoItem.title.trim();
if (!todoItem.title) {
this.removeTodo(todoItem);
}
}
}
TodoList.directive.ts
import {TodoListCtrl} from './TodoList.controller';
export class TodoList implements ng.IDirective {
scope = {
todos: '=',
statusFilter: '='
};
templateUrl: string = 'components/TodoList/TodoList.template.html';
controllerAs: string = 'list';
bindToController: boolean = true;
controller: Function = TodoListCtrl;
}
TodoList.module.ts
import * as angular from 'angular';
import {TodoList} from './TodoList.directive';
const ngModule = angular.module('todomvc').directive('todoList', [() => new TodoList]);
export default ngModule;
TodoList.template.html
<section class="main" ng-show="list.todos.length" ng-cloak>
<input class="toggle-all" type="checkbox" ng-model="list.allChecked" ng-click="list.markAll(!list.allChecked)">
<label for="toggle-all">Mark all as complete</label>
<ul class="todo-list">
<li ng-repeat="todo in list.todos | filter:list.statusFilter track by $index" ng-class="{completed: todo.completed, editing: todo == list.editedTodo}">
<div class="view">
<input class="toggle" type="checkbox" ng-model="todo.completed">
<label ng-dblclick="list.editTodo(todo)">{{todo.title}}</label>
<button class="destroy" ng-click="list.removeTodo(todo)"></button>
</div>
<form ng-submit="list.doneEditing(todo)">
<input class="edit" ng-model="todo.title" todo-blur="list.doneEditing(todo)" todo-focus="todo == list.editedTodo">
</form>
</li>
</ul>
</section>
Let's export it from index.ts
:
index.ts
export * from './App/App.module';
export * from './TodoHeader/Header.module';
export * from './TodoList/TodoList.module';
and then add it to index.html
index.html
<section class="todoapp" ng-controller="App as app">
<todo-header on-submit="app.onSubmit(newTodo)"></todo-header>
<todo-list todos="app.todos" status-filter="app.statusFilter"></todo-list>
</section>
If you noticed above, we introduced a custom interface, TodoItem
, in the TodoListCtrl
. For the time being, we can define it in out typings/tsd.d.ts
file:
typings/tsd.d.ts
interface TodoItem {
title: string;
completed: boolean;
}
You should be able to re-run the app again and see the new TodoList
component. :)
Let's add our changes and commit:
$ git add components/TodoList/
$ git add components/index.ts
$ git add index.html
$ git add typings/tsd.d.ts
$ git commit -m 'added TodoList component'
TodoFooter
Finally, let's add our final component, the TodoFooter
$ mkdir components/TodoFooter
$ touch components/TodoFooter/TodoFooter.controller.ts
$ touch components/TodoFooter/TodoFooter.directive.ts
$ touch components/TodoFooter/TodoFooter.module.ts
$ touch components/TodoFooter/TodoFooter.template.html
TodoFooter.controller.ts
export class TodoFooterCtrl {
location: any;
todos: Array<TodoItem>;
static $inject = ['$scope', '$location'];
constructor(private $scope: ng.IScope,
private $location: ng.ILocationService) {
this.location = $location;
$scope.$watch('footer.location', location => this.location = location);
}
clearDoneTodos() {
this.todos = this.todos = this.todos.filter(todoItem => !todoItem.completed);
};
}
TodoFooter.directive.ts
import {TodoFooterCtrl} from './TodoFooter.controller';
export class TodoFooter implements ng.IDirective {
scope = {
todos: '=',
doneCount: '=',
remainingCount: '='
};
templateUrl: string = 'components/TodoFooter/TodoFooter.template.html';
controllerAs: string = 'footer';
bindToController: boolean = true;
controller: Function = TodoFooterCtrl;
}
TodoFooter.module.ts
import * as angular from 'angular';
import {TodoFooter} from './TodoFooter.directive';
const ngModule = angular.module('todomvc').directive('todoFooter', [() => new TodoFooter]);
export default ngModule;
TodoFooter.template.html
<footer class="footer" ng-show="footer.todos.length" ng-cloak>
<span class="todo-count"><strong>{{footer.remainingCount}}</strong>
<ng-pluralize count="footer.remainingCount" when="{ one: 'item left', other: 'items left' }"></ng-pluralize>
</span>
<ul class="filters">
<li>
<a ng-class="{selected: footer.location.path() == '/'} " href="#/">All</a>
</li>
<li>
<a ng-class="{selected: footer.location.path() == '/active'}" href="#/active">Active</a>
</li>
<li>
<a ng-class="{selected: footer.location.path() == '/completed'}" href="#/completed">Completed</a>
</li>
</ul>
<button class="clear-completed" ng-click="footer.clearDoneTodos()" ng-show="footer.doneCount">Clear completed</button>
</footer>
Let's export it from index.ts
:
index.ts
export * from './App/App.module';
export * from './TodoHeader/Header.module';
export * from './TodoList/TodoList.module';
export * from './TodoFooter/TodoFooter.module';
..and then add it to index.html
index.html
<section class="todoapp" ng-controller="App as app">
<todo-header on-submit="app.onSubmit(newTodo)"></todo-header>
<todo-list todos="app.todos" status-filter="app.statusFilter"></todo-list>
<todo-footer todos="app.todos"
done-count="app.doneCount"
remaining-count="app.remainingCount">
</todo-footer>
</section>
Let's add and commit to git
$ git add components/TodoFooter/
$ git add components/index.ts
$ git add index.html
$ git commit -m 'added TodoFooter component'