knockout.jscustom-bindingbootstrap-tokenfield

Add new tags in custom binding


I am using knockout with bootstrap-tokenfield and typeahead to show tags. Previously I needed a way to show my tags in a nice way and therefore I created a custom binding. It worked really nice when the list of tags was not changing and only selected tags were changing.

So a really simplified example looks like this. As you see, you can type various tags (tag1, tag2, ..., tag5) and observable is changing. So my custom binding works in this case.

Here it is:

ko.bindingHandlers.tags = {
    init: function(element, valueAccessor, allBindings) {
        var initializeTags = function(listOfTags, inputID, max){
            var tags = new Bloodhound({
                local: listOfTags,
                datumTokenizer: function(d) {return Bloodhound.tokenizers.whitespace(d.value);},
                queryTokenizer: Bloodhound.tokenizers.whitespace
            });
            tags.initialize();
            inputID.tokenfield({
                limit : max,
                typeahead: {source: tags.ttAdapter()}
            }).on('tokenfield:preparetoken', function (e) {
                var str = e.token.value,
                    flag = false,
                    i, l;
                for(i = 0, l = listOfTags.length; i < l; i++){
                    if (listOfTags[i]['value'] === str){
                        flag = true;
                        break;
                    }
                }

                if (!flag){
                    e.token = false;
                }
            });
        }

        var options = allBindings().tagsOptions,
            currentTagsList = Helper.tags1List,
            currentTagsInverted = Helper.tags1Inverted;

        initializeTags(currentTagsList, $(element), 4);

        ko.utils.registerEventHandler(element, "change", function () {
            var tags = $(element).tokenfield('getTokens'),
                tagsID = [],
                observable = valueAccessor(), i, l, tagID;

            for (i = 0, l = tags.length, tagID; i < l; i++){
                tagID = currentTagsInverted[tags[i].value];

                if (typeof tagID !== 'undefined'){
                    tagsID.push(parseInt(tagID));
                }
            }

            observable( tagsID );
        });
    },
    update: function(element, valueAccessor, allBindings) {
        var arr     = ko.utils.unwrapObservable(valueAccessor()),
            options = allBindings().tagsOptions,
            currentTags = Helper.tags1, tagsName = [], i, l, tagName;

        if ( !(arr instanceof Array) ){
            arr = [];
        }

        for (i = 0, l = arr.length, tagName; i < l; i++){
            tagName = currentTags[arr[i]];
            if (typeof tagName !== 'undefined'){
                tagsName.push(tagName);
            }

        }
        $(element).tokenfield('setTokens', tagsName);
    }
};

But the problem is that I need to add additional tag: tag6 and if I simply do

Helper.getAllTags({
    "1":{"value":"tag1"}, ..., "6":{"value":"tag6"}
})

it will not work (which is not a surprise to me, I know why it does not work). What it the proper way of doing this.

P.S.


Solution

  • Updated answer, 'correct' pattern

    I've created a KnockoutJS binding for bootstrap-tokenfield.

    https://github.com/mryellow/knockoutjs-tokenfield

    First up lets look at updates coming in from the valueAccessor().

    update: function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
        var observable = valueAccessor() || { };
        var peeked = ko.unwrap(observable.peek());
    
        ko.tokenfield[element.id].handlerEnabled = false;
    
        $(element).tokenfield('setTokens',peeked);
    
        ko.tokenfield[element.id].handlerEnabled = true;
    }
    

    Here we create tokens for any incoming data from the model. All the tokens, as valueAccessor() gives us the complete object. This however will trigger tokenfield:createdtoken which is on the init side of our binding. So to avoid re-saving these tokens to the model we set a variable handlerEnabled to control the events flow.

    Now for any user interaction, HTML value attributes, or model changes this event will be triggered:

    ko.utils.registerEventHandler(element, 'tokenfield:createdtoken', function (e) {
        // Detect private token created.
        if (e.attrs[ko.tokenfield[element.id].bindings['KeyDisplay']].indexOf("_") === 0) {
            console.log('tt-private');
            $(e.relatedTarget).addClass('tt-private');
        }
    
        // Allow `update` to temporarily disable pushing back when this event fires.
        if (ko.tokenfield[element.id].handlerEnabled == true) observable.push(e.attrs);
    
    });
    

    Note the handlerEnabled global to block re-adding to the valueAccessor().

    When removing tokens the extra meta-data that came from our AJAX autocomplete is missing from tokenfield (patched). Thus we must look it up based on the attributes that do exist:

    ko.utils.registerEventHandler(element, 'tokenfield:removedtoken', function (e) {
        var peeked = observable.peek();
        var item;
        // Find item using tokenfield default values, other values are not in tokenfield meta-data.
        ko.utils.arrayForEach(peeked, function(x) {
            if (ko.unwrap(x.label) === e.attrs.label && ko.unwrap(x.value) === e.attrs.value) item = x;
        });
    
        observable.remove(item); // Validation of `item` likely needed
    });
    

    So that about covers the internals of the binder. Now we're saving everything directly into the bound model as KnockoutJS would expect, without the duplication of data or sync issues. Lets get that CSV field back, using a observableArray.fn which returns a computed is nice and reusable.

    Usage: self.tags_csv = self.tags.computeCsv();.

    ko.observableArray['fn'].computeCsv = function() {
        console.log('observableArray.computeCsv');
        var self = this;        
    
        return ko.computed({
            read: function () {
                console.log('computed.read');
    
                var csv = '';
                ko.utils.arrayForEach(ko.unwrap(self), function(item) {
                    console.log('item:'+JSON.stringify(item));
                    if (csv != '') csv += ',';
                    // Our ID from AJAX response.
                    if (item.id !== undefined) {
                        csv += item.id;
                    // Tokenfield's ID form `value` attrs.
                    } else if (item.value !== undefined) {
                        csv += item.value;
                    // The label, no ID available.
                    } else {
                        csv += item.label;
                    }                   
                });
    
                return csv;
            },
            write: function (value) {
                console.log('computed.write');
    
                ko.utils.arrayForEach(value.split(','), function(item) {
                    self.push({
                        label: item,
                        value: item
                    });
                });
    
            }
        });
    };
    

    Now we have an array of objects and a CSV representation in our model, ready to be mapped or manipulated before sending to server.

    "tags": [
        {
            "label": "tag1",
            "value": "tag1"
        },
        {
            "id": "id from AJAX",
            "field": "field from AJAX",
            "label": "tag2",
            "value": "tag2"
        }
    ],
    "tags_csv": "tag1,id from AJAX"