Extending the TagList with auto completion

Before we start, let me say this: The following tutorial, while interesting, is not compatible with jidejs-1.0.0-beta2. While writing the demo for it, I noticed a few bugs (will be fixed in beta3) that prevent the code presented here from functioning properly.

I believe it is still a very interesting article about jide.js and highlights a few powerful concepts.

In the previous tutorial, we have created a TagList control. Today, I would like to show you how you can improve it by adding auto completion to it.

Naturally, this means we have to change a few things. First of all, we want to add a list of known tags to our TagList. When we’re editing the controls, we want to display a popup which contains our list of known tags and we want to use the Up and Down keys to navigate through it.

Start by editing the TagList $init function:

$init: function(config) {
  config || (config = {});
  this.tags = ObservableList(config.tags || []);
  delete config.tags;
  this.knownTags = ObservableList(config.knownTags || []);
  delete config.knownTags;
 
  Control.call(this, config);
}

Now, our TagList has a new property called knownTags. However, for our auto completion popup, we’ll need to filter the knownTags list based on the input we already have in our TextField. While jidejs/base/ObservableList has a filter method that accepts an observable value, we do not yet have a way to create an observable value from a promise. Let’s change that:

function fromPromise(promise) {
  var value = Observable(null);
  promise.then(function(result) {
    value.set(result);
  }, function(error) {
    value.set(error);
  });
  return value;
}

Great! Now we can use functional programming to transform our TextField into a filter function!

var filterFunction = fromPromise(this.queryComponent('x-edit'))
  .map(function(edit) {
    return edit && edit.textProperty;
  })
  .map(function(filterProperty) {
    var filter = filterProperty && filterProperty.get() || null;
    return function(tag) {
      return startsWith(tag.name, filter);
    };
  });

Now, while the promise is still pending, the filter function will simply reject everything. However, when the promise is fulfilled with our TextField, the first map operation will return its textProperty which will then force the final map operation to create a new filter function. Whenever the filterProperty changes, a new filter function will be generated.

There are more efficient ways to do this (FilteredCollection has methods to tighten or loosen the current filter), this is pretty easy and good enough to get us started.

We can now pass the filterFunction property to the filter method on our list of knownTags. We’ll also want to create a SingleSelectionModel for our list.
When we create the Popup later on, we also want to bind its visibility to the amount of known tags, i.e. we don’t want to display the popup if there are no known tags anyway.

var knownTags = tagList.knownTags.filter(filterFunction);
var knownTagsSelectionModel = this.knownTagsSelectionModel = new SingleSelectionModel(knownTags);

var hasKnownTags = filterFunction.map(function(filterFunction) {
  return knownTags.length > 0;
});

We’re using the map operation on our filterFunction so that whenever the filter is recreated, we also get an update on our hasKnownTags observable.

So far, we have prepared variables we’ll need to create and display the auto completion popup. Now, let’s create the popup.

var popup = this.popup = new Popup({
  owner: tagList,
  autoHide: false,
  consumeAutoHidingEvents: false,
  content: new ListView({
    selectionModel: knownTagsSelectionModel,
    items: knownTags,

    converter: function(tag) {
      return tag.name;
    }
  }),
  visible: hasKnownTags
});

We have bound the visible property of the popup to the hasKnownTags observable value we created earlier and set a ListView as its content. Since we handle showing and hiding the popup ourselves, we deactivated the autoHide and consumeAutoHidingEvents properties of the popup.

Our ListView has the selection model we previously defined and our filtered list of knownTags. We’re using the convert function to convert a tag into something that can be rendered. There are more complex and powerful ways to do this but there is no need for it now.

Finally, let us add some event handlers to our TextField to complete this modification.

this.queryComponent('x-edit').then(function(edit) {
  // specify the position of the TextField as the base of the popup
  popup.setLocation(edit, Pos.BOTTOM);
  self.managed(
    popup.on('focus', function() {
      // when the popup becomes visible, it will steal the focus from our
      // TextField so we need to request it there as soon as possible
      Dispatcher.nextTick(function() {
        edit.focus();
      });
    }),
    edit.on({
      key: {
        'Down': function() {
          knownTagsSelectionModel.selectNext();
        },
        'Up': function() {
          knownTagsSelectionModel.selectPrevious();
        }
      }
    })
  );
});

What is now left (besides of the startsWith utility function) is a way to accept the currently selected value when the user pressed enter. Thus we need to modify our addTag function:

addTag: function(event) {
  var tagList = this.component,
    textField = event.source,
    tag = (this.knownTagsSelectionModel.selectedItem
      && this.knownTagsSelectionModel.selectedItem.name)
      || textField.text;
  // in jide.js-1.0.0-beta3 this will simply be tagList.tags.contains(tag)
  // since that version fixes a bug that prevents primitives from being used
  // in a foreach binding
  if(!tagList.tags.findFirst(tagPredicate(tag))) {
    tagList.tags.add({name:tag});
    textField.text = '';
    this.popup.visible = false;
  }
},

Great. Now our TagList control supports auto completion!

Leave a Reply

Your email address will not be published. Required fields are marked *