.Net MVC4 で KnockoutJS ~【応用編4】Knockoutの多層オブジェクトをWebAPIで一気にセーブ
こんにちは。ザクです。
前回、けっこう手間をかけてセーブのためにモデルまわりの調整をしました。Knockoutで作成されたオブジェクトをMVC4のWebAPI経由でDBにセーブするための準備だったわけですが、ここまで準備できているとセーブの方はわりとさっくり出来てしまいます。さっそくやってみましょう~
モーダルにSaveボタンを設置する
とりあえず、モーダルにSaveボタンを設置します。これを押すことで、モーダルに表示されている状態をDBに保存できるようにするのが本日のお題です。モーダルのビューを直します。
<div class="modal-footer"> <div id="mcontrols" class="row"> <div id="mmessage" class="col-md-6"> </div> <div class="col-md-6 text-right"> <button class="btn btn-primary" data-bind="click: save">Save</button> <button type="button" class="btn btn-default" data-dismiss="modal">Close</button> </div> </div> </div>
モーダルのフッター部分にBootstrapのbtn-primaryで新たにボタンを追加し、clickバインディングでsaveに紐付けました。Studentビューモデルの中にself.saveを追加すればファンクションとして呼び出せるはずですね。
さらにフッターをBootstrapのcol-mg-6を使ってボタンが表示されている行を2つに割りました。前の部分をmmessageとして、中は空にします。ここにSaveボタンを押された後に表示するメッセージを入れる予定です。あとで、JavaScriptやcssなどで調整することになります。後の半分をボタンの入れ物としました。divのcol数を指定するだけで、勝手にレイアウトしてくれるのでチョー便利ですね。
Saveファンクションをビューモデルに追加する
次にボタンが呼び出すファンクションです。記述先は、Studentモデルの中ですね。モーダル全体がStudentオブジェクトにバインドされているので、この中で押したボタンはStudentの中のファンクション呼び出しになります。self.saveとしてファンクションを作ります。
self.save = function () { var stddata = { "StudentId": self.StudentId(), "LastName": self.LastName(), "FirstMidName": self.FirstMidName(), "EnrollmentDate": self.EnrollmentDate(), "Enrollments": self.Enrollments(), }; Update(ko.toJSON(stddata), "StudentAPI", self.StudentId()); }
Studentモデルの一番下、canAddの下に上のコードをアペンドしました。中身は単に、stddataという保存用のオブジェクトの入れ物を用意して、モーダルに使われているStudentのデータを割り当てます。モーダルから呼び出された時点でどのStudentと紐付いているのかKnockoutはわかっているので、そのままself.???とStudentのプロパティを渡してやるだけです。各プロパティに()を付けるのを忘れないで下さい。KOのObservableはファンクションなので、中の値を取るには()が必要です。覚えてますか~
EnrollmentsだけはObservableArrayです。これにはMVC4側のDAL/SchoolContext.csの中でDbSetとして定義されている名前と同じ入れ物を作り、そこへObservableArrayをそのまま渡すことでデータが入ります。
これはKOからWebAPIでPUTする時の重要なポイントです。これが出来るのでセーブの記述が最小限で済みます。Enrollments以下にもいくつか階層があったとしても、セーブの単位となるオブジェクト(ここではStudentですね)にまとめて渡すだけで、全体のセーブが出来るようになるんです。
文だけでは伝わりにくいので、後で実例を見せて説明しますね。
Studentモデルのファンクションが出来たので、実際にWebAPIを呼び出すファンクションを作ります。
@section Scripts { <script> function Update(data, path, id) { var url = '/api/' + path + '/' + id; $.isLoading({ text: "Updating", tpl: '<span class="isloading-wrapper %wrapper%">%text% <img src="/Content/images/ajax-loader.gif"/></span>' }); setTimeout(function(){ $.ajax({ url: url, type: "PUT", contentType: 'application/json; charset=utf-8', data: data, success: function (result) { // Handle the response here. $.isLoading("hide"); $("#mmessage").html('<span id="msuccess">Saved!</span>'); $("#msuccess").fadeOut(5000); }, error: function (jqXHR, textStatus, errorThrown) { // Handle error. $.isLoading("hide"); $("#mmessage").html('<span id="merror">Error. Please contact Admin.</span>'); } }); }, 1000); } </script> <script type="text/javascript"> (function () { var COURSES = @Html.Raw(Json.Encode(ViewBag.Courses));
Index.cshtmlの@section Scriptsの先頭に新たにscriptタグで囲まれたUpdateファンクションを作りました。KOのscriptの中に書かなかったのは、後々、ユニットテストをするためにファイルを分けるのに都合がいいからです。また、このUpdateファンクションは、WebAPIの名前がpathで指定できるので、Studentだけでなく別のモデルのセーブにも使えます。
やってることはself.saveから渡されたdata、path、idを使ってStudentAPIのPUTを$.ajaxで呼び出しているだけですが、処理中や処理の結果をうまくユーザーに表示するためのJQueryが色々入っています。
最初のisLoadingはSaveボタンが押されたら、モーダルを含めて画面全体をグレーアウトして、ローディングのアイコンをグルグル回します。これで、「今セーブをやっている」とユーザーに示すわけです。また、ボタンの2重押しの処理とか面倒臭いので、グレーアウトして2回押せなくするという効能もありますね。
successとerrorは処理結果に応じてメッセージを表示します。処理が終わったら、isLoadingをhideで完了し、フッターにつけたdivにメッセージを表示します。successの場合は緑の文字で「Saved!」と表示させ、ずっと表示しても意味がないので5秒でフェードアウトさせます。errorの場合は、赤文字で「Please contact Admin.」を表示させっぱなしにしました。
処理が早すぎるとLoadingが表示されても早すぎて見えないので、seTimeoutで最低1秒は表示するようにしました。ユーザーは必ず1秒待つことになりますが、こうしたほうがUXが統一できてメリットが多かったりします。PUTの呼び出しはこれだけです。
isLoadingとメッセージスタイルの調整
最後は見た目の調整です。これをやるかやらないかで、出来上がりのクオリティが大きく変わるので不思議なものです。手を抜かずにやりたいもんです。
isLoadingはローティングを表示するためのJQueryライブラリなので、ダウンロードしてきてプロジェクトに追加します。
Scriptsフォルダの中にコピーしたら、ビューに取り込まれるようにバンドルするんでしたよね。おぼえてますか~?
public static void RegisterBundles(BundleCollection bundles) { bundles.Add(new ScriptBundle("~/bundles/js").Include( "~/Scripts/jquery-{version}.js", "~/Scripts/jquery.unobtrusive*", "~/Scripts/jquery.validate*", "~/Scripts/jquery.isloading*", "~/Scripts/knockout-{version}.js", "~/Scripts/knockout-array-oftype.js", "~/Scripts/bootstrap.js")); var commonStylesBundle = new CustomStyleBundle(BootstrapPath); commonStylesBundle.Orderer = new NullOrderer(); commonStylesBundle.Include("~/Content/bootstrap/bootstrap.less"); bundles.Add(commonStylesBundle); bundles.Add(new StyleBundle("~/Content/css").Include("~/Content/site.css")); }
次にisLoadingのグルグルやメッセージが綺麗に表示されるようにcssを追加します。ザクは、アプリ用に追加するcssはデフォルトのSite.cssを取っておいて、そこに加えるようにしています。これならBootstrapを直接いじることはないですし、BSだけをアップデートしたり、テーマを変更したりしやすいですよね。
#mmessage { text-align: left; } #mmessage #msuccess { color: #18bc9c; } #mmessage #merror { color: #e74c3c; } .isloading-overlay { position: relative; text-align: center; } .isloading-overlay .isloading-wrapper { background: #FFFFFF; -webkit-border-radius: 7px; -webkit-background-clip: padding-box; -moz-border-radius: 7px; -moz-background-clip: padding; border-radius: 7px; background-clip: padding-box; display: inline-block; margin: 0 auto; padding: 10px 20px; top: 10%; z-index: 9000; }
さいごに、isLoadingで使うグルグルのgifをContent/imagesに入れておきます。イメージの場所は別の場所でも良いですが、その場合、Updateの中のisLoadingの中身を変更することをお忘れなく。グルグルgifは適当なものを入れてください。大事なのはUpdateの中とファイル名が合っているかだけです。
ここまでできたら、いったんSaveボタンを押してみます。
MVC4の多階層モデルのセーブにはGraph Diffを使う
Saveボタンを押すと、「Saved!」の文字がフッターの左側に表示されてフェードアウトしていきます。とりあえず、期待通りの動きです。ただ問題は、ここでIndexをリロードしても変更点が反映されず、データを取ってきた初期状態に戻ります。それもそのはずで、StudentAPIでセーブしているのはStudentのオブジェクトだけで、肝心のEnrollmentsは無視されるからです。
// PUT api/StudentAPI/5 public HttpResponseMessage PutStudent(int id, Student student) { if (!ModelState.IsValid) { return Request.CreateErrorResponse(HttpStatusCode.BadRequest, ModelState); } if (id != student.StudentId) { return Request.CreateResponse(HttpStatusCode.BadRequest); } db.Entry(student).State = EntityState.Modified; try { db.SaveChanges(); } catch (DbUpdateConcurrencyException ex) { return Request.CreateErrorResponse(HttpStatusCode.NotFound, ex); } return Request.CreateResponse(HttpStatusCode.OK); }
上はスキャフォールディングで作られたまんまのPutStudnetです。ModelState.IsValidで送られてきたデータを確認し、問題なければdb.Entry(student).State = EntityState.Modified;で変更を反映させてからSaveChangesします。よくあるアップデートのパターンです。
EntityState.Modifiedを使ったUpdateはMVC4の不便ポイントの一つで、階層のあるデータを受け取った時に、一層目のデータしか更新できません。シングルページアプリでは、データは何階層にもなっていて、下の層のデータは追加されたり、変更されたり、削除されたり様々な状態になっていることがほとんどです。せっかくシングルページアプリのデータの状態を正確にキャプチャしMVC4側に渡しても、MVC4側ではデータの状態を分析して良きにはからってはくれません。これをやるには、コードの中で受け取ったオブジェクトの奥のほうまでループで掘り下げて、一個一個状態確認し、Add、Update、Deleteのどれを行うか決めて処理する必要が出てきます。
これ、今回のモデルのようにプロパティが少なく、2層くらいなら我慢してコードを書こうかと思いますが、プロパティが大量にあり、また階層も4層以上になるとコントローラーに大量のコードを書くことになりゲンナリします。
そこで、この問題を解決するために使うのが「GraphDiff」です!
GraphDiffはBrent Mckendrickさんが作った、デタッチされたエンティティグラフをアップデートするためのツールです。よくわからない説明です。つまり、Jsonで渡されたデータ(MVC4ではデタッチされたエンティティとみなされる)も、データの型がMVC4の型と合致していたら、そのデータをMVC4のモデルとして状態を判断しAdd、Update、Deleteを行います!
説明書くより、見せたほうが早いですね。GraphDiffはNugetのパッケージになっているのでVSのパッケージマネージャーからインストールできますが、最新版はMVC5/EF6用となっているため、MVC4/EF5用のものを使うには、プロジェクトをダウンロードしてきて自分でコンパイルしてパッケージを作る必要があります。コンパイル用のプロジェクトはこちらからダウンロードできます。(EF4-5のブランチに切り替えます。)
refactorthis/GraphDiff · GitHub
GraphDiffをインストールできたら、PutStudentのコードを修正します。
// 上にusing RefactorThis.GraphDiff;も追加する!! // PUT api/StudentAPI/5 public HttpResponseMessage PutStudent(int id, Student student) { if (!ModelState.IsValid) { return Request.CreateErrorResponse(HttpStatusCode.BadRequest, ModelState); } if (id != student.StudentId) { return Request.CreateResponse(HttpStatusCode.BadRequest); } //db.Entry(student).State = EntityState.Modified; try { db.UpdateGraph(student, map => map .OwnedCollection(s => s.Enrollments)); db.SaveChanges(); } catch (DbUpdateConcurrencyException ex) { return Request.CreateErrorResponse(HttpStatusCode.NotFound, ex); } return Request.CreateResponse(HttpStatusCode.OK); }
真ん中のdb.Entryはコメントアウトします。代わりにtryの中で、db.UpdateGraphを追加しました。EnrollmentsはStundentに対して一対多のモデルになるのでGraphDiffでは.OwnedCollectionを使います。GraphDiffの説明によると、一対一ならOwnedEntity、多対多ならAssociatedCollectionを使います。ここではやりませんが、アップデートする階層を掘り下げる場合はOwnedCollectionのなかでwithを使って、更に下の層のオブジェクトと関連付けられます。また、同じレベルで複数のオブジェクトと関連付ける場合は、.OwnedCollectionを複数並べます。文では説明しづらいですが、下のリンク先の記述サンプルを見たら、なんとなく記述方法がわかると思います。
The art of simplicity: Entity Framework Tooling: GraphDiff
ビルドして、セーブしてみます。生徒のモーダルを開き、適当に授業を追加してSaveボタンを押してみます。緑で「Saved!」と表示されました。Indexをリロードします。最初に取ってくるデータに今追加した授業の分が反映されています!
Graph DiffはJson/WebAPI専用というわけではなく、普通にMVC4のセーブにも使えます。今回のチュートリアルではAddにしか使っていませんが、Update、Deleteもできますので、これらの実装にもチャレンジしてみてください!
.Net MVC4 で KnockoutJS ~【応用編3】WebAPIでセーブするための準備とか
ザクです。
モーダル上でKnockoutのビューモデルにデータの追加とかできるようになったので、そろそろWebAPIでPUTしてデータをDBにセーブとかしたいです。さっそく本題に入りたいところですが、今のままだとKOのビューモデルとMVC4側のモデルがぴったり一緒というわけでないので、KOのオブジェクトをMVC4のアクションに渡してもまんまセーブできません。
今回は、次回のための準備にあてますね。MVC4とKnockout両方のチュートリアルとなっております!
Enumなってるプロパティの扱い方
KOにせよ、MVC4にせいよ、Enumって結構ビュー上で扱いにくいんですよね。セレクトメニューにするのにもいろいろ技が必要ですし、値がintなのは困りポイント満載です。ザクは、Enumを使うメリットがない限りほとんど使いませんけど、このチュートリアルではモデルの設計をズルしたために、なんとかせねばなりません。。。
チュートリアルでは、Enumのフィールドはセレクトメニューにすると入力しやすいので直します。EnumになっていたのはGradeでしたね。前回では、Knockoutで使うselectの選択肢gradeOptionsはコード上に直書きしていました。これもなんなので、Indexを呼び出すときにGradeのEnumで中身を作って、ViewBagで渡せるようにしてみます。StudentコントローラーのIndexアクションをこんな風に直します。
public ActionResult Index() { var gradeEnums = Enum.GetValues(typeof(Grade)).Cast<object>(); var gradelist = from enumValue in gradeEnums select new SelectListItem { Text = enumValue.ToString(), Value = ((int)enumValue).ToString() }; ViewBag.GradeTypeList = gradelist; return View(); }
いろいろ調べた所、EnumにしてるモデルのプロパティからセレクトメニューのもとになるオブジェクトSelectListItemを作るのはこんな感じになります。詳細ははしょります。各自しらべてみてくださいw
できあがったgradelistをViewBag.GradeTypeListに渡してビューで利用できるようにしています。次に、View側で受け取る方です。
// Add function for Enrollment self.gradeOptions = ko.observableArray(@Html.Raw(Json.Encode(ViewBag.GradeTypeList)));
ViewBagの中身はまんまオブジェクトですので、Json.EncodeしてHtml.Rawで生出力してやります。ブラウザー表示させてからソースを見るとこんな感じになってるはずです。
// Add function for Enrollment self.gradeOptions = ko.observableArray([{"Selected":false,"Text":"A","Value":"0"},{"Selected":false,"Text":"B","Value":"1"},{"Selected":false,"Text":"C","Value":"2"},{"Selected":false,"Text":"D","Value":"3"},{"Selected":false,"Text":"F","Value":"4"}]);
ちょっと見た目はあれですが、セレクトの中身を見せても害はなさそうなので、このまま行ってしまいますw ベターなやり方は皆さんで考えてみてください。
これでMVC4のモデルからEnumを取ってきて、Knockoutのoptionバインディングに展開できるようになりました。モーダルを開いてメニューを動かすとちゃんとセットしたメニューになっていますね!
どうせなら授業もセレクトちゃう?
ですね。。。MVC4側のCourseモデルを吟味すると、授業のデータが色々入っています。モーダルでAbenomicsみたいにオレオレ授業を作れてしまうのはちょっとマズいです。なのでここもセレクトに変えてみたいと思います。
もっと欲張ると、CourseごとにCreditsの値が決まっているので、Creditsもinputで値を入力させずにCourseのセレクトメニューで選んだコースに対し自動的に単位数がセットされるとかっこいいです。ここはKnockoutの出番っぽいですなぁ。まずは、ビュー側のフォームを調整してやります。
<tr> <td><select data-bind="value: addtitle, options: $root.Courses, optionsText: 'Title', optionsValue: 'CourseId'"></select></td> <td class="text-center"><span data-bind="text: addcredit"></span></td> <td class="text-center"><select data-bind="value: addgrade, options: gradeOptions, optionsText: 'Text', optionsValue: 'Value'"></select></td> <td class="text-center"><button type="button" class="btn btn-xs btn-success" data-bind="enable: canAdd, click: addEnroll">Add Grade</button></td> </tr>
Knockoutでよくある課題
Courseの扱いに関してはいろいろ課題がありまして、検討した結果、フォーム側は上のようになりました。(汗)とりあえずaddtitleの部分はselectにして、addcreditはtextバインディングで表示するだけにしました。
optionsが$root.Coursesになっています。ビューモデルのところのCoursesオブジェクトを指しています。Studentsのお隣りさんということですね。optionsTextやoptionsValueの名前は、これから作るCourseモデルの定義に事前に合わせています。
Courseの課題は、まずCourseをどのタイミングで取ってくる?とCourseの情報をKnockout上でどう持たせるか?です。Knockoutで扱うビューモデルの構成が何層もの階層構造になると、モデルの定義とかチュートリアルでやっているように別々に分離していくんですが、モデルが別れるとスコープの問題がいろいろ出てきて結構悩みます。特に子供モデルの中で親のモデルのプロパティを使いたいときどうしたらいいの?というカベにぶち当たります。どういうことか説明するために、まずCourseモデルを定義してやりましょう。
// Course Model function Course(course) { var self = this; self.CourseId = ko.observable(course.CourseId); self.Title = ko.observable(course.Title); self.Credits = ko.observable(course.Credits); self.DepartmentId = ko.observable(course.DepartmentId); } // Enrollment Model
Courseモデルの中は、MVC4とほぼ一緒になりました。ザクはビューモデルにモデル定義を追加するときは上にプリペンドしてます。親となるビューモデルから離れるほど上にくるという感じです。だからEnrollmentモデルの上ですね。さて、どこから取ってきて、どこに置くか?です。普通に考えると、取ってくるのはStudentsを取ってくる時と同じでいいんじゃないかなと思います。Studentsのデータはビューモデルの中でGetSchoolDataというファンクションを呼び出して、$.ajaxのGETでWebAPIから取ってきてます。Student→Enrollment→Courseという繋がりでCourseを取ってきても、Courseの全てはとれないので、Courseだけを直接まるごと取ってきてやる必要がありそうです。なので、Course用に別のWebAPIを作り、専用の$.ajaxでGETするようにします。
WebAPIの解説の回を参考に、CourseAPIを作ります。MVC4側でチュートリアルのままのモデルでは、ナビゲーションプロパティが双方向になっていて、WebAPIでほしいデータとしては扱いづらいので、なるべく階層構造になるように余計なナビプロはけずってください。ザクは、今回の修正用にCourseモデルについていたナビプロを全部コメントアウトし、Instructorモデルとの関係も必要ないので断ち切りました。シードデータが入っているConfiguration.csにも色々影響が出る(特にInstructor関連)ので、次々にコメントアウトします。ここまで変えると、新たにadd-migrationが必要なので、加えました。詳細は書きませんが、ざっくり説明するとMVC4のモデルの修正はこんか感じです。
GetSchoolDataも修正します。
self.Students = ko.observableArray().ofType(Student); self.Courses = ko.observableArray().ofType(Course); GetSchoolData(); //Function to call Web API function GetSchoolData() { //Ajax Call Get $.ajax({ type: "GET", url: "/api/StudentAPI/", contentType: "application/json; charset=utf-8", dataType: "json", success: function (data) { self.Students(data); //Put the response in ObservableArray }, error: function (error) { alert(error.status + "<--and--> " + error.statusText); } }); $.ajax({ type: "GET", url: "/api/CourseAPI/", contentType: "application/json; charset=utf-8", dataType: "json", success: function (data) { self.Courses(data); //Put the response in ObservableArray }, error: function (error) { alert(error.status + "<--and--> " + error.statusText); } }); //Ends Here
self.CoursesとしてObservableArrayを作るのもお忘れなく。Students同様、$.ajaxの中でself.Coursesに取ってきたデータを流し込めば出来上がりです。Chromeのデバッガーで見ると、Studentsに並んでちゃんとCoursesのデータが入っています。
これをつかったaddcreditの修正方法を考えてみてください。やりたいことは、選択されたセレクトからTitleを特定し、Coursesオブジェクトの中からそのTitleに合致したものを見つけてきて、Creditsの値を取り出し、addcreditの値に入れることです。こうすれば、科目のセレクトと単位の数字が連動できそうです。なのでaddcreditはaddtitleの値と紐付いたcomputedにしてやる必要がありますね。
self.addcredit = ko.computed(function () { var selectcourse = ko.utils.arrayFilter(self.Courses(), function (c) { return c.CourseId() == self.addtitle(); }); return selectcourse.Credits(); });
最初ザクが想定していたcomputedのコードはこんな感じでした。KOユーティリティを使って、self.Coursesの中を検索し、マッチしたオブジェクトからCreditsの値を返すcomputedです。しかし、このコードには致命的なミスがあります。わかるかな~。
スコープが違うために、self.addcreditのあるStudentモデルの中からself.Coursesは見えないんですねぇ~ビューモデルが複雑になってくるとこういうことしょっちゅうあります。
で、どうするか?あんまりここで頑張りたくないので、代わりにこんな風にしました。
self.addcredit = ko.computed(function () { var value = 0; var selectcourse = $.grep(COURSES, function (e) { return e.CourseId == self.addtitle(); }); if (selectcourse.length > 0) { value = selectcourse[0].Credits; } return value; });
ちょっとヘボいですが、JavaScript全体のファンクションの一番上にCOURSESというグローバル変数を用意して、Index表示時にViewBag経由で値を入れてやることにしました。しかたありません。。。
(function () { var COURSES = @Html.Raw(Json.Encode(ViewBag.Courses)); // Course Model
グローバル変数の定義はこんな感じです。Course定義の真上ですね。ViewBagで持ってくるのはセレクトのオプションと同じです。コントローラーでViewBagも用意してやります。
public ActionResult Index() { var courses = db.Courses; ViewBag.Courses = courses; var gradeEnums = Enum.GetValues(typeof(Grade)).Cast<object>(); var gradelist = from enumValue in gradeEnums select new SelectListItem { Text = enumValue.ToString(), Value = ((int)enumValue).ToString() }; ViewBag.GradeTypeList = gradelist; return View(); }
Enumリストの上で、Entity Frameworkを使ってCoursesを丸ごと取ってきてます。それをViewBag.Coursesに代入して準備完了です。モーダルを開いて、Titleのセレクトを変えると、Creditsの値も変わります!かっこいいですね~
仕上げにaddEnrollも直す
あともうちょいです。Student内で使えるCoursesのデータができたので、addEnrollも変えます。
self.addEnroll = function () { var title = ""; var selectcourse = $.grep(COURSES, function (e) { return e.CourseId == self.addtitle(); }); if (selectcourse.length > 0) { title = selectcourse[0].Title; } var formdata = { "CourseId": self.addtitle(), "StudentId": self.StudentId(), "Grade": parseFloat(self.addgrade()), "Course": { "Title": title, "Credits": parseFloat(self.addcredit())} }; self.Enrollments.push(formdata); self.addtitle(''); //self.addcredit(''); self.addgrade(''); }
フォームでTitleはセレクトメニューに変わったので、見た目はコースのタイトルが出ていますが、ValueはCourseIdになっています。なので、addtitleの中身はCourseIdに代入するようにformdataを修正します。
Courseの中もだいぶ変わりました。Titleには数字ではなく、コースタイトルを入れたいです。そのために、先ほど作ったCOURSESグローバルを使って、addtitleの値をもとにタイトルの文字列を取ってきています。addcreditでやったのとほとんど同じです。できあがったtitleはCourseのTitleにセットします。Creditsの方は、同じスコープ内なのでまんまaddcreditを使えばいいですね。
最後にpushした後のリセットでaddcreditはcomputedになったのでもう不要ですね。コメントアウトしました。これで出来上がりっすかね。あーつかれた。準備だけで一回使ったの、わかりますよね?
モーダルを見てみましょう。
できた~(あれ?ボタンも細くなってる!)
.Net MVC4 で KnockoutJS ~【応用編2】モーダルからビューモデルにデータを追加してみる
ザクです。ご無沙汰しております!仕事で更新がストップしてしまいました。
前回はモーダル上で生徒が取っている科目と成績が見れるようにしました。演習では、EnumになっているGradeの値を成績らしくアルファベットに変換する問題を出したと思います。下がその答え合わせです。
// Translate Enum Grade to Alphabets ko.bindingHandlers.grade = { update: function (element, valueAccessor, allBindingsAccessor) { return ko.bindingHandlers.text.update(element, function () { var value = ko.utils.unwrapObservable(valueAccessor()), valueToWrite = 'N/A'; switch (value) { case 0: valueToWrite = "A" break; case 1: valueToWrite = "B" break; case 2: valueToWrite = "C" break; } return valueToWrite; }); } };
gradeカスタムバインディングを作成して、参考リンク先のCurrency辺りのコードを拝借しながらvalueAccessor経由で受け取った値の中身を確認、valueToWriteに変換しています。大したことはありませんでしたね。できましたか?
モーダル使って詳細情報を見れるようになりましたけど、普通データの修正とかもやりますね。今回は、モーダル画面上でEnrollmentモデルにデータを追加したら、表の中が自動更新されるとかやってみます。
モーダル上にEnrollmentの追加フォームを作る
データの追加にはフォームが必要ですね。モーダルのテンプレートにフォームを入れます。
<script id="enrollment" type="text/html"> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> <h4 class="modal-title">Class Enrolled (<span data-bind="text: LastName"></span>, <span data-bind="text: FirstMidName"></span>)</h4> </div> <div class="modal-body"> <table class="table table-bordered table-condensed"> <thead> <tr> <th>Title</th> <th class="text-center">Credits</th> <th class="text-center">Grade</th> <th></th> </tr> </thead> <tbody> <!-- ko foreach: Enrollments --> <tr> <td><span data-bind="text: Title"></span></td> <td class="text-center"><span data-bind="text: Credits"></span></td> <td class="text-center"><span data-bind="grade: Grade"></span></td> <td></td> </tr> <!-- /ko --> <tr> <td><input data-bind="value: addtitle" /></td> <td><input data-bind="value: addcredit" /></td> <td><select data-bind="value: addgrade, options: gradeOptions, optionsText: 'Text', optionsValue: 'Value'"></select></td> <td><button type="button" class="btn btn-mini btn-success" >Add PO</button></td> </tr> </tbody> </table> </div> <div class="modal-footer"> <button type="button" class="btn btn-default" data-dismiss="modal">Close</button> </div> </div><!-- /.modal-content --> </div><!-- /.modal-dialog --> </script>
Enrollmentsのループの下に、一行フォームを用意しました。クラス名(Title)と単位(Credits)はinputにして、Gradeは3択なのでselectにしました。selectを利用するには、選択肢になるObservableが必要です。Observableはoptionsバインディングを使ってバインドします。
Knockout : The "options" binding
とりあえずフォームができたので、ビューモデルにフォーム用のObservableを追加します。
// Add function for Enrollment self.gradeOptions = ko.observableArray([{ Text: 'A', Value: '0' }, { Text: 'B', Value: '1' }, { Text: 'C', Value: '2' }]); self.addtitle = ko.observable(); self.addcredit = ko.observable(); self.addgrade = ko.observable();
追加する場所はStudentモデルの定義の一番下です。Studentの中ならどこでもいいのですが、Gitでコードを管理してたら下にアペンドしてたほうがゴチャゴチャしなくてわかりやすいかもです。フォームの各値にObservableを用意してやります。selectには選択肢になる別のObservableArrayが必要なのでgradeOptionsを作りました。中身はselectの選択肢のオブジェクト({})が入った配列([])です。オブジェクトの中は、2つのKey/Valueのペアになっているので、フォーム側のoptionTextとoptionValueに使った名前TextとValueに合わせます。これだけで、とりあえずフォームは表示できるはずです。
フォームが表示されました!
フォームからObservableArrayにプッシュする
これで入力の準備は整いました。Title、Credits、Gradeを入力し、Add Gradeを押したらテーブルに一行アペンドするようにします。とりあえず、Add Gradeを押した時に呼び出されるAddEnrollファンクションを作ります。
self.addEnroll = function () { var formdata = { "StudentId": self.StudentId(), "Grade": parseFloat(self.addgrade()), "Course": { "Title": self.addtitle(), "Credits": parseFloat(self.addcredit())} }; self.Enrollments.push(formdata); self.addtitle(''); self.addcredit(''); self.addgrade(''); }
フォーム用のObservableの下に続けて、上のコードを追加します。EnrollmentsのObsrevableArrayに差し込めるように、Enrollmentモデルに合った形にフォームのObservableをformdataとしてまとめたオブジェクトを作ります。Enrollmentモデルは、Web APIから取ってきたJsonデータの都合で、Courseオブジェクトを下にぶら下げないと入力できません。なので、TitleとCreditsはCourseオブジェクトの中に入れてやります。
formdataが準備できたらpushを使ってEnrollments(EnrollmentのObservableArray)に差し込みます。仕上げに、フォームのObservableの中身を空にしてリセットしたら完成です。
では、フォームに値を入れてAdd Enroll
成績のなかったLauraさんとNinoさんに、AbenomicsとHatsunemicsを履修させて成績をつけました。Enrollmentが追加されたら、後ろの成績表の中も更新されてます。
Knockoutらしく入力規則をつける
入力ができるようになりましたけど、TitleやCreditsに値がなくてもガンガン追加できてしまいます。これらに値が入っていなかったらボタンを押せないようにしたら、空で追加は防げそうです。
Knockoutらしく入力規則を作ってみます。
self.canAdd = ko.computed(function () { var yesyoucan = false; if (self.addtitle() && self.addcredit()) { yesyoucan = true; } return yesyoucan; });
ここで再びComputed Observableの登場です。フォームのaddtitleとaddcreditを監視するフラグをcomputedで作りました。この2つに値が入っていたらyesyoucanフラグにtrueを入れて返すようにします。次にフォームです。
<tr> <td><input data-bind="value: addtitle" /></td> <td><input data-bind="value: addcredit" /></td> <td><select data-bind="value: addgrade, options: gradeOptions, optionsText: 'Text', optionsValue: 'Value'"></select></td> <td><button type="button" class="btn btn-mini btn-success" data-bind="enable: canAdd, click: addEnroll">Add Grade</button></td> </tr>
Add Gradeボタンに、enableバインディングを追加して、値にcanAddを入れます。これだけでTitleとCreditsの値がなければ押せないボタンの出来上がりです。
Add Gradeボタンが薄い緑になって、押せなくなっています!
.Net MVC4 で KnockoutJS ~【応用編1】BootstrapモーダルをKnockoutから呼び出す(カスタムバインディング)
こんにちは。ザクです。今回から「応用編」に突入します。タイトル通り、Bootstrapをより便利に使うためにKnockout独特の利用方法を解説していきます。
先に前回基礎編4で出した演習の答え合わせをしておきましょうか。
// Calculate GPA self.gpa = ko.computed(function () { var point = (self.gradeA() * 4) + (self.gradeB() * 3) + (self.gradeC() * 2); return (point / self.totalcredits()).toFixed(1); });
式のまんまですね。グレードごとの単位数の出力は前回できたので、それを使ってポイントのトータルを出します。各グレードの単位数にポイントを掛け算するだけですね。全グレードで合計したら、ポイントの合計が出ます。これを単位数で割って出た数字がGPAとなります。割り算で小数点が出ますので、GPAの表記通り小数点第一で丸めます。(toFixed(1)を使いました)できましたか?
KnockoutでBootstrapのモーダルってどう出す?
「普通にHTMLにモーダルのdivを用意して、data-toggleで呼び出せばいいんじゃないの?」と思った人はあまいです。Knockoutでモーダル画面を使う時、モーダルをKnockoutのビューモデルとバインドさせたい場合がほとんだと思います。Bootstrapの説明通りではバインドできません。JQueryでゴリゴリやってできるのかもしれませんが、とてつもなく面倒くさそうです。もっとKnockoutらしくやる方法はないのでしょうか?あるんですねぇ。
knockout.js - Knockoutjs bind model to template created with ko.renderTemplate - Stack Overflow
Knockoutの開発をやっていると度々お世話になるサイトstackoverflowのこれを応用してみます。RP Niemeyerさんの回答のコードに少しだけ手を加えます。(まんまコピーでも大丈夫かも)ko.applyBindingsの下に追加します。(ちなみにRP Niemeyerさんは、このブログでも度々登場しているknockmeout.netの執筆者です)
ko.applyBindings(new SchoolViewModel()); // Custom Binding for BS Modal ko.bindingHandlers.modal = { init: function (element, valueAccessor, allBindings, vm, context) { var modal = valueAccessor(); //init the modal and make sure that we clear the observable no matter how the modal is closed $(element).modal({ show: false, backdrop: 'static' }).on("hidden.bs.modal", function () { if (ko.isWriteableObservable(modal)) { modal(null); } }); //template's name field can accept a function to return the name dynamically var templateName = function () { var value = modal(); return value.template; }; //a computed to wrap the current modal data var templateData = ko.computed(function () { var value = modal(); return value; }); //apply the template binding to this element return ko.applyBindingsToNode(element, { template: { 'if': modal, name: templateName, data: templateData } }, context); }, update: function(element, valueAccessor) { var data = ko.utils.unwrapObservable(valueAccessor()); //show or hide the modal depending on whether the associated data is populated $(element).modal(data ? "show" : "hide"); } };
ko.bindingHandlersというのが出てきました。これはKnockoutのカスタムバインディングを作るときに使います。
Knockout : Creating custom bindings
中でinitとupdateを定義して使います。このコードではupdateのほうがわかりやすいので先に説明します。updateはこのバインディングを呼び出しているノード(element)にバインドされているデータ(valueAccessor)を監視して、変更があれば実行されます。ここでは単に、データがあったらJQueryでモーダルを表示(show)し、無ければ非表示(hide)にしています。
initは名前の通りイニシャライズです。このバインディングが呼ばれた時に実行されます。最初の部分はコメントに書いてあるとおり、モーダルがhiddenになっていたらバインドされているObservable(コード内の変数名はmodal)をnull(空)にしています。ただ、これだけだと、何やりたいのかちょっとわからんですよね。ここで何をしたいのかは、今回の実装の全容が明らかになったらわかるようになると思います。initの残りの部分はわかりやすいです。バインドされたデータから、テンプレート名とデータそのものを取り出して、templateバインディングしています。
「テンプレート(template)」は似たようなビューを中身を変えて何度も表示しなければならない時、ひな形を作ってそこにビューモデル内の任意のモデルを流し込んで表示させるKnockoutの便利機能です。Knockoutのウリの一つになっていますよね。
カスタムバインディングでテンプレートにバインドする
各生徒の成績は数字で出せたものの、何の授業を取っているのかわからないので、取っている授業の一覧をモーダルで表示してみます。モーダルはカスタムバインディングから呼び出されるテンプレートを用意します。
テンプレートを作成する前に、先ほど追加したモーダルカスタムバインディングをビューから呼び出すイベントを先に決めます。成績表のCreditsの数字をリンクにして、クリックできるようにしてみましょうか。
<table class="table table-bordered"> <tr> <th rowspan="2"> Last Name </th> <th rowspan="2"> First Name </th> <th class="text-center" rowspan="2"> Credits </th> <th class="text-center" colspan="3"> Grade </th> <th class="text-center" rowspan="2"> GPA </th> </tr> <tr> <th class="text-center">A</th> <th class="text-center">B</th> <th class="text-center">C</th> </tr> <!-- ko foreach: Students --> <tr> <td><span data-bind="text: LastName"></span></td> <td><span data-bind="text: FirstMidName"></span></td> <td class="text-center"><a href="#" data-bind="click: $root.currentModal, text: totalcredits"></a></td> <td class="text-center"><span data-bind="text: gradeA"></span></td> <td class="text-center"><span data-bind="text: gradeB"></span></td> <td class="text-center"><span data-bind="text: gradeC"></span></td> <td class="text-center"><span data-bind="text: gpa"></span></td> </tr> <!-- /ko --> </table> <div class="modal fade" data-bind="modal: currentModal"> </div>
今あるビューに少し手を加えました。spanの中でtotalcreditを表示していたのをリンクに変えてバインドしました。また、clickバインディングを追加してクリックイベントを起こしました。ここでは、このリンクがクリックされたらビューモデルのトップ(ルートレベル)にあるcurrentModalというObservableにクリックした要素にあるStudentオブジェクトを渡します。currentModalに値が入ることで、上のコードの下の方にあるdata-bind="modal: currentModal"があるdiv要素がko.bindingHandlers.modalを実行します。ここがモーダルカスタムバインディングになっています。モーダルカスタムバインディングではcurrentModalにデータが入っているかどうかを監視してモーダル表示をオン・オフしているわけです。Knockoutの機能を活かしたBootstrapのトグル実装になっていますね。initの最初の部分は、Closeボタンを押すなどBootstrapの方法でモーダルを非表示にする場合、とりあえずcurrentModalの中身を空にするためのコードだったというわけです。(currentModalにnullを代入してもモーダルは閉じます)
currentModalはまだビューモデルに存在しないので、Observableとして追加します。ビューモデルの直下、var self = this;の辺りでしょうか。これでクリックイベントの後、値を入れる入れ物ができました。(同時にバインディングの実行にもなります)
function SchoolViewModel() { var self = this; // current Modal object to be saved self.currentModal = ko.observable();
生徒の受講一覧のテンプレートを作ります。Bootstrap3の表記法に従って書きます。とりあえず場所はモーダルカスタムバインディングのdivの真下でいいでしょう。
<div class="modal fade" data-bind="modal: currentModal"> </div> <script id="enrollment" type="text/html"> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> <button type="button" class="close" data-dismiss="modal" aria-hidden="true">×</button> <h4 class="modal-title">Class Enrolled</h4> </div> <div class="modal-body"> <table class="table table-bordered table-condensed"> <thead> <tr> <th>Title</th> <th>Credits</th> <th>Grade</th> </tr> </thead> <tbody> <tr> <td>Chemistry</td> <td>3</td> <td>A</td> </tr> </tbody> </table> </div> <div class="modal-footer"> <button type="button" class="btn btn-default" data-dismiss="modal">Close</button> </div> </div><!-- /.modal-content --> </div><!-- /.modal-dialog --> </script>
こんな感じになります。モーダルのテンプレートは数が増えてきたら別ファイルにしてRazorの@Html.Partialで呼んできてもいいですね。スッキリ整理できます。
scriptタグの中がKnockoutのテンプレートになります。使い方は簡単で、idでテンプレート名を指定するだけです。普通はビューの中でko templateを使ってバインドしますが、今回はカスタムバインディングのコードの中から呼び出してます。template:となっている所がそうでした。先のコードでは、currentModal(コードの中ではmodal)にオブジェクトが入っていれば、initでセットしたテンプレート名とオブジェクトを使ってテンプレートにバインドしています。
currentModalに入ったオブジェクトからテンプレート名(template)を取り出してどのテンプレートを使うかを特定するので、ビューモデルのどこかにテンプレート名を格納する変数を用意する必要があります。どこに入れたらいいでしょうか?モーダルカスタムバインディングから取り出すには、クリックした時currentModalに渡されるモデルの中に入っている必要がありますね。Studentモデルの中です。
// Student Model function Student(student) { var self = this; self.template = "enrollment";
こちらもselfの後に加えました。値はテンプレートのidに必ず合わせます。これで単位(Credits)のリンクをクリックしたら、モーダルが表示されるようになっているはずです。クリックしてみましょう。
中身はモックのままですが、モーダルカスタムバインディングでBootstrapのモーダル画面を表示することができました。モーダル画面の中ではちゃんとクリックした生徒のオブジェクトも取得できています。これを使ってモーダルの中の表示を作ってやります。とりあえず、Title、Credits、Gradeのテーブルを完成させます。
<script id="enrollment" type="text/html"> <div class="modal-dialog"> <div class="modal-content"> <div class="modal-header"> <h4 class="modal-title">Class Enrolled (<span data-bind="text: LastName"></span>, <span data-bind="text: FirstMidName"></span>)</h4> </div> <div class="modal-body"> <table class="table table-bordered table-condensed"> <thead> <tr> <th>Title</th> <th>Credits</th> <th>Grade</th> </tr> </thead> <tbody> <!-- ko foreach: Enrollments --> <tr> <td><span data-bind="text: Title"></span></td> <td><span data-bind="text: Credits"></span></td> <td><span data-bind="text: Grade"></span></td> </tr> <!-- /ko --> </tbody> </table> </div> <div class="modal-footer"> <button type="button" class="btn btn-default" data-dismiss="modal">Close</button> </div> </div><!-- /.modal-content --> </div><!-- /.modal-dialog --> </script>
生徒が持っているEnrollmentsの中身をループで表示します。ko foreachを使いtbodyの中を挟みました。ヘッダーのタイトルには、どの生徒かわかるように生徒の名前もつけました。リロードして、モーダルを表示させます。
授業のリストはちゃんと表示されましたが、Gradeが数字のままです。GradeはMVC4ではEnumだったのでintで送られてくるんでした。表示はアルファベットに変えたいですね。
【演習】Gradeのアウトプットを調整する
Gradeの数字をアルファベットに変換するのは、ビューのdata-bindの中でゴニョゴニョやってもできそうですが、カスタムバインディングを使ってビューでdata-bind="grade"となっていたら数字からアルファベットに変換して表示するようにしてみましょうか。これも演習にしてみます。ko.bindingHandlers.gradeを自分で書いてみてください。こことか参考になります。
10 Knockout Binding Handlers I Don't Want To Live Without - Tech.pro
こんな感じに表示することができました!カスタムバインディングはko.computedとならぶKnockout開発のキモになるので、しっかりマスターしてください。
.Net MVC4 で KnockoutJS ~【基礎編4】成績表を作ってみよう Part2
ザクです。
前回はko.utils.arrayForEachとko.computedを組み合わせてエクセル関数的なself.totalcreditsという変数を作ってみました。全単位の合計は計算して表示することができましたが、肝心の成績表の部分がモックのままです。ここを仕上げてみたいと思います。
ko.utils.arrayFilterで条件で配列を取り出す
ko.utilsはKnockoutの便利なユーティリティです。前回はarrayForEachを使ってObservableArrayに対してループを回してみました。今回使うのはko.utils.arrayFilterです。これはObservableArrayに対して条件で絞込みをします。ちょっと変わった書き方するので、慣れが必要です。
グレードごとに取得した単位の合計を求めるのに使ってみます。
var counter = ko.utils.arrayFilter(self.Enrollments(), function (el) { return el.Grade() === 0; });
こんな風に書きます。結果に条件に引っかかったObservableArrayの中身がオブジェクトの配列で帰ってきますので、入れ物の変数を用意してやります。カウンターっぽく使うのでcounterとしました。中身は条件に合致したEnrollmentのオブジェクトです。functionの後の括弧の中にファンクションの中で使うオブジェクトの変数を決めてやり、returnの後が「条件節」になっています。したがって、EnrollmentオブジェクトのGradeがゼロ(0)か見ています。ここでゼロと確認しているのは、MVC4側のGradeがEnumでAがintのゼロになっているからです。
これを応用して、各グレードの単位数の合計を計算します。ko.utils.arrayFilterを使って、生徒がA(やBやC)を取った授業の単位数を積算していきます。こんな感じのコードになりました。
// Counters for each grade self.gradeA = ko.computed(function () { var counter = ko.utils.arrayFilter(self.Enrollments(), function (el) { return el.Grade() === 0; }); var total = 0; ko.utils.arrayForEach(counter, function (co) { var value = co.Credits(); if (!isNaN(value)) { total += value; } }); return total; }); self.gradeB = ko.computed(function () { var counter = ko.utils.arrayFilter(self.Enrollments(), function (el) { return el.Grade() === 1; }); var total = 0; ko.utils.arrayForEach(counter, function (co) { var value = co.Credits(); if (!isNaN(value)) { total += value; } }); return total; }); self.gradeC = ko.computed(function () { var counter = ko.utils.arrayFilter(self.Enrollments(), function (el) { return el.Grade() === 2; }); var total = 0; ko.utils.arrayForEach(counter, function (co) { var value = co.Credits(); if (!isNaN(value)) { total += value; } }); return total; });
なんか冗長ですが、ビューに出すときにグレードごとに計算されていたほうが扱いやすいのでこのまま行きます。いま作ったko.computed変数がちゃんと計算しているか確認するには、Context Debuggerを使ってもいいですし、Viewに出力してしまってもいいです。もう、どうやるかわかりますよね?
ついでなので、GPAも計算して横に出してみましょうか?せっかくなので、これは演習にしてみましょう。GPAを計算するko.computedを実装してみてください。GPAの計算方法は以下のリンク先の通りです。
ビューはこんな感じにしておきましょうか。(Bootstrapマジ便利っすね!)
<h2>Student Grades</h2> <table class="table table-bordered"> <tr> <th rowspan="2"> Last Name </th> <th rowspan="2"> First Name </th> <th class="text-center" rowspan="2"> Credits </th> <th class="text-center" colspan="3"> Grade </th> <th class="text-center" rowspan="2"> GPA </th> </tr> <tr> <th class="text-center">A</th> <th class="text-center">B</th> <th class="text-center">C</th> </tr> <!-- ko foreach: Students --> <tr> <td><span data-bind="text: LastName"></span></td> <td><span data-bind="text: FirstMidName"></span></td> <td class="text-center"><span data-bind="text: totalcredits"></span></td> <td class="text-center"><span data-bind="text: gradeA"></span></td> <td class="text-center"><span data-bind="text: gradeB"></span></td> <td class="text-center"><span data-bind="text: gradeC"></span></td> <td class="text-center"><span data-bind="text: gpa"></span></td> </tr> <!-- /ko --> </table>
ザクの実装ではこんな結果になりました!