Knockout JS ObservableArray with many-to-many relationships
Asked Answered
P

2

4

I am creating a guest list app using Knockout.js, and so far things are going swimmingly. However I have a best-practices question. My app has several different types of objects: guests and tags among them. Guests can have multiple tags, and tags can have multiple guests. At different points in the app, I need to display both arrays individually. For example, I have a "Guests" view where one can see all the guests along with their associated tags, and I also have a "Tags" view where one can see all tags and their associated guests. At present, the code for me to add a tag to a guest looks something like this:

var tag = function(opts) {
  this.guests = ko.observableArray()

  // Other tag code here...
}

var guest = function(opts) {
  this.tags = ko.observableArray()
  // Other guest code here...

  var self = this

  this.addTag = function(tag) {
    self.tags.push(tag)
    tag.guests.push(self)
  }
}

I know there must be a better way of doing this kind of many-to-many relationship in Knockout other than updating each observableArray independently. This also leads to a kind of recursive app structure where a guest has a tags property/array which contains a tag, which has a guest property/array which contains a guest, which has a tags property... you get the picture.

Right now, the ViewModel structure is like so:

- Parent Object
  - Guests ObservableArray
    - Guest Object
      - Tag Object as property of Guest
  - Tags ObservableArray
    - Tag Object
      - Guest Object as property of Tag

So I guess my question is twofold: 1) Is there a better way to structure my ViewModel to avoid recursive arrays? and 2) how can I better use Knockout.js to update my ViewModel in a DRY manner, rather than updating both the tags array AND the guests array individually? Thanks!

Pothook answered 9/7, 2012 at 22:54 Comment(5)
Is there some problem with my answer? If you let me know what's wrong, I can try to improve it.Macrogamete
Hey Tyrsius, I liked the answer which is why I upvoted, but I didn't feel it entirely addressed the structuring question. I agree saving IDs and using a compute function to lookup is a better idea than saving the full object, but if I'm using many different instances of Guests and Tags that each need to be able to loop through their respective associations, how do I do that in your model? Currently it seems your answer only works if I want to loop through a single object at a time. Sorry if I'm being too nitpicky, a little new at this.Pothook
So, I am not entirely sure what you mean by that. You want to see multiple "selectedTags" at once? BTW, nitpicky is fine. You have a specific problem, and you need help with it. Be nitpicky =)Macrogamete
Yes you could put it that way. I need a "selectedTags" view for EACH guest at the same time. The difference being that in your model I'm selecting one guest or tag at a time, and therefore you can make "selectedGuests" and "selectedTags" on the parent ViewModel. But if I want to display multiple guests at a time each with their own list of tags, and multiple tags at a time each with their own list of guests, how should I structure things so that I can update both easily? Thanks!Pothook
Yes that does require a different viewmodel. I'll cook something up for you.Macrogamete
M
6

There are probably other ways to do this, but this method has pretty minimal duplication, without sacrificing proper modeling. A server should have no trouble generating the data in this format.

Here it is in a (crudely styled) fiddle. Note, clicking a tag or guest will cause the selections below it to update (the first is selected by default).

Basically, store the relationships by id, and use computed array's to represent associations. Here is a basic viewmodel:

var ViewModel = function(guests, tags) {
    var self = this;
    self.guests = ko.observableArray(
        ko.utils.arrayMap(guests, function(i){ return new Guest(i); }
    ));
    self.tags= ko.observableArray(
        ko.utils.arrayMap(tags, function(i){ return new Tag(i); }
    ));

    self.selectedGuest = ko.observable(self.guests()[0]);
    self.selectedTag = ko.observable(self.tags()[0]);

    self.guestTags = ko.computed(function() {
        return ko.utils.arrayFilter(self.tags(), function(t) {
            return self.selectedGuest().tags().indexOf(t.id()) > -1;
        });        
    });

    self.tagGuests = ko.computed(function() {
        return ko.utils.arrayFilter(self.guests (), function(g) {
            return self.selectedTag().guests().indexOf(g.id()) > -1;
        });        
    });
};

