Zen and the art of...

Showing posts with label javascript. Show all posts
Showing posts with label javascript. Show all posts

2010-01-31

Closure Library Tutorial: Tasks (part 2)

It's been nearly two months since I've written about JavaScript and now is the time to follow up on the previous part of this tutorial. Since then, there have been some changes to the Closure library, nothing major, mostly improvements to the documentation and bug fixes.

Sliders

Adding a Slider is unsurprisingly as easy to do as any other components. Contrary to those we already used though, it doesn't come with a nice interface with pre-made CSS and images. Closure let you decide how your sliders will look like, which is comprehensible as this control is really versatile and it would be hard to come up with a good generic design for it. A slider element is composed of two DIVs, one representing the slider itself, the other being the thumb. Both have a class name to let you control their appearance. For the slider element this name is composed of a prefix found in the Slider's CSS_CLASS_PREFIX static property followed by either of "-horizontal" or "-vertical" depending on the orientation. The thumb has only a single class name determined by the THUMB_CSS_CLASS property, note that as the thumb is contained in the slider element, it can be styled depending on orientation too.

There's some rules to obey while styling a slider:

  • a slider cannot be inlined, but you can use inline-block;
  • a slider cannot be floated;
  • the thumb position style must be set to absolute or relative.

Note that it's preferable to make both elements hide their overflowing contents to prevent scrolling bars from appearing.

Using Sliders

As sliders cannot be floated, we have a problem with the existing code. If you remember the way tasks were constructed in the previous tutorial, the action buttons were added to the summary DIV directly with their float styles set to right. This isn't the correct manner of doing things anyway, so we'll fix that first. We'll group the task controls into a single floating element.

    this.summaryControlsDiv = goog.dom.createDom('div', { 'class': 'controls' });
    this.summaryDiv = goog.dom.createDom('div', { 'class': 'summary' }, this.summary, this.summaryControlsDiv);

This break some ugly part of our code, the makeActionButton function is being passed the task DIV and renders the button being created by selecting the appropriate child node. We'll just do a quick fix and correct this issue in the next section.

    button.render(element.childNodes[0].childNodes[1]);

Another more subtle change we need to make is in makeButtons, we must change the order in which the action buttons are created. We must also change some styles and add an entry for the new CSS class.

      .task .summary .controls { float: right; }
      .task .description { background: #eee; border-top: solid 1px #333; }
      
      .taskButton { margin-top: -6px; margin-left: 7px; }
      .editorButton { margin: 0.2em 0.3em 0.5em; }

The following code create a slider, we'll stop event propagation the same way it's done for tasks' action buttons.

mu.tutorial.tasks.Task.prototype.makeSlider = function(task, element) {
    if (task.parent.id == 'taskList') {
        var slider = new goog.ui.Slider;
        slider.createDom();
        slider.render(element.childNodes[0].childNodes[1]);

        slider.addEventListener(
            goog.ui.Component.EventType.CHANGE,
            function() { /* TODO: something... */ });

        goog.events.listen(
            slider.getContentElement(),
            goog.events.EventType.CLICK,
            function(e) { e.stopPropagation(); });
    }
}

We need to call this function from makeDom just before creating the task buttons. To make the page actually show the sliders, we'll add the styles below.

      .goog-slider-horizontal, .goog-slider-thumb {
        overflow: hidden;
        height: 20px;
      }
      
      .goog-slider-horizontal {
        background-color: #ccc;
        display: inline-block;
        position: relative;
        width: 200px;
      }
 
      .goog-slider-thumb {
        background-color: #777;
        position: absolute;
        width: 20px;
      }

Cleaning Up

Earlier, we talked about how bad it was for makeActionButton to have to know where to render the button. We'll take the time to clean that up. If we look back at the code, one thing is obvious, we pass lots of arguments around. That make the code overly functional and this isn't a good approach here. Another thing is that we carefully initialized prototype members for every elements contained in a task and we end up not using them.

For this section I'll skip the code, so use your imagination. First thing to do is to remove the element argument from functions having it. For the controls, we'll use the object's properties, summaryControlsDiv for action buttons and sliders, and editorContainer for editor buttons. Finally, we need to replace the taskDiv local variable in makeDom by a property that we'll name element. We can't use it directly in our callback function as it is a closure and this doesn't point to the task object. We can work around that simply by using a temporary variable, we'll rewrite that code later anyway.

Making the Sliders Do Something

To put the sliders to good use, we'll make them control the priority of a task. We'll first make the tasks display their priorities, to be able to test things manually.

    this.priorityDiv =  goog.dom.createDom('div', { 'class': 'priority' }, this.priority.toString());
    this.summaryDiv = goog.dom.createDom('div', { 'class': 'summary' },
                                         this.summary,
                                         this.summaryControlsDiv,
                                         this.priorityDiv);

We'll also have to add styling for the new priority DIV, it must float to the right and have some padding on the right side. Now, lets add two things to the makeSlider function. Firstly, setting the slider value to the priority of the task currently being created. Secondly, replacing our TODO comment by changing the priority property as well as the content of the priority DIV.

        slider.setValue(this.priority);

        var task = this;
        slider.addEventListener(
            goog.ui.Component.EventType.CHANGE,
            function() {
                task.priority = slider.getValue();
                task.priorityDiv.innerHTML = task.priority;
            });

There's a small bug with this code, can you find it?

If you delete or mark a task as done, then undo that action, you'll see that the resurrected tasks' slider thumb is set at its lowest value. If you insert an alert to display the sliders' value when the task is recreated, you'll see that it's value is correct. Also the slider behave as if the thumb was at the correct position strangely, it can even go into infinite loop. This all points out toward the fact that when we recreate our tasks, they're being added to an invisible element. We'll reorganize our code in the next section to circumvent this issue.

Sorting Tasks

The sliders are now working (with some issues) that's good but it doesn't add much to our little application feature-wise. The obvious use of tasks priority would be to sort them. We'll need to do some major modification to our code to do this though. Currently the tasks add themselves to their containers and appear in whatever order they're being appended. We'll need to create a new object to represent task lists. I won't be showing all the changes made, just the most important ones, you can always refer to the final code if you need more details. First, we'll need to provide a new class name for our TaskList prototype.

goog.provide('mu.tutorial.tasks.TaskList');

mu.tutorial.tasks.TaskList = function(name, data, container) {
    this.name = name;
    this.container = container;
    this.tasks = [];

    for (var i = 0; i < data.length; i++) {
        this.tasks.push(new mu.tutorial.tasks.Task(data[i], this));
    }

    this.sort()
    this.render();
}

It's followed by its constructor which creates, sorts and renders the given tasks, this greatly simplify the makeTasks function. With this modification, we introduced some unwanted duplication of information, our tasks objects already have a reference to their containers. We'll just change that reference for another pointed at the list it's associated to. We'll also change the way we're using the taskLists namespace variable, it will now contains TaskList objects. Now lets see how the render function works.

mu.tutorial.tasks.TaskList.prototype.noTasks = function() {
    this.container.appendChild(
        goog.dom.createDom('h2', { 'class': 'empty' }, 'This list is empty!'));
}

mu.tutorial.tasks.TaskList.prototype.render = function() {
    goog.dom.removeChildren(this.container);

    if (this.tasks.length == 0)
        this.noTasks();
    else
        for (var i = 0; i < this.tasks.length; i++) {
            this.tasks[i].makeDom();
        }
}

It's basically the same code that was previously in makeTasks with the only difference that it clean up its container before adding new elements. We also moved noTasks into the TaskList object for convenience.

Next, some new code. We've got an array of tasks and we'll sort it using the default JavaScript sort function. Google has thought about adding helper functions for arrays including one for sorting, its advantage over the default JavaScript one is that it sorts numbers correctly. Here this sort function wouldn't be helping us as we'll write our own compare function.

mu.tutorial.tasks.defaultCompare = function(t1, t2) {
    return goog.array.defaultCompare(
        t2.priority,
        t1.priority);
};

mu.tutorial.tasks.TaskList.prototype.sort = function() {
    this.tasks.sort(mu.tutorial.tasks.defaultCompare);
}

We'll need to insert and remove tasks from our task lists. As they're always sorted, the Closure Library has two functions to help us: binaryInsert and binaryRemove. These allow for fast manipulation of sorted arrays by using a binary search algorithm.

mu.tutorial.tasks.TaskList.prototype.add = function(task) {
    task.list = this;
    
    goog.array.binaryInsert(
        this.tasks,
        task,
        mu.tutorial.tasks.defaultCompare);
}

mu.tutorial.tasks.TaskList.prototype.remove = function(task) {
    this.container.removeChild(task.element);
    
    goog.array.binaryRemove(
        this.tasks,
        task,
        mu.tutorial.tasks.defaultCompare);

    if (this.tasks.length == 0)
        this.render();
}

The add method is very simple, it just set the current task list as the given task parent and insert it into the array. For the remove method though, it's actually in charge of cleaning up the element of the task being removed. It also ensures to call noTask if there's no task left in that task list.

Next, we'll replace the clickActionButton listener code by a call to a new Task method called moveTo. This method will be in charge of removing the task from its parent list and inserting it into the specified one using the above methods.

mu.tutorial.tasks.Task.prototype.moveTo = function(target) {
    this.list.remove(this);
    mu.tutorial.tasks.taskLists[target].add(this);
}

Yet another change that merit a mention is the switchPanel function which takes a new argument to know what TaskList to render. This could be made cleaner, but we'll keep it that way for this part of the tutorial.

When a user change the priority of a task, we better have them sorted. We'll create a new Task method that will be called when someone stop using the slider. It make use of a new property of the Task object to remember the previous value so as not to reload a task list if the priority didn't changed.

mu.tutorial.tasks.Task.prototype.reload = function() {
    if (this.priority != this.previous_priority) {
        this.list.sort();
        this.list.render();
        this.previous_priority = this.priority;
    }
}

But now we have a problem. When shall we call this function? I've thought about it for some time and came up with a complex and convoluted solution. The correct solution, that is to use a timer and accumulate events, being too much bothersome for the scope of this tutorial, I've decided to settle on a small hack I've found that should do the job.

By the principles of YAGNI, I figured out that the sliders are to heavy to handle. Why would we need four different ways of modifying a task priority? So, from now on, we'll only listen for MOUSEOUT and KEYUP events.

        goog.events.listen(
            slider.getContentElement(),
            [goog.events.EventType.MOUSEOUT,
             goog.events.EventType.KEYUP],
            function() { task.reload(); });

There's still a problem with dragging the sliders, if only we could make that issue disappear.

Invisible Sliders

Sometimes the best style is no styles at all. We must face it, the sliders are ugly. Making them look awesome can take some time for a non-designer like me. While thinking about this problem, I've came up with a solution that would also simplify sliders event handling code. We could make the sliders invisible and put them on top of the priority numbers shown. That doesn't actually fix our issue, we only prevent users from thinking about dragging the slider thumb. This is just a hack and should only be done if you're really short on time. Talking of time, this part is much more longer than I expected. So I'll let you look at the result rather than the code as it's a very simple modification, mostly new DOM elements and CSS.

Less Rendering and More Stability

One last thing, our code is working like a charm and is more than fast enough for the small data set we're testing it with. But in real world situations, there's some bottlenecks that could potentially slow our application down. I'll let the testing for another part, but we'll remove one of those bottleneck.

The most processor hungry function in our code is certainly the render one. Presently, we're calling it every time the priority of a task change. But if the order of tasks don't change, we don't need to render them again. Moreover, the JavaScript sort function isn't stable, two tasks having the same priority could be reordered. So, to kill two birds with one stone a second time, we'll make our own stable sort function. Well, in fact, we'll just take the code from goog.array.stableSort and alter it to return true if the array changed.

mu.tutorial.tasks.TaskList.prototype.sort = function() {
    var arr = this.tasks
    for (var i = 0; i < arr.length; i++) {
        arr[i] = {index: i, value: arr[i]};
    }

    function stableCompareFn(obj1, obj2) {
        return mu.tutorial.tasks.defaultCompare(obj1.value, obj2.value) ||
            obj1.index - obj2.index;
    };
    
    goog.array.sort(arr, stableCompareFn);

    var changed = false;
    for (var i = 0; i < arr.length; i++) {
        if (i != arr[i].index)
            changed = true;
        arr[i] = arr[i].value;
    }
    return changed;
}

Then, it's only a matter of calling render when the sort call returns true in the reload method.

mu.tutorial.tasks.Task.prototype.reload = function() {
    if (this.priority != this.previous_priority) {
        if (this.list.sort())
            this.list.render();
        this.previous_priority = this.priority;
    }
}

Phew, that was a big post, I hope you enjoyed it! I'm still not quite sure about what will be the subject of the next part, if you have any suggestion, drop by a comment.

2009-12-03

Closure Library Tutorial: Tasks (part 1)

Today we'll create something with the recently released Closure Library. Nothing amazing, just a simple application to manage daily tasks (I would have liked to say a 'task manager' but that could be confusing). JavaScript has finally matured and with this library, we nearly have all the bells and whistles of a desktop environment graphical user interface.

The Closure Library is part of a set of tools that Google's engineers have been working on for quite some time and they have more than proved their worth. Even though not directly based on it, this library has been greatly influenced by the Dojo Toolkit. I've never used that framework before, most of my experience being with jQuery, so I'm not really qualified to compare them, learning Closure will be enough for now. Lets list some of the features offered by this library:

  • History management
  • Solid and portable event handling (with a timer class and ways to delay or throttle events)
  • Internationalization
  • Basic support for spell checkers
  • A complete debugging and testing framework
  • DOM manipulation helpers
  • Code to help working with data sets
  • Cross-browser support for drawing using SVG, VML or the new HTML5 canvas element
  • A module system to dynamically load compiled JavaScript code
  • UI widgets and effects

And there's much more! All that backed up by the company that dominate the web, how could we ask for more? So in this tutorial we'll concentrate on two areas: UI widgets and event handling. I know, this is already covered in the Google code tutorial, but I found it to be plain and boring, that library deserves more.

This tutorial will build upon the official one, just refer to it if you're lost at some point. We begin by declaring the namespaces we'll be providing and we'll also require the goog.dom namespace and the UI widgets we need.

goog.provide('mu.tutorial.tasks');
goog.provide('mu.tutorial.tasks.Task');

goog.require('goog.dom');
goog.require('goog.ui.CustomButton');
goog.require('goog.ui.Toolbar');
goog.require('goog.ui.ToolbarButton');
goog.require('goog.ui.Zippy');

First thing to note is that we have to provide not only the namespace, but the name of all classes in that namespace. That's because classes aren't really classes, they're still just plain JaveScript prototypes. So class names are simply namespaces and are thus orthogonal to the inheritance mechanism. You can inherit from classes that aren't being provided by any namespace. The provide and require functions are only there to check for dependencies and are also used by the debug resolver.

Now lets add something new, a toolbar. Before delving into code, we'll have to go fetch the stylesheet and images that can be found in the goog/demos folder. In the meantime you can also get those for the button widget that we'll use later on. Another file not to forget is the common.css stylesheet that contains styles common to all widgets. Here's all the code needed to create our toolbar.

mu.tutorial.tasks.switchPanel = function(target) {
    return function() {
        goog.dom.$('taskList').style.display = "none";
        goog.dom.$('completedTaskList').style.display = "none";
        goog.dom.$('deletedTaskList').style.display = "none";
        goog.dom.$('settings').style.display = "none";
        goog.dom.$(target).style.display = "block";
    }
}

mu.tutorial.tasks.attachToolbarButton = function(toolbar, label, tooltip, target) {
    var button = new goog.ui.ToolbarButton(label);
    button.setTooltip(tooltip);
    toolbar.addChild(button, true);
    goog.events.listen(button.getContentElement(), goog.events.EventType.CLICK,
        mu.tutorial.tasks.switchPanel(target));
}

mu.tutorial.tasks.attachToolbar = function(container) {
    var toolbar = new goog.ui.Toolbar();

    mu.tutorial.tasks.attachToolbarButton(toolbar, 'Tasks', 'List currently active tasks.', 'taskList');
    mu.tutorial.tasks.attachToolbarButton(toolbar, 'Completed', 'List completed tasks.', 'completedTaskList');
    mu.tutorial.tasks.attachToolbarButton(toolbar, 'Trash', 'List deleted tasks.', 'deletedTaskList');
    mu.tutorial.tasks.attachToolbarButton(toolbar, 'Settings', 'Settings', 'settings');

    toolbar.render(container);
}

First, we create a toolbar, then attach all buttons and finally render everything in the given container. There's a function to help us attach buttons, it add a tooltip and an event listener to switch between the various panels. Those panels are just a bunch of divs that are shown successively by changing their display attribute, using the switchPanel function.

For an application managing tasks, we better create a Task class. This part of the tutorial is very similar to Google's one. Task objects are structurally identical to Note objects, with summary instead title and description instead of content as properties.

mu.tutorial.tasks.Task = function(data, container) {
    this.summary = data.summary;
    this.description = data.description;
    this.priority = data.priority;
    this.parent = container;
}

mu.tutorial.tasks.Task.prototype.closeEditor = function() {
  this.contentElement.innerHTML = this.description;
  this.contentElement.style.display = "block";
  this.editorContainer.style.display = "none";
};

mu.tutorial.tasks.Task.prototype.openEditor = function(e) {
  this.editorElement.value = this.description;
  this.contentElement.style.display = "none";
  this.editorContainer.style.display = "inline";
};

mu.tutorial.tasks.Task.prototype.save = function(e) {
  this.description = this.editorElement.value;
  this.closeEditor();
};

As you see, there's nothing new here. I've also included the functions that will be used by the editor, then again, same thing as the official tutorial. We'll only add one new kind of action. In fact this is a function returning a closure that will move a task to the desired panel.

mu.tutorial.tasks.Task.prototype.clickActionButton = function(task, element, target) {
    return function(e) {
        var parent = element.parentNode;
        parent.removeChild(element);
        if (parent.childNodes.length == 0)
            mu.tutorial.tasks.noTasks(parent);

        task.parent = mu.tutorial.tasks.taskLists[target];
        if (task.parent.childNodes[0].className == 'empty')
            task.parent.removeChild(task.parent.childNodes[0]);

        task.makeDom();
        e.stopPropagation();
    };
}

Here, the noTasks function only create an h2 header tag to tell the user there's no tasks in that panel. The taskLists namespace variable is a simple dictionary that we'll fill later.

We're now ready to jump to the makeDom function, where we create the DOM structure for a task, add buttons, wire up event listeners and use the Zippy class.

mu.tutorial.tasks.Task.prototype.makeDom = function() {
    this.summaryDiv = goog.dom.createDom('div', { 'class': 'summary' }, this.summary);
    this.contentElement = goog.dom.createDom('div', { 'class': 'description' }, this.description);
    this.editorElement = goog.dom.createDom('textarea', null, '');

    this.editorContainer = goog.dom.createDom('div', {'style': 'display:none;'},
                                              this.editorElement);

    this.descriptionContainer = goog.dom.createDom('div', null,
                                                   this.contentElement,
                                                   this.editorContainer);

    var taskDiv = goog.dom.createDom('div', { 'class': 'task' },
                                     this.summaryDiv,
                                     this.descriptionContainer);

    this.parent.appendChild(taskDiv);

    this.makeButtons(this, taskDiv);

    goog.events.listen(this.contentElement, goog.events.EventType.CLICK,
                       this.openEditor, false, this);

    this.zippy = new goog.ui.Zippy(
        this.summaryDiv,
        this.descriptionContainer);
}

There is two type of button for tasks, action buttons that will use the clickActionButton function and editor buttons for the editor. Each type of button has a function to create them, add a class name, render them and add an event listener.

mu.tutorial.tasks.Task.prototype.makeActionButton = function(task, element, target, name) {
    var button = new goog.ui.CustomButton(name);

    button.addClassName('taskButton');

    button.render(element.childNodes[0]);

    goog.events.listen(
        button.getContentElement(),
        goog.events.EventType.CLICK,
        this.clickActionButton(task, element, target));
}

mu.tutorial.tasks.Task.prototype.makeEditorButton = function(element, name, callback) {
    var button = new goog.ui.CustomButton(name);

    button.addClassName('editorButton');

    button.render(element.childNodes[1].childNodes[1]);

    goog.events.listen(
        button.getContentElement(),
        goog.events.EventType.CLICK,
        callback, false, this);
}

Here's the makeButtons function.

mu.tutorial.tasks.Task.prototype.makeButtons = function(task, element) {
    if (task.parent.id == 'taskList') {
        this.makeActionButton(task, element, 'completed', 'Done');
        this.makeActionButton(task, element, 'deleted', 'Delete');
    }
    else
        this.makeActionButton(task, element, 'active', 'Undo');

    this.makeEditorButton(element, 'Save', this.save);
    this.makeEditorButton(element, 'Cancel', this.closeEditor);
}

The action buttons are attached to each tasks depending if the given task is active or not. Active tasks can be deleted or marked as completed and we can reactivate them after that. All tasks will remain editable for now, so we add the editor buttons to each one of them.

Finally, the last function takes a bunch of raw task data, create Task objects and call their makeDom method, inserting them into the given container. It also fills the taskLists dictionary entry for that list.

mu.tutorial.tasks.makeTasks = function(name, data, container) {
    if (data.length == 0)
        mu.tutorial.tasks.noTasks(container);
    else
        for (var i = 0; i < data.length; i++) {
            var task = new mu.tutorial.tasks.Task(data[i], container);
            task.makeDom();
        }

    mu.tutorial.tasks.taskLists[name] = container;
}

We still have to make use of all that code, an HTML page will contain the remaining parts. We only need one div for the toolbar, then four others for each sections of our application. After that we add some more JavaScript to attach the toolbar and create some tasks to test if everything is working.

  <body>
    <h1>Closure Library Tutorial</h1>
    <div id="menu"></div>
    <div id="taskList"></div>
    <div id="completedTaskList"></div>
    <div id="deletedTaskList"></div>
    <div id="settings">TODO...</div>
    <script type="text/javascript">
      function main() {

          var menu = document.getElementById('menu');
          mu.tutorial.tasks.attachToolbar(menu);

          var makeTaskData = function(summary, description, priority) {
              return { 'summary': summary, 'description': description, 'priority': priority };
          };

          var taskList = document.getElementById('taskList');
          mu.tutorial.tasks.makeTasks('active', [
              makeTaskData("Make the toolbar actually do something.", "Need more studying of Toolbar demo.", 99),
              makeTaskData("Make tasks editable.", "Like in the Closure Library Notes tutorial.", 88),
              makeTaskData("Create different task lists.", "One for active tasks, another for completed ones and finally a list containing deleted tasks.", 100)
              ], taskList);

          var completedTaskList = document.getElementById('completedTaskList');
          mu.tutorial.tasks.makeTasks('completed', [], completedTaskList);

          var deletedTaskList = document.getElementById('deletedTaskList');
          mu.tutorial.tasks.makeTasks('deleted', [
              { 'summary': 'test', 'description': 'test', 'priority': 1 }
              ], deletedTaskList);
      }
      main();
    </script>
  </body>

You can grab the full HTML file here (look at the source or use "Save Link As...") and the JavaScript used there. When testing it, you'll notice the loading time can be long for a static page. Using Firebug net panel, we can see that the page ask for no less than 54 JavaScript files, that's a lot of requests! Over the wire, this can quickly become a problem, especially for high latency connections. To fix this problem, we'll use the calcdeps.py script found in the bin directory. You simply have to call it like that:

> ./closure/bin/calcdeps.py -i tasks.js -p . -o script > tasks.nodeps.js

Still, it makes our little JS file a lot heavier, it now weight in at 656KB, 650 more than the original. That's a lot, but can be mitigated by server settings like file compression and expire headers.

Here's a demo if you're not interested by setting up everything yourself. In the next tutorial we'll learn to use sliders and color pickers to make the settings panel do something and we'll make the active task list sortable by priority.

Any questions, corrections or insults?

About Me

My photo
Quebec, Canada
Your humble servant.