Question

This is a rather complex issue. I've tried to refine the code as much as possible to only that which is required to demonstrate the problem. This is a very long post - consider yourself warned!

A working demonstration of this issue can be seen at this jsFiddle.


What I'm trying to do is aggregate an array into groups based on a common property of the items in the array. Actually aggregating the arrays into groups is fairly trivial:

function ViewModel() {
    var self = this;

    this.groupProperty = ko.observable('groupId');

    this.data = ko.observableArray();
    this.view = ko.computed(function () {
        var i, element, value, grouped = {};

        for (i = 0; i < self.data().length; i++) {
            element = self.data()[i];
        
            if (!element.hasOwnProperty(self.groupProperty()))
                continue; //Exclude objects that don't have the grouping property...
        
            value = ko.isObservable(element[self.groupProperty()]) ? element[self.groupProperty()]() : element[self.groupProperty];
        
            if (!grouped.hasOwnProperty(value))
                grouped[value] = new Group(value);
        
            grouped[value].items().push(element);
        }
    
        return transformObjectIntoArray(grouped);
    });
}

transformObjectIntoArray can be seen in the jsFiddle example. It just turns an object into an array by getting rid of the properties basically.

This takes an array of data like:

[
    Item { label: 'Item 1', groupId: 'A' },
    Item { label: 'Item 2', groupId: 'A' },
    Item { label: 'Item 1', groupId: 'B' },
    Item { label: 'Item 2', groupId: 'B' }
]

Turns it into the following object:

{
    'A': [
        Item { label: 'Item 1', groupId: 'A' },
        Item { label: 'Item 2', groupId: 'A' }
    ],
    'B': [
        Item { label: 'Item 1', groupId: 'B' },
        Item { label: 'Item 2', groupId: 'B' }
    ]
}

Which is then transformed into the following array:

[
    [
        Item { label: 'Item 1', groupId: 'A' },
        Item { label: 'Item 2', groupId: 'A' }
    ],
    [
        Item { label: 'Item 1', groupId: 'B' },
        Item { label: 'Item 2', groupId: 'B' }
    ]
]

Everything works as intended up to this point, as can be seen in the jsFiddle.

The problem begins when I add a new element to the data array. vm is my ViewModel instance in the following code

vm.data.push(new Item('Item X', 'A')); //Add a new item called 'Item X' to the 'A' group

As you might have guessed, this causes the computed observable view to execute, which re-aggregates the underlying data array. All new Group objects are created, which means, any state information associated with them is lost.

This is a problem for me because I store the state of if they should be expanded or collapsed on the Group object:

function Group(label) {
    this.expanded = ko.observable(false); //Expanded state is stored here
    this.items = ko.observableArray();
    this.label = ko.observable(label);
}

So, since they get recreated, that state information is lost. Thus, all groups revert to their default state (which is collapsed).


I know the problem I'm facing. However, I'm struggling to come up with a solution that isn't unwieldy. Possible solutions I've thought of include the following:

Maintaining a group state map

The first idea I had was to create an object that would serve as a map. It would use the groupId as the key name, and the state would be the value:

{
    'A': GroupState { expanded: true },
    'B': GroupState { expanded: false }
}

Even though the groups are getting recreated each time an element is added, the GroupState would persist. A problem I couldn't solve was how to remove groups that no longer exist from the GroupState map.

For example,

vm.data(someNewArray);

Where someNewArray was an array of items with no groupIds corresponding to those currently in the GroupState map. How would I remove the entries that no longer had a reference? This seems like it would be a memory leak in my application.

This problem can be seen demonstrated at this jsFiddle. Notice, after clicking the button, the group state size grows to 5 elements, but there are only 3 groups. This is because the original 2 groups aren't removed despite no longer being used.

Maintaining a viewState array and removing the computed observable

The second idea I had was to remove the computed observable view. Instead, I'd have a second observable array called viewState which would be the data array after it had been aggregated into the current "view". However, I quickly ran into problems with this idea.

First, I'd have to write a couple methods that would maintain state between the two arrays: add(), remove(), clear(), etc. Considering having to do this made me immediately start questioning if having a second array was a good idea at all.

Second, removing the computed array means linearly searching the viewState array to see if any current elements contain a groupId similar to the incoming item. While in practice, this would be blazing fast, I don't like the theory of iterating the entire array on each add (O(n) vs O(1)). Down the road, I might be working with thousands of items.


I feel like there is probably an easy solution to this, but I'm struggling to put my finger on it. Does anyone have any ideas that might help with this problem, or know an entirely better approach to accomplishing this with KnockoutJS? Perhaps, one of my ideas above will work with some added insight (I still feel like the GroupState map was on the right track)

Please let me know if I've left out some crucial information and I will do my best to add it

Was it helpful?

Solution

What about this proposition:

  • don't use any computed
  • during creation of a new item, create the group if it does not exist and add the item to the group

Note: I am using arrayFirst to find if a group exist during the creation of an item, it is O(n) but you can instead store groups in properties so that the lookup should be O(log(n)) (not tested)

function Item(label, groupId) {
    var self = this;
    self.label = ko.observable(label);
    self.groupId = ko.observable(groupId);
}

function Group(label) {
    var self = this;
    self.expanded = ko.observable(false);
    self.items = ko.observableArray();
    self.label = ko.observable(label);

    self.addItem = function(item) {
        self.items.push(item);
    }
}

function ViewModel() {
    var self = this;

    self.groups = ko.observableArray();

    self.addItem = function(label, groupId) {
        var group = ko.utils.arrayFirst(self.groups(), function(gr) {
            return groupId == gr.label();
        });

        if(!group) {
            console.log('not group');
            group = self.addGroup(groupId);
        }

        var item = new Item(label, groupId);
        group.addItem(item);
    }

    self.addGroup = function(groupId) {
        var group = new Group(groupId);
        this.groups.push(group);
        return group;
    }

    this.buttonPressed = function () {
        vm.addItem("Item X", "A");
    };
}

var vm = new ViewModel();
ko.applyBindings(vm);

vm.addItem("Item 1", 'A');
vm.addItem("Item 2", 'A');
vm.addItem("Item 1", 'B');
vm.addItem("Item 2", 'B');

JSFiddle link

Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top