UPDATE

So I have made a new fiddle to demonstrate a different kind of mapping, but this code could easily co-exist with the above viewmodel; its only seperate for demonstration. Instead of working off selection, it offers a general lookup, so that any code can consume it. Below is the HTML from Tags (guests is symmetrical), and the guestMap function that was added to the viewmodel.

You will note that the names are inputs now, so you can change the names and watch all the bindings stay up to date. Let me know what you think:

<div>Tags
    <ul data-bind="foreach: tags">
        <li>
            <input data-bind="value: name, valueUpdate: 'afterkeydown'" />
            </br><span>Tags</span>
            <ul data-bind="foreach: guests">
                <li><span data-bind="text: $parents[1].guestMap($data).name()"></span></li>
            </ul>
        </li>
    </ul>
</div>

self.guestMap = function(id) {
        return ko.utils.arrayFirst(self.guests(), function(g) {
            return id == g.id();
        });
    };   
Macrogamete answered 9/7, 2012 at 23:45 Comment(6)
Thanks Tyrsius! One final question: is there any way to store the data so as to avoid redundancy? What I mean is, right now on each Guest you list each of their tags, and on each Tag you list each of their guests. But this data is identical; if I wanted to add a tag to one of the guests, I would need to update both the Guests array and the Tags array. Any way to avoid that, so I can update just one of the arrays? Thanks again!Pothook
Hmm. That's something I should have thought about earlier. I was thinking the relations were seperate, so that a guest could have a tag without that tag needing the guest. The relational db solution might be good here, actually having a tagGuest object that just stored an array of Id pairs. I'll see if I can come up with somethingMacrogamete
For sure this solution works, but appears very inefficient, since each change triggers a search for recomputing related elements. A better (but more complex solution) should cache the found relations into index structures, so each change requires just a more efficient index updateCrusado
@FrancescoAbbruzzese thats very true, this was all very off-the-cuff, and I'm sure many optimizations (or just straight up better methods) exist. I would be interested in seeing your solution.Macrogamete
There are various possibilities each with advantages and disadvantages, for example: 1) each object has an array with all nodes it is related to (on both side of the relation). There are two methods addRelation(node1, node2), and deleteRelation(node1, node2). Adding, new nodes, and relation are done very qucicly, deletion of relation require just to scan two array...however deleting a node has a very high cost: scanning as many array as the related nodes are that is the cost is quadratic. logical deletion may iprove because eliminate the need to process the relations cached in the objectsCrusado
@Tyrsius, I considered also other solutions based on trees, and more complex structures, but they have an overhead that is worth only when each father have thousands of children. my previous solution with logical delete is optimal, since all operations are performed efficiently. Logical deletes can be turned into physical deletes at 0 cost when a list of children is scanned for performing another operation, such as a read, or when sending data to the server. Unlukly the children lists cannot be built on the server otherwise json serializer duplicate the children instead of using pointersCrusado
M
0

I had the same kind of problem. Displaying many to many related stuff. I had to do 'grid' style display and have an update mechanism in it.

I ended up replicating the structure I had in the backend DB. Two tables of items with join table in between. Pulled the data out from those in three arrays and kept updating the 'join' array. The test data and fiddle of my testings with it below.

var items = [{name: "T1",id: 1}, {name: "T2",id: 2}, {name: "T3",id: 3}, {name: "T4",id: 4}];
var cats = [{catname: 'C1', catid: 1}, {catname: 'C2',catid: 2}, {catname: 'C3',catid: 3}, {catname: 'C4',catid: 4}];
var catsItems = [{catid:1,itemid:2},{catid:2,itemid:1},{catid:4,itemid:2},{catid:3,itemid:4},{catid:3,itemid:1},{catid:1,itemid:3},{catid:2,itemid:4}];

Join table fiddle

I'm not really sure how efficient this method is with lots of data but did the stuff I needed it to do.

Medina answered 5/4, 2013 at 17:24 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.