ひとまず、クライアントサイドだけで動作する Todoアプリ を作成します。
まずは HTMLだけを先に作成し、雰囲気を確認します。
bootstrap を使用するので、Layoutとスタイルシートを少し修正しておきます。
Views/Shared/_LayoutPage1.cshtml
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>@ViewBag.Title</title>
@System.Web.Optimization.Styles.Render("~/bundle/style")
</head>
<body>
@RenderBody()
@System.Web.Optimization.Scripts.Render("~/bundle/script")
</body>
</html>
Content/base.css
body {
margin-top: 70px;
}
つづいて、HTMLをゴリゴリ書いていきます。
ToDoの中身にはとりあえずダミーのデータを設定しておきます。
Views/Home/Index.cshtml
@{
ViewBag.Title = "Index";
Layout = "~/Views/Shared/_LayoutPage1.cshtml";
}
<nav class="navbar navbar-inverse navbar-fixed-top">
<div class="container">
<div class="navbar-header">
<a class="navbar-brand" href="#">KnockoutTodo</a>
</div>
<button class="btn btn-link navbar-btn navbar-right">ログアウト</button>
</div>
</nav>
<div class="container">
<div class="row">
<div class="col-md-4">
<div class="list-group">
<a href="#" class="list-group-item active">
<h4 class="list-group-item-heading">
item title
</h4>
<p class="list-group-item-text">item text</p>
</a>
<a href="#" class="list-group-item">
<h4 class="list-group-item-heading">
item title
</h4>
<p class="list-group-item-text">item text</p>
</a>
<a href="#" class="list-group-item">
<h4 class="list-group-item-heading">
item title
</h4>
<p class="list-group-item-text">item text</p>
</a>
</div>
<div>
<button class="btn btn-primary btn-info btn-lg btn-block">
<span class="glyphicon glyphicon-plus"></span> 新しいToDoを追加
</button>
</div>
</div>
<div class="col-md-8">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">#id</h3>
</div>
<div class="panel-body">
<form class="form-horizontal">
<div class="form-group">
<label for="txtSummary" class="col-sm-2 control-label">概要</label>
<div class="col-sm-10"><input type="text" class="form-control" id="txtSummary"></div>
</div>
<div class="form-group">
<label for="txtDetail" class="col-sm-2 control-label">詳細</label>
<div class="col-sm-10"><textarea id="txtDetail" rows="3" class="form-control"></textarea></div>
</div>
<div class="form-group">
<label for="txtLimit" class="col-sm-2 control-label">期限</label>
<div class="col-sm-10"><input type="date" class="form-control" id="txtLimit"></div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<div class="checkbox">
<label>
<input type="checkbox" id="chkDone"> 完了
</label>
</div>
</div>
</div>
</form>
</div>
<div class="panel-footer">
<button class="btn btn-primary">
<span class="glyphicon glyphicon-floppy-disk"></span>
登録
</button>
<button class="btn btn-danger">
<span class="glyphicon glyphicon-trash"></span>
削除
</button>
<button class="btn btn-default">
キャンセル
</button>
</div>
</div>
</div>
</div>
</div>
デバッグ実行して画面のイメージを確認します。
それでは、JavaScriptを実装して動きを付けていきましょう。
まず、モデルを定義します。
app.js
/**
* Todo Model
* @param 初期値 {object}
*/
var ToDoModel = function (params) {
var self = this;
// 引数未指定なら空のobjectを生成
if (!params) {
params = {};
}
// 初期値
var options = {
id: 0,
summary: '',
detail: '',
limit: '',
done: false
};
// 初期値を引数で指定された値で上書き
$.extend(options, params);
// Id (number)
self.id = ko.observable(options.id);
// 概要
self.summary = ko.observable(options.summary);
// 詳細
self.detail = ko.observable(options.detail);
// 期限
self.limit = ko.observable(options.limit);
// 完了
self.done = ko.observable(options.done);
};
$.extend(obj1, obj2)
は jqueryのメソッドで、obj2
の内容を obj1
にマージします。
ここでは、初期値を引数で指定されたパラメータで上書きします。
その後、各値を observable
の変数にセットします。
/**
* ViewModel
*/
var AppViewModel = function () {
var self = this;
// Todoリスト
self.todoList = ko.observableArray([
new ToDoModel({ id: 1, summary: 'hoge', detail: 'foobar1', limit: '', done:false }),
new ToDoModel({ id: 2, summary: 'foo', detail: 'foobar2', limit: '', done:false }),
new ToDoModel({ id: 3, summary: 'bar', detail: 'foobar3', limit: '', done:false })
]);
};
observableArray
は配列を監視し、要素が追加/削除されると View に反映されます。
ここでは、Todoのリストを observableArray
とし、追加・削除されると
画面左側のTodoリストに反映されるように実装していきます。
// エントリポイント
$(function(){
ko.applyBindings(new AppViewModel());
});
HTMLが読み込まれたタイミングで、ViewModel を body に紐付けています。
<div class="col-md-4">
<div class="list-group" data-bind="foreach: todoList">
<a href="#" class="list-group-item">
<h4 class="list-group-item-heading"
data-bind="text: summary"></h4>
<p class="list-group-item-text"
data-bind="text: detail"></p>
</a>
</div>
foreach
繰り返し処理の構文です。foreach
の内側では、カレントのスコープが各アイテムになります。
ここでは、foreach: todoList
としているので
AppViewModel
の todoList
の中身を一つづつ取り出し、処理します。
また、todoList
は observableArray
なので、要素が追加/削除されるたびに
foreach
が呼び出され、todoList
の内容がViewに反映されます。
編集フォームのデータは、登録ボタンをクリックしたタイミングで反映させたいので 入力内容が即時反映されないように、選択されたTodoをコピーし、別のTodoModelのインスタンスに格納します。
登録時は、別のインスタンスから該当のインスタンスに値を戻します。
ViewModelにプロパティとメソッドを追加します。
// 選択されたTodoを格納
self.selectedItem = ko.observable();
/**
* リストからTodoを選択する
* @param item {ToDoModel} 選択された項目
*/
self.selectTodo = function (item) {
self.selectedItem(new ToDoModel({
id: item.id(),
summary: item.summary(),
detail: item.detail(),
limit: item.limit(),
done: item.done()
}));
};
<div class="list-group" data-bind="foreach: todoList">
<a href="#" class="list-group-item"
data-bind="click: $root.selectTodo">
<h4 class="list-group-item-heading"
data-bind="text: summary"></h4>
<p class="list-group-item-text"
data-bind="text: detail"></p>
</a>
</div>
aタグに data-bind="click: $root.selectedItem"
と追記します。
$root
は body にバインドした AppViewModel を表します。
foreach
の中では、h4タグやpタグでのデータバインドのように、
デフォルトでは配列要素のプロパティが選択されます。
aタグをクリックしたときには、配列要素では無く、 AppViewModel に定義したメソッドを呼び出したいのでこのような記述になります。
click
で指定したメソッドの引数には バインディング・コンテキスト が渡されます。
バインディング・コンテキスト
バインディング・コンテキストは、バインディングから参照できるデータをもつオブジェクトです。 バインディングの適用にあたって、Knockout は自動的にバインディング・コンテキストの階層を作成・管理します。 階層のルートは、ko.applyBindings(viewModel) に渡した viewModel です。 また、with や foreach などのフロー制御バインディングを使う度に、 ネストされた ViewModel データを参照する子コンテキストが作成されます。
今回は foreach
内で click
を定義したので、メソッドの引数には配列の各要素が渡されます。
<div class="panel panel-default" data-bind="visible: selectedItem, with: selectedItem">
<div class="panel-heading">
<h3 class="panel-title">#<span data-bind="text: id"></span></h3>
</div>
<div class="panel-body">
<form class="form-horizontal">
<div class="form-group">
<label for="txtSummary" class="col-sm-2 control-label">概要</label>
<div class="col-sm-10">
<input type="text" class="form-control"
id="txtSummary"
data-bind="value: summary">
</div>
</div>
<div class="form-group">
<label for="txtDetail" class="col-sm-2 control-label">詳細</label>
<div class="col-sm-10">
<textarea id="txtDetail" rows="3" class="form-control"
data-bind="value: detail"></textarea>
</div>
</div>
<div class="form-group">
<label for="txtLimit" class="col-sm-2 control-label">期限</label>
<div class="col-sm-10">
<input type="date" class="form-control"
id="txtLimit"
data-bind="value: limit">
</div>
</div>
<div class="form-group">
<div class="col-sm-offset-2 col-sm-10">
<div class="checkbox">
<label>
<input type="checkbox" id="chkDone"
data-bind="checked: done"> 完了
</label>
</div>
</div>
</div>
</form>
</div>
<div class="panel-footer">
<button class="btn btn-primary">
<span class="glyphicon glyphicon-floppy-disk"></span>
登録
</button>
<button class="btn btn-danger">
<span class="glyphicon glyphicon-trash"></span>
削除
</button>
<button class="btn btn-default">
キャンセル
</button>
</div>
</div>
指定された要素が true
と判定されるような場合、該当要素が表示されます。
逆に、指定された要素が false
と判定されるような場合(boolean
のfalse
、string
の""
、number
の0
、null
、undefined
)、該当要素は非表示となります。
with バインディングは新たな バインディング・コンテキスト を作成します。
ここでは、編集フォーム内の バインディング・コンテキスト を selectedItem
に変更しています。
関連付けられた DOM エレメントの値と ViewModel のプロパティーをリンクさせます。
<input>
や <select>
, <textarea>
などのフォーム部品で使用します。
ViewModel のプロパティとチェックボックス や ラジオボタン などのチェックできるフォーム部品をリンクします。
現在編集しているTodoがどれなのか分かりやすいように、選択されている要素の背景色を青にします。
bootstrap の active クラスを設定するだけで、青地に白文字で表示されるようになりますので knockout では 選択要素の aタグに activeクラスをセットするように実装します。
ViewModel に、その要素が選択されている要素かどうかを判定するメソッドを追加します。
/**
* リストの項目が選択されたTodoかどうか
* @param target {TodoModel}
* @return {boolean}
*/
self.isActive = function (target) {
var item = self.selectedItem();
if (item) {
return target.id() == item.id();
}
return false;
};
aタグを以下のように修正します。
<a href="#" class="list-group-item"
data-bind="click: $root.selectTodo, css: { active: $root.isActive($data) }">
<h4 class="list-group-item-heading">
<span data-bind="text: summary"></span>
</h4>
<p class="list-group-item-text" data-bind="text: detail"></p>
</a>
css: { active: $root.isActive($data) }
とした場合、 $root.isActive($data)
が
true
となった場合のみ、DOM要素に active
クラスをセットします。
foreach
でループしている時の「現在のアイテム」になります。
$data
に別名を付けることもできます。
foreach: { data: items, as: 'item' }
とすると、$data
の代わりに item
で
各要素を参照できます。
foreach
をネストする場合は別名を付けたほうが分かりやすいでしょう。
編集フォームに空のTodoをセットします。
ViewModelに以下のメソッドを追加します。
/**
* 新しいTodoの入力フォームを表示する
*/
self.addTodo = function () {
self.selectedItem(new ToDoModel());
};
追加ボタンにメソッドを紐付けます。
<button class="btn btn-primary btn-info btn-lg btn-block"
data-bind="click: $root.addTodo">
<span class="glyphicon glyphicon-plus"></span> 新しいToDoを追加
</button>
追加時には削除ボタンが使用できないように非表示とします。
selectedItem の id が 0 の場合は追加、
selectedItem の id が 0 以外の場合は更新と判断します。
/**
* 削除が可能かどうか
* @return {boolean}
*/
self.isDeletable = function () {
return self.selectedItem().id() != 0;
};
非表示となるように、 visibleバインディングを設定します。
<button class="btn btn-danger"
data-bind="visible:$root.isDeletable()">
<span class="glyphicon glyphicon-trash"></span>
削除
</button>
キャンセルをクリックした時は selectedItem に null
をセットします。
/**
* キャンセルボタンのクリック
*/
self.cancelEdit = function () {
// 編集フォームを閉じる
self.selectedItem(null);
};
キャンセルボタンにメソッドを紐付けます。
<button class="btn btn-default" data-bind="click: $root.cancelEdit">
キャンセル
</button>
selectedItem の id が 0 の場合は追加、
selectedItem の id が 0 以外の場合は更新なので、
id の値に応じて処理を呼び分けます。
/**
* 登録ボタンのクリック
*/
self.registTodo = function () {
var item = self.selectedItem();
if (item.id() == 0) {
addItem(item);
} else {
updateItem(item);
}
// 編集フォームを閉じる
self.selectedItem(null);
};
/**
* Todoを登録する
*/
function addItem (todoItem) {
// リストに登録されている末尾の要素のIDを+1する
var len = self.todoList().length;
var newId = self.todoList()[len - 1].id() + 1;
// idを設定
todoItem.id(newId);
// リストに追加
self.todoList.push(todoItem);
}
/**
* Todoを更新する
*/
function updateItem (todoItem) {
var len = self.todoList().length;
for (var i=0; i<len; i++) {
var item = self.todoList()[i];
if (todoItem.id() == item.id()) {
// Todoを更新
item.summary(todoItem.summary());
item.detail(todoItem.detail());
item.limit(todoItem.limit());
item.done(todoItem.done());
break;
}
}
}
observableArray は通常の配列のように length
で要素数を取得したり
push
で要素を追加できます。
<button class="btn btn-primary" data-bind="click: $root.registTodo">
<span class="glyphicon glyphicon-floppy-disk"></span>
登録
</button>
登録ボタンをクリックしたらメソッドを呼び出すように、clickバインディングで紐付けます。
self.
から始まるプロパティ、メソッドは public だと考えてください。
var
や function
から始まるプロパティ、メソッドは private です。
ViewModelの外から参照することはできません。
これも JavaScript でよく使用されるテクニックのひとつです。
observableArray の remove
メソッドで、現在選択されている要素を削除します。
/**
* 削除ボタンのクリック
*/
self.deleteTodo = function () {
self.todoList.remove(function(item) {
return item.id() == self.selectedItem().id();
});
// 編集フォームを閉じる
self.selectedItem(null);
};
todoList
の各要素に対して remove
メソッドで指定された function
が実行され、
true
となった要素が削除されます。
<button class="btn btn-danger"
data-bind="visible:$root.isDeletable(), click: $root.deleteTodo">
<span class="glyphicon glyphicon-trash"></span>
削除
</button>
clickバインディングで紐付けます。
登録、削除が完了したらモーダルダイアログでメッセージを表示するように実装します。
bootstrap の modalダイアログを利用します。
<div class="modal fade" id="dialog" data-bind="with: dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" data-bind="text: title"></h4>
</div>
<div class="modal-body">
<p class="lead" data-bind="text: message"></p>
</div>
<div class="modal-footer">
<button class="btn btn-primary" data-dismiss="modal">OK</button>
</div>
</div>
</div>
</div>
DialogModel を定義します。
/**
* modal dialog model
*/
var DialogModel = function () {
var self = this;
self.id = '#dialog';
self.title = ko.observable('');
self.message = ko.observable('');
/**
* ダイアログの表示
* @param {object}
*/
self.show = function (opts) {
// 初期値
var def = {
title: '',
message: ''
};
$.extend(def, opts);
self.title(def.title);
self.message(def.message);
// モーダルダイアログの表示
$(self.id).modal('show');
};
/**
* ダイアログの非表示
*/
self.hide = function () {
$(self.id).modal('hide');
};
};
AppViewModel で DialogModel を生成します。
var AppViewModel = function () {
var self = this;
// Todoリスト
self.todoList = ko.observableArray([ ... ]);
// 選択されたTodo
self.selectedItem = ko.observable();
// モーダルダイアログ
self.dialog = new DialogModel();
/* ~~ 省略 ~~ */
};
登録、削除後にモーダルダイアログを表示するよう 処理を修正します。
/**
* 登録ボタンのクリック
*/
self.registTodo = function () {
var item = self.selectedItem();
if (item.id() == 0) {
addItem(item);
} else {
updateItem(item);
}
// 編集フォームを閉じる
self.selectedItem(null);
self.dialog.show({
title: '登録完了',
message: '登録が完了しました。'
});
};
/**
* 削除ボタンのクリック
*/
self.deleteTodo = function () {
self.todoList.remove(function(item) {
return item.id() == self.selectedItem().id();
});
// 編集フォームを閉じる
self.selectedItem(null);
self.dialog.show({
title: '削除完了',
message: '削除が完了しました。'
});
};
以上で、クライアントサイドだけで動作する Todoアプリが完成しました。
当然、データを保存する機能がないため、ブラウザをリロードすると更新した内容が失われます。
次回は Ajax で Web API を呼び出すことで クライアントサイドとサーバーサイドの処理を連携させ、 Todoアプリを完成させます。