technoshop

杉並区和泉のソフトウェアハウス、株式会社テラソフトの技術ブログです。主に.NET MVCとKnockoutJSの情報をまとめます。

.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になったのでもう不要ですね。コメントアウトしました。これで出来上がりっすかね。あーつかれた。準備だけで一回使ったの、わかりますよね?

モーダルを見てみましょう。

f:id:technoshop:20141107152121j:plain

できた~(あれ?ボタンも細くなってる!)