けさらんぱさらん

方向性は定めず、ただ思いつくままに

KnockoutjsでTwitter風の表示をするには?

KnockoutjsでTwitter風の表示を作りたいと試行錯誤してみました。
とりあえずこうゆう風に表示できればOK
f:id:cer1974:20130114140535p:plain
つぶやきの一覧の中に更に返信の一覧が表示できるようにしたい。


Knockoutjsのバージョンは2.2.0を使います。


サーバ側のソース

Model
/// <summary>
/// つぶやき
/// </summary>
public class Tsubuyaki
{
    public int Id { get; set; }
    public string UserName { get; set; }
    public string Comment { get; set; }
    public bool ExistReply { get; set; }
    public string CreatedAt { get; set; }

    public IEnumerable<Reply> Replies { get; set; }
}

/// <summary>
/// 返信
/// </summary>
public class Reply
{
    public int Id { get; set; }
    public int ParentId { get; set; }
    public string UserName { get; set; }
    public string Comment { get; set; }
    public string CreatedAt { get; set; }
}
Controller
/// <summary>
/// 最初にデータが無い画面だけ取得
/// </summary>
/// <returns></returns>
public ActionResult Index()
{
    return View();
}

/// <summary>
/// つぶやき情報をjsonで取得
/// </summary>
/// <returns></returns>
public ActionResult ContentIndex()
{
    var tsubuyakis = new Tsubuyaki[]{
        new Tsubuyaki{ 
            Id = 1,
            UserName = "Bob", 
            Comment = "Hello",
            CreatedAt = DateTime.Now.ToString( "yyyy/MM/dd HH:mm:ss" ),
            Replies = new List<Reply>(),
        },
        new Tsubuyaki{ 
            Id = 2,
            UserName = "たかし", 
            Comment = "こんにちは^^", 
            CreatedAt = DateTime.Now.AddMinutes( -1 ).ToString( "yyyy/MM/dd HH:mm:ss" ),
            ExistReply = true,
            Replies = new Reply[]{
                new Reply{ UserName = "たろう", Comment = "ども", CreatedAt = DateTime.Now.AddMinutes( 10 ).ToString( "yyyy/MM/dd HH:mm:ss" ) },
                new Reply{ UserName = "じろう", Comment = "おっす", CreatedAt = DateTime.Now.AddMinutes( 12 ).ToString( "yyyy/MM/dd HH:mm:ss" ) }
            }.OrderByDescending( x => x.CreatedAt ),
        },
        new Tsubuyaki{ 
            Id = 3,
            UserName = "Nick", 
            Comment = "Hi!", 
            CreatedAt = DateTime.Now.AddMinutes( -3 ).ToString( "yyyy/MM/dd HH:mm:ss" ),
            Replies = new List<Reply>(),
        },
                
    }.OrderByDescending( x => x.CreatedAt );

    return Json( tsubuyakis, JsonRequestBehavior.AllowGet );
}

クライアント側のソース

サーバ側は、ほぼどうでも良く今回の問題はクライアント側です。

HTML
<div id="timeline" data-bind="foreach: tsubuyakis">
    <div class="tsubuyaki">
        <div data-bind="foreach: Replies">
            <div class="replies" >
                <div data-bind="text: UserName"></div>
                <div data-bind="text: Comment"></div>
                <div data-bind="text: CreatedAt"></div>
            </div>
        </div>
        <div data-bind="attr: { id: Id }, text: UserName"></div>
        <div data-bind="text: Comment"></div>
        <div data-bind="text: CreatedAt"></div>
    </div>
</div>

つぶやきのループの中に返信のループがネストする構造です。

Javascript
<script type="text/javascript">
    $(function() {

        function viewModel() {
            var self = this;
                
            self.tsubuyakis = null;
        };

        $.ajax({
            type: "GET",
            url: "/Tsubuyaki/ContentIndex",
            dataType: 'json',
            success: function(data) {
                var v = new viewModel();
                v.tsubuyakis = ko.observableArray(data);
                ko.applyBindings(v);
            },
        });
    });
</script>

Indexメソッドで画面だけ取得してあとから別途Jsonで情報を取得するようにしました。
最初は返信も一度に返しちゃいます。
その結果が一番上の画像です。


最新のつぶやきを取得する

まずタイムラインを更新して新しいつぶやきを追加できるようにしてみます。

これが
f:id:cer1974:20130114152529p:plain
[最新のつぶやきを取得]リンクを押すとこうなる(返信は消しました)
f:id:cer1974:20130114152606p:plain

Controller

Controllerにつぶやきを追加できるようにメソッドを追加します。

public ActionResult NextTsubuyakis()
{
    var tsubuyakis = new Tsubuyaki[]{
        new Tsubuyaki{ 
            Id = 4,
            UserName = "きよし", 
            Comment = "あうあう",
            CreatedAt = DateTime.Now.AddMinutes( 20 ).ToString( "yyyy/MM/dd HH:mm:ss" ),
            Replies = new List<Reply>(),
        },
        new Tsubuyaki{ 
            Id = 5,
            UserName = "さとし", 
            Comment = "ねもい", 
            CreatedAt = DateTime.Now.AddMinutes( 25 ).ToString( "yyyy/MM/dd HH:mm:ss" ),
            Replies = new List<Reply>(),
        },
                
    }.OrderBy( x => x.CreatedAt );

    return Json( tsubuyakis, JsonRequestBehavior.AllowGet );
}
HTML

リンクを追加

<a href="#" data-bind="click: getNextTsubuyakis">最新のつぶやきを取得</a>
Javascript

ViewModelにメソッドを追加

self.getNextTsubuyakis = function() {
    $.ajax({
        type: "GET",
        url: "/Tsubuyaki/NextTsubuyakis",
        dataType: 'json',
        success: function(data) {
            $.each(data, function() {
                self.tsubuyakis.unshift(this);
            });
        }
    });
}

ViewModelのtubuyakisにデータを追加します。
これだけでつぶやき一覧が更新され上記の図のような状態になります。


つぶやきの返信を取得する

Controller

Controllerに返信が取得できるメソッドを追加します。

public ActionResult ReplyIndex(int Id)
{
    var replies = new Reply[]{
                new Reply{ UserName = "さぶろう", Comment = "・・・", CreatedAt = DateTime.Now.AddMinutes( 15 ).ToString( "yyyy/MM/dd HH:mm:ss" ) },
                new Reply{ UserName = "しろう", Comment = "ちゃお!", CreatedAt = DateTime.Now.AddMinutes( 20 ).ToString( "yyyy/MM/dd HH:mm:ss" ) }
            }.OrderBy( x => x.CreatedAt );
            
    return Json( replies, JsonRequestBehavior.AllowGet );
}
HTML

各つぶやきにリンクを付けますが、返信があるものだけにリンクを付けるように判定をします。

<div id="timeline" data-bind="foreach: tsubuyakis">
    <div class="tsubuyaki">
        <div data-bind="foreach: Replies">
            <div class="replies" >
                <div data-bind="text: UserName"></div>
                <div data-bind="text: Comment"></div>
                <div data-bind="text: CreatedAt"></div>
            </div>
        </div>
        <div data-bind="attr: { id: Id }, text: UserName"></div>
        <div data-bind="text: Comment"></div>
        <div data-bind="text: CreatedAt"></div>
        <div data-bind="if: ExistReply">
            <a href="#" data-bind="click: $parent.getReply">会話を表示</a>
        </div>
    </div>
</div>
Javascript

リンクがクリックされたときのイベントをViewModelに追加します。

self.getReply = function(d, e) {

    $.ajax({
        type: "GET",
        url: "/Tsubuyaki/ReplyIndex",
        data: { id: d.Id },
        dataType: 'json',
        success: function(data) {
            $.each(data, function() {
                d.Replies.unshift(this);
            });
        },
    });
};

でもこのまま実行してもうまく行きません。
ajaxメソッドは呼ばれているのでそこはOKなんですが
Kockoutjsのメソッドが呼ばれていないのでうまくバインドできていないようです。

VisualStudioデバッグしてみるとコレクションには登録されているんですけどね
f:id:cer1974:20130114213024p:plain


withを使うのかなーと思ってこんなんしてみましたが・・・

<div data-bind="with: Replies">
    <div data-bind="foreach: $data">
        <div class="replies" >
            <div data-bind="text: UserName"></div>
            <div data-bind="text: Comment"></div>
            <div data-bind="text: CreatedAt"></div>
        </div>
    </div>
</div>

・・・だめでした。

どなたか分かれば教えていただけると助かります。

自己解決しました。
Javascript載せておきます。

$(function() {

    function viewModel() {
        var self = this;
                
        self.tsubuyakis = ko.observableArray();

        self.getNextTsubuyakis = function() {
            $.ajax({
                type: "GET",
                url: "/Tsubuyaki/NextTsubuyakis",
                dataType: 'json',
                success: function(data) {
                    $.each(data, function() {
                        this.Replies = ko.observableArray();
                        self.tsubuyakis.unshift(this);
                    });
                }
            });
        }

        self.getReply = function(d, e) {

            $.ajax({
                type: "GET",
                url: "/Tsubuyaki/ReplyIndex",
                data: { id: d.Id },
                dataType: 'json',
                success: function(data) {
                    $.each(data, function() {
                        d.Replies.unshift(this);
                    });

                },
            });
        };
    };

    var tsubuyakiViewModel = new viewModel();

    $.ajax({
        type: "GET",
        url: "/Tsubuyaki/ContentIndex",
        dataType: 'json',
        success: function(data) {
            $.each(data, function() {
                this.Replies = ko.observableArray();
                tsubuyakiViewModel.tsubuyakis.push(this);
            });

            ko.applyBindings(tsubuyakiViewModel);
        },
    });
});

ポイントは、Repliesをko.observableArray()で初期化すること
まあ出来てしまえば当たり前のことでしたね・・・たどり着くまでに時間かかったわ